Help us shape the future of Go! Take the 2026 Go Developer Survey

configsync

package
v1.4.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package configsync mirrors user-editable DB rows to YAML sidecar files on disk. Per-app sidecar: {apps_dir}/{slug}/simpledeploy.yml Global sidecar: {data_dir}/config.yml

Writers are debounced (500ms) so rapid successive calls coalesce into one write. Importers perform idempotent upserts, restoring config after a DB wipe.

Index

Constants

View Source
const Version = 1

Version is the sidecar schema version written to every file.

Variables

This section is empty.

Functions

func RunFirstBootSeedIfNeeded

func RunFirstBootSeedIfNeeded(ctx context.Context, db *store.Store, s *Syncer, cfg *config.Config) error

RunFirstBootSeedIfNeeded checks system_meta for the seeded marker. If absent, writes per-app + global FS files from current DB state and records the marker. Idempotent.

Types

type APIKeyEntry

type APIKeyEntry struct {
	Username  string     `yaml:"username"`
	Name      string     `yaml:"name"`
	ExpiresAt *time.Time `yaml:"expires_at,omitempty"`
}

APIKeyEntry mirrors store.APIKeyRecord. Key hash lives in GlobalSecrets.

type APIKeySecretsEntry

type APIKeySecretsEntry struct {
	KeyHash   string     `yaml:"key_hash"`
	Username  string     `yaml:"username"`
	Name      string     `yaml:"name"`
	ExpiresAt *time.Time `yaml:"expires_at,omitempty"`
}

type AccessEntry

type AccessEntry struct {
	Username string `yaml:"username" json:"username"`
}

AccessEntry records a user who has explicit access to this app.

type AlertRuleEntry

type AlertRuleEntry struct {
	Metric      string  `yaml:"metric" json:"metric"`
	Operator    string  `yaml:"operator" json:"operator"`
	Threshold   float64 `yaml:"threshold" json:"threshold"`
	DurationSec int     `yaml:"duration_sec" json:"duration_sec"`
	Webhook     string  `yaml:"webhook" json:"webhook"` // Webhook.Name
	Enabled     bool    `yaml:"enabled" json:"enabled"`
}

AlertRuleEntry is a portable alert rule (webhook referenced by name, not ID).

type AppMeta

type AppMeta struct {
	Slug        string `yaml:"slug" json:"slug"`
	DisplayName string `yaml:"display_name" json:"display_name"`
}

AppMeta holds identifying info about the app.

type AppSecrets

type AppSecrets struct {
	Version       int                  `yaml:"version"`
	Slug          string               `yaml:"slug"`
	BackupConfigs []BackupSecretsEntry `yaml:"backup_configs,omitempty"`
}

AppSecrets is {apps_dir}/<slug>/simpledeploy.secrets.yml. Mode 0600.

type AppSidecar

type AppSidecar struct {
	Version       int                 `yaml:"version"`
	App           AppMeta             `yaml:"app"`
	AlertRules    []AlertRuleEntry    `yaml:"alert_rules,omitempty"`
	BackupConfigs []BackupConfigEntry `yaml:"backup_configs,omitempty"`
	Access        []AccessEntry       `yaml:"access,omitempty"`
}

AppSidecar is the per-app YAML sidecar written to {apps_dir}/{slug}/simpledeploy.yml.

type BackupConfigEntry

type BackupConfigEntry struct {
	ID             string `yaml:"id" json:"id"` // UUID, correlates with secrets file entry
	Strategy       string `yaml:"strategy" json:"strategy"`
	Target         string `yaml:"target" json:"target"`
	ScheduleCron   string `yaml:"schedule_cron" json:"schedule_cron"`
	RetentionMode  string `yaml:"retention_mode" json:"retention_mode"`
	RetentionCount int    `yaml:"retention_count" json:"retention_count"`
	RetentionDays  *int   `yaml:"retention_days" json:"retention_days"`
	VerifyUpload   bool   `yaml:"verify_upload" json:"verify_upload"`
	PreHooks       string `yaml:"pre_hooks,omitempty" json:"pre_hooks,omitempty"`
	PostHooks      string `yaml:"post_hooks,omitempty" json:"post_hooks,omitempty"`
	Paths          string `yaml:"paths,omitempty" json:"paths,omitempty"`
}

BackupConfigEntry mirrors store.BackupConfig without DB IDs. target_config_enc lives in the secrets sidecar, correlated by ID (UUID).

type BackupSecretsEntry

type BackupSecretsEntry struct {
	ID              string `yaml:"id"`
	TargetConfigEnc string `yaml:"target_config_enc"`
}

type DBBackupSecretsEntry

type DBBackupSecretsEntry struct {
	TargetConfigEnc string `yaml:"target_config_enc"`
}

type GlobalSecrets

type GlobalSecrets struct {
	Version    int                    `yaml:"version"`
	Users      []UserSecretsEntry     `yaml:"users,omitempty"`
	APIKeys    []APIKeySecretsEntry   `yaml:"api_keys,omitempty"`
	Registries []RegistrySecretsEntry `yaml:"registries,omitempty"`
	Webhooks   []WebhookSecretsEntry  `yaml:"webhooks,omitempty"`
	DBBackup   *DBBackupSecretsEntry  `yaml:"db_backup,omitempty"`
}

GlobalSecrets is {data_dir}/secrets.yml. Mode 0600.

type GlobalSidecar

type GlobalSidecar struct {
	Version        int               `yaml:"version"`
	Users          []UserEntry       `yaml:"users,omitempty"`
	APIKeys        []APIKeyEntry     `yaml:"api_keys,omitempty"`
	Registries     []RegistryEntry   `yaml:"registries,omitempty"`
	Webhooks       []WebhookEntry    `yaml:"webhooks,omitempty"`
	DBBackupConfig map[string]string `yaml:"db_backup_config,omitempty"`
}

GlobalSidecar is the global YAML sidecar written to {data_dir}/config.yml.

type ImportOptions added in v1.4.3

type ImportOptions struct {
	// SkipAccess leaves user_app_access untouched. Set it for sidecars pulled
	// from a git remote: access grants are managed in the dashboard and must
	// never change because of a pull. Disaster-recovery imports (empty DB,
	// `config import`) leave it false so grants are restored.
	SkipAccess bool
}

ImportOptions tunes ImportAppSidecarWithOptions.

type LoadedApp

type LoadedApp struct {
	Slug    string
	Sidecar *AppSidecar
	Secrets *AppSecrets
}

LoadedApp aggregates an app's sidecar and secrets read from disk. Either field may be nil if the corresponding file is absent.

type LoadedGlobal

type LoadedGlobal struct {
	Sidecar *GlobalSidecar
	Secrets *GlobalSecrets
}

LoadedGlobal aggregates the global sidecar and secrets read from disk. Either field may be nil if the corresponding file is absent.

type RedactedGlobalSidecar

type RedactedGlobalSidecar struct {
	Version          int                `yaml:"version"`
	Users            []RedactedUser     `yaml:"users,omitempty"`
	Registries       []RedactedRegistry `yaml:"registries,omitempty"`
	Webhooks         []RedactedWebhook  `yaml:"webhooks,omitempty"`
	DBBackupSchedule string             `yaml:"db_backup_schedule,omitempty"`
	DBBackupTarget   string             `yaml:"db_backup_target,omitempty"`
}

DEPRECATED: Redundant with FS-authoritative config.yml (which is already non-secret). Kept for gitsync-pushed snapshot compatibility. Remove once gitsync is migrated to push config.yml directly. See plan docs/superpowers/plans/2026-04-27-fs-authoritative-state.md.

RedactedGlobalSidecar is a git-safe view of global config. Stored at {apps_dir}/_global.yml. Contains NO secrets: no password hashes, no api-key hashes, no encrypted credentials, no webhook URLs. Used by gitsync for portable config sharing; never used for DR.

type RedactedRegistry

type RedactedRegistry struct {
	ID   string `yaml:"id"`
	Name string `yaml:"name"`
	URL  string `yaml:"url"`
}

RedactedRegistry holds non-secret registry fields (URL is the registry hostname, not a secret).

type RedactedUser

type RedactedUser struct {
	Username    string `yaml:"username"`
	Role        string `yaml:"role"`
	DisplayName string `yaml:"display_name,omitempty"`
	Email       string `yaml:"email,omitempty"`
}

RedactedUser holds non-secret user fields.

type RedactedWebhook

type RedactedWebhook struct {
	Name string `yaml:"name"`
	Type string `yaml:"type"`
}

RedactedWebhook holds non-secret webhook fields (no URL, no headers, no template).

type RegistryEntry

type RegistryEntry struct {
	ID   string `yaml:"id"`
	Name string `yaml:"name"`
	URL  string `yaml:"url"`
}

RegistryEntry mirrors store.Registry. Encrypted creds live in GlobalSecrets.

type RegistrySecretsEntry

type RegistrySecretsEntry struct {
	ID          string `yaml:"id"`
	UsernameEnc string `yaml:"username_enc"`
	PasswordEnc string `yaml:"password_enc"`
}

type SidecarWriteHook

type SidecarWriteHook func(path string, reason string)

SidecarWriteHook is invoked after every successful sidecar write with the absolute path of the file written and a short reason string.

type Syncer

type Syncer struct {
	// contains filtered or unexported fields
}

Syncer writes and reads config sidecar files, debouncing frequent writes.

func New

func New(st *store.Store, appsDir, dataDir string) *Syncer

New creates a Syncer. It does not start any background work until Schedule* is called.

func (*Syncer) AccessFromDashboardOnly added in v1.4.3

func (s *Syncer) AccessFromDashboardOnly() bool

AccessFromDashboardOnly reports the SetAccessFromDashboardOnly setting.

func (*Syncer) ApplyAppSidecar

func (s *Syncer) ApplyAppSidecar(slug string, loaded *LoadedApp) error

ApplyAppSidecar reconciles per-app DB rows to match the FS-loaded sidecar for the given slug. The app row must already exist. Performs full-replace for alert_rules, backup_configs, and user_app_access (the latter skipped while SetAccessFromDashboardOnly is on). Updates apps.name from sidecar display_name. Alert rules referencing unknown webhooks are dropped with a log line (rather than failing the whole apply).

loaded.Sidecar must be non-nil. loaded.Secrets may be nil.

func (*Syncer) ApplyGlobalSidecar

func (s *Syncer) ApplyGlobalSidecar(loaded *LoadedGlobal) error

ApplyGlobalSidecar reconciles global DB rows (users, api_keys, registries, webhooks, db_backup_config) to match the FS-loaded global sidecar.

CRITICAL DR safety: if loaded.Secrets == nil (secrets file missing) we run only the non-secret diff and leave secret columns (password_hash, key_hash, *_enc, webhook URL/headers/template, db backup target_config_enc) untouched. This prevents a missing secrets.yml from wiping the admin's password and locking everyone out.

Full-replace semantics: rows present in the DB but absent from the sidecar are deleted (users, api_keys, registries, webhooks). db_backup_config is upserted (single config row, not a set).

func (*Syncer) AppsDir

func (s *Syncer) AppsDir() string

AppsDir returns the apps directory the syncer was configured with.

func (*Syncer) ArchiveDir

func (s *Syncer) ArchiveDir() string

ArchiveDir returns the directory where tombstones are stored.

func (*Syncer) Close

func (s *Syncer) Close() error

Close flushes all pending debounced writes and stops all timers. Safe to call multiple times.

func (*Syncer) DataDir

func (s *Syncer) DataDir() string

DataDir returns the data directory the syncer was configured with.

func (*Syncer) DeleteAppSidecar

func (s *Syncer) DeleteAppSidecar(slug string) error

DeleteAppSidecar removes {apps_dir}/{slug}/simpledeploy.yml if it exists. Safe to call even if the file or directory is gone. Ignores not-exist errors.

func (*Syncer) DeleteTombstone

func (s *Syncer) DeleteTombstone(slug string) error

DeleteTombstone removes the tombstone for a slug. Missing file is not an error.

func (*Syncer) EnsureSecretsGitignore added in v1.4.2

func (s *Syncer) EnsureSecretsGitignore()

EnsureSecretsGitignore best-effort adds .gitignore entries so secrets sidecars are never committed: "*.secrets.yml" in apps_dir and "secrets.yml" in data_dir. Idempotent.

func (*Syncer) ImportAppSidecar

func (s *Syncer) ImportAppSidecar(data *AppSidecar) error

ImportAppSidecar performs idempotent upserts for all rows in data into the store. Existing alert_rules, backup_configs and access grants for the app are replaced wholesale. Intended for disaster recovery; see ImportAppSidecarWithOptions for untrusted (git-pulled) input. The caller must ensure the app exists in the DB before calling.

func (*Syncer) ImportAppSidecarIfMissing

func (s *Syncer) ImportAppSidecarIfMissing(slug string) (bool, error)

ImportAppSidecarIfMissing reads the per-app sidecar and imports it only when the app's DB-side state (alert rules + backup configs + access grants) is empty. Returns (imported bool, err error). Missing sidecar -> (false, nil). If any of the three is non-empty, treat as "DB has state" and skip import. The app row must already exist before calling.

func (*Syncer) ImportAppSidecarWithOptions added in v1.4.3

func (s *Syncer) ImportAppSidecarWithOptions(data *AppSidecar, opts ImportOptions) error

ImportAppSidecarWithOptions is ImportAppSidecar with options.

func (*Syncer) ImportGlobal

func (s *Syncer) ImportGlobal(data *GlobalSidecar) error

ImportGlobal performs idempotent upserts for all rows in data into the store. Reads the sibling secrets.yml (if present) and correlates secrets to non-secret rows by their natural keys. Import order: webhooks -> users -> api_keys -> registries -> db_backup_config.

func (*Syncer) ImportGlobalIfEmpty

func (s *Syncer) ImportGlobalIfEmpty() (bool, error)

ImportGlobalIfEmpty imports the global sidecar only when the DB has zero users. Returns (imported bool, err error). If no sidecar exists -> (false, nil). If users table is non-empty -> (false, nil) without modifying DB. When it imports, it also records the first-boot seed as done (see RunFirstBootSeedIfNeeded), even if the import then fails.

func (*Syncer) ImportRedactedGlobal

func (s *Syncer) ImportRedactedGlobal(data *RedactedGlobalSidecar) error

ImportRedactedGlobal applies non-secret deltas from the redacted file to the DB. Preserves existing password hashes, encrypted registry credentials, and webhook URLs. Does NOT delete users/registries/webhooks that are absent from the file. Does NOT touch api_keys.

Not used on the git pull path: _global.yml is push-only. Content pulled from a remote must never change users, roles, registries, webhooks or DB backup settings, so do not wire this to remote-controlled input.

func (*Syncer) IsSelfWrite

func (s *Syncer) IsSelfWrite(path string, within time.Duration) bool

func (*Syncer) LoadAppFromFS

func (s *Syncer) LoadAppFromFS(slug string) (*LoadedApp, error)

LoadAppFromFS reads {apps_dir}/{slug}/simpledeploy.yml and the matching secrets sidecar. Missing files are reported as nil fields, not errors.

func (*Syncer) LoadGlobalFromFS

func (s *Syncer) LoadGlobalFromFS() (*LoadedGlobal, error)

LoadGlobalFromFS reads {data_dir}/config.yml and the global secrets sidecar. Missing files are reported as nil fields, not errors.

func (*Syncer) MarkSelfWrite

func (s *Syncer) MarkSelfWrite(path string)

MarkSelfWrite records that path was just written by the syncer itself. IsSelfWrite returns true if path was self-written within the given window (and consumes the entry so a manual edit immediately after would still be honored).

func (*Syncer) PruneOrphanSidecars

func (s *Syncer) PruneOrphanSidecars() ([]string, error)

PruneOrphanSidecars removes per-app sidecar files from folders that are no longer apps: no docker-compose.yml, no app row (active or archived) and no archive tombstone. A folder with a compose file is never an orphan, DB row or not: after a DB loss the reconciler re-creates the row from it and imports the sidecar. Nothing is pruned while the DB has no apps (fresh or wiped DB), or when a check fails; a stale file is safer than lost config. Safe to call on every startup. Returns the list of slugs whose sidecars were removed. Skips hidden dirs and names starting with '_' or '.'.

func (*Syncer) ReadAppSecrets

func (s *Syncer) ReadAppSecrets(slug string) (*AppSecrets, error)

ReadAppSecrets reads the per-app secrets sidecar. Returns (nil, nil) if absent. A symlinked app directory or secrets file is refused.

func (*Syncer) ReadAppSidecar

func (s *Syncer) ReadAppSidecar(slug string) (*AppSidecar, error)

ReadAppSidecar reads the app sidecar from disk. Returns (nil, nil) if the file does not exist. Unknown YAML keys are tolerated (logged as warnings). A symlinked app directory or sidecar file is refused with an error.

func (*Syncer) ReadGlobal

func (s *Syncer) ReadGlobal() (*GlobalSidecar, error)

ReadGlobal reads the global sidecar from disk. Returns (nil, nil) if the file does not exist.

func (*Syncer) ReadGlobalSecrets

func (s *Syncer) ReadGlobalSecrets() (*GlobalSecrets, error)

ReadGlobalSecrets reads the global secrets sidecar. Returns (nil, nil) if absent.

func (*Syncer) ReadRedactedGlobal

func (s *Syncer) ReadRedactedGlobal() (*RedactedGlobalSidecar, error)

ReadRedactedGlobal reads the redacted global sidecar. Returns (nil, nil) if missing.

func (*Syncer) ReadTombstone

func (s *Syncer) ReadTombstone(slug string) (*Tombstone, error)

ReadTombstone reads the tombstone for a slug. Returns the parsed struct or an os error (use os.IsNotExist to detect a missing file).

func (*Syncer) ReconcileDBFromFS

func (s *Syncer) ReconcileDBFromFS(ctx context.Context) error

ReconcileDBFromFS scans the apps directory and applies each per-app sidecar to the DB, then applies the global sidecar. Missing files are skipped (not treated as deletions) so this is safe to run on every boot. Apps with no DB row (e.g. after a DB loss) are left alone: the reconciler re-creates the row from the app folder and then imports the sidecar.

func (*Syncer) RestoreSidecarAccess added in v1.4.3

func (s *Syncer) RestoreSidecarAccess(slug string) (bool, error)

RestoreSidecarAccess rewrites the access list in {apps_dir}/{slug}/simpledeploy.yml to match user_app_access in the DB, leaving every other field as found on disk. Returns true when the file was rewritten; a missing sidecar returns (false, nil). An app that is not in the DB has no grants.

gitsync calls this after every pull so the access list on disk never carries a pulled change that a later import could apply (e.g. the DR import in ImportAppSidecarIfMissing). It does not call the write hook; the caller commits the result.

func (*Syncer) RestoreSidecarAccessTo added in v1.4.3

func (s *Syncer) RestoreSidecarAccessTo(slug string, usernames []string) (bool, error)

RestoreSidecarAccessTo is RestoreSidecarAccess with an explicit username list instead of the current DB grants.

func (*Syncer) ScheduleAppWrite

func (s *Syncer) ScheduleAppWrite(slug string)

ScheduleAppWrite schedules a debounced write of the app sidecar for slug.

func (*Syncer) ScheduleGlobalWrite

func (s *Syncer) ScheduleGlobalWrite()

ScheduleGlobalWrite schedules a debounced write of both global sidecars (config.yml and _global.yml) in a single 500ms window.

func (*Syncer) SetAccessFromDashboardOnly added in v1.4.3

func (s *Syncer) SetAccessFromDashboardOnly(on bool)

SetAccessFromDashboardOnly controls whether ApplyAppSidecar (file watcher, boot-time FS reload, bundle import) applies the sidecar's access list. Git sync enables it: access grants are then managed in the dashboard only, so a sidecar edited on disk or pulled from the remote cannot change them. Disaster-recovery imports (ImportAppSidecar, ImportAppSidecarIfMissing) still restore grants.

func (*Syncer) SetSidecarWriteHook

func (s *Syncer) SetSidecarWriteHook(h SidecarWriteHook)

SetSidecarWriteHook installs a callback invoked after each successful sidecar write. The hook receives the absolute file path. Panics from the hook are recovered so a buggy hook cannot corrupt writes.

func (*Syncer) WriteAppSecrets

func (s *Syncer) WriteAppSecrets(slug string, secrets *AppSecrets) error

WriteAppSecrets writes the per-app secrets sidecar at mode 0600.

func (*Syncer) WriteAppSidecar

func (s *Syncer) WriteAppSidecar(slug string) error

WriteAppSidecar reads the app and its config from the store and writes the sidecar pair atomically. Secrets file (mode 0600) is written FIRST; if that fails, the non-secret sidecar is not written. Non-secret sidecar is mode 0644.

func (*Syncer) WriteGlobal

func (s *Syncer) WriteGlobal() error

WriteGlobal reads global config from the store and writes the global sidecar pair atomically. Secrets file (mode 0600) is written FIRST; if that fails, the non-secret config.yml is not written. config.yml is mode 0644.

func (*Syncer) WriteGlobalSecrets

func (s *Syncer) WriteGlobalSecrets(g *GlobalSecrets) error

WriteGlobalSecrets writes the global secrets sidecar at mode 0600.

func (*Syncer) WriteRedactedGlobal

func (s *Syncer) WriteRedactedGlobal() error

WriteRedactedGlobal reads global config from the store and writes the redacted {apps_dir}/_global.yml. Contains no secrets.

func (*Syncer) WriteTombstone

func (s *Syncer) WriteTombstone(slug string, archivedAt time.Time) error

WriteTombstone builds a tombstone from current DB state for the slug and writes it atomically to {data_dir}/archive/<slug>.yml (mode 0644).

type Tombstone

type Tombstone struct {
	Version       int                 `yaml:"version" json:"version"`
	ArchivedAt    time.Time           `yaml:"archived_at" json:"archived_at"`
	App           AppMeta             `yaml:"app" json:"app"`
	AlertRules    []AlertRuleEntry    `yaml:"alert_rules,omitempty" json:"alert_rules,omitempty"`
	BackupConfigs []BackupConfigEntry `yaml:"backup_configs,omitempty" json:"backup_configs,omitempty"`
	Access        []AccessEntry       `yaml:"access,omitempty" json:"access,omitempty"`
}

Tombstone is the snapshot written to {data_dir}/archive/<slug>.yml when an app is archived (its dir disappeared from apps_dir).

type UserEntry

type UserEntry struct {
	Username    string `yaml:"username"`
	Role        string `yaml:"role"`
	DisplayName string `yaml:"display_name,omitempty"`
	Email       string `yaml:"email,omitempty"`
}

UserEntry mirrors store.User. Password hash lives in GlobalSecrets.

type UserSecretsEntry

type UserSecretsEntry struct {
	Username     string `yaml:"username"`
	PasswordHash string `yaml:"password_hash"`
}

type WebhookEntry

type WebhookEntry struct {
	Name string `yaml:"name"`
	Type string `yaml:"type"`
}

WebhookEntry mirrors store.Webhook. URL/headers/template live in GlobalSecrets.

type WebhookSecretsEntry

type WebhookSecretsEntry struct {
	Name             string `yaml:"name"`
	URL              string `yaml:"url"`
	HeadersJSON      string `yaml:"headers_json,omitempty"`
	TemplateOverride string `yaml:"template_override,omitempty"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL