Documentation
¶
Overview ¶
Package config holds the parsed sentra.yaml document and the passphrase resolver that the CLI commands use to construct a *Repo.
The schema mirrors docs/plans/2026-05-02-sentra-design.md ("sentra.yaml" section). Defaults are embedded at the top of Load so a missing file or missing fields produce a sensible runnable config without surprises.
Index ¶
- Constants
- Variables
- func DeleteKeyringPassphrase(opts StoreKeyringOptions) (bool, error)
- func DiscoverPath() string
- func KeyringUserForConfig(cfg *Config) string
- func LegacyKeyringUsersForConfig(cfg *Config) []string
- func Render(cfg *Config) []byte
- func Resolve(opts ResolveOptions) ([]byte, error)
- func ResolveNonInteractive(passphraseFile string) ([]byte, string, error)
- func StoreKeyringPassphrase(opts StoreKeyringOptions, passphrase []byte) error
- func Update(path string, mutate func(*Config) error) error
- func Write(path string, cfg *Config) error
- type Config
- type PolicyAfterBackup
- type PolicyConfig
- type PolicyHooks
- type PolicySchedule
- type ResolveOptions
- type StoreKeyringOptions
Constants ¶
const ( PassphraseSourceFile = "--passphrase-file" //nolint:gosec // G101: the flag/env NAME, displayed to the operator; not a credential. PassphraseSourceEnv = envPassphrase )
Passphrase source labels. They name a source, never a secret, so they are safe to render in a UI — which is the point: `sentra setup` initializes a repository under whatever passphrase it resolved, and an operator who cannot see WHICH source that was has no way to notice a mismatch until every later command fails to decrypt.
const DefaultFileName = "sentra.yaml"
DefaultFileName is the canonical config file name: what discovery looks for in the working directory, what init writes there, and the last path segment of the user-level fallback. internal/cli's configFileName aliases it so the two surfaces cannot drift apart — the name is part of the surface contract (AGENTS.md).
const KeyringDefaultUser = "default"
KeyringDefaultUser is the keyring user for a config with no bucket. A single-repo user never collides, and it keeps a clean install from failing hard before a bucket is chosen.
const KeyringService = "sentra"
KeyringService is the service name Sentra passes to the OS keyring. It is a fixed namespace so every repo's entry lives under one service and only the per-repo user string disambiguates them.
Variables ¶
var ErrKeyringEntryNotFound = errors.New("config: keyring entry not found")
ErrKeyringEntryNotFound is the canonical "no entry yet" error from the keyring lookup. The resolver treats this as a soft miss and falls through to the prompt path so the user can enter the passphrase once and (later) opt to save it to the keyring.
var ErrNoPassphraseSource = errors.New("config: no passphrase source available (set SENTRA_PASSPHRASE, --passphrase-file, or run on a TTY)")
ErrNoPassphraseSource is returned by Resolve when none of the configured sources (file, env, keyring, prompt) can supply a passphrase. Common cause: a non-interactive run with no env var set and no Prompt callback.
Functions ¶
func DeleteKeyringPassphrase ¶
func DeleteKeyringPassphrase(opts StoreKeyringOptions) (bool, error)
DeleteKeyringPassphrase removes the configured passphrase from the OS keyring. It returns false without error when no entry exists.
func DiscoverPath ¶
func DiscoverPath() string
DiscoverPath returns the config path commands use when the operator did not pass --config explicitly:
- ./sentra.yaml, when it exists as a regular file — a project-local config always outranks the user-level one.
- $XDG_CONFIG_HOME/sentra/sentra.yaml, defaulting XDG_CONFIG_HOME to ~/.config. This is the gh-CLI convention: ~/.config even on macOS, deliberately not os.UserConfigDir's ~/Library/Application Support.
When neither file exists the home path is still returned — it is the write target a first-run setup should persist to, so bare `sentra` from any directory lands on the wizard once and the dashboard forever after. If the home directory cannot be determined, fall back to the cwd-relative name (the pre-discovery behavior) rather than failing.
DiscoverPath only names the path; it never reads or writes the file.
func KeyringUserForConfig ¶
KeyringUserForConfig derives the per-repo keyring identifier from the S3 coordinates. Binding both bucket and prefix means two repos that share a bucket but differ only by prefix get distinct keyring entries — the fix for the earlier bug where they aliased onto the same stored passphrase.
func LegacyKeyringUsersForConfig ¶
LegacyKeyringUsersForConfig lists the pre-prefix keyring identifiers to try after the current KeyringUserForConfig misses. Before the bucket+prefix identity existed, entries were keyed on the bucket alone; this lets an existing entry still resolve after an upgrade. It returns nothing when the current identity already equals the bucket (nothing to fall back to).
func Render ¶
Render produces the on-disk sentra.yaml body that init, setup, policy add/remove, and passwd-forget write. We render from the resolved Config (defaults + yaml + env overlay + flags/prompts) so the file faithfully reflects what the user actually configured.
The body is hand-shaped rather than yaml.Marshal'd so we can keep the inline comments that explain each field — the file is a teaching artifact, not just a serialization. No secret material (passphrase, wrapped keys, salts) is ever a Config field, so this can never leak one.
func Resolve ¶
func Resolve(opts ResolveOptions) ([]byte, error)
Resolve looks up the passphrase per the documented priority and returns the bytes. The caller is responsible for zeroizing the returned slice after deriving keys from it.
On a keyring lookup miss (ErrKeyringEntryNotFound), Resolve falls through to the prompt branch — a clean install hasn't stored the passphrase in the keyring yet. Other keyring errors surface as-is.
func ResolveNonInteractive ¶
ResolveNonInteractive resolves the passphrase from the non-interactive sources only — the --passphrase-file path, then SENTRA_PASSPHRASE — and reports which one supplied it. It is the first half of Resolve's priority list, split out for callers that must decide whether to prompt at all rather than pass a prompt callback: the TUI setup wizard skips its passphrase entry stage when a source answers here, so the repository is initialized under the same secret every later command will resolve.
A clean miss (no file path, no env var) returns (nil, "", nil) — the caller's interactive path is the normal continuation, not an error. A named file that cannot be read, or that has group/world-readable bits, IS an error: the operator pointed at a source, and quietly prompting instead would initialize the repo under a different passphrase than the one they configured.
The caller owns zeroizing the returned bytes.
func StoreKeyringPassphrase ¶
func StoreKeyringPassphrase(opts StoreKeyringOptions, passphrase []byte) error
StoreKeyringPassphrase saves passphrase in the OS keyring. It never writes the secret to sentra.yaml; callers should store only non-secret keyring lookup settings in config.
func Update ¶
Update applies mutate to the config as it exists on disk and writes the result back to path. It is the safe way to change one field of sentra.yaml.
The base is loadOnDisk, not Load. Load overlays SENTRA_* env vars, and rendering that resolved Config back out would bake a transient override into the file permanently: flipping the cosmetic splash toggle under `SENTRA_REPO__S3__BUCKET=scratch` used to rewrite the operator's real bucket. Everything mutate does not touch round-trips back exactly as the file had it.
mutate sees Defaults() + whatever the file said, so a partial sentra.yaml still renders complete. Returning an error from mutate aborts before any write — the file is left untouched — which lets callers fold validation (duplicate policy names, unknown keys) into the same critical section.
func Write ¶
Write renders cfg and writes it to path with 0o600 perms. The file names the bucket/region/profile but never a secret, yet 0o600 keeps it out of other users' reach as a matter of policy hygiene.
Write creates path's parent directory (0o700) when missing — the user-level config location may not exist on a fresh machine.
Write authors the *whole* file from the Config it is handed, so it is only correct when the caller means to materialize a resolved config: `init` (Defaults < yaml < env < flags, and it must record the bucket it just initialized) and `setup` (the wizard prompts for these values and shows them on the review screen before provisioning against them). Both would otherwise write `bucket: ""` after acting on an env-supplied bucket.
To change one field of an existing file, use Update. Passing a Config from Load to Write silently persists that process's SENTRA_* overrides.
The replacement is atomic and written through a symlinked path (atomicfile.Write): a crash mid-write must never leave a truncated sentra.yaml, because an empty file still counts as configured (ConfigExists) yet loads as bucket "" — the next launch lands on the connect gate with the bucket, profile and every policy gone, and nothing on screen says why. And a sentra.yaml kept in a dotfiles repo behind a symlink must stay a symlink across every settings toggle, policy add or passwd forget.
Types ¶
type Config ¶
type Config struct {
Repo struct {
S3 struct {
Bucket string `koanf:"bucket"`
Prefix string `koanf:"prefix"`
Region string `koanf:"region"`
Profile string `koanf:"profile"`
EndpointURL string `koanf:"endpoint_url"`
// StorageClass is passed through to S3 PutObject for new
// blobs (e.g. STANDARD_IA, INTELLIGENT_TIERING). Empty
// means the bucket default. Asynchronous-retrieval classes
// (GLACIER, DEEP_ARCHIVE) are refused — restore fetches
// chunks synchronously.
StorageClass string `koanf:"storage_class"`
} `koanf:"s3"`
} `koanf:"repo"`
Agent struct {
Provider string `koanf:"provider"`
Model string `koanf:"model"`
MaxFindingsToLLM int `koanf:"max_findings_to_llm"`
} `koanf:"agent"`
Backup struct {
IgnoreFile string `koanf:"ignore_file"`
ExcludeCaches bool `koanf:"exclude_caches"`
// Concurrency caps the walker/upload worker count during
// backup. 0 means one worker per logical CPU.
Concurrency int `koanf:"concurrency"`
// MaxUploadRate caps backup upload bandwidth in bytes per
// second (paced at the blobstore layer). 0 means unlimited.
MaxUploadRate int64 `koanf:"max_upload_rate"`
} `koanf:"backup"`
Retention struct {
KeepLast int `koanf:"keep_last"`
KeepDaily int `koanf:"keep_daily"`
KeepWeekly int `koanf:"keep_weekly"`
KeepMonthly int `koanf:"keep_monthly"`
} `koanf:"retention"`
// Policies contains named backup policies. Each policy is
// non-secret local configuration: source paths, optional tags,
// schedule metadata, and post-backup maintenance preferences.
Policies map[string]PolicyConfig `koanf:"policies"`
// Passphrase contains optional passphrase-resolution settings.
// Stored under "passphrase" in the YAML; never carries the
// passphrase itself. Recognised keys: use_keyring (bool).
Passphrase struct {
UseKeyring bool `koanf:"use_keyring"`
} `koanf:"passphrase"`
// UI contains optional presentation settings for the TUI. Stored under
// "ui" in the YAML; carries no secrets.
//
// HideSplash is negated deliberately. Go's zero value for bool is false,
// so a sentra.yaml written before this field existed loads as "don't
// hide" — the welcome splash shows by default, with no migration and no
// pointer field.
UI struct {
HideSplash bool `koanf:"hide_splash"`
} `koanf:"ui"`
// Notify holds notification preferences. Stored under "notify" in
// the YAML; carries no secrets.
//
// DisableDesktop is negated for the same reason HideSplash is: a
// sentra.yaml written before notifications existed loads as
// "notify", so every backup run announces itself on the desktop by
// default with no migration and no pointer field.
Notify struct {
DisableDesktop bool `koanf:"disable_desktop"`
} `koanf:"notify"`
}
Config is the typed view of a sentra.yaml document. Field tags use koanf so we can use the lowercase / snake_case YAML keys without fighting the JSON tag conventions used elsewhere in the codebase.
func Defaults ¶
func Defaults() Config
Defaults returns a Config populated with sensible zero-value overrides. Used both by Load (when the file is missing) and as the base for the YAML overlay (so a partial file still lands the documented defaults for unspecified fields).
func Load ¶
Load reads a sentra.yaml document from path, overlays SENTRA_* env variables, and returns the merged Config. This is the *resolved* view: what this process should act on right now.
A missing path returns Defaults() and a nil error — that's the "haven't run sentra init yet" path. Any other I/O or parse error surfaces with a wrapped error so callers can show a helpful message.
Env-var overlay: SENTRA_<key path joined with __>. So "repo.s3.bucket" maps to SENTRA_REPO__S3__BUCKET. The double- underscore separator avoids ambiguity with single underscores inside leaf keys (e.g. "max_findings_to_llm").
Do NOT pair Load with Write to edit one field — the overlay would be rendered back into the file, making a transient override permanent. Update exists for that; see its doc comment.
type PolicyAfterBackup ¶
PolicyAfterBackup controls optional maintenance after a policy run. Prune is a string so the CLI can distinguish "", "off", "dry-run", and "apply" explicitly.
type PolicyConfig ¶
type PolicyConfig struct {
Paths []string `koanf:"paths"`
Tags []string `koanf:"tags"`
Schedule PolicySchedule `koanf:"schedule"`
AfterBackup PolicyAfterBackup `koanf:"after_backup"`
Hooks PolicyHooks `koanf:"hooks"`
}
PolicyConfig is the typed view of one entry under sentra.yaml's `policies:` map.
type PolicyHooks ¶
type PolicyHooks struct {
// Before runs before any snapshot; a failure aborts the run
// (database-dump hooks exist precisely so the backup captures
// their output).
Before string `koanf:"before"`
// After runs only after a fully successful run.
After string `koanf:"after"`
// OnFailure runs when any stage — Before included — fails.
OnFailure string `koanf:"on_failure"`
// OnFailureWebhookEnv names the env var carrying a URL to POST a
// {policy, status, error} JSON document to on failure.
OnFailureWebhookEnv string `koanf:"on_failure_webhook_env"`
}
PolicyHooks are optional commands run around a policy execution. Commands run via `sh -c` with the policy run's stdout/stderr. OnFailureWebhookEnv names an ENVIRONMENT VARIABLE holding a webhook URL — the URL itself (which often embeds a token) never lands in sentra.yaml, honoring the no-secrets-in-config invariant.
type PolicySchedule ¶
type PolicySchedule struct {
Cadence string `koanf:"cadence"`
At string `koanf:"at"`
Weekday string `koanf:"weekday"`
}
PolicySchedule describes when a named policy should run. Validation of supported cadences and clock values lives in internal/policy so config loading stays a pure parse/overlay step.
type ResolveOptions ¶
type ResolveOptions struct {
// PassphraseFile is the optional path passed via --passphrase-file.
// Empty disables this branch.
PassphraseFile string
// UseKeyring enables the OS-keyring lookup branch (sourced from
// sentra.yaml's passphrase.use_keyring or a future flag).
UseKeyring bool
// KeyringService is the service name passed to the keyring lib.
// Defaults to "sentra" when empty.
KeyringService string
// KeyringUser is the per-repo identifier passed to the keyring.
// Defaults to "default" when empty — fine for single-repo users.
KeyringUser string
// KeyringFallbackUsers are legacy per-repo identifiers to try after
// KeyringUser misses. Non-empty values are tried in order with duplicates
// removed. Other keyring errors still fail closed instead of falling back.
KeyringFallbackUsers []string
// Prompt is the interactive callback. Typically wired to
// ui.PromptPassphrase or ui.PromptPassphraseWithConfirm. Nil
// disables the prompt branch (useful in tests / scripts).
Prompt func() ([]byte, error)
}
ResolveOptions configures how the resolver sources the passphrase.
The documented priority is:
- PassphraseFile (--passphrase-file flag)
- SENTRA_PASSPHRASE env var
- OS keyring (when UseKeyring is set)
- Prompt callback (typically ui.PromptPassphrase)
Each source short-circuits the rest. A keyring miss falls through to the next source so a clean install (no entry yet) doesn't fail hard.
type StoreKeyringOptions ¶
type StoreKeyringOptions struct {
// KeyringService is the service name passed to the keyring lib.
// Defaults to "sentra" when empty.
KeyringService string
// KeyringUser is the per-repo identifier passed to the keyring.
// Defaults to "default" when empty.
KeyringUser string
}
StoreKeyringOptions configures where a passphrase should be saved in the OS keyring.
func KeyringOptionsForConfig ¶
func KeyringOptionsForConfig(cfg *Config) StoreKeyringOptions
KeyringOptionsForConfig builds the StoreKeyringOptions used to save or delete the passphrase for cfg's repo.