reconcile

package
v0.39.8 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 46 Imported by: 0

Documentation

Overview

Package reconcile provides GitOps reconciliation functionality.

Index

Constants

View Source
const (
	GitCloneTimeout = 5 * time.Minute
	GitFetchTimeout = 2 * time.Minute
	GitLocalTimeout = 30 * time.Second
)

Git operation timeouts

View Source
const (
	HealthGateScopeCritical = "critical"
	HealthGateScopeDeclared = "declared"
	HealthGateScopeOff      = "off"
)

Health gate scope values (Config.HealthGateScope). Selects what the post-compose-up gate polls and rolls back on.

View Source
const (
	DefaultMaxRetries = 3
	InitialBackoff    = 1 * time.Second
)

SSH retry configuration

View Source
const (
	SSHConnectTimeout    = 5 * time.Second
	SSHTimeout           = 30 * time.Second
	RemoteDeployTimeout  = 5 * time.Minute
	RemoteCleanupTimeout = 10 * time.Second
)

Deploy operation timeouts

View Source
const ComposeProjectLabel = "com.docker.compose.project"

ComposeProjectLabel is the label Docker Compose v2 sets on managed containers.

View Source
const ComposeServiceLabel = "com.docker.compose.service"

ComposeServiceLabel is the label Docker Compose v2 sets for the service name.

View Source
const DefaultBackupTimeout = 5 * time.Minute

DefaultBackupTimeout bounds backup creation + verification when no BackupTimeout is configured. Prevents the pre-deploy backup step from wedging the reconcile indefinitely (#319).

View Source
const DefaultComposeUpTimeout = 10 * time.Minute

DefaultComposeUpTimeout is the maximum time allowed for docker compose up.

View Source
const DefaultIncludeSubdir = "templates"

DefaultIncludeSubdir is the subdirectory (relative to the render source directory) that include/fromJsonFile reads are confined to by default. Secrets (SOPS files, age keys) and bosun.yaml live in the infra root, NOT under templates/, so confining reads here keeps them unreachable.

View Source
const DefaultLockDir = "/var/run/bosun"

DefaultLockDir is the default directory for per-target lock files.

View Source
const DefaultLockFile = "/var/run/bosun/reconcile.lock"

DefaultLockFile is the default path for the reconciliation lock file.

View Source
const DefaultStateDir = "/var/lib/bosun"

DefaultStateDir is the default directory for persistent deploy state. Uses /var/lib/bosun/ per FHS conventions for mutable application state.

View Source
const DefaultStateFile = "deploy-state.json"

DefaultStateFile is the default filename for the deploy state file.

View Source
const DefaultTargetName = "default"

DefaultTargetName is the name used for the implicit single-target backwards-compat target.

View Source
const FUSEDeployPathPrefix = "/mnt/user"

FUSEDeployPathPrefix is Unraid's user-share FUSE (shfs) mount prefix. Writes through this mount need extra time to settle before post-sync hooks (or other consumers, e.g. the `bosun doctor` check) read the files back — see resolveHookSettleDelay.

View Source
const HealthGatePollInterval = 5 * time.Second

HealthGatePollInterval is the default interval for polling container status.

View Source
const IsRepoCheckTimeout = 2 * time.Second

IsRepoCheckTimeout is the timeout for checking if a directory is a git repository.

View Source
const MaxAttempts = 3

MaxAttempts is the circuit breaker threshold — after this many consecutive failures on the same commit, stop retrying until a new commit or --force.

View Source
const MaxVerifyDecompressedBytes int64 = 10 << 30 // 10 GiB

MaxVerifyDecompressedBytes caps the TOTAL decompressed size that verifyArchiveIntegrity will read across an entire archive. A crafted gzip/tar member can decompress to tens of GB (a decompression bomb), spinning CPU past BackupTimeout during the integrity read-through (#240). The cap is a TOTAL across all members (not per-member) because a bomb can be split across many members each individually small — the total is the true bound on verify work. 10 GiB is far above any real bosun config backup (rendered compose/config files) yet small enough to reject a bomb well before it exhausts memory or CPU.

Variables

View Source
var (
	ErrDeployInvariantEmptyWrite  = errors.New("deploy invariant: source has files but no writes recorded")
	ErrDeployInvariantStaleMtime  = errors.New("deploy invariant: destination file has stale mtime")
	ErrDeployInvariantMissingFile = errors.New("deploy invariant: destination file missing")
)

Sentinel errors returned by verifyDeployTarget. Callers branch via errors.Is.

View Source
var ErrAgeKeyNotFound = errors.New("age key not found")

ErrAgeKeyNotFound is returned when no age key is found for SOPS decryption.

View Source
var ErrAppdataInaccessible = errors.New("local appdata path is configured but inaccessible")

ErrAppdataInaccessible is returned when LocalAppdataPath is configured but the path cannot be accessed (e.g. mount is down).

View Source
var ErrBackupTooLarge = errors.New("backup archive exceeds maximum verifiable decompressed size")

ErrBackupTooLarge is returned when a backup archive decompresses past MaxVerifyDecompressedBytes during verification — a likely decompression bomb.

View Source
var ErrComposeDirMissing = errors.New("staging compose directory does not exist")

ErrComposeDirMissing is returned by ExtractDeclaredState when the staging compose directory does not exist on disk. This indicates a misconfigured staging path and is always treated as a fatal reconcile error — operators cannot opt out, because there is no way to distinguish "intentionally empty repo" from "wrong path."

View Source
var ErrComposeUnhealthy = errors.New("compose up completed with unhealthy containers")

ErrComposeUnhealthy indicates compose exited non-zero but all containers are running (some unhealthy). This is recoverable and should not trigger rollback.

View Source
var ErrNoDeclaredServices = errors.New("no declared services in staging compose directory")

ErrNoDeclaredServices is returned by ExtractDeclaredState when the staging compose directory exists but contains no parseable services. This is overridable via BOSUN_ALLOW_EMPTY_DECLARED_STATE=true for genuinely empty repos (early scaffolding, archive branches).

View Source
var ErrNotSOPSFile = errors.New("file is not SOPS-encrypted")

ErrNotSOPSFile is returned when a file is not a valid SOPS-encrypted file.

View Source
var ErrRollbackFailed = errors.New("deployment and rollback both failed")

ErrRollbackFailed indicates both deployment and rollback failed.

View Source
var ErrRollbackSucceeded = errors.New("deployment failed, rollback succeeded")

ErrRollbackSucceeded indicates deployment failed but rollback succeeded.

View Source
var ErrTransferIntegrity = errors.New("remote transfer integrity check failed")

ErrTransferIntegrity marks a remote tar-over-SSH transfer whose staged tree failed SHA-256 verification against the locally-built manifest — a truncated, partial, or misdirected transfer that must never be promoted to the live target (#334). It is RETRYABLE: retryWithBackoff stages into a fresh remote tmpDir on the next attempt, so a re-transfer is never overlaid on the dirty leftover of a failed one.

View Source
var ErrUnsafeChecksumPath = errors.New("unsafe filename for checksum manifest")

ErrUnsafeChecksumPath marks a staged filename that cannot be represented in a `sha256sum -c` manifest: busybox sha256sum lacks GNU's backslash escaping, so a newline or backslash in a name would corrupt the newline-delimited, space-separated manifest parse. Rejected at the local staging walk rather than trusted to format escaping (#334). The input is repo-controlled, so this guards against a pathological rendered filename, not a hostile actor.

Functions

func CheckCriticalContainerHealth added in v0.26.0

func CheckCriticalContainerHealth(
	ctx context.Context,
	client *docker.Client,
	containers []string,
	timeout time.Duration,
	interval time.Duration,
) error

CheckCriticalContainerHealth polls critical containers via Docker API until all are healthy or the timeout expires. Returns nil if all containers pass, or an error listing which containers failed and why.

Health classification:

  • "healthy": pass
  • no healthcheck defined (empty Health field + running): pass
  • "unhealthy": fail
  • "starting" at timeout: fail
  • missing or not running: fail

func DriftAlertKey added in v0.10.0

func DriftAlertKey(item DriftItem) string

DriftAlertKey returns a deduplication key for a drift item in the format "service:type".

func EnrichUnhealthyItems added in v0.15.0

func EnrichUnhealthyItems(ctx context.Context, client *docker.Client, report *DriftReport, actual []ActualService)

EnrichUnhealthyItems inspects unhealthy containers to populate DriftItem.Actual with the last health check result (exit code + output). Only calls Docker Inspect for containers with DriftUnhealthy type, so the cost scales with the number of unhealthy containers, not total containers.

func ExecutePostSyncHooks added in v0.6.0

func ExecutePostSyncHooks(ctx context.Context, client *docker.Client, hooks []PostSyncHook, settleDelay time.Duration) error

ExecutePostSyncHooks runs matched hooks by performing the specified action on containers. settleDelay is a global pause before any hooks run (filesystem propagation).

func IsUnderFUSEDeployPath added in v0.37.6

func IsUnderFUSEDeployPath(path string) bool

IsUnderFUSEDeployPath reports whether path is FUSEDeployPathPrefix itself or nested under it. Checks the path-segment boundary (not a bare string prefix) so a lookalike sibling mount such as "/mnt/userdata" is not mistaken for Unraid's "/mnt/user" share.

func MergeTargetSecrets added in v0.30.0

func MergeTargetSecrets(secrets map[string]any, scope string) map[string]any

MergeTargetSecrets creates a copy of the secrets map with per-target overrides applied. Keys under "targets.<scope>.*" override same-named top-level keys. The original map is not modified.

Example: if secrets contains {"db_password": "shared", "targets": {"unraid": {"db_password": "secret1"}}} and scope is "unraid", the result has {"db_password": "secret1", "targets": {...}}.

func MinLen added in v0.3.0

func MinLen(s string, n int) int

MinLen returns min(len(s), n) for safe string slicing.

func ResolveHealthGateScope added in v0.39.0

func ResolveHealthGateScope(scope string) (string, error)

ResolveHealthGateScope normalizes and validates a configured scope. Empty resolves to "critical". An unknown value returns an error naming the valid set so callers (the daemon env/config parse, and runHealthGate) can surface it rather than silently guessing.

func SaveState added in v0.3.0

func SaveState(path string, state *DeployState) error

SaveState atomically writes the deploy state to disk. Uses the pattern: write temp (same dir) → fsync temp → rename → fsync dir.

func ShouldAlert added in v0.6.0

func ShouldAlert(attemptCount, lastAlertedAttempt int) bool

ShouldAlert returns true if a failure alert should be sent for the current attempt count, given the last attempt that triggered an alert. Schedule: alert on attempt 1, 3, 10, 30, then every 30th attempt. Circuit breaker activation (attempt == MaxAttempts) always alerts.

func TargetLockFile added in v0.30.0

func TargetLockFile(baseLockDir string, t Target) string

TargetLockFile returns the lock file path for a target. The default target uses the legacy path; named targets use reconcile-<name>.lock.

func TargetStagingDir added in v0.30.0

func TargetStagingDir(baseStagingDir string, t Target) string

TargetStagingDir returns the staging directory for a target. The default target uses the base staging dir; named targets use <staging>/<name>/.

func TargetStateFile added in v0.30.0

func TargetStateFile(baseStateDir string, t Target) string

TargetStateFile returns the state file path for a target. The default target uses the legacy path; named targets use deploy-state-<name>.json.

func ValidateDriftIgnoreRules added in v0.37.9

func ValidateDriftIgnoreRules(rules []DriftIgnoreRule) (warnings []string, err error)

ValidateDriftIgnoreRules validates drift_ignore rules -- from the config file or the BOSUN_DRIFT_IGNORE environment override -- against the implemented DriftType enum and glob syntax.

A rule with an unknown type or an invalid service glob is returned as an error, because such a rule silently never matches and leaves drift unreported; the offending rule's index is named so operators can find it. A rule that suppresses all drift (service "*" and type "*") is syntactically valid but disables drift detection entirely, so it is reported separately as a non-fatal warning: callers decide whether to treat it as an error (e.g. `bosun validate`, where it should block) or a loud warning (daemon startup, where an intentional full mute is allowed but never silent).

func ValidateProjectName added in v0.34.3

func ValidateProjectName(name string) error

ValidateProjectName validates a docker compose project name for use in shell commands. Must start with alphanumeric and may contain alphanumeric, underscore, hyphen, or dot. Maximum length is 128 characters (docker compose's own limit). Rejects shell metacharacters that could enable command injection.

func ValidateRemotePath added in v0.34.3

func ValidateRemotePath(path string) error

ValidateRemotePath validates a filesystem path intended for use in SSH-executed shell commands. Accepts absolute paths (starting with /) and relative paths using alphanumeric, underscore, hyphen, dot, space, and forward slash. Spaces are permitted to support Unraid NAS paths (e.g. "/mnt/user/My Media/appdata"); shellquote.Join handles quoting at call sites. Rejects path traversal (../), shell metacharacters, and paths starting with "-" (CLI flag injection).

func ValidateSOPSFile

func ValidateSOPSFile(path string) error

ValidateSOPSFile checks if a file is a valid SOPS-encrypted file. Returns nil if valid, or an actionable error describing the problem.

func ValidateTargetName added in v0.30.0

func ValidateTargetName(name string) error

ValidateTargetName checks that a target name is safe for use in filesystem paths. Rejects empty names, path traversal attempts, absolute paths, and special characters.

Types

type ActualService added in v0.3.0

type ActualService struct {
	Name          string
	ContainerName string // Full Docker container name (for inspect calls)
	Image         string
	State         string
	Health        string
}

ActualService represents the observed state of a running container.

func CollectActualState added in v0.3.0

func CollectActualState(ctx context.Context, client *docker.Client, projectName string) ([]ActualService, error)

CollectActualState queries Docker for running containers filtered by the compose project label to scope to bosun-managed services.

type AlertSender

type AlertSender interface {
	SendDeploySuccess(ctx context.Context, commit, target string, services []string, duration time.Duration) error
	SendDeployFailure(ctx context.Context, commit, target, reason string, services []string, duration time.Duration) error
	SendDeployRecovery(ctx context.Context, commit, target string, priorFailures int) error
	SendUnhealthyContainers(ctx context.Context, target string, containers []string) error
	SendRollbackSuccess(ctx context.Context, target, backupName string) error
	SendRollbackFailure(ctx context.Context, target, reason string) error
}

AlertSender sends alerts for reconciliation events.

type ComposeFileResult added in v0.29.0

type ComposeFileResult struct {
	File       string // absolute path to the compose file
	Success    bool   // true if compose up succeeded (including unhealthy-only)
	RolledBack bool   // true if the file was rolled back from backup after failure
	Err        error  // nil on success, the compose-up error on failure
}

ComposeFileResult records the outcome of a single compose file's deployment.

type ComposeUpSummary added in v0.29.0

type ComposeUpSummary struct {
	Results    []ComposeFileResult
	Succeeded  int
	Failed     int
	RolledBack int
}

ComposeUpSummary aggregates per-file compose up results.

type Config

type Config struct {
	// RepoURL is the git repository URL.
	RepoURL string
	// RepoBranch is the branch to track.
	RepoBranch string
	// RepoDir is the local directory for the cloned repository.
	RepoDir string
	// StagingDir is the directory for rendered templates.
	StagingDir string
	// BackupDir is the directory for configuration backups.
	BackupDir string
	// LogDir is the directory for log files.
	LogDir string
	// LockFile is the path to the reconciliation lock file.
	LockFile string

	// TargetName identifies the deployment target (e.g., "unraid", "pi", "default").
	// Set by ConfigForTarget; used in alert messages and log context.
	TargetName string
	// TargetHost is empty for local deployment, or "user@host" for remote.
	TargetHost string
	// LocalAppdataPath is the path to appdata when running locally.
	LocalAppdataPath string
	// RemoteAppdataPath is the path to appdata on the remote host.
	RemoteAppdataPath string

	// Targets is the list of deployment targets. When empty, an implicit default
	// target is synthesized from the flat config fields above (TargetHost,
	// LocalAppdataPath, RemoteAppdataPath, ProjectName).
	Targets []Target

	// TargetsFromEnv records that Targets came from the BOSUN_TARGETS env var.
	// Hot-reload must not override env-provided per-target config (env wins
	// over the repo's bosun.yaml) — see applyTargetOverrides.
	TargetsFromEnv bool

	// DeployMode overrides automatic deploy mode detection.
	// Valid values: "" (auto-detect), "local", "remote".
	// When set, resolveDeployMode skips heuristics and uses the specified mode.
	DeployMode string

	// DryRun if true, only shows what would be done.
	DryRun bool
	// Force if true, runs deployment even if no changes detected. This is the
	// human-supplied override (CLI --force, socket/TCP/HTTP trigger `force`):
	// it bypasses the commit-unchanged skip AND the circuit breaker AND the
	// deploy_paths allowlist gate. An unattended trigger must never set this.
	Force bool
	// ForceRedeployUnchanged bypasses only the commit-unchanged skip in
	// shouldSkipDeploy, for triggers that need a redeploy despite the commit
	// hash not moving (drift self-heal: image_mismatch/unhealthy drift on a
	// running container doesn't change the declared commit). Unlike Force,
	// it does NOT bypass the circuit breaker or the deploy_paths allowlist
	// gate — an unattended self-heal loop must never override a human
	// decision (a tripped breaker) or silently ignore path scoping.
	ForceRedeployUnchanged bool
	// Source identifies what triggered this reconciliation (e.g., "webhook:github", "poll", "cli").
	Source string

	// SecretsFiles is the list of SOPS-encrypted secret files to decrypt.
	SecretsFiles []string
	// SecretsScope is the key prefix for per-target secrets scoping.
	// When set, keys under "targets.<scope>.*" in the decrypted secrets
	// override same-named top-level keys for this target's template rendering.
	SecretsScope string
	// InfraSubDir is the subdirectory within the repo containing infrastructure configs.
	// Use "." for repos where the root is the infrastructure (dedicated infra repos).
	// Use a path like "infrastructure" for repos where infra is nested (e.g., dotfiles).
	InfraSubDir string

	// TemplateIncludeDir overrides the subtree that template include/fromJsonFile
	// reads are confined to. Empty resolves to <infraDir>/templates (the default
	// allowlist root). A relative value is joined to the infra directory; an
	// absolute value is used as-is. Confining reads here keeps sibling SOPS files
	// and bosun.yaml unreachable from templates. Configurable via
	// BOSUN_TEMPLATE_INCLUDE_DIR or the template_include_dir config field.
	TemplateIncludeDir string

	// BackupsToKeep is the number of backups to retain.
	BackupsToKeep int

	// ProjectName is the docker compose project name for consistent container namespacing.
	// All compose operations will use this name, ensuring --remove-orphans works correctly.
	ProjectName string

	// StateFile is the path to the deploy state file that tracks last successful deployment.
	StateFile string

	// HealthCheckTimeout is the maximum time to poll container health after
	// compose up. Zero disables health verification entirely. Default 60s.
	HealthCheckTimeout time.Duration

	// HealthCheckInterval is how often to poll container health during
	// post-deploy verification. Default 5s.
	HealthCheckInterval time.Duration

	// RestartBreakerEnabled controls whether the restart circuit breaker runs
	// during drift checks. Default true.
	RestartBreakerEnabled bool
	// RestartThreshold is the restart count delta that trips the breaker. Default 5.
	RestartThreshold int
	// RestartWindow is the time window for measuring restart velocity. Default 10m.
	RestartWindow time.Duration

	// PostSyncHooks defines container restart actions triggered by file changes.
	PostSyncHooks ConfigField[[]PostSyncHook]

	// HookSettleDelay is a global pause after deploy but before any post-sync hooks run.
	// Allows filesystem propagation on FUSE mounts (e.g., Unraid's shfs).
	HookSettleDelay ConfigField[time.Duration]

	// ContentHashSync if true, compares file content hashes before writing.
	// Skips writes for unchanged files to avoid FUSE handle invalidation.
	ContentHashSync bool

	// RemoveOrphans if true, passes --remove-orphans to docker compose up.
	// Removes containers belonging to services deleted from the compose file.
	// Defaults to true.
	RemoveOrphans ConfigField[bool]

	// DeployPaths is an allowlist of glob patterns for deploy-relevant paths.
	// When configured, commits that only touch files outside these patterns skip the pipeline.
	DeployPaths ConfigField[[]string]

	// DeploySyncPaths is an allowlist of glob patterns for deploy sync targets.
	// When non-empty, only staging directory entries matching these patterns are deployed.
	DeploySyncPaths ConfigField[[]string]

	// DeploySyncExclude is a blocklist of glob patterns for deploy sync targets.
	// Matching entries are excluded from deployment. Exclude wins over include.
	DeploySyncExclude ConfigField[[]string]

	// CriticalContainers is a list of container names that must be healthy after compose up.
	// When configured, the health gate runs after startup grace period before state save.
	// Empty list (default) skips the health gate entirely.
	CriticalContainers ConfigField[[]string]

	// DriftIgnore is a list of rules for suppressing known drift noise.
	DriftIgnore ConfigField[[]DriftIgnoreRule]

	// HealthGateTimeout is the maximum time to poll critical container health.
	// Default 60s; non-positive disables the gate. Configurable via
	// BOSUN_HEALTH_GATE_TIMEOUT.
	HealthGateTimeout time.Duration

	// HealthGateScope selects which containers the post-compose-up health gate
	// polls and rolls back on: "critical" (default), "declared", or "off".
	// Empty resolves to "critical". Configurable via BOSUN_HEALTH_GATE_SCOPE.
	//
	//   - "critical": today's behavior — gate only on CriticalContainers members;
	//     an empty list skips the gate. A declared-but-non-critical service coming
	//     up unhealthy does NOT trigger rollback.
	//   - "declared": gate on ALL declared services (pollContainerHealth), with the
	//     #392 pre-existing-casualty exemption; any service THIS deploy made
	//     unhealthy triggers the same rollback branch. Opt-in because it adds
	//     flapping-healthcheck churn risk on top of PR #336's fail+retry+alert.
	//   - "off": no health gate at all.
	HealthGateScope string

	// OnFailure gates failure alert dispatch. When false, no failure alerts are sent.
	// Defaults to true via DefaultConfig(). A bare Config{} leaves this false.
	OnFailure bool

	// OnSuccess gates success and recovery alert dispatch. When false, neither
	// success nor recovery alerts are sent. Defaults to false.
	OnSuccess bool

	// ComposeUpTimeout is the maximum time allowed for docker compose up.
	// Zero means use DefaultComposeUpTimeout (10 minutes).
	ComposeUpTimeout time.Duration

	// BackupTimeout bounds backup creation + verification.
	// Zero means use DefaultBackupTimeout (5 minutes).
	BackupTimeout time.Duration

	// ConfigReloader loads project config from a directory path.
	// Set by daemon/CLI to break the config→reconcile import cycle.
	// When nil, config reload is skipped.
	ConfigReloader ConfigReloaderFunc

	// AllowEmptyDeclaredState relaxes the ErrNoDeclaredServices invariant when
	// the staging compose directory exists but contains no parseable services.
	// Use only for genuinely empty repos (early scaffolding, archive branches).
	// Set via BOSUN_ALLOW_EMPTY_DECLARED_STATE=true. Default false.
	// Note: ErrComposeDirMissing (compose dir does not exist at all) is always
	// fatal regardless of this setting.
	AllowEmptyDeclaredState bool

	// SkipDeployInvariant disables the post-deploy mtime + WrittenFiles
	// invariant check that runs between deploy sync and compose-up. Use for
	// diagnostic or development scenarios only — silent-success deploys are
	// the failure mode this guards against. Set via
	// BOSUN_SKIP_DEPLOY_INVARIANT=true. Default false.
	SkipDeployInvariant bool
}

Config holds the reconciliation configuration.

func DefaultConfig

func DefaultConfig() *Config

DefaultConfig returns a Config with sensible defaults.

func (*Config) ConfigForTarget added in v0.30.0

func (c *Config) ConfigForTarget(t Target) *Config

ConfigForTarget returns a shallow copy of the base config with per-target fields (TargetHost, appdata paths, ProjectName, StagingDir, StateFile, LockFile, CriticalContainers, PostSyncHooks, DeploySyncPaths/Exclude) overridden from the given Target. This lets the existing pipeline run unchanged per-target - the daemon creates a ConfigForTarget copy before each reconciler instantiation.

func (*Config) ResolveTargets added in v0.30.0

func (c *Config) ResolveTargets() ([]Target, error)

ResolveTargets returns the effective target list for this config. When Targets is empty, it synthesizes a single implicit default target from the flat config fields for backwards compatibility. A lone target named `default` (case-insensitive) is the implicit default's configuration — its fields (project_name above all) are honored, not discarded (#391). A multi-target config that includes a `default` target is a hard error: silently dropping it would deploy that target project-less and collide.

type ConfigField added in v0.33.0

type ConfigField[T any] struct {
	Value  T
	Source ConfigSource
}

ConfigField tracks a configuration value alongside its origin. The zero value is safe: Value is T's zero value and Source is SourceDefault.

func EnvConfigField added in v0.33.0

func EnvConfigField[T any](v T) ConfigField[T]

EnvConfigField creates a ConfigField with SourceEnv.

func FileConfigField added in v0.33.0

func FileConfigField[T any](v T) ConfigField[T]

FileConfigField creates a ConfigField with SourceFile.

func NewConfigField added in v0.33.0

func NewConfigField[T any](v T) ConfigField[T]

NewConfigField creates a ConfigField with SourceDefault.

func (ConfigField[T]) FromEnv added in v0.33.0

func (f ConfigField[T]) FromEnv() bool

FromEnv returns true if the field was set by an environment variable.

func (*ConfigField[T]) SetFromEnv added in v0.33.0

func (f *ConfigField[T]) SetFromEnv(v T)

SetFromEnv sets the value and marks its source as environment.

func (*ConfigField[T]) SetFromFile added in v0.33.0

func (f *ConfigField[T]) SetFromFile(v T)

SetFromFile sets the value and marks its source as config file.

type ConfigReloaderFunc added in v0.12.1

type ConfigReloaderFunc func(dir string) (*ReloadedConfig, error)

ConfigReloaderFunc loads project config from a directory path. Returns nil ReloadedConfig if no config file is found (not an error).

type ConfigSource added in v0.33.0

type ConfigSource int

ConfigSource tracks where a configuration value originated. Environment variables always take precedence over config file values during hot-reload — fields with SourceEnv are never overwritten.

const (
	// SourceDefault indicates the field holds its zero value or a hardcoded default.
	SourceDefault ConfigSource = iota
	// SourceFile indicates the field was loaded from bosun.yaml.
	SourceFile
	// SourceEnv indicates the field was set via an environment variable.
	SourceEnv
)

type ContainerHealthResult added in v0.26.0

type ContainerHealthResult struct {
	Name   string
	Status string // "healthy", "unhealthy", "starting", "no_healthcheck", "missing", "not_running"
	Detail string // Human-readable detail (e.g. health log output)
}

ContainerHealthResult describes the health gate outcome for a single container.

type DeclaredService added in v0.3.0

type DeclaredService struct {
	Name  string `json:"name"`
	Image string `json:"image"`
}

DeclaredService represents a service that should be running per the manifests.

func ExtractDeclaredState added in v0.3.0

func ExtractDeclaredState(stagingDir string) ([]DeclaredService, error)

ExtractDeclaredState parses rendered compose files from a staging directory and returns the list of declared services with their images.

Returns ErrComposeDirMissing if the compose directory does not exist (always fatal). Returns ErrNoDeclaredServices if the directory exists but no services are declared (overridable). Other errors indicate I/O failures.

type DeployOps

type DeployOps struct {
	// DryRun if true, only shows what would be done without making changes.
	DryRun bool
	// ProjectName is the docker compose project name for consistent container namespacing.
	ProjectName string
	// ContentHashSync if true, skips writing files whose content has not changed.
	// Reduces unnecessary FUSE handle invalidation on copy-on-write filesystems.
	ContentHashSync bool
	// RemoveOrphans if true, passes --remove-orphans to docker compose up.
	// Removes containers belonging to services deleted from the compose file.
	RemoveOrphans bool
	// ComposeUpTimeout is the maximum time allowed for docker compose up.
	// Zero means use DefaultComposeUpTimeout.
	ComposeUpTimeout time.Duration
	// contains filtered or unexported fields
}

DeployOps provides deployment operations including backup, file sync, and service management.

func NewDeployOps

func NewDeployOps(dryRun bool, projectName string) *DeployOps

NewDeployOps creates a new DeployOps instance.

func (*DeployOps) Backup

func (d *DeployOps) Backup(ctx context.Context, backupDir string, paths []string) (string, error)

Backup creates a timestamped tar.gz backup of the specified paths.

func (*DeployOps) BackupRemote

func (d *DeployOps) BackupRemote(ctx context.Context, host, backupDir string, remotePaths []string) (string, error)

BackupRemote creates a backup from a remote host via SSH. Retries on transient SSH errors with exponential backoff.

func (*DeployOps) CheckSSHConnectivity

func (d *DeployOps) CheckSSHConnectivity(ctx context.Context, host string) error

CheckSSHConnectivity verifies SSH connectivity to a remote host. Returns nil if connection succeeds, error with actionable details otherwise.

func (*DeployOps) CleanupBackups

func (d *DeployOps) CleanupBackups(ctx context.Context, backupDir string, keep int) error

CleanupBackups removes old backups, keeping only the most recent N VALID ones. A candidate counts toward retention only if it passes the SAME VerifyBackup used to select a rollback anchor — reusing that helper rather than a private, weaker notion of "good" so the two can never drift (a truncated-but-nonempty archive is "good" to a mere existence check yet junk to VerifyBackup). A corrupt or partial dir (missing, unlistable, or truncated archive) does NOT occupy a keep slot and is removed outright; otherwise it could evict an older known-good backup (#353). Verification shares ctx with backup creation, so a stuck tar/gzip read cannot wedge cleanup.

func (*DeployOps) ComposeUp

func (d *DeployOps) ComposeUp(ctx context.Context, composeFile string) error

ComposeUp runs docker compose up for the specified compose file. Uses the configured compose up timeout if the parent context has no deadline. Returns an error if compose up fails (caller should handle rollback).

func (*DeployOps) ComposeUpIsolated added in v0.29.0

func (d *DeployOps) ComposeUpIsolated(ctx context.Context, composeFiles []string, backupPath string) (*ComposeUpSummary, error)

ComposeUpIsolated runs compose up per-file with isolated failure handling. Phase 1: each file gets its own compose up (no --remove-orphans). On failure, the single file is rolled back from backup if available. Phase 2: a single orphan-reconciliation pass with all files and --remove-orphans.

func (*DeployOps) ComposeUpMultiple added in v0.2.8

func (d *DeployOps) ComposeUpMultiple(ctx context.Context, composeFiles []string) error

ComposeUpMultiple runs docker compose up for multiple compose files. Uses the configured compose up timeout if the parent context has no deadline. Returns an error if compose up fails (caller should handle rollback).

func (*DeployOps) ComposeUpRemote

func (d *DeployOps) ComposeUpRemote(ctx context.Context, host, composeDir string) error

ComposeUpRemote runs docker compose up on a remote host via SSH. Retries on transient SSH errors with exponential backoff.

func (*DeployOps) DeployLocal

func (d *DeployOps) DeployLocal(ctx context.Context, sourceDir, targetDir string, result *DeployResult, prevManaged map[string]bool) error

DeployLocal syncs files locally using native Go file operations. Performs atomic copy: copies to temp directory first, then replaces target. Uses --delete semantics: removes files in target that don't exist in source.

func (*DeployOps) DeployLocalFile

func (d *DeployOps) DeployLocalFile(ctx context.Context, sourceFile, targetFile string, result *DeployResult) error

DeployLocalFile syncs a single file locally using native Go file operations. Uses atomic copy via temp file. When ContentHashSync is enabled, skips writing if the file content has not changed.

func (*DeployOps) DeployRemote

func (d *DeployOps) DeployRemote(ctx context.Context, sourceDir, targetHost, targetDir string, verifyChecksums bool) error

DeployRemote syncs files to a remote host using tar-over-SSH. Uses RemoteDeployTimeout if the parent context has no deadline. Retries on transient SSH errors with exponential backoff. Deployment is safe-by-ordering: tar to a temp dir, then a retain-old rename-swap that never deletes the live target before the replacement is in place (#343), with crash recovery for interrupted swaps.

When verifyChecksums is true, the staged tree is SHA-256 verified against a locally-built manifest AFTER the tar lands and BEFORE the swap to live: a truncated or misdirected transfer fails with ErrTransferIntegrity and retries into a fresh remote tmpDir (#334). Callers set it from a once-per-deploy `sha256sum` availability probe (remoteHasSha256sum); false degrades to the pre-#334 behavior of promoting whatever landed.

func (*DeployOps) DeployRemoteFile

func (d *DeployOps) DeployRemoteFile(ctx context.Context, sourceFile, targetHost, targetFile string) error

DeployRemoteFile syncs a single file to a remote host using scp. Uses RemoteDeployTimeout if the parent context has no deadline. Retries on transient SSH errors with exponential backoff. Performs atomic copy: scp to temp file, then move to target.

func (*DeployOps) EnsureRemoteDir

func (d *DeployOps) EnsureRemoteDir(ctx context.Context, host, dir string) error

EnsureRemoteDir ensures a directory exists on a remote host via SSH. Uses SSHTimeout if the parent context has no deadline. Retries on transient SSH errors with exponential backoff.

func (*DeployOps) LatestVerifiedBackup added in v0.37.10

func (d *DeployOps) LatestVerifiedBackup(ctx context.Context, backupDir string) (string, error)

LatestVerifiedBackup returns the path to the most recent backup directory under backupDir whose archive passes VerifyBackup, scanning newest-first. It is the rollback-anchor fallback when a fresh backup fails: a deploy must never proceed without a verified anchor, so the caller uses this to recover a prior good backup (and aborts if none qualifies) (#240).

Verification runs under ctx; a caller deadline/cancellation aborts the scan.

func (*DeployOps) RollbackFromBackupSet added in v0.39.1

func (d *DeployOps) RollbackFromBackupSet(ctx context.Context, set RollbackSet, backupPath string) error

RollbackFromBackupSet restores a full managed tree from the backup archive at backupPath, then re-applies the restored compose files. Order:

  1. copy every backed-up managed file over its live counterpart
  2. (opt-in) delete live managed files absent from the backup
  3. `docker compose up` on the restored compose files (LAST)

A per-file failure does NOT abort the restore — failures are collected and returned via errors.Join, so one unreadable file cannot strand the rest of the tree half-restored. Returns errRollbackNotAttempted when there is no usable backup, mirroring the prior compose-only path.

func (*DeployOps) RollbackRemoteCompose added in v0.38.5

func (d *DeployOps) RollbackRemoteCompose(ctx context.Context, host, remoteComposeDir, backupPath string, deployErr error) error

RollbackRemoteCompose restores the remote compose dir from the backup anchor after a failed remote `docker compose up`, then re-deploys and restarts (#340). It is the remote counterpart to the local rollback path. The re-push goes through the SAME hardened DeployRemote, so the transfer integrity check (#334) covers the restored tree automatically.

deployErr is the original compose-up failure being recovered from; it is wrapped into the returned sentinel. RollbackRemoteCompose ALWAYS returns a non-nil error:

  • ErrRollbackSucceeded (wrapping deployErr) when the backup was restored and re-applied cleanly, and
  • ErrRollbackFailed (wrapping both errors) when any rollback step failed.

remoteComposeDir is the live remote compose dir the failed deploy just wrote — passed by the caller where it is in scope, never derived from r.lastComposeFiles (only deployLocal sets that).

func (*DeployOps) SignalContainer

func (d *DeployOps) SignalContainer(ctx context.Context, containerName, signal string) error

SignalContainer sends a signal to a Docker container.

func (*DeployOps) SignalContainerRemote

func (d *DeployOps) SignalContainerRemote(ctx context.Context, host, containerName, signal string) error

SignalContainerRemote sends a signal to a Docker container on a remote host. Retries on transient SSH errors with exponential backoff.

func (*DeployOps) VerifyBackup

func (d *DeployOps) VerifyBackup(ctx context.Context, backupPath string) error

VerifyBackup checks that a backup archive is valid and non-empty. The archive listing runs under ctx so a caller deadline or cancellation aborts verification rather than blocking on a large/growing archive (#319).

func (*DeployOps) VerifyContainerHealth

func (d *DeployOps) VerifyContainerHealth(ctx context.Context, composeFile string) error

VerifyContainerHealth checks if containers from a compose file are healthy.

type DeployResult added in v0.12.0

type DeployResult struct {
	// WrittenFiles contains relative paths of files that were written to disk.
	WrittenFiles []string

	// DeletedFiles contains relative paths of files removed from disk by
	// removeStaleFiles's --delete-style pruning. Tracked separately from
	// WrittenFiles because a deletion is not a write; callers that need "every
	// file touched this deploy" (e.g. post-sync hook matching) combine both
	// lists explicitly.
	DeletedFiles []string

	// ManagedFiles is the full set of files bosun deployed this run (every
	// regular file in the source tree, not just the changed ones in
	// WrittenFiles), as appdata-relative paths. Persisted to DeployState as the
	// manifest that scopes the next reconcile's stale-file pruning.
	ManagedFiles []string
}

DeployResult tracks which files were actually written or deleted during deployment. Used to inform post-sync hooks about actual on-disk changes.

func (*DeployResult) AddDeleted added in v0.37.6

func (r *DeployResult) AddDeleted(files ...string)

AddDeleted appends file paths to the result's deleted files list.

func (*DeployResult) AddManaged added in v0.37.1

func (r *DeployResult) AddManaged(files ...string)

AddManaged appends file paths to the result's managed-files manifest.

func (*DeployResult) AddWritten added in v0.12.0

func (r *DeployResult) AddWritten(files ...string)

AddWritten appends file paths to the result's written files list.

func (*DeployResult) PrefixLatest added in v0.32.2

func (r *DeployResult) PrefixLatest(snapshot int, prefix string)

PrefixLatest prepends prefix to all WrittenFiles entries added after the snapshot index. Call with len(r.WrittenFiles) before a DeployLocal call, then PrefixLatest after, to give the new entries context needed for hook glob matching.

func (*DeployResult) PrefixLatestDeleted added in v0.37.6

func (r *DeployResult) PrefixLatestDeleted(snapshot int, prefix string)

PrefixLatestDeleted prepends prefix to all DeletedFiles entries added after the snapshot index. Mirrors PrefixLatest so deleted paths get the same staging-relative prefix needed for hook glob matching.

type DeployState added in v0.3.0

type DeployState struct {
	SchemaVersion       int       `json:"schema_version"`
	LastDeployedCommit  string    `json:"last_deployed_commit,omitempty"`
	DeployedAt          time.Time `json:"deployed_at,omitempty"`
	DeployCount         int       `json:"deploy_count,omitempty"`
	Source              string    `json:"source,omitempty"`
	LastAttemptedCommit string    `json:"last_attempted_commit,omitempty"`
	AttemptCount        int       `json:"attempt_count,omitempty"`
	LastAlertedAttempt  int       `json:"last_alerted_attempt,omitempty"`

	// Declared state snapshot from last successful deployment.
	DeclaredServices []DeclaredService `json:"declared_services,omitempty"`

	// Drift detection results from last check.
	DriftCheckedAt time.Time   `json:"drift_checked_at,omitempty"`
	DriftItems     []DriftItem `json:"drift_items,omitempty"`

	// Drift alert deduplication state.
	DriftAlertedAt    time.Time            `json:"drift_alerted_at,omitempty"`
	DriftAlertedItems map[string]time.Time `json:"drift_alerted_items,omitempty"`

	// Drift alert debounce state: tracks first-seen timestamps for items
	// within their debounce window. Items graduate to the dedup layer
	// when the debounce duration elapses, or are removed if drift resolves.
	DriftDebounceItems map[string]time.Time `json:"drift_debounce_items,omitempty"`

	// NeedsRedeploy indicates that a previous deploy partially succeeded
	// (configs were synced to disk) but compose up failed. When true, the
	// next reconcile will re-run the deploy pipeline even if there are no
	// new git changes, bypassing the shouldSkipDeploy check.
	NeedsRedeploy bool `json:"needs_redeploy,omitempty"`

	// Health verification results from last post-deploy check.
	HealthVerifiedAt         time.Time `json:"health_verified_at,omitempty"`
	HealthVerificationPassed bool      `json:"health_verification_passed,omitempty"`

	// Restart circuit breaker: tracks per-container restart counts to detect crash loops.
	RestartTracking map[string]RestartTrackingEntry `json:"restart_tracking,omitempty"`

	// DeployedFiles is the manifest of files bosun wrote on the last successful
	// deploy, as appdata-relative paths (e.g. "authelia/configuration.yml").
	// Stale-file pruning deletes only files in this set that are gone from the
	// current source — never files bosun did not deploy (e.g. container runtime
	// data). Empty on a fresh state file, which makes the first deploy prune
	// nothing and simply seed the manifest.
	DeployedFiles []string `json:"deployed_files,omitempty"`
}

DeployState tracks the last successful deployment and attempt history.

func LoadState added in v0.3.0

func LoadState(path string) *DeployState

LoadState reads the deploy state file. Returns zero state on missing or corrupt files — this is correct fail-open behavior (triggers a full deploy).

func (*DeployState) RecordDriftAlerts added in v0.39.8

func (s *DeployState) RecordDriftAlerts(items []DriftItem, alertedAt time.Time)

RecordDriftAlerts advances alert state after successful delivery. A service can have only one active critical drift type, so a delivered replacement retires the previous type without reporting a false resolution.

type DeployTarget added in v0.28.0

type DeployTarget struct {
	// RelPath is the path relative to the staging subdirectory (e.g. "appdata/traefik", "compose").
	// Used for source path construction and filter matching.
	RelPath string
	// TargetPath is the path relative to the appdata base directory on the deploy target.
	// For appdata children, this strips the "appdata/" prefix (e.g. "traefik").
	// For top-level entries, this matches RelPath (e.g. "compose").
	TargetPath string
	// IsDir indicates whether the target is a directory (true) or a single file (false).
	IsDir bool
}

DeployTarget represents a discovered directory or file in the staging area that should be synced to the target host during deployment.

type Deployer

type Deployer interface {
	// Deploy syncs files to the target directory.
	// For local deployment, host should be empty.
	// For remote deployment, host should be "user@host".
	Deploy(ctx context.Context, srcDir, host, dstDir string) error

	// DeployFile syncs a single file to the target.
	DeployFile(ctx context.Context, srcFile, host, dstFile string) error

	// Backup creates a timestamped backup of the specified paths.
	// For local backup, host should be empty.
	// For remote backup, host should be "user@host".
	Backup(ctx context.Context, host, backupDir string, paths []string) (string, error)

	// EnsureDir ensures a directory exists.
	// For local, host should be empty.
	// For remote, host should be "user@host".
	EnsureDir(ctx context.Context, host, dir string) error

	// ComposeUp runs docker compose up for the specified compose file or directory.
	// For local, host should be empty.
	// For remote, host should be "user@host".
	ComposeUp(ctx context.Context, host, composePath string) error

	// SignalContainer sends a signal to a Docker container.
	// For local, host should be empty.
	// For remote, host should be "user@host".
	SignalContainer(ctx context.Context, host, containerName, signal string) error

	// CleanupBackups removes old backups, keeping only the most recent N valid ones.
	CleanupBackups(ctx context.Context, backupDir string, keep int) error
}

Deployer handles file deployment.

type DockerClientFunc added in v0.3.0

type DockerClientFunc func() *docker.Client

DockerClientFunc returns a Docker client, or nil if unavailable.

type DriftIgnoreRule added in v0.29.1

type DriftIgnoreRule struct {
	Service string `yaml:"service" json:"service"` // Glob pattern matching service name (e.g., "traefik", "*.monitoring")
	Type    string `yaml:"type" json:"type"`       // Drift type to ignore: "missing", "image_mismatch", "unhealthy", or "*"
}

DriftIgnoreRule defines a pattern for suppressing known drift noise. Service supports glob patterns (filepath.Match) and Type can be a specific drift type or "*" to match all types.

type DriftItem added in v0.3.0

type DriftItem struct {
	Service  string    `json:"service"`
	Type     DriftType `json:"type"`
	Declared string    `json:"declared,omitempty"`
	Actual   string    `json:"actual,omitempty"`
}

DriftItem describes a single drift between declared and actual state.

func FilterDebounced added in v0.18.0

func FilterDebounced(currentItems []DriftItem, debounceItems map[string]time.Time, debounce time.Duration) []DriftItem

FilterDebounced filters drift items through the debounce layer. Items that have not persisted beyond the debounce duration are held back. Items that have persisted past the debounce window are returned (graduated) for normal dedup processing.

The debounceItems map is mutated in place:

  • New items are added with the current timestamp
  • Graduated items (past window) are removed
  • Items no longer in currentItems are removed (resolved)

When debounce is zero (disabled), all items pass through unchanged.

func FilterIgnoredDriftItems added in v0.29.1

func FilterIgnoredDriftItems(items []DriftItem, rules []DriftIgnoreRule) []DriftItem

FilterIgnoredDriftItems returns only the drift items that do not match any ignore rule. This is the exported entry point for callers outside the reconcile package.

func ShouldAlertDrift added in v0.10.0

func ShouldAlertDrift(activeItems, alertCandidates []DriftItem, alertedItems map[string]time.Time, cooldown time.Duration) (alertItems []DriftItem, resolvedKeys []string)

ShouldAlertDrift compares active drift and alert candidates against previously alerted items, returning which candidates should trigger alerts and which services have resolved.

An alert candidate triggers if it is new (not in alertedItems) or if its cooldown has expired. A key is considered resolved only when its service is absent from activeItems; a missing/unhealthy type transition is still active drift for that service. alertCandidates may be a debounce-filtered subset of activeItems.

type DriftReport added in v0.3.0

type DriftReport struct {
	CheckedAt    time.Time
	Items        []DriftItem
	IgnoredCount int // Number of items filtered out by ignore rules
}

DriftReport is the result of comparing declared vs actual state.

func CompareDrift added in v0.3.0

func CompareDrift(declared []DeclaredService, actual []ActualService) *DriftReport

CompareDrift compares declared services against actual running services and returns a drift report identifying any discrepancies.

func RunDriftCheck added in v0.3.0

func RunDriftCheck(ctx context.Context, client *docker.Client, stateFile, projectName string, ignoreRules []DriftIgnoreRule) (*DriftReport, error)

RunDriftCheck performs a full drift check: loads declared state from the state file and compares against actual Docker state. Ignore rules filter out known drift noise before reporting.

func (*DriftReport) DriftSummaries added in v0.15.0

func (r *DriftReport) DriftSummaries() []string

DriftSummaries returns a slice of "service:type" strings for each drift item. Suitable for structured log fields where the full list of drifting containers needs to be queryable (not just the count).

func (*DriftReport) HasCriticalDrift added in v0.3.0

func (r *DriftReport) HasCriticalDrift() bool

HasCriticalDrift returns true if drift includes missing or unhealthy services.

func (*DriftReport) HasDrift added in v0.3.0

func (r *DriftReport) HasDrift() bool

HasDrift returns true if any drift items were detected.

type DriftType added in v0.3.0

type DriftType string

DriftType classifies the kind of drift detected.

const (
	// DriftMissing indicates a declared service is not running.
	DriftMissing DriftType = "missing"
	// DriftImageMismatch indicates a running service uses a different image than declared.
	DriftImageMismatch DriftType = "image_mismatch"
	// DriftUnhealthy indicates a declared service is running but unhealthy.
	DriftUnhealthy DriftType = "unhealthy"
)

type Duration added in v0.8.0

type Duration struct {
	Duration time.Duration
}

Duration wraps time.Duration with YAML/JSON marshaling support. It accepts Go duration strings ("5s", "2m30s") and bare seconds ("5", "30").

func (Duration) IsZero added in v0.8.0

func (d Duration) IsZero() bool

IsZero returns true if the duration is zero, supporting omitempty in YAML/JSON.

func (Duration) MarshalJSON added in v0.8.0

func (d Duration) MarshalJSON() ([]byte, error)

MarshalJSON writes the duration as a Go duration string.

func (Duration) MarshalYAML added in v0.8.0

func (d Duration) MarshalYAML() (any, error)

MarshalYAML writes the duration as a Go duration string.

func (*Duration) UnmarshalJSON added in v0.8.0

func (d *Duration) UnmarshalJSON(b []byte) error

UnmarshalJSON handles both string ("5s") and number (5 as seconds) formats.

func (*Duration) UnmarshalYAML added in v0.8.0

func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error

UnmarshalYAML handles string values like "5s" or bare seconds "5".

type GitOperations

type GitOperations interface {
	// Sync clones or pulls depending on whether repo exists.
	// Returns (changed, beforeCommit, afterCommit, error).
	// For fresh clones, changed is always true.
	Sync(ctx context.Context) (changed bool, before, after string, err error)

	// IsRepo checks if the directory is a git repository.
	// Uses the provided context for timeout control.
	IsRepo(ctx context.Context) bool

	// DiffFiles returns the list of changed file paths between two commits.
	// Paths are relative to the repository root.
	DiffFiles(ctx context.Context, fromCommit, toCommit string) ([]string, error)
}

GitOperations defines git sync operations.

type GitOps

type GitOps struct {
	// RepoURL is the git repository URL to clone.
	RepoURL string
	// Branch is the branch to checkout/track.
	Branch string
	// Dir is the local directory for the repository.
	Dir string
}

GitOps represents git operations for the reconciliation workflow.

func NewGitOps

func NewGitOps(url, branch, dir string) *GitOps

NewGitOps creates a new GitOps instance.

func (*GitOps) Clone

func (g *GitOps) Clone(ctx context.Context, depth int) error

Clone clones the repository with the specified depth. If depth is 0, a full clone is performed. Uses GitCloneTimeout if the parent context has no deadline.

func (*GitOps) DiffFiles added in v0.6.0

func (g *GitOps) DiffFiles(ctx context.Context, fromCommit, toCommit string) ([]string, error)

DiffFiles returns the list of changed file paths between two commits. If fromCommit is empty, returns all files in toCommit.

func (*GitOps) GetCommitMessage

func (g *GitOps) GetCommitMessage(ctx context.Context) (string, error)

GetCommitMessage returns the commit message for the current HEAD.

func (*GitOps) GetLatestCommit

func (g *GitOps) GetLatestCommit(ctx context.Context) (string, error)

GetLatestCommit returns the current HEAD commit hash.

func (*GitOps) IsDirty

func (g *GitOps) IsDirty(ctx context.Context) (bool, error)

IsDirty checks if the repository has uncommitted changes.

func (*GitOps) IsRepo

func (g *GitOps) IsRepo(ctx context.Context) bool

IsRepo checks if the directory is a git repository. Uses the provided context for timeout control.

func (*GitOps) Pull

func (g *GitOps) Pull(ctx context.Context) (bool, string, string, error)

Pull fetches and resets to the remote branch. Returns (changed, beforeCommit, afterCommit, error). Uses GitFetchTimeout for network operations.

func (*GitOps) RemoteBranchExists

func (g *GitOps) RemoteBranchExists(ctx context.Context, branch string) (bool, error)

RemoteBranchExists checks if a remote branch exists.

func (*GitOps) Sync

func (g *GitOps) Sync(ctx context.Context) (bool, string, string, error)

Sync clones or pulls depending on whether repo exists. Returns (changed, beforeCommit, afterCommit, error). For fresh clones, changed is always true.

type HealthCheckResult added in v0.19.0

type HealthCheckResult struct {
	Passed     bool
	Unhealthy  []string
	Duration   time.Duration
	Iterations int
}

HealthCheckResult holds the outcome of a post-deploy health poll.

type PostSyncHook added in v0.6.0

type PostSyncHook struct {
	// Paths are glob patterns matched against changed files (relative to repo root).
	Paths []string `json:"paths" yaml:"paths"`
	// Action is the operation to perform: "restart" (default) or "exec".
	Action string `json:"action" yaml:"action"`
	// Container is the name of the container to act on.
	Container string `json:"container" yaml:"container"`
	// Command is the command to execute inside the container (for action: "exec").
	// Accepts a list of strings (e.g., ["nginx", "-s", "reload"]).
	Command []string `json:"command,omitempty" yaml:"command,omitempty"`
	// Delay is an optional pause before executing this hook's action.
	// Useful when a container needs extra time for config propagation.
	Delay Duration `json:"delay,omitempty" yaml:"delay,omitempty"`
}

PostSyncHook defines a container action triggered when specific file paths change.

func EvaluatePostSyncHooks added in v0.6.0

func EvaluatePostSyncHooks(changedFiles []string, hooks []PostSyncHook) []PostSyncHook

EvaluatePostSyncHooks matches changed file paths against hook glob patterns and returns the hooks that should be executed. Deduplication keys on container+action, so a container can have both a restart and an exec hook fire from the same change set.

type Reconciler

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

Reconciler orchestrates the GitOps reconciliation workflow.

func NewReconciler

func NewReconciler(cfg *Config, opts ...ReconcilerOption) *Reconciler

NewReconciler creates a new Reconciler with the given configuration.

func (*Reconciler) Run

func (r *Reconciler) Run(ctx context.Context) (runErr error)

Run executes the full reconciliation workflow.

func (*Reconciler) SetRunOptions added in v0.3.0

func (r *Reconciler) SetRunOptions(source string, force bool)

SetRunOptions sets per-run options (source and force) on the reconciler config. This is called by the daemon before each Run() to pass trigger context.

type ReconcilerOption

type ReconcilerOption func(*Reconciler)

ReconcilerOption is a functional option for configuring the Reconciler.

func WithAlerter

func WithAlerter(alerter AlertSender) ReconcilerOption

WithAlerter sets the alert sender for notifications.

func WithDeployOps

func WithDeployOps(deploy *DeployOps) ReconcilerOption

WithDeployOps sets the DeployOps implementation.

func WithDockerClient added in v0.3.0

func WithDockerClient(client *docker.Client) ReconcilerOption

WithDockerClient sets the Docker client for post-deploy verification.

func WithDockerClientFunc added in v0.3.0

func WithDockerClientFunc(fn DockerClientFunc) ReconcilerOption

WithDockerClientFunc sets a lazy Docker client provider for post-deploy verification.

func WithGitOperations

func WithGitOperations(git GitOperations) ReconcilerOption

WithGitOperations sets the GitOperations implementation.

func WithLockFile

func WithLockFile(path string) ReconcilerOption

WithLockFile sets the lock file path.

func WithSecretsDecryptor

func WithSecretsDecryptor(sops SecretsDecryptor) ReconcilerOption

WithSecretsDecryptor sets the SecretsDecryptor implementation.

type ReloadedConfig added in v0.12.1

type ReloadedConfig struct {
	PostSyncHooks      []PostSyncHook
	HookSettleDelay    *time.Duration
	DeployPaths        []string
	DeploySyncPaths    []string
	DeploySyncExclude  []string
	CriticalContainers []string
	DriftIgnore        []DriftIgnoreRule
	OnFailure          *bool
	OnSuccess          *bool
	RemoveOrphans      *bool
	// ProjectName is the repo bosun.yaml's root-level project_name. When
	// non-nil and non-empty, the default-target reconciler adopts it before
	// the first deploy (#390); a lone `default` target's project_name wins
	// over it.
	ProjectName *string
	// Targets is the reloaded target list from the repo's bosun.yaml.
	// When non-nil, the daemon uses these targets for the next reconciliation cycle.
	Targets []Target
}

ReloadedConfig holds the fields that can be reloaded from the repo's bosun.yaml.

type RestartBreakerResult added in v0.20.0

type RestartBreakerResult struct {
	// Tripped contains service names that exceeded the restart threshold.
	Tripped []string
	// Resolved contains service names that stabilized after being tripped.
	Resolved []string
	// Updated is the new tracking state to persist.
	Updated map[string]RestartTrackingEntry
}

RestartBreakerResult holds the outcome of a restart circuit breaker evaluation.

func RunRestartBreaker added in v0.20.0

func RunRestartBreaker(
	ctx context.Context,
	client *docker.Client,
	actual []ActualService,
	state *DeployState,
	threshold int,
	window time.Duration,
) (*RestartBreakerResult, error)

RunRestartBreaker performs restart circuit breaker evaluation and takes action on containers that exceed the restart threshold. Returns the result for alerting.

type RestartTrackingEntry added in v0.20.0

type RestartTrackingEntry struct {
	RestartCount int       `json:"restart_count"`
	CheckedAt    time.Time `json:"checked_at"`
	Tripped      bool      `json:"tripped"`
	TrippedAt    time.Time `json:"tripped_at,omitempty"`
}

RestartTrackingEntry tracks the restart count for a container across drift checks.

type RollbackSet added in v0.39.1

type RollbackSet struct {
	// Files are the LIVE absolute paths of every managed file this deploy wrote
	// (DeployResult.ManagedFiles joined under the appdata root). Each is restored
	// from its backed-up copy. A file ABSENT from the backup was added by the
	// failed deploy; it is removed only when DeleteMissing is set.
	Files []string

	// ComposeFiles are the LIVE compose file paths to `docker compose up` after
	// the tree is restored — the restart, run LAST so it acts on the fully
	// reverted config.
	ComposeFiles []string

	// Root is the appdata directory every entry in Files must resolve within. It
	// backstops the DELETE and restore paths: even though Files are derived from
	// ManagedFiles (always inside appdata today), an explicit containment check
	// means a future absolute-path-target regression can't silently reopen the
	// filepath.Join prefix-drop footgun on a path we os.Remove.
	Root string

	// DeleteMissing removes live managed files absent from the backup (files the
	// failed deploy added). Default false, and safe to enable ONLY against a
	// fresh anchor — this deploy's own pre-deploy backup, tracked by the
	// Reconciler's lastBackupIsFresh bool. A stale fallback anchor predates files
	// a later legitimate deploy added, so deleting "missing" files against it
	// would destroy real data (the #331 incident class).
	DeleteMissing bool
}

RollbackSet describes a full managed-tree restore from a backup archive — the single chokepoint the health-gate rollback funnels through (#445). It widens rollback beyond the compose files: a failed deploy's appdata config writes are reverted too, so a rollback no longer leaves a hybrid tree (old compose paired with the failed deploy's new config).

ComposeUpIsolated keeps its OWN compose-only per-file rollback and does NOT go through this set — its rollback is a targeted restart of a single failed stack, not a whole-tree revert.

type SOPSOps

type SOPSOps struct{}

SOPSOps provides SOPS decryption operations.

func NewSOPSOps

func NewSOPSOps() *SOPSOps

NewSOPSOps creates a new SOPSOps instance.

func (*SOPSOps) CheckAgeKey

func (s *SOPSOps) CheckAgeKey() error

CheckAgeKey verifies that an age key is available for SOPS decryption. It checks in order:

  1. SOPS_AGE_KEY environment variable
  2. SOPS_AGE_KEY_FILE environment variable
  3. Default location: ~/.config/sops/age/keys.txt

Returns nil if a key is found, or an error with setup instructions if not.

func (*SOPSOps) Decrypt

func (s *SOPSOps) Decrypt(ctx context.Context, file string) ([]byte, error)

Decrypt decrypts a SOPS-encrypted file and returns the plaintext bytes as JSON. It first validates the file is SOPS-encrypted and checks that an age key is available. Uses go-sops library for in-process decryption - no external binary required.

func (*SOPSOps) DecryptFiles

func (s *SOPSOps) DecryptFiles(ctx context.Context, files []string) (map[string]any, error)

DecryptFiles decrypts multiple SOPS files and merges them into a single map. Later files override earlier ones for duplicate keys. This method implements the SecretsDecryptor interface.

func (*SOPSOps) DecryptToJSON

func (s *SOPSOps) DecryptToJSON(ctx context.Context, files []string) ([]byte, error)

DecryptToJSON decrypts files and returns merged JSON bytes.

func (*SOPSOps) DecryptToMap

func (s *SOPSOps) DecryptToMap(ctx context.Context, file string) (map[string]any, error)

DecryptToMap decrypts a SOPS-encrypted file and returns the data as a map. It first checks that an age key is available.

type SecretsDecryptor

type SecretsDecryptor interface {
	// DecryptFiles decrypts multiple SOPS files and merges them into a single map.
	// Later files override earlier ones for duplicate keys.
	DecryptFiles(ctx context.Context, files []string) (map[string]any, error)

	// CheckAgeKey verifies that an age key is available for SOPS decryption.
	CheckAgeKey() error
}

SecretsDecryptor handles SOPS decryption.

type Target added in v0.30.0

type Target struct {
	// Name identifies this target (e.g., "unraid", "pi"). Used in file paths and logs.
	Name string `json:"name"`
	// TargetHost is empty for local deployment, or "user@host" for remote.
	TargetHost string `json:"target_host,omitempty"`
	// LocalAppdataPath is the path to appdata when running locally.
	LocalAppdataPath string `json:"local_appdata_path,omitempty"`
	// RemoteAppdataPath is the path to appdata on the remote host.
	RemoteAppdataPath string `json:"remote_appdata_path,omitempty"`
	// ProjectName is the docker compose project name for this target.
	ProjectName string `json:"project_name,omitempty"`
	// StateFile overrides the derived state file path. When empty, derived from Name.
	StateFile string `json:"state_file,omitempty"`
	// StagingDir overrides the derived staging directory. When empty, derived from Name.
	StagingDir string `json:"staging_dir,omitempty"`
	// SecretsScope is the key prefix for per-target secrets (e.g., "unraid" -> targets.unraid.*).
	SecretsScope string `json:"secrets_scope,omitempty"`
	// CriticalContainers overrides the global list for this target.
	CriticalContainers []string `json:"critical_containers,omitempty"`
	// PostSyncHooks overrides the global hooks for this target.
	PostSyncHooks []PostSyncHook `json:"post_sync_hooks,omitempty"`
	// DeploySyncPaths overrides the global allowlist for this target.
	DeploySyncPaths []string `json:"deploy_sync_paths,omitempty"`
	// DeploySyncExclude overrides the global blocklist for this target.
	DeploySyncExclude []string `json:"deploy_sync_exclude,omitempty"`
}

Target describes a single deployment target (a server/host to deploy to). Each target has its own host, appdata paths, project name, state file, staging directory, secrets scope, and operational overrides.

func ValidateAndSanitizeTargets added in v0.34.3

func ValidateAndSanitizeTargets(targets []Target, warn func(target, field string, err error)) []Target

ValidateAndSanitizeTargets validates security-sensitive fields on each target, clearing invalid fields with a warning rather than dropping the whole target. This mirrors the YAML-load semantics in extractTargets — a single bad field must not block deployments to that host.

The logger parameter receives structured warn entries; pass nil to suppress logging.

func (Target) IsDefault added in v0.30.0

func (t Target) IsDefault() bool

IsDefault returns true if this is the implicit default target. The comparison is case-insensitive so a case-variant name ("Default", "DEFAULT") cannot slip past as a distinct target and fragment state from the base (#228).

type TemplateOps

type TemplateOps struct {
	// Data is the template data available during rendering.
	Data map[string]any

	// IncludeDir is the allowlisted root for include/fromJsonFile reads: only
	// files located within this subtree may be read, enumerating what MAY be
	// read rather than blocklisting what may not. When empty, RenderDirectory
	// defaults it to <sourceDir>/DefaultIncludeSubdir. When still empty at
	// render time (e.g. a bare ExecuteTemplate call with no tree), validation
	// is skipped (backwards-compatible for callers that supply no root).
	IncludeDir string
}

TemplateOps provides template rendering operations using Go's text/template with sprig functions.

func NewTemplateOps

func NewTemplateOps(data map[string]any) *TemplateOps

NewTemplateOps creates a new TemplateOps instance with the given data.

func (*TemplateOps) ExecuteTemplate

func (t *TemplateOps) ExecuteTemplate(_ context.Context, templateFile, outputFile string) error

ExecuteTemplate renders a single template file using Go's text/template with sprig functions. Template data is passed directly to the template context. Templates can access data via {{ .key }} syntax and use sprig functions.

func (*TemplateOps) RenderDirectory

func (t *TemplateOps) RenderDirectory(ctx context.Context, sourceDir, stagingDir, subDir string) error

RenderDirectory processes all .tmpl files in sourceDir and renders them to stagingDir. Non-template files are copied as-is.

type TemplateRenderer

type TemplateRenderer interface {
	// Render processes all .tmpl files in srcDir and renders them to dstDir.
	// Non-template files are copied as-is.
	Render(ctx context.Context, srcDir, dstDir string, secrets map[string]any) error
}

TemplateRenderer handles Go template rendering with Sprig functions.

Jump to

Keyboard shortcuts

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