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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## v0.3.0 - 2026-10-08

### Breaking Changes

- Workload `environment` has been removed from config, deployment metadata, deployment state, and deploy command environment variables.
- Use optional `release_prefix` to scope GitHub release metadata lookup when a repository publishes multiple deployable artifact streams.

```yaml
workloads:
- name: my-app
release_prefix: my-app-prod
```

### Changed

- Notifications now identify deployments by workload name instead of environment.
- When `release_prefix` is empty, GitHub release lookup considers all releases newest-first and deploys the first release with valid `deployment-metadata.json`.

## v0.2.0 - 2026-10-08

### Breaking Changes
Expand Down
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ image state. It is not a container orchestrator.

For each configured workload, `reconD` repeatedly:

1. Fetches the latest `deployment-metadata.json` for an environment.
1. Fetches the latest relevant `deployment-metadata.json`.
2. Reads the currently committed deployment state from disk.
3. Inspects the configured container to detect runtime drift.
4. Pulls the desired image.
Expand Down Expand Up @@ -60,16 +60,23 @@ networking, routing, or workload composition, it belongs outside the agent.

## Deployment Metadata

The Git provider implementation fetches and unmarshals `deployment-metadata.json`.
The current GitHub implementation looks at image releases for the configured
environment. In practice, the environment usually corresponds to the prefix used
in image tags, such as `dev` or `prod`.
The Git provider implementation fetches and unmarshals `deployment-metadata.json`
from GitHub Releases.

By default, reconD checks releases newest-first and deploys the first release
that has a valid `deployment-metadata.json` asset. If a workload config sets
`release_prefix`, reconD only considers release tags that start with that
prefix. This is recommended when one repository publishes multiple deployable
artifacts, packages, or deployment streams.

For example, `release_prefix: api-prod` matches release tags such as
`api-prod-sha-10a3e42`, while `release_prefix: worker-staging` matches
`worker-staging-sha-8d1af01`.

Example metadata:

```json
{
"environment": "prod",
"image": "ghcr.io/my-account/app-repo",
"image_tag": "prod-sha-10a3e42",
"manifest_digest": "sha256:19779d908d890f704a2170d7dde679e266a9137613ea299b38939eb889545f8e",
Expand All @@ -91,7 +98,7 @@ Minimal example:
```yaml
workloads:
- name: my-app
environment: prod
release_prefix: my-app-prod
container_name: my-container
deploy_command: make redeploy-app
check_interval: 60s
Expand Down Expand Up @@ -264,7 +271,6 @@ or manage the Compose file.
Required workload fields:

- `name`
- `environment`
- `container_name`
- `deploy_command`
- `state_dir`
Expand All @@ -274,6 +280,7 @@ Required workload fields:

Common optional fields:

- `release_prefix`: release tag prefix used to scope metadata lookup. Recommended when a repository publishes more than one deployable artifact stream.
- `notification_url`: Slack webhook URL.
- `check_interval`: defaults to `60s` when below `10s`.
- `health_check.retries`: defaults to `12`.
Expand Down
119 changes: 94 additions & 25 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,36 +13,94 @@ import (
)

type Config struct {
// Workloads is the required list of container workloads reconD should watch
// and reconcile. Configure one entry for each independently deployed
// service.
Workloads []Workload `yaml:"workloads"`
}

// Workload is a standalone unit of deployment (e.g., a container) reconD can watch and reconcile
type Workload struct {
Name string `yaml:"name"`
Environment string `yaml:"environment"`
ContainerName string `yaml:"container_name"`
NotificationURL string `yaml:"notification_url"`
DeployCommand string `yaml:"deploy_command"`
CheckInterval Duration `yaml:"check_interval"`
StateDir string `yaml:"state_dir"`
GitProvider GitProvider `yaml:"git_provider"`
HealthCheck HealthCheck `yaml:"health_check"`
Strategy Strategy `yaml:"strategy"`

// Labels optionally scope docker image pruning to this workload's images.
// Name is required and identifies this workload in logs, notifications, and
// persisted deployment state.
Name string `yaml:"name"`

// ReleasePrefix is optional and scopes GitHub release selection by tag
// prefix. For example, "api-prod" matches release tags like
// "api-prod-sha-10a3e42". Leave it empty only when this repository publishes
// one deployable artifact stream or when the newest valid metadata release is
// always the desired workload.
ReleasePrefix string `yaml:"release_prefix"`

// ContainerName is required and must match the Docker container reconD
// inspects to determine the workload's current runtime state.
ContainerName string `yaml:"container_name"`

// NotificationURL is optional. When set, reconD sends deployment events to
// this Slack webhook URL; when empty, notifications are disabled.
NotificationURL string `yaml:"notification_url"`

// DeployCommand is required. reconD runs it with /bin/sh after applying the
// deployment strategy, passing workload and image metadata in WORKLOAD_*
// environment variables.
DeployCommand string `yaml:"deploy_command"`

// CheckInterval is optional and controls how often reconD checks for new
// deployment metadata. Values may be Go duration strings like "60s" or a
// 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"`

// HealthCheck is optional. When configured, reconD waits for the workload to
// become healthy after deploy and rollback commands; a failed check triggers
// rollback when previous state is available.
HealthCheck HealthCheck `yaml:"health_check"`

// Strategy is optional. Use it when deployment metadata must be written
// somewhere before DeployCommand runs, such as updating an env file with the
// image tag.
Strategy Strategy `yaml:"strategy"`

// Labels is optional and scopes Docker image pruning to images with these
// labels after a successful deployment.
Labels map[string]string `yaml:"labels"`
}

type GitProvider struct {
// Owner is required and names the GitHub owner or organization containing
// the deployment metadata repository.
Owner string `yaml:"owner"`
Repo string `yaml:"repo"`
// Token is optional; set via GITHUB_TOKEN env var for private repos.

// Repo is required and names the GitHub repository containing deployment
// metadata for this workload.
Repo string `yaml:"repo"`

// Token is optional and is used to authenticate GitHub API requests for
// private repositories. If omitted, reconD reads GITHUB_TOKEN from the
// process environment.
Token string `yaml:"token"`
}

type Strategy struct {
Type StrategyType `yaml:"type"`
EnvFilePath string `yaml:"env_file_path"`
ImageTagKey string `yaml:"image_tag_key"`
// Type is optional. Leave it empty or set it to "none" to run no strategy;
// set it to "env_file" to write deployment metadata into an env file before
// DeployCommand runs.
Type StrategyType `yaml:"type"`

// EnvFilePath is required when Type is "env_file" and ignored otherwise. It
// points to the env file reconD should create or update.
EnvFilePath string `yaml:"env_file_path"`

// ImageTagKey is optional for the "env_file" strategy and defaults to
// "IMAGE_TAG". It is the key reconD writes with the selected image tag.
ImageTagKey string `yaml:"image_tag_key"`
}

type StrategyType string
Expand All @@ -53,11 +111,25 @@ const (
)

type HealthCheck struct {
Type HealthCheckType `yaml:"type"`
URL string `yaml:"url"`
Retries int `yaml:"retries"`
Interval Duration `yaml:"interval"`
Timeout Duration `yaml:"timeout"`
// Type is optional. Leave it empty or set it to "none" to skip health
// checks; set it to "http" or "tcp" to poll URL after deployment.
Type HealthCheckType `yaml:"type"`

// URL is required when Type is "http" or "tcp" and ignored otherwise. Use
// an HTTP URL for HTTP checks or a tcp://host:port URL for TCP checks.
URL string `yaml:"url"`

// Retries is optional and defaults to 12. It controls how many health check
// attempts reconD makes before treating the deployment as unhealthy.
Retries int `yaml:"retries"`

// Interval is optional and defaults to 10s. It controls how long reconD waits
// between health check attempts.
Interval Duration `yaml:"interval"`

// Timeout is optional and defaults to 5s. It controls the per-attempt timeout
// for HTTP requests and TCP connections.
Timeout Duration `yaml:"timeout"`
}

type HealthCheckType string
Expand Down Expand Up @@ -159,9 +231,6 @@ func (w Workload) validate() error {
if w.StateDir == "" {
return errors.New("state_dir is required")
}
if w.Environment == "" {
return errors.New("environment is required")
}
if w.GitProvider.Owner == "" {
return errors.New("git_provider.owner is required")
}
Expand Down
12 changes: 5 additions & 7 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ func TestLoadRejectsYAMLWithoutWorkloadsKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "workload.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
name: gymportal-api
environment: prod
container_name: gymportal-api
deploy_command: docker stop gymportal-api || true && docker rm gymportal-api || true && docker run -d --name gymportal-api "$WORKLOAD_IMAGE_REF"
state_dir: /tmp/recond/gymportal-api
Expand All @@ -42,7 +41,7 @@ func TestLoadWorkloadListYAML(t *testing.T) {
require.NoError(t, os.WriteFile(path, []byte(`
workloads:
- name: api
environment: prod
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
Expand All @@ -54,7 +53,6 @@ workloads:
type: tcp
url: tcp://localhost:8080
- name: worker
environment: prod
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
Expand All @@ -68,9 +66,11 @@ workloads:
require.NoError(t, err)
require.Len(t, cfg.Workloads, 2)
require.Equal(t, "api", cfg.Workloads[0].Name)
require.Equal(t, "api-prod", cfg.Workloads[0].ReleasePrefix)
require.Equal(t, 45*time.Second, cfg.Workloads[0].CheckInterval.Duration)
require.Equal(t, HealthCheckTCP, cfg.Workloads[0].HealthCheck.Type)
require.Equal(t, "worker", cfg.Workloads[1].Name)
require.Empty(t, cfg.Workloads[1].ReleasePrefix)
require.Equal(t, 2*time.Minute, cfg.Workloads[1].CheckInterval.Duration)
}

Expand All @@ -82,7 +82,7 @@ func TestLoadRejectsJSONConfigExtension(t *testing.T) {
"workloads": [
{
"name": "api",
"environment": "prod",
"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",
Expand Down Expand Up @@ -122,7 +122,7 @@ func TestLoadRejectsWorkloadsMapping(t *testing.T) {
require.NoError(t, os.WriteFile(path, []byte(`
workloads:
name: api
environment: prod
release_prefix: api-prod
`), 0o600))

_, err := Load(path)
Expand All @@ -136,7 +136,6 @@ func TestLoadRejectsInvalidDuration(t *testing.T) {
require.NoError(t, os.WriteFile(path, []byte(`
workloads:
- name: api
environment: 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
Expand Down Expand Up @@ -170,7 +169,6 @@ func TestLoadRejectsTCPHealthCheckWithoutURL(t *testing.T) {
require.NoError(t, os.WriteFile(path, []byte(`
workloads:
- name: api
environment: 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
Expand Down
10 changes: 3 additions & 7 deletions deployment/docker_deployer.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func (d *dockerDeployer) Deploy(ctx context.Context, workload config.Workload, m
}
noPreviousState := errors.Is(err, state.ErrFileNotFound)

d.notifier.NotifyOnNewDeploymentStarted(meta)
d.notifier.NotifyOnNewDeploymentStarted(workload.Name, meta)

newState, err := d.deploy(ctx, workload, meta, stateMgr)
if err == nil {
Expand All @@ -164,7 +164,7 @@ func (d *dockerDeployer) Deploy(ctx context.Context, workload config.Workload, m
return nil
}

d.notifier.NotifyOnDeploymentFailed(meta, err)
d.notifier.NotifyOnDeploymentFailed(workload.Name, meta, err)
d.logger.Error("deployment failed", "workload", workload.Name, "error", err)
if !errorShouldTriggerRollback(err) {
return fmt.Errorf("deployment failed but no rollback required: %w", err)
Expand All @@ -181,7 +181,7 @@ func (d *dockerDeployer) Deploy(ctx context.Context, workload config.Workload, m
return nil
}

d.notifier.NotifyOnDeploymentFailed(meta, fmt.Errorf("ROLLBACK FAILED: %w", rollbackErr))
d.notifier.NotifyOnDeploymentFailed(workload.Name, meta, fmt.Errorf("ROLLBACK FAILED: %w", rollbackErr))
return fmt.Errorf("deployment and rollback failed: %w", rollbackErr)
}

Expand All @@ -203,7 +203,6 @@ func (d *dockerDeployer) deploy(ctx context.Context, workload config.Workload, m

newState = state.DeploymentState{
Workload: workload.Name,
Environment: meta.Environment,
Image: meta.Image,
ImageTag: meta.ImageTag,
ManifestDigest: meta.ManifestDigest,
Expand Down Expand Up @@ -233,7 +232,6 @@ func (d *dockerDeployer) rollback(ctx context.Context, stateMgr state.Manager, w
)

previousStateMeta := git_provider.DeploymentMetadata{
Environment: previousState.Environment,
Image: previousState.Image,
ImageTag: previousState.ImageTag,
ManifestDigest: previousState.ManifestDigest,
Expand All @@ -255,7 +253,6 @@ func (d *dockerDeployer) rollback(ctx context.Context, stateMgr state.Manager, w

rollbackState = state.DeploymentState{
Workload: workload.Name,
Environment: previousState.Environment,
Image: previousState.Image,
ImageTag: previousState.ImageTag,
ManifestDigest: previousState.ManifestDigest,
Expand Down Expand Up @@ -382,7 +379,6 @@ func deploymentEnv(workload config.Workload, meta git_provider.DeploymentMetadat
return []string{
"WORKLOAD_NAME=" + workload.Name,
"WORKLOAD_CONTAINER_NAME=" + workload.ContainerName,
"WORKLOAD_ENVIRONMENT=" + meta.Environment,
"WORKLOAD_IMAGE=" + meta.Image,
"WORKLOAD_IMAGE_TAG=" + meta.ImageTag,
"WORKLOAD_MANIFEST_DIGEST=" + meta.ManifestDigest,
Expand Down
5 changes: 1 addition & 4 deletions git_provider/git_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ var (
// DeploymentMetadata is the structure published as a release asset by CI.
// The GitHub Actions workflow serializes this after a successful image push.
type DeploymentMetadata struct {
// Environment is the target environment name (e.g. "dev", "production").
Environment string `json:"environment"`

// Image is the full image reference including tag.
Image string `json:"image"`

Expand All @@ -34,5 +31,5 @@ type DeploymentMetadata struct {
}

type GitProvider interface {
FetchLatestDeploymentMetadata(ctx context.Context, environment string) (meta DeploymentMetadata, err error)
FetchLatestDeploymentMetadata(ctx context.Context, releasePrefix string) (meta DeploymentMetadata, err error)
}
Loading
Loading