feat(agent): UPDATE_TIMEOUT and DOCKER_CONFIG settings (#7)

This commit is contained in:
2026-10-12 18:40:15 +02:00
parent c9c4e0cadb
commit e2b73dfd81
35 changed files with 104 additions and 2161 deletions
+42
View File
@@ -2,10 +2,14 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
var masterKeyPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
@@ -17,8 +21,17 @@ type Config struct {
Token string // AGENT_TOKEN, shown once when the agent was added
SelfID string // SELF_ID, defaults to the hostname (= container ID prefix)
LogLevel string // LOG_LEVEL, default info
// UpdateTimeout bounds one update job (UPDATE_TIMEOUT, default 5m).
UpdateTimeout time.Duration
// DockerConfig is the directory holding config.json with registry
// credentials (DOCKER_CONFIG). Empty means anonymous registry access.
DockerConfig string
}
// defaultUpdateTimeout is used when UPDATE_TIMEOUT is unset.
const defaultUpdateTimeout = 5 * time.Minute
// Load reads and validates the agent configuration. It fails fast so a
// misconfigured agent never starts dialling.
func Load() (*Config, error) {
@@ -28,6 +41,9 @@ func Load() (*Config, error) {
Token: os.Getenv("AGENT_TOKEN"),
SelfID: os.Getenv("SELF_ID"),
LogLevel: os.Getenv("LOG_LEVEL"),
UpdateTimeout: defaultUpdateTimeout,
DockerConfig: os.Getenv("DOCKER_CONFIG"),
}
switch {
case cfg.MasterURL == "":
@@ -41,6 +57,18 @@ func Load() (*Config, error) {
case cfg.Token == "":
return nil, errors.New("AGENT_TOKEN is required")
}
if v := os.Getenv("UPDATE_TIMEOUT"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return nil, fmt.Errorf("UPDATE_TIMEOUT must be a positive duration such as 5m, got %q", v)
}
cfg.UpdateTimeout = d
}
if cfg.DockerConfig != "" {
if err := checkDockerConfig(cfg.DockerConfig); err != nil {
return nil, err
}
}
if cfg.SelfID == "" {
cfg.SelfID, _ = os.Hostname()
}
@@ -49,3 +77,17 @@ func Load() (*Config, error) {
}
return cfg, nil
}
// checkDockerConfig fails when DOCKER_CONFIG is set but its config.json is
// missing or not JSON, so a typo does not silently turn into anonymous pulls.
func checkDockerConfig(dir string) error {
path := filepath.Join(dir, "config.json")
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("DOCKER_CONFIG: read %s: %w", path, err)
}
if !json.Valid(raw) {
return fmt.Errorf("DOCKER_CONFIG: %s is not valid JSON", path)
}
return nil
}
+47 -1
View File
@@ -2,7 +2,9 @@ package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -16,6 +18,8 @@ func setValid(t *testing.T) {
t.Setenv("AGENT_TOKEN", "pua_abc")
t.Setenv("SELF_ID", "")
t.Setenv("LOG_LEVEL", "")
t.Setenv("UPDATE_TIMEOUT", "")
t.Setenv("DOCKER_CONFIG", "")
}
func TestLoad_Valid(t *testing.T) {
@@ -25,7 +29,7 @@ func TestLoad_Valid(t *testing.T) {
host, _ := os.Hostname()
assert.Equal(t, &Config{
MasterURL: "wss://10.0.0.5:8090/agent", MasterKey: validKey, Token: "pua_abc",
SelfID: host, LogLevel: "info",
SelfID: host, LogLevel: "info", UpdateTimeout: 5 * time.Minute,
}, cfg)
}
@@ -48,3 +52,45 @@ func TestLoad_Invalid(t *testing.T) {
})
}
}
func TestLoad_JobSettings(t *testing.T) {
withConfig := func(t *testing.T, body string) string {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o600))
return dir
}
cases := []struct {
name string
timeout string
dockerCfg func(t *testing.T) string
wantTime time.Duration
wantErr string
}{
{name: "defaults", wantTime: 5 * time.Minute},
{name: "custom timeout", timeout: "90s", wantTime: 90 * time.Second},
{name: "zero timeout", timeout: "0", wantErr: "UPDATE_TIMEOUT"},
{name: "garbage timeout", timeout: "abc", wantErr: "UPDATE_TIMEOUT"},
{name: "docker config without file", dockerCfg: func(t *testing.T) string { return t.TempDir() }, wantErr: "DOCKER_CONFIG"},
{name: "docker config not json", dockerCfg: func(t *testing.T) string { return withConfig(t, "nope") }, wantErr: "not valid JSON"},
{name: "docker config ok", dockerCfg: func(t *testing.T) string { return withConfig(t, `{"auths":{}}`) }, wantTime: 5 * time.Minute},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
setValid(t)
t.Setenv("UPDATE_TIMEOUT", c.timeout)
dir := ""
if c.dockerCfg != nil {
dir = c.dockerCfg(t)
t.Setenv("DOCKER_CONFIG", dir)
}
cfg, err := Load()
if c.wantErr != "" {
assert.ErrorContains(t, err, c.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, c.wantTime, cfg.UpdateTimeout)
assert.Equal(t, dir, cfg.DockerConfig)
})
}
}
+6 -4
View File
@@ -18,10 +18,11 @@ import (
)
const (
labelProject = "com.docker.compose.project"
labelService = "com.docker.compose.service"
labelWorkingDir = "com.docker.compose.project.working_dir"
labelSwarmTask = "com.docker.swarm.service.id"
labelProject = "com.docker.compose.project"
labelService = "com.docker.compose.service"
labelWorkingDir = "com.docker.compose.project.working_dir"
labelConfigFiles = "com.docker.compose.project.config_files"
labelSwarmTask = "com.docker.swarm.service.id"
)
// DockerClient is the subset of the Docker SDK the collector needs.
@@ -148,6 +149,7 @@ func (c *Collector) fromContainer(ct container.Summary, ref string, repoDigests
svc.ComposeProject = project
svc.ComposeService = service
svc.WorkingDir = ct.Labels[labelWorkingDir]
svc.ConfigFiles = ct.Labels[labelConfigFiles]
return svc
}
svc.Kind = protocol.KindContainer
@@ -200,3 +200,12 @@ func TestHello(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, protocol.Hello{Hostname: "swarm-mgr", SwarmManager: true, SwarmClusterID: "cluster-1"}, got)
}
func TestSnapshot_ComposeConfigFilesIsRawLabel(t *testing.T) {
ct := composeContainer("abc123", "slusk", "web", "slusk:1", "sha256:img1", "running")
ct.Labels[labelConfigFiles] = "/s/a/compose.yaml,/s/a/compose.override.yaml"
got, err := New(&fakeDocker{containers: []container.Summary{ct}}, "").Snapshot(context.Background())
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "/s/a/compose.yaml,/s/a/compose.override.yaml", got[0].ConfigFiles)
}
@@ -0,0 +1,54 @@
package updater
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/shcizo/package-updater/internal/discovery"
)
// ComposeExecutor invokes `docker compose` as a subprocess.
type ComposeExecutor struct{}
// NewComposeExecutor returns an executor that shells out to docker compose.
func NewComposeExecutor() *ComposeExecutor {
return &ComposeExecutor{}
}
// Execute runs `docker compose -f <file>... -p <project> pull <service>`
// followed by `... up -d <service>`. Working directory is set to
// job.WorkingDir so any relative paths in the compose file resolve correctly.
func (e *ComposeExecutor) Execute(ctx context.Context, job discovery.Job) error {
if job.Refused {
return fmt.Errorf("refused: %s", job.RefusedReason)
}
if err := e.run(ctx, job, "pull"); err != nil {
return fmt.Errorf("pull: %w", err)
}
if err := e.run(ctx, job, "up", "-d"); err != nil {
return fmt.Errorf("up: %w", err)
}
return nil
}
func (e *ComposeExecutor) run(ctx context.Context, job discovery.Job, args ...string) error {
cliArgs := []string{"compose"}
for _, f := range job.ConfigFiles {
cliArgs = append(cliArgs, "-f", f)
}
cliArgs = append(cliArgs, "-p", job.Project)
cliArgs = append(cliArgs, args...)
cliArgs = append(cliArgs, job.Service)
cmd := exec.CommandContext(ctx, "docker", cliArgs...)
cmd.Dir = job.WorkingDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker %s: %w (output: %s)",
strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package discovery
import (
"path/filepath"
"strings"
)
// IsInsideRoot reports whether path is the same as, or nested inside,
// root. Both are cleaned before comparison so trailing slashes, "."
// segments, and ".." escapes are handled. Prefix tricks like
// "/foo" vs "/foo-evil" are NOT considered inside.
func IsInsideRoot(root, path string) bool {
r := filepath.Clean(root)
p := filepath.Clean(path)
if p == r {
return true
}
rel, err := filepath.Rel(r, p)
if err != nil {
return false
}
return !strings.HasPrefix(rel, "..")
}
+32
View File
@@ -0,0 +1,32 @@
package discovery_test
import (
"testing"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/stretchr/testify/require"
)
func TestIsInsideRoot(t *testing.T) {
cases := []struct {
name string
root string
path string
want bool
}{
{"direct child", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted/myapp", true},
{"nested", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted/a/b/c", true},
{"root itself", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted", true},
{"sibling", "/home/shcizo/self-hosted", "/home/shcizo/other", false},
{"parent", "/home/shcizo/self-hosted", "/home/shcizo", false},
{"unrelated", "/home/shcizo/self-hosted", "/etc/passwd", false},
{"prefix-trick", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted-evil", false},
{"dotdot escape", "/home/shcizo/self-hosted", "/home/shcizo/self-hosted/../etc", false},
{"trailing slash root", "/home/shcizo/self-hosted/", "/home/shcizo/self-hosted/x", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
require.Equal(t, c.want, discovery.IsInsideRoot(c.root, c.path))
})
}
}
+52
View File
@@ -0,0 +1,52 @@
package updater
import (
"context"
"fmt"
"github.com/docker/docker/api/types/swarm"
"github.com/shcizo/package-updater/internal/discovery"
)
// SwarmDockerClient is the subset of the Docker SDK SwarmExecutor depends
// on. Defined as an interface so tests can supply a fake.
type SwarmDockerClient interface {
ServiceInspectWithRaw(ctx context.Context, serviceID string, opts swarm.ServiceInspectOptions) (swarm.Service, []byte, error)
ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, opts swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error)
}
// SwarmExecutor updates a Swarm service's image via the Docker API,
// equivalent to `docker service update --image`.
type SwarmExecutor struct {
cli SwarmDockerClient
}
// NewSwarmExecutor returns an executor that drives Swarm service updates
// through the Docker API.
func NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor {
return &SwarmExecutor{cli: cli}
}
// Execute inspects the service to get its current spec and version (required
// by the Docker API as an optimistic-concurrency token), sets the new image
// on the container spec, and calls ServiceUpdate. QueryRegistry is set so a
// floating tag (e.g. ":latest") resolves to a fresh digest and actually
// triggers a rolling update instead of being treated as unchanged.
func (e *SwarmExecutor) Execute(ctx context.Context, job discovery.Job) error {
if job.Refused {
return fmt.Errorf("refused: %s", job.RefusedReason)
}
svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.ServiceID, swarm.ServiceInspectOptions{})
if err != nil {
return fmt.Errorf("inspect service %s: %w", job.ServiceID, err)
}
spec := svc.Spec
spec.TaskTemplate.ContainerSpec.Image = job.Image
if _, err := e.cli.ServiceUpdate(ctx, job.ServiceID, svc.Version, spec, swarm.ServiceUpdateOptions{QueryRegistry: true}); err != nil {
return fmt.Errorf("update service %s: %w", job.ServiceID, err)
}
return nil
}
@@ -0,0 +1,99 @@
package updater_test
import (
"context"
"errors"
"testing"
"github.com/docker/docker/api/types/swarm"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/shcizo/package-updater/internal/updater"
"github.com/stretchr/testify/require"
)
type fakeSwarmDockerClient struct {
inspectService swarm.Service
inspectErr error
updateErr error
inspectCalled bool
updateCalled bool
updateCalledServiceID string
updateCalledVersion swarm.Version
updateCalledSpec swarm.ServiceSpec
updateCalledOpts swarm.ServiceUpdateOptions
}
func (f *fakeSwarmDockerClient) ServiceInspectWithRaw(_ context.Context, _ string, _ swarm.ServiceInspectOptions) (swarm.Service, []byte, error) {
f.inspectCalled = true
return f.inspectService, nil, f.inspectErr
}
func (f *fakeSwarmDockerClient) ServiceUpdate(_ context.Context, serviceID string, version swarm.Version, spec swarm.ServiceSpec, opts swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) {
f.updateCalled = true
f.updateCalledServiceID = serviceID
f.updateCalledVersion = version
f.updateCalledSpec = spec
f.updateCalledOpts = opts
return swarm.ServiceUpdateResponse{}, f.updateErr
}
func mkInspectService(version uint64, image string) swarm.Service {
return swarm.Service{
ID: "svc-myapp",
Meta: swarm.Meta{Version: swarm.Version{Index: version}},
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "myapp_web"},
TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{Image: image}},
},
}
}
func TestSwarmExecutor_UpdatesImageAndVersion(t *testing.T) {
fake := &fakeSwarmDockerClient{inspectService: mkInspectService(7, "registry.example.com/myapp:v1")}
e := updater.NewSwarmExecutor(fake)
job := discovery.Job{Service: "myapp_web", ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"}
err := e.Execute(context.Background(), job)
require.NoError(t, err)
require.True(t, fake.updateCalled)
require.Equal(t, "svc-myapp", fake.updateCalledServiceID)
require.Equal(t, uint64(7), fake.updateCalledVersion.Index)
require.Equal(t, "registry.example.com/myapp:v2", fake.updateCalledSpec.TaskTemplate.ContainerSpec.Image)
require.True(t, fake.updateCalledOpts.QueryRegistry)
}
func TestSwarmExecutor_InspectError(t *testing.T) {
fake := &fakeSwarmDockerClient{inspectErr: errors.New("service not found")}
e := updater.NewSwarmExecutor(fake)
err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-missing", Image: "x:v2"})
require.Error(t, err)
require.False(t, fake.updateCalled)
}
func TestSwarmExecutor_UpdateError(t *testing.T) {
fake := &fakeSwarmDockerClient{
inspectService: mkInspectService(1, "registry.example.com/myapp:v1"),
updateErr: errors.New("update rejected"),
}
e := updater.NewSwarmExecutor(fake)
err := e.Execute(context.Background(), discovery.Job{ServiceID: "svc-myapp", Image: "registry.example.com/myapp:v2"})
require.Error(t, err)
}
func TestSwarmExecutor_RefusedJobIsNotExecuted(t *testing.T) {
fake := &fakeSwarmDockerClient{}
e := updater.NewSwarmExecutor(fake)
job := discovery.Job{ServiceID: "svc-myapp", Image: "x:v2", Refused: true, RefusedReason: "not opted in"}
err := e.Execute(context.Background(), job)
require.Error(t, err)
require.False(t, fake.inspectCalled)
require.False(t, fake.updateCalled)
}