From b47c2ab4e3af54779500f5f851b215045e1e77ff Mon Sep 17 00:00:00 2001 From: ercross Date: Thu, 8 Oct 2026 01:17:46 +0100 Subject: [PATCH] yaml as only supported configuration specification format --- CHANGELOG.md | 27 +++++++++ README.md | 115 +++++++++++++++++++---------------- config/config.go | 79 ++++++++++-------------- config/config_test.go | 136 +++++++++++++++++++++++++++++++++++------- main.go | 9 +-- 5 files changed, 236 insertions(+), 130 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f04a368 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## v0.2.0 - 2026-10-08 + +### Breaking Changes + +- Configuration files must now be YAML and use a `.yaml` or `.yml` extension. +- JSON configuration files are no longer supported. +- Config files must define a top-level `workloads` list, even for a single workload. + + ```yaml + workloads: + - name: my-app + environment: prod + ``` + +- Single-workload shorthand YAML is no longer supported. + + ```yaml + # No longer supported: + name: my-app + environment: prod + ``` + +### Changed + +- Updated config loading, tests, and README examples for YAML-only configuration. diff --git a/README.md b/README.md index 89edea4..45f5343 100644 --- a/README.md +++ b/README.md @@ -80,42 +80,37 @@ Example metadata: ## Configuration -`reconD` currently loads JSON config files. +`reconD` loads YAML config files. Config paths must use a `.yaml` or `.yml` +extension. + +Every config file must define a top-level `workloads` list, even when it only +contains one workload. Minimal example: -```json -{ - "workloads": [ - { - "name": "my-app", - "environment": "prod", - "container_name": "my-container", - "deploy_command": "make redploy-app", - "check_interval": "60s", - "state_dir": "/path/to/state/dir", - "git_provider": { - "owner": "my-account", - "repo": "app-repo" - }, - "strategy": { - "type": "env_file", - "env_file_path": "/path/to/.env", - "image_tag_key": "IMAGE_TAG" - }, - "health_check": { - "type": "http", - "url": "http://localhost:8080/health", - "retries": 12, - "interval": "10s", - "timeout": "5s" - }, - "labels": { - "app": "my-app" - } - } - ] -} +```yaml +workloads: + - name: my-app + environment: prod + 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 + strategy: + type: env_file + env_file_path: /path/to/.env + image_tag_key: IMAGE_TAG + health_check: + type: http + url: http://localhost:8080/health + retries: 12 + interval: 10s + timeout: 5s + labels: + app: my-app ``` ## Deployment Strategy @@ -125,14 +120,11 @@ executed. The first supported strategy is `env_file`. -```json -{ - "strategy": { - "type": "env_file", - "env_file_path": "/opt/gymportal/.env.deploy", - "image_tag_key": "IMAGE_TAG" - } -} +```yaml +strategy: + type: env_file + env_file_path: /opt/gymportal/.env.deploy + image_tag_key: IMAGE_TAG ``` This updates or creates the env file and sets: @@ -161,16 +153,13 @@ Recognized but not implemented: HTTP example: -```json -{ - "health_check": { - "type": "http", - "url": "http://localhost:8080/health", - "retries": 12, - "interval": "10s", - "timeout": "5s" - } -} +```yaml +health_check: + type: http + url: http://localhost:8080/health + retries: 12 + interval: 10s + timeout: 5s ``` ## State Files @@ -218,7 +207,7 @@ env GOCACHE=/private/tmp/recond-go-build go test ./... Run the agent: ```sh -GITHUB_TOKEN=your_token_here go run . -config ./test/sample.config.json +GITHUB_TOKEN=your_token_here go run . -config your-config.yaml ``` Logging is JSON by default. @@ -230,6 +219,26 @@ LOG_FORMAT=text LOG_LEVEL=debug ``` +## Operating Model + +For production use, `reconD` is best treated as a host-level service: install +the binary on the host, run it under a process manager such as systemd, keep +configuration under a host path such as `/etc/reconD`, and keep workload state +under a persistent host path such as `/var/lib/reconD`. + +This matches the agent's job. It observes host container state, runs the local +`docker` command, writes local deployment state, and executes the configured +`deploy_command`, which may depend on host paths, scripts, Make targets, Compose +files, or environment files. + +Running `reconD` as a Docker container is possible, but it should be considered +an advanced packaging option rather than the default operating model. A +containerized agent typically needs access to the host Docker socket, persistent +state and config mounts, and sometimes host networking or host project +directories. Mounting the Docker socket gives the agent broad control over the +host Docker daemon, so the security boundary is not the same as an ordinary +isolated application container. + ## Local Test Fixture The `test/` directory contains a sample config and local Docker Compose fixture. @@ -238,7 +247,7 @@ Example: ```sh make -f ./test/Makefile dev-up -GITHUB_TOKEN=your_token_here LOG_FORMAT=text go run . -config ./test/sample.config.json +GITHUB_TOKEN=your_token_here LOG_FORMAT=text go run . -config ./test/sample.config.yaml ``` The sample deploy command calls: diff --git a/config/config.go b/config/config.go index f817770..cf8d6a0 100644 --- a/config/config.go +++ b/config/config.go @@ -2,7 +2,6 @@ package config import ( - "encoding/json" "errors" "fmt" "os" @@ -14,36 +13,36 @@ import ( ) type Config struct { - Workloads []Workload `json:"workloads" yaml:"workloads"` + Workloads []Workload `yaml:"workloads"` } type Workload struct { - Name string `json:"name" yaml:"name"` - Environment string `json:"environment" yaml:"environment"` - ContainerName string `json:"container_name" yaml:"container_name"` - NotificationURL string `json:"notification_url" yaml:"notification_url"` - DeployCommand string `json:"deploy_command" yaml:"deploy_command"` - CheckInterval Duration `json:"check_interval" yaml:"check_interval"` - StateDir string `json:"state_dir" yaml:"state_dir"` - GitProvider GitProvider `json:"git_provider" yaml:"git_provider"` - HealthCheck HealthCheck `json:"health_check" yaml:"health_check"` - Strategy Strategy `json:"strategy" yaml:"strategy"` + 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. - Labels map[string]string `json:"labels" yaml:"labels"` + Labels map[string]string `yaml:"labels"` } type GitProvider struct { - Owner string `json:"owner" yaml:"owner"` - Repo string `json:"repo" yaml:"repo"` + Owner string `yaml:"owner"` + Repo string `yaml:"repo"` // Token is optional; set via GITHUB_TOKEN env var for private repos. - Token string `json:"token" yaml:"token"` + Token string `yaml:"token"` } type Strategy struct { - Type StrategyType `json:"type" yaml:"type"` - EnvFilePath string `json:"env_file_path" yaml:"env_file_path"` - ImageTagKey string `json:"image_tag_key" yaml:"image_tag_key"` + Type StrategyType `yaml:"type"` + EnvFilePath string `yaml:"env_file_path"` + ImageTagKey string `yaml:"image_tag_key"` } type StrategyType string @@ -54,11 +53,11 @@ const ( ) type HealthCheck struct { - Type HealthCheckType `json:"type" yaml:"type"` - URL string `json:"url" yaml:"url"` - Retries int `json:"retries" yaml:"retries"` - Interval Duration `json:"interval" yaml:"interval"` - Timeout Duration `json:"timeout" yaml:"timeout"` + Type HealthCheckType `yaml:"type"` + URL string `yaml:"url"` + Retries int `yaml:"retries"` + Interval Duration `yaml:"interval"` + Timeout Duration `yaml:"timeout"` } type HealthCheckType string @@ -98,15 +97,6 @@ func parseConfig(path string, raw []byte) (*Config, error) { if err := unmarshalConfig(path, raw, &cfg); err != nil { return nil, fmt.Errorf("error parsing config file %q: %w", path, err) } - if len(cfg.Workloads) == 0 { - var workload Workload - if err := unmarshalConfig(path, raw, &workload); err != nil { - return nil, fmt.Errorf("error parsing config file %q: %w", path, err) - } - if workload.Name != "" { - cfg.Workloads = []Workload{workload} - } - } return &cfg, nil } @@ -116,7 +106,7 @@ func unmarshalConfig(path string, raw []byte, v any) error { case ".yaml", ".yml": return yaml.Unmarshal(raw, v) default: - return json.Unmarshal(raw, v) + return fmt.Errorf("unsupported config file extension %q; only .yaml and .yml are supported", filepath.Ext(path)) } } @@ -217,24 +207,15 @@ func (s Strategy) validate() error { return nil } -func (d *Duration) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err == nil { - return d.setString(s) - } - var seconds int64 - if err := json.Unmarshal(data, &seconds); err != nil { - return err - } - d.Duration = time.Duration(seconds) * time.Second - return nil -} - func (d *Duration) UnmarshalYAML(value *yaml.Node) error { - var s string - if err := value.Decode(&s); err == nil { + if value.ShortTag() == "!!str" { + var s string + if err := value.Decode(&s); err != nil { + return err + } return d.setString(s) } + var seconds int64 if err := value.Decode(&seconds); err != nil { return err diff --git a/config/config_test.go b/config/config_test.go index 74588a3..5c278c7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoadSingleWorkloadYAML(t *testing.T) { +func TestLoadRejectsYAMLWithoutWorkloadsKey(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "workload.yaml") @@ -31,16 +31,50 @@ health_check: url: http://localhost:8080/health `), 0o600)) + _, err := Load(path) + require.ErrorContains(t, err, "at least one workload must be configured") +} + +func TestLoadWorkloadListYAML(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "workloads.yml") + 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 + check_interval: 45 + git_provider: + owner: toughbred + repo: gymportal + health_check: + 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 + check_interval: 2m + git_provider: + owner: toughbred + repo: gymportal +`), 0o600)) + cfg, err := Load(path) require.NoError(t, err) - require.Len(t, cfg.Workloads, 1) - require.Equal(t, "gymportal-api", cfg.Workloads[0].Name) - require.Equal(t, 30*time.Second, cfg.Workloads[0].CheckInterval.Duration) - require.Equal(t, 12, cfg.Workloads[0].HealthCheck.Retries) - require.Equal(t, "IMAGE_TAG", cfg.Workloads[0].Strategy.ImageTagKey) + require.Len(t, cfg.Workloads, 2) + require.Equal(t, "api", cfg.Workloads[0].Name) + 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.Equal(t, 2*time.Minute, cfg.Workloads[1].CheckInterval.Duration) } -func TestLoadWorkloadListJSON(t *testing.T) { +func TestLoadRejectsJSONConfigExtension(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "workloads.json") @@ -65,11 +99,68 @@ func TestLoadWorkloadListJSON(t *testing.T) { ] }`), 0o600)) - cfg, err := Load(path) - require.NoError(t, err) - require.Len(t, cfg.Workloads, 1) - require.Equal(t, 45*time.Second, cfg.Workloads[0].CheckInterval.Duration) - require.Equal(t, HealthCheckTCP, cfg.Workloads[0].HealthCheck.Type) + _, err := Load(path) + require.ErrorContains(t, err, "unsupported config file extension") +} + +func TestLoadRejectsEmptyWorkloadsList(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "workloads.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +workloads: [] +`), 0o600)) + + _, err := Load(path) + require.ErrorContains(t, err, "at least one workload must be configured") +} + +func TestLoadRejectsWorkloadsMapping(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "workloads.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +workloads: + name: api + environment: prod +`), 0o600)) + + _, err := Load(path) + require.ErrorContains(t, err, "cannot unmarshal") +} + +func TestLoadRejectsInvalidDuration(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "workloads.yaml") + 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 + check_interval: sometimes + git_provider: + owner: toughbred + repo: gymportal +`), 0o600)) + + _, err := Load(path) + require.ErrorContains(t, err, "parse duration") +} + +func TestLoadRejectsUnsupportedConfigExtension(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "workloads.toml") + require.NoError(t, os.WriteFile(path, []byte(` +workloads: + - name: api +`), 0o600)) + + _, err := Load(path) + require.ErrorContains(t, err, "unsupported config file extension") } func TestLoadRejectsTCPHealthCheckWithoutURL(t *testing.T) { @@ -77,16 +168,17 @@ func TestLoadRejectsTCPHealthCheckWithoutURL(t *testing.T) { path := filepath.Join(t.TempDir(), "workload.yaml") require.NoError(t, os.WriteFile(path, []byte(` -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 -git_provider: - owner: toughbred - repo: gymportal -health_check: - type: tcp +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 + git_provider: + owner: toughbred + repo: gymportal + health_check: + type: tcp `), 0o600)) _, err := Load(path) diff --git a/main.go b/main.go index 20c3854..c6536a7 100644 --- a/main.go +++ b/main.go @@ -28,7 +28,7 @@ func main() { } func run() error { - configPath := flag.String("config", "", "path to agent config file") + configPath := flag.String("config", "/etc/reconD/config.yaml", "path to agent config file") flag.Parse() if *configPath == "" { return fmt.Errorf("missing config path") @@ -47,7 +47,6 @@ func run() error { var wg sync.WaitGroup for _, workload := range cfg.Workloads { - workload := workload stateMgr, err := state.NewManagerWithLocalFileSystem(workload.StateDir) if err != nil { @@ -67,11 +66,9 @@ func run() error { log, ) - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { r.StartPeriodicReconciliation(ctx) - }() + }) } <-ctx.Done()