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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
115 changes: 62 additions & 53 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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:
Expand Down
79 changes: 30 additions & 49 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
package config

import (
"encoding/json"
"errors"
"fmt"
"os"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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))
}
}

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