Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -273,7 +273,6 @@ Required workload fields:
- `name`
- `container_name`
- `deploy_command`
- `state_dir`
- `git_provider.owner`
- `git_provider.repo`
- `strategy.type`
Expand Down
12 changes: 5 additions & 7 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand All @@ -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")
}
Expand Down
26 changes: 24 additions & 2 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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()

Expand Down
14 changes: 7 additions & 7 deletions deployment/docker_deployer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -252,15 +252,15 @@ 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,
GitSHA: previousState.GitSHA,
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
Expand Down
165 changes: 165 additions & 0 deletions deployment/docker_deployer_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading