feat(agent): UPDATE_TIMEOUT and DOCKER_CONFIG settings (#7)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "..")
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user