From c484b26f5c35d73843433732cc4a3f956bb07a50 Mon Sep 17 00:00:00 2001 From: ercross Date: Thu, 8 Oct 2026 05:38:40 +0100 Subject: [PATCH] Centralize deployment state management --- CHANGELOG.md | 15 +++ README.md | 11 +- config/config.go | 12 +- config/config_test.go | 26 +++- deployment/docker_deployer.go | 14 +- deployment/docker_deployer_test.go | 165 ++++++++++++++++++++++++ main.go | 24 ++-- notifier/slack_notifier.go | 4 +- reconciler/reconciler.go | 4 +- reconciler/reconciler_test.go | 114 ++++++++++++++++ state/local_fs.go | 200 ++++++++++++++++++++--------- state/local_fs_test.go | 142 ++++++++++++++++++-- state/state.go | 35 +++-- 13 files changed, 650 insertions(+), 116 deletions(-) create mode 100644 deployment/docker_deployer_test.go create mode 100644 reconciler/reconciler_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2493244..3b8ae63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v0.4.1 - 2026-10-08 + +### Changed + +- Deployment state is now managed centrally in one deployments state file for all workloads, keyed by workload name. +- Added the `-state` flag to configure the deployments state file path. It defaults to `/var/lib/reconD/deployments_state.json`. +- Workload-level `state_dir` is no longer required and is no longer used. +- Duplicate workload names are now rejected, since workload name is the deployment state key. +- Missing state errors now distinguish between a missing state file and missing deployment state. + +### Upgrade Notes + +- This is not treated as a breaking config change: existing YAML files that still include `state_dir` continue to load, but `state_dir` is ignored. +- Existing per-workload `deployed.json` and `previous.json` files are not automatically migrated into the new central state file. Operators should migrate state manually or expect the first run with the new state file to behave like no deployment state has been recorded yet. + ## v0.4.0 - 2026-10-08 ### Breaking Changes diff --git a/README.md b/README.md index cec46b1..e44b2f5 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,6 @@ workloads: container_name: my-container deploy_command: make redeploy-app check_interval: 60s - state_dir: /path/to/state/dir git_provider: owner: my-account repo: app-repo @@ -171,12 +170,13 @@ health_check: ## State Files -State is stored under each workload's `state_dir`. +State for all workloads is stored in one deployments state file. Set it with +the `-state` flag; it defaults to `/var/lib/reconD/deployments_state.json`. -Files: +File: -- `deployed.json`: the current successful deployment. -- `previous.json`: the previous successful deployment, used for rollback. +- `deployments_state.json`: the current and previous successful deployment for + each workload, identified by workload name. The state includes workload name, image, image tag, manifest digest, git SHA, deployment time, and rollback information when applicable. @@ -273,7 +273,6 @@ Required workload fields: - `name` - `container_name` - `deploy_command` -- `state_dir` - `git_provider.owner` - `git_provider.repo` - `strategy.type` diff --git a/config/config.go b/config/config.go index 7bdc5e7..820da41 100644 --- a/config/config.go +++ b/config/config.go @@ -63,10 +63,6 @@ type Workload struct { // number of seconds; values below 10s default to 60s. CheckInterval Duration `yaml:"check_interval"` - // StateDir is required and points to the local directory where reconD stores - // deployed and rollback state for this workload. - StateDir string `yaml:"state_dir"` - // GitProvider is required and tells reconD which repository to read // deployment metadata from. GitProvider GitProvider `yaml:"git_provider"` @@ -223,10 +219,15 @@ func (cfg Config) validate() error { return fmt.Errorf("at least one workload must be configured") } + workloadNames := make(map[string]struct{}, len(cfg.Workloads)) for i, workload := range cfg.Workloads { if err := workload.validate(); err != nil { return fmt.Errorf("workload %d: %w", i, err) } + if _, ok := workloadNames[workload.Name]; ok { + return fmt.Errorf("workload %d: duplicate name %q", i, workload.Name) + } + workloadNames[workload.Name] = struct{}{} } return nil } @@ -241,9 +242,6 @@ func (w Workload) validate() error { if w.DeployCommand == "" { return errors.New("deploy_command is required") } - if w.StateDir == "" { - return errors.New("state_dir is required") - } if w.GitProvider.Owner == "" { return errors.New("git_provider.owner is required") } diff --git a/config/config_test.go b/config/config_test.go index aa649dd..6fde8a3 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -47,7 +47,6 @@ workloads: release_prefix: api-prod container_name: api deploy_command: docker stop api || true && docker rm api || true && docker run -d --name api "$WORKLOAD_IMAGE_REF" - state_dir: /tmp/recond/api check_interval: 45 git_provider: owner: toughbred @@ -58,7 +57,6 @@ workloads: - name: worker container_name: worker deploy_command: docker stop worker || true && docker rm worker || true && docker run -d --name worker "$WORKLOAD_IMAGE_REF" - state_dir: /tmp/recond/worker check_interval: 2m git_provider: owner: toughbred @@ -79,6 +77,30 @@ workloads: require.Equal(t, 2*time.Minute, cfg.Workloads[1].CheckInterval.Duration) } +func TestLoadRejectsDuplicateWorkloadName(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "workloads.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +workloads: + - name: api + container_name: api + deploy_command: docker stop api || true && docker rm api || true && docker run -d --name api "$WORKLOAD_IMAGE_REF" + git_provider: + owner: toughbred + repo: gymportal + - name: api + container_name: api-v2 + deploy_command: docker stop api-v2 || true && docker rm api-v2 || true && docker run -d --name api-v2 "$WORKLOAD_IMAGE_REF" + git_provider: + owner: toughbred + repo: gymportal +`), 0o600)) + + _, err := Load(path) + require.ErrorContains(t, err, `duplicate name "api"`) +} + func TestLoadRejectsJSONConfigExtension(t *testing.T) { t.Parallel() diff --git a/deployment/docker_deployer.go b/deployment/docker_deployer.go index fb70583..3c87bdc 100644 --- a/deployment/docker_deployer.go +++ b/deployment/docker_deployer.go @@ -147,11 +147,11 @@ func (d *dockerDeployer) Deploy(ctx context.Context, workload config.Workload, m "git_sha", meta.GitSHA, ) - previousState, err := stateMgr.LoadDeployed() - if err != nil && !errors.Is(err, state.ErrFileNotFound) { + previousState, err := stateMgr.LoadDeployed(workload.Name) + if err != nil && !state.IsNotFound(err) { d.logger.Warn("could not load previous state", "error", err) } - noPreviousState := errors.Is(err, state.ErrFileNotFound) + noPreviousState := state.IsNotFound(err) d.notifier.NotifyOnNewDeploymentStarted(workload.Name, meta) @@ -202,14 +202,14 @@ func (d *dockerDeployer) deploy(ctx context.Context, workload config.Workload, m } newState = state.DeploymentState{ - Workload: workload.Name, + WorkloadName: workload.Name, Image: meta.Image, ImageTag: meta.ImageTag, ManifestDigest: meta.ManifestDigest, GitSHA: meta.GitSHA, DeployedAt: time.Now().UTC(), } - if err := stateMgr.CommitDeployed(newState); err != nil { + if err := stateMgr.CommitDeployed(workload.Name, newState); err != nil { d.logger.Error("failed to commit deployment state", "phase", logger.PhaseCommit, "error", err) } @@ -252,7 +252,7 @@ func (d *dockerDeployer) rollback(ctx context.Context, stateMgr state.Manager, w } rollbackState = state.DeploymentState{ - Workload: workload.Name, + WorkloadName: workload.Name, Image: previousState.Image, ImageTag: previousState.ImageTag, ManifestDigest: previousState.ManifestDigest, @@ -260,7 +260,7 @@ func (d *dockerDeployer) rollback(ctx context.Context, stateMgr state.Manager, w DeployedAt: time.Now().UTC(), RollbackFrom: failedDigest, } - if err := stateMgr.CommitRollback(rollbackState); err != nil { + if err := stateMgr.CommitRollback(workload.Name, rollbackState); err != nil { d.logger.Error("failed to commit rollback state", "phase", logger.PhaseRollback, "error", err) } return rollbackState, nil diff --git a/deployment/docker_deployer_test.go b/deployment/docker_deployer_test.go new file mode 100644 index 0000000..5ab7933 --- /dev/null +++ b/deployment/docker_deployer_test.go @@ -0,0 +1,165 @@ +package deployment + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ercross/reconD/config" + "github.com/ercross/reconD/git_provider" + "github.com/ercross/reconD/notifier" + "github.com/ercross/reconD/state" + "github.com/stretchr/testify/require" +) + +type fakeStateManager struct { + loadDeployedErr error + deployed state.DeploymentState + committed state.DeploymentState + committedName string + rollback state.DeploymentState + rollbackName string +} + +func (m *fakeStateManager) LoadDeployed(string) (state.DeploymentState, error) { + return m.deployed, m.loadDeployedErr +} + +func (m *fakeStateManager) LoadPrevious(string) (state.DeploymentState, error) { + return state.DeploymentState{}, nil +} + +func (m *fakeStateManager) CommitDeployed(deploymentName string, s state.DeploymentState) error { + m.committedName = deploymentName + m.committed = s + return nil +} + +func (m *fakeStateManager) CommitRollback(deploymentName string, s state.DeploymentState) error { + m.rollbackName = deploymentName + m.rollback = s + return nil +} + +func TestDockerDeployerDeployCommitsDeploymentStateForWorkload(t *testing.T) { + t.Parallel() + + stateMgr := &fakeStateManager{} + workload := config.Workload{ + Name: "api", + ContainerName: "api", + DeployCommand: "true", + } + meta := git_provider.DeploymentMetadata{ + Image: "ghcr.io/example/api", + ImageTag: "v1.0.0", + ManifestDigest: "sha256:api", + GitSHA: "abc123", + } + + deployer := testDockerDeployer(workload) + newState, err := deployer.deploy(context.Background(), workload, meta, stateMgr) + require.NoError(t, err) + + require.Equal(t, "api", stateMgr.committedName) + require.Equal(t, "api", stateMgr.committed.WorkloadName) + require.Equal(t, meta.ManifestDigest, stateMgr.committed.ManifestDigest) + require.Equal(t, newState, stateMgr.committed) +} + +func TestDockerDeployerDeployTreatsMissingFileAsNoPreviousState(t *testing.T) { + t.Parallel() + + stateMgr := &fakeStateManager{loadDeployedErr: state.ErrFileNotFound} + workload := config.Workload{ + Name: "api", + ContainerName: "api", + DeployCommand: "false", + } + + err := testDockerDeployer(workload).Deploy(context.Background(), workload, testDeploymentMetadata(), stateMgr) + require.ErrorContains(t, err, "rollback failed: no previous deployment state") + require.Empty(t, stateMgr.rollbackName) +} + +func TestDockerDeployerDeployTreatsMissingDeploymentAsNoPreviousState(t *testing.T) { + t.Parallel() + + stateMgr := &fakeStateManager{loadDeployedErr: state.ErrDeploymentStateNotFound} + workload := config.Workload{ + Name: "api", + ContainerName: "api", + DeployCommand: "false", + } + + err := testDockerDeployer(workload).Deploy(context.Background(), workload, testDeploymentMetadata(), stateMgr) + require.ErrorContains(t, err, "rollback failed: no previous deployment state") + require.Empty(t, stateMgr.rollbackName) +} + +func TestDockerDeployerDeployRollsBackToPreviousState(t *testing.T) { + t.Parallel() + + deployCommand := firstCallFailsThenSucceedsCommand(t) + previousState := state.DeploymentState{ + WorkloadName: "api", + Image: "ghcr.io/example/api", + ImageTag: "v0.9.0", + ManifestDigest: "sha256:previous", + GitSHA: "previous-sha", + DeployedAt: time.Now().UTC(), + } + stateMgr := &fakeStateManager{deployed: previousState} + workload := config.Workload{ + Name: "api", + ContainerName: "api", + DeployCommand: deployCommand, + } + meta := testDeploymentMetadata() + + err := testDockerDeployer(workload).Deploy(context.Background(), workload, meta, stateMgr) + require.NoError(t, err) + + require.Equal(t, "api", stateMgr.rollbackName) + require.Equal(t, "api", stateMgr.rollback.WorkloadName) + require.Equal(t, previousState.ManifestDigest, stateMgr.rollback.ManifestDigest) + require.Equal(t, meta.ManifestDigest, stateMgr.rollback.RollbackFrom) +} + +func testDockerDeployer(workload config.Workload) *dockerDeployer { + return NewDockerDeployer( + workload, + notifier.Noop{}, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ).(*dockerDeployer) +} + +func testDeploymentMetadata() git_provider.DeploymentMetadata { + return git_provider.DeploymentMetadata{ + Image: "ghcr.io/example/api", + ImageTag: "v1.0.0", + ManifestDigest: "sha256:desired", + GitSHA: "desired-sha", + } +} + +func firstCallFailsThenSucceedsCommand(t *testing.T) string { + t.Helper() + + counterPath := filepath.Join(t.TempDir(), "counter") + scriptPath := filepath.Join(t.TempDir(), "deploy.sh") + script := fmt.Sprintf(`#!/bin/sh +if [ ! -f %[1]q ]; then + echo 1 > %[1]q + exit 1 +fi +exit 0 +`, counterPath) + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o700)) + return scriptPath +} diff --git a/main.go b/main.go index ebe89b8..3098066 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,11 @@ import ( "github.com/ercross/reconD/state" ) +var ( + defaultConfigFile = "/etc/reconD/config.yaml" + defaultDeploymentsStateFile = "/var/lib/reconD/deployments_state.json" +) + func main() { if err := run(); err != nil { _, _ = fmt.Fprintf(os.Stderr, "agent error: %v\n", err) @@ -28,11 +33,15 @@ func main() { } func run() error { - configPath := flag.String("config", "/etc/reconD/config.yaml", "path to agent config file") + configPath := flag.String("config", defaultConfigFile, "path to agent config file") + statePath := flag.String("state", defaultDeploymentsStateFile, "path to deployments state file") flag.Parse() if *configPath == "" { return fmt.Errorf("missing config path") } + if *statePath == "" { + return fmt.Errorf("missing deployments state path") + } cfg, err := config.Load(*configPath) if err != nil { @@ -40,19 +49,18 @@ func run() error { } log := logger.Setup(cfg.Log) - log.Info("deployment agent starting", "config", *configPath) + log.Info("deployment agent starting", "config", *configPath, "state", *statePath) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() + stateMgr, err := state.NewManagerWithLocalFileSystem(*statePath) + if err != nil { + return fmt.Errorf("create deployments state manager: %w", err) + } + var wg sync.WaitGroup for _, workload := range cfg.Workloads { - - stateMgr, err := state.NewManagerWithLocalFileSystem(workload.StateDir) - if err != nil { - return fmt.Errorf("create state manager for workload %q: %w", workload.Name, err) - } - gp, err := gitProviderFor(workload) if err != nil { return fmt.Errorf("create git provider for workload %q: %w", workload.Name, err) diff --git a/notifier/slack_notifier.go b/notifier/slack_notifier.go index 03421e9..aa3eafe 100644 --- a/notifier/slack_notifier.go +++ b/notifier/slack_notifier.go @@ -88,9 +88,9 @@ func slackNotificationFromDeploymentMeta(workloadName string, meta git_provider. func slackNotificationFromDeploymentState(depState state.DeploymentState, st deploymentState) slackNotification { return slackNotification{ State: st, - Workload: depState.Workload, + Workload: depState.WorkloadName, Image: depState.Image, - ImageTag: "", + ImageTag: depState.ImageTag, ManifestDigest: depState.ManifestDigest, GitSHA: depState.GitSHA, RollbackFrom: depState.RollbackFrom, diff --git a/reconciler/reconciler.go b/reconciler/reconciler.go index 9293118..c9eb04a 100644 --- a/reconciler/reconciler.go +++ b/reconciler/reconciler.go @@ -83,8 +83,8 @@ func (r *Reconciler) reconcileOnce(ctx context.Context) { return } - currentState, err := r.stateMgr.LoadDeployed() - if err != nil && !errors.Is(err, state.ErrFileNotFound) { + currentState, err := r.stateMgr.LoadDeployed(r.workload.Name) + if err != nil && !state.IsNotFound(err) { r.log.Error("failed to load deployed state", "phase", logger.PhaseDrift, "error", err, diff --git a/reconciler/reconciler_test.go b/reconciler/reconciler_test.go new file mode 100644 index 0000000..c60f936 --- /dev/null +++ b/reconciler/reconciler_test.go @@ -0,0 +1,114 @@ +package reconciler + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + "time" + + "github.com/ercross/reconD/config" + "github.com/ercross/reconD/deployment" + "github.com/ercross/reconD/git_provider" + "github.com/ercross/reconD/state" + "github.com/stretchr/testify/require" +) + +type fakeGitProvider struct { + meta git_provider.DeploymentMetadata + err error +} + +func (p fakeGitProvider) FetchLatestDeploymentMetadata(context.Context, string) (git_provider.DeploymentMetadata, error) { + return p.meta, p.err +} + +type fakeDeployer struct { + deployCalls int +} + +func (d *fakeDeployer) PruneDanglingImages(context.Context, map[string]string) error { + return nil +} + +func (d *fakeDeployer) CurrentRuntimeState(context.Context, config.Workload) (deployment.RuntimeState, error) { + return deployment.RuntimeState{}, nil +} + +func (d *fakeDeployer) Deploy(context.Context, config.Workload, git_provider.DeploymentMetadata, state.Manager) error { + d.deployCalls++ + return nil +} + +type fakeStateManager struct { + loadDeployedErr error + deployed state.DeploymentState +} + +func (m fakeStateManager) LoadDeployed(string) (state.DeploymentState, error) { + return m.deployed, m.loadDeployedErr +} + +func (m fakeStateManager) LoadPrevious(string) (state.DeploymentState, error) { + return state.DeploymentState{}, nil +} + +func (m fakeStateManager) CommitDeployed(string, state.DeploymentState) error { + return nil +} + +func (m fakeStateManager) CommitRollback(string, state.DeploymentState) error { + return nil +} + +func TestReconcileDeploysWhenStateFileIsMissing(t *testing.T) { + t.Parallel() + + deployer := &fakeDeployer{} + r := testReconciler(state.ErrFileNotFound, deployer) + + r.reconcileOnce(context.Background()) + + require.Equal(t, 1, deployer.deployCalls) +} + +func TestReconcileDeploysWhenDeploymentStateIsMissing(t *testing.T) { + t.Parallel() + + deployer := &fakeDeployer{} + r := testReconciler(state.ErrDeploymentStateNotFound, deployer) + + r.reconcileOnce(context.Background()) + + require.Equal(t, 1, deployer.deployCalls) +} + +func TestReconcileSkipsDeployWhenStateLoadFailsUnexpectedly(t *testing.T) { + t.Parallel() + + deployer := &fakeDeployer{} + r := testReconciler(errors.New("permission denied"), deployer) + + r.reconcileOnce(context.Background()) + + require.Zero(t, deployer.deployCalls) +} + +func testReconciler(loadDeployedErr error, deployer *fakeDeployer) *Reconciler { + return &Reconciler{ + workload: config.Workload{ + Name: "api", + ContainerName: "api", + CheckInterval: config.Duration{Duration: time.Minute}, + }, + pollInterval: time.Minute, + gitProvider: fakeGitProvider{meta: git_provider.DeploymentMetadata{ + Image: "ghcr.io/example/api", + ManifestDigest: "sha256:desired", + }}, + deployer: deployer, + stateMgr: fakeStateManager{loadDeployedErr: loadDeployedErr}, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} diff --git a/state/local_fs.go b/state/local_fs.go index c90a896..6db6369 100644 --- a/state/local_fs.go +++ b/state/local_fs.go @@ -6,106 +6,184 @@ import ( "fmt" "os" "path/filepath" + "sync" ) -// managerWithLocalFileSystem handles reading and writing deployment state for one workload. +type deploymentsStateFile struct { + Deployments []DeploymentStateNew `json:"deployments"` +} + +// managerWithLocalFileSystem handles reading and writing deployment state for all workloads. type managerWithLocalFileSystem struct { - dir string + path string + mu sync.Mutex } -// NewManagerWithLocalFileSystem creates a state manager rooted at dir. -// It creates the directory if it does not exist. -func NewManagerWithLocalFileSystem(dir string) (Manager, error) { - if err := os.MkdirAll(dir, 0o750); err != nil { - return nil, fmt.Errorf("create state dir %q: %w", dir, err) +// NewManagerWithLocalFileSystem creates a state manager backed by path. +// It creates the parent directory if it does not exist. +func NewManagerWithLocalFileSystem(path string) (Manager, error) { + if path == "" { + return nil, fmt.Errorf("state file path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create state dir for %q: %w", path, err) } - return &managerWithLocalFileSystem{dir: dir}, nil + return &managerWithLocalFileSystem{path: path}, nil } -// LoadDeployed returns the currently deployed state. -// Returns nil, nil if no state has been committed yet (first deployment). -func (m *managerWithLocalFileSystem) LoadDeployed() (DeploymentState, error) { - return m.load(m.deployedPath()) +// LoadDeployed returns the currently deployed state for deploymentName. +func (m *managerWithLocalFileSystem) LoadDeployed(deploymentName string) (DeploymentState, error) { + if deploymentName == "" { + return DeploymentState{}, fmt.Errorf("deployment name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + record, err := m.loadDeployment(deploymentName) + if err != nil { + return DeploymentState{}, err + } + if isZeroDeploymentState(record.Current) { + return DeploymentState{}, ErrDeploymentStateNotFound + } + return record.Current, nil } -// LoadPrevious returns the previous deployment state. -// Returns nil, nil if there is no previous state (e.g. first rollback attempt). -func (m *managerWithLocalFileSystem) LoadPrevious() (DeploymentState, error) { - return m.load(m.previousPath()) +// LoadPrevious returns the previous deployment state for deploymentName. +func (m *managerWithLocalFileSystem) LoadPrevious(deploymentName string) (DeploymentState, error) { + if deploymentName == "" { + return DeploymentState{}, fmt.Errorf("deployment name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + record, err := m.loadDeployment(deploymentName) + if err != nil { + return DeploymentState{}, err + } + if isZeroDeploymentState(record.Previous) { + return DeploymentState{}, ErrDeploymentStateNotFound + } + return record.Previous, nil } -// CommitDeployed atomically persists s as the current deployed state. -// The previous deployed.json (if any) is promoted to previous.json first, -// so we always have a valid rollback target after a successful deployment. -func (m *managerWithLocalFileSystem) CommitDeployed(s DeploymentState) error { - // Promote current → previous before writing new current. - if err := m.promoteToPrevious(); err != nil { - if !errors.Is(err, ErrFileNotFound) { - return fmt.Errorf("promote previous state: %w", err) - } - // No current state to promote — this is the first deployment. +// CommitDeployed atomically persists s as the current deployed state for deploymentName. +// The current state, if any, is promoted to previous first. +func (m *managerWithLocalFileSystem) CommitDeployed(deploymentName string, s DeploymentState) error { + if deploymentName == "" { + return fmt.Errorf("deployment name is required") } - return m.writeAtomic(m.deployedPath(), s) + + m.mu.Lock() + defer m.mu.Unlock() + + fileState, err := m.loadAll() + if err != nil && !errors.Is(err, ErrFileNotFound) { + return err + } + + record := upsertDeployment(&fileState, deploymentName) + if !isZeroDeploymentState(record.Current) { + record.Previous = record.Current + } + s.WorkloadName = deploymentName + record.Current = s + + return m.writeAtomic(fileState) } -// CommitRollback persists s as the deployed state after a rollback. -// It does NOT update previous.json, preserving the failed-deployment record -// for post-incident analysis. -func (m *managerWithLocalFileSystem) CommitRollback(s DeploymentState) error { - return m.writeAtomic(m.deployedPath(), s) +// CommitRollback persists s as the current deployed state after a rollback. +// It does not update Previous, preserving the last known-good rollback target. +func (m *managerWithLocalFileSystem) CommitRollback(deploymentName string, s DeploymentState) error { + if deploymentName == "" { + return fmt.Errorf("deployment name is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + fileState, err := m.loadAll() + if err != nil && !errors.Is(err, ErrFileNotFound) { + return err + } + + record := upsertDeployment(&fileState, deploymentName) + s.WorkloadName = deploymentName + record.Current = s + + return m.writeAtomic(fileState) } -// promoteToPrevious copies deployed.json → previous.json. -// This is called before committing a new deployment so we retain the last -// known-good state as a rollback target. -func (m *managerWithLocalFileSystem) promoteToPrevious() error { - current, err := m.LoadDeployed() +func (m *managerWithLocalFileSystem) loadDeployment(deploymentName string) (DeploymentStateNew, error) { + fileState, err := m.loadAll() if err != nil { - return fmt.Errorf("failed to load current deployed: %w", err) + return DeploymentStateNew{}, err } - return m.writeAtomic(m.previousPath(), current) + + for _, deployment := range fileState.Deployments { + if deployment.WorkloadName == deploymentName { + return deployment, nil + } + } + return DeploymentStateNew{}, ErrDeploymentStateNotFound } -func (m *managerWithLocalFileSystem) load(path string) (s DeploymentState, err error) { - data, err := os.ReadFile(path) +func (m *managerWithLocalFileSystem) loadAll() (deploymentsStateFile, error) { + var fileState deploymentsStateFile + + data, err := os.ReadFile(m.path) if errors.Is(err, os.ErrNotExist) { - return s, ErrFileNotFound + return fileState, ErrFileNotFound } if err != nil { - return s, fmt.Errorf("read state %q: %w", path, err) + return fileState, fmt.Errorf("read state %q: %w", m.path, err) + } + if len(data) == 0 { + return fileState, nil } - if err = json.Unmarshal(data, &s); err != nil { - return s, fmt.Errorf("parse state %q: %w", path, err) + if err = json.Unmarshal(data, &fileState); err != nil { + return fileState, fmt.Errorf("parse state %q: %w", m.path, err) } - return s, nil + return fileState, nil } -// writeAtomic writes s to path using a write-then-rename pattern. -// This ensures that readers never see a partial write, even if the agent -// crashes mid-write. -func (m *managerWithLocalFileSystem) writeAtomic(path string, s DeploymentState) error { - data, err := json.MarshalIndent(s, "", " ") +func (m *managerWithLocalFileSystem) writeAtomic(fileState deploymentsStateFile) error { + if err := os.MkdirAll(filepath.Dir(m.path), 0o750); err != nil { + return fmt.Errorf("create state dir for %q: %w", m.path, err) + } + + data, err := json.MarshalIndent(fileState, "", " ") if err != nil { return fmt.Errorf("marshal state: %w", err) } - // Write to a temp file in the same directory so the rename is atomic - // (both source and destination are on the same filesystem). - tmp := path + ".tmp" + tmp := m.path + ".tmp" if err := os.WriteFile(tmp, data, 0o640); err != nil { return fmt.Errorf("write temp state %q: %w", tmp, err) } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) // best-effort cleanup - return fmt.Errorf("rename state %q → %q: %w", tmp, path, err) + if err := os.Rename(tmp, m.path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename state %q to %q: %w", tmp, m.path, err) } return nil } -func (m *managerWithLocalFileSystem) deployedPath() string { - return filepath.Join(m.dir, "deployed.json") +func upsertDeployment(fileState *deploymentsStateFile, deploymentName string) *DeploymentStateNew { + for i := range fileState.Deployments { + if fileState.Deployments[i].WorkloadName == deploymentName { + return &fileState.Deployments[i] + } + } + + fileState.Deployments = append(fileState.Deployments, DeploymentStateNew{ + WorkloadName: deploymentName, + }) + return &fileState.Deployments[len(fileState.Deployments)-1] } -func (m *managerWithLocalFileSystem) previousPath() string { - return filepath.Join(m.dir, "previous.json") + +func isZeroDeploymentState(s DeploymentState) bool { + return s == DeploymentState{} } diff --git a/state/local_fs_test.go b/state/local_fs_test.go index 9e39a1d..efec139 100644 --- a/state/local_fs_test.go +++ b/state/local_fs_test.go @@ -1,6 +1,9 @@ package state import ( + "encoding/json" + "os" + "path/filepath" "testing" "time" @@ -10,7 +13,8 @@ import ( func TestLocalFileSystemManagerCommitDeployedWritesFirstDeployment(t *testing.T) { t.Parallel() - manager, err := NewManagerWithLocalFileSystem(t.TempDir()) + stateFile := filepath.Join(t.TempDir(), "deployments_state.json") + manager, err := NewManagerWithLocalFileSystem(stateFile) require.NoError(t, err) expected := DeploymentState{ @@ -20,22 +24,24 @@ func TestLocalFileSystemManagerCommitDeployedWritesFirstDeployment(t *testing.T) DeployedAt: time.Now().UTC(), } - require.NoError(t, manager.CommitDeployed(expected)) + require.NoError(t, manager.CommitDeployed("api", expected)) - actual, err := manager.LoadDeployed() + actual, err := manager.LoadDeployed("api") require.NoError(t, err) + require.Equal(t, "api", actual.WorkloadName) require.Equal(t, expected.Image, actual.Image) require.Equal(t, expected.ManifestDigest, actual.ManifestDigest) require.Equal(t, expected.GitSHA, actual.GitSHA) - _, err = manager.LoadPrevious() - require.ErrorIs(t, err, ErrFileNotFound) + _, err = manager.LoadPrevious("api") + require.ErrorIs(t, err, ErrDeploymentStateNotFound) } func TestLocalFileSystemManagerCommitDeployedPromotesPreviousDeployment(t *testing.T) { t.Parallel() - manager, err := NewManagerWithLocalFileSystem(t.TempDir()) + stateFile := filepath.Join(t.TempDir(), "deployments_state.json") + manager, err := NewManagerWithLocalFileSystem(stateFile) require.NoError(t, err) first := DeploymentState{ @@ -51,14 +57,130 @@ func TestLocalFileSystemManagerCommitDeployedPromotesPreviousDeployment(t *testi DeployedAt: time.Now().UTC(), } - require.NoError(t, manager.CommitDeployed(first)) - require.NoError(t, manager.CommitDeployed(second)) + require.NoError(t, manager.CommitDeployed("api", first)) + require.NoError(t, manager.CommitDeployed("api", second)) - deployed, err := manager.LoadDeployed() + deployed, err := manager.LoadDeployed("api") require.NoError(t, err) require.Equal(t, second.ManifestDigest, deployed.ManifestDigest) - previous, err := manager.LoadPrevious() + previous, err := manager.LoadPrevious("api") + require.NoError(t, err) + require.Equal(t, first.ManifestDigest, previous.ManifestDigest) +} + +func TestLocalFileSystemManagerStoresMultipleDeploymentsInOneFile(t *testing.T) { + t.Parallel() + + stateFile := filepath.Join(t.TempDir(), "deployments_state.json") + manager, err := NewManagerWithLocalFileSystem(stateFile) + require.NoError(t, err) + + api := DeploymentState{ + Image: "ghcr.io/example/api:v1.0.0", + ManifestDigest: "api-v1.0.0", + GitSHA: "api-sha", + DeployedAt: time.Now().UTC(), + } + worker := DeploymentState{ + Image: "ghcr.io/example/worker:v1.0.0", + ManifestDigest: "worker-v1.0.0", + GitSHA: "worker-sha", + DeployedAt: time.Now().UTC(), + } + + require.NoError(t, manager.CommitDeployed("api", api)) + require.NoError(t, manager.CommitDeployed("worker", worker)) + + actualAPI, err := manager.LoadDeployed("api") + require.NoError(t, err) + require.Equal(t, "api-v1.0.0", actualAPI.ManifestDigest) + + actualWorker, err := manager.LoadDeployed("worker") + require.NoError(t, err) + require.Equal(t, "worker-v1.0.0", actualWorker.ManifestDigest) + + raw, err := os.ReadFile(stateFile) + require.NoError(t, err) + + var persisted deploymentsStateFile + require.NoError(t, json.Unmarshal(raw, &persisted)) + require.Len(t, persisted.Deployments, 2) + require.ElementsMatch(t, []string{"api", "worker"}, []string{ + persisted.Deployments[0].WorkloadName, + persisted.Deployments[1].WorkloadName, + }) +} + +func TestLocalFileSystemManagerCommitRollbackPreservesPreviousDeployment(t *testing.T) { + t.Parallel() + + stateFile := filepath.Join(t.TempDir(), "deployments_state.json") + manager, err := NewManagerWithLocalFileSystem(stateFile) + require.NoError(t, err) + + first := DeploymentState{ + Image: "ghcr.io/example/api:v1.0.0", + ManifestDigest: "api-v1.0.0", + GitSHA: "api-sha-v1", + DeployedAt: time.Now().UTC(), + } + second := DeploymentState{ + Image: "ghcr.io/example/api:v1.1.0", + ManifestDigest: "api-v1.1.0", + GitSHA: "api-sha-v2", + DeployedAt: time.Now().UTC(), + } + rollback := DeploymentState{ + Image: first.Image, + ManifestDigest: first.ManifestDigest, + GitSHA: first.GitSHA, + DeployedAt: time.Now().UTC(), + RollbackFrom: second.ManifestDigest, + } + + require.NoError(t, manager.CommitDeployed("api", first)) + require.NoError(t, manager.CommitDeployed("api", second)) + require.NoError(t, manager.CommitRollback("api", rollback)) + + current, err := manager.LoadDeployed("api") + require.NoError(t, err) + require.Equal(t, first.ManifestDigest, current.ManifestDigest) + require.Equal(t, second.ManifestDigest, current.RollbackFrom) + + previous, err := manager.LoadPrevious("api") require.NoError(t, err) require.Equal(t, first.ManifestDigest, previous.ManifestDigest) } + +func TestLocalFileSystemManagerLoadDeployedMissingFile(t *testing.T) { + t.Parallel() + + stateFile := filepath.Join(t.TempDir(), "deployments_state.json") + manager, err := NewManagerWithLocalFileSystem(stateFile) + require.NoError(t, err) + + _, err = manager.LoadDeployed("api") + require.ErrorIs(t, err, ErrFileNotFound) + require.True(t, IsNotFound(err)) +} + +func TestLocalFileSystemManagerLoadDeployedMissingDeployment(t *testing.T) { + t.Parallel() + + stateFile := filepath.Join(t.TempDir(), "deployments_state.json") + manager, err := NewManagerWithLocalFileSystem(stateFile) + require.NoError(t, err) + + require.NoError(t, manager.CommitDeployed("api", DeploymentState{ + Image: "ghcr.io/example/api:v1.0.0", + ManifestDigest: "api-v1.0.0", + GitSHA: "api-sha", + DeployedAt: time.Now().UTC(), + })) + + _, err = manager.LoadDeployed("worker") + require.ErrorIs(t, err, ErrDeploymentStateNotFound) + require.NotErrorIs(t, err, ErrFileNotFound) + require.True(t, IsNotFound(err)) +} diff --git a/state/state.go b/state/state.go index 996784e..c007c9a 100644 --- a/state/state.go +++ b/state/state.go @@ -1,11 +1,10 @@ -// Package state manages persistent deployment state for each workload. -// State is stored as JSON files on disk, making it inspectable by operators +// Package state manages persistent deployment state for all workloads. +// State is stored as a JSON file on disk, making it inspectable by operators // and safe across agent restarts. // -// File layout per workload: +// File layout: // -// {state_dir}/deployed.json — the currently active deployment -// {state_dir}/previous.json — the deployment before the last successful one +// {state_file} — the current and previous deployment for every workload // // The write path uses atomic rename to prevent partial writes from corrupting // the state file during a crash. @@ -17,17 +16,31 @@ import ( ) type Manager interface { - LoadDeployed() (DeploymentState, error) - LoadPrevious() (DeploymentState, error) - CommitDeployed(s DeploymentState) error - CommitRollback(s DeploymentState) error + LoadDeployed(deploymentName string) (DeploymentState, error) + LoadPrevious(deploymentName string) (DeploymentState, error) + CommitDeployed(deploymentName string, s DeploymentState) error + CommitRollback(deploymentName string, s DeploymentState) error } -var ErrFileNotFound = errors.New("file not found") +var ( + ErrFileNotFound = errors.New("state file not found") + ErrDeploymentStateNotFound = errors.New("deployment state not found") +) + +func IsNotFound(err error) bool { + return errors.Is(err, ErrFileNotFound) || errors.Is(err, ErrDeploymentStateNotFound) +} + +// DeploymentStateNew represents the current and previous deployment for one workload. +type DeploymentStateNew struct { + WorkloadName string `json:"workload_name" yaml:"workload_name"` + Current DeploymentState `json:"current" yaml:"current"` + Previous DeploymentState `json:"previous" yaml:"previous"` +} // DeploymentState represents a point-in-time snapshot of what is deployed. type DeploymentState struct { - Workload string `json:"workload"` + WorkloadName string `json:"workload_name"` // Image is the full image reference that is deployed. Image string `json:"image"`