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
- func RunFirstBootSeedIfNeeded(ctx context.Context, db *store.Store, s *Syncer, cfg *config.Config) error
- type APIKeyEntry
- type APIKeySecretsEntry
- type AccessEntry
- type AlertRuleEntry
- type AppMeta
- type AppSecrets
- type AppSidecar
- type BackupConfigEntry
- type BackupSecretsEntry
- type DBBackupSecretsEntry
- type GlobalSecrets
- type GlobalSidecar
- type ImportOptions
- type LoadedApp
- type LoadedGlobal
- type RedactedGlobalSidecar
- type RedactedRegistry
- type RedactedUser
- type RedactedWebhook
- type RegistryEntry
- type RegistrySecretsEntry
- type SidecarWriteHook
- type Syncer
- func (s *Syncer) AccessFromDashboardOnly() bool
- func (s *Syncer) ApplyAppSidecar(slug string, loaded *LoadedApp) error
- func (s *Syncer) ApplyGlobalSidecar(loaded *LoadedGlobal) error
- func (s *Syncer) AppsDir() string
- func (s *Syncer) ArchiveDir() string
- func (s *Syncer) Close() error
- func (s *Syncer) DataDir() string
- func (s *Syncer) DeleteAppSidecar(slug string) error
- func (s *Syncer) DeleteTombstone(slug string) error
- func (s *Syncer) EnsureSecretsGitignore()
- func (s *Syncer) ImportAppSidecar(data *AppSidecar) error
- func (s *Syncer) ImportAppSidecarIfMissing(slug string) (bool, error)
- func (s *Syncer) ImportAppSidecarWithOptions(data *AppSidecar, opts ImportOptions) error
- func (s *Syncer) ImportGlobal(data *GlobalSidecar) error
- func (s *Syncer) ImportGlobalIfEmpty() (bool, error)
- func (s *Syncer) ImportRedactedGlobal(data *RedactedGlobalSidecar) error
- func (s *Syncer) IsSelfWrite(path string, within time.Duration) bool
- func (s *Syncer) LoadAppFromFS(slug string) (*LoadedApp, error)
- func (s *Syncer) LoadGlobalFromFS() (*LoadedGlobal, error)
- func (s *Syncer) MarkSelfWrite(path string)
- func (s *Syncer) PruneOrphanSidecars() ([]string, error)
- func (s *Syncer) ReadAppSecrets(slug string) (*AppSecrets, error)
- func (s *Syncer) ReadAppSidecar(slug string) (*AppSidecar, error)
- func (s *Syncer) ReadGlobal() (*GlobalSidecar, error)
- func (s *Syncer) ReadGlobalSecrets() (*GlobalSecrets, error)
- func (s *Syncer) ReadRedactedGlobal() (*RedactedGlobalSidecar, error)
- func (s *Syncer) ReadTombstone(slug string) (*Tombstone, error)
- func (s *Syncer) ReconcileDBFromFS(ctx context.Context) error
- func (s *Syncer) RestoreSidecarAccess(slug string) (bool, error)
- func (s *Syncer) RestoreSidecarAccessTo(slug string, usernames []string) (bool, error)
- func (s *Syncer) ScheduleAppWrite(slug string)
- func (s *Syncer) ScheduleGlobalWrite()
- func (s *Syncer) SetAccessFromDashboardOnly(on bool)
- func (s *Syncer) SetSidecarWriteHook(h SidecarWriteHook)
- func (s *Syncer) WriteAppSecrets(slug string, secrets *AppSecrets) error
- func (s *Syncer) WriteAppSidecar(slug string) error
- func (s *Syncer) WriteGlobal() error
- func (s *Syncer) WriteGlobalSecrets(g *GlobalSecrets) error
- func (s *Syncer) WriteRedactedGlobal() error
- func (s *Syncer) WriteTombstone(slug string, archivedAt time.Time) error
- type Tombstone
- type UserEntry
- type UserSecretsEntry
- type WebhookEntry
- type WebhookSecretsEntry
Constants ¶
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 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 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 ¶
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 SidecarWriteHook ¶
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 (*Syncer) AccessFromDashboardOnly ¶ added in v1.4.3
AccessFromDashboardOnly reports the SetAccessFromDashboardOnly setting.
func (*Syncer) ApplyAppSidecar ¶
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) ArchiveDir ¶
ArchiveDir returns the directory where tombstones are stored.
func (*Syncer) Close ¶
Close flushes all pending debounced writes and stops all timers. Safe to call multiple times.
func (*Syncer) DeleteAppSidecar ¶
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 ¶
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 ¶
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 ¶
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) LoadAppFromFS ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
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
RestoreSidecarAccessTo is RestoreSidecarAccess with an explicit username list instead of the current DB grants.
func (*Syncer) ScheduleAppWrite ¶
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
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 ¶
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 ¶
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 ¶
WriteRedactedGlobal reads global config from the store and writes the redacted {apps_dir}/_global.yml. Contains no secrets.
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 WebhookEntry ¶
WebhookEntry mirrors store.Webhook. URL/headers/template live in GlobalSecrets.