diff --git a/Dockerfile b/Dockerfile index c6c52c6..114a1a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,15 +8,17 @@ COPY . . ARG VERSION=dev ARG COMMIT=unknown ARG BUILD_TIME=unknown -RUN CGO_ENABLED=0 go build \ - -ldflags="-s -w \ - -X main.version=${VERSION} \ - -X main.commit=${COMMIT} \ - -X main.buildTime=${BUILD_TIME}" \ - -o /out/package-updater ./cmd/server +RUN for cmd in server:package-updater agent:updater-agent; do \ + CGO_ENABLED=0 go build \ + -ldflags="-s -w \ + -X main.version=${VERSION} \ + -X main.commit=${COMMIT} \ + -X main.buildTime=${BUILD_TIME}" \ + -o /out/${cmd#*:} ./cmd/${cmd%%:*} || exit 1; \ + done FROM alpine:3.20 RUN apk add --no-cache docker-cli docker-cli-compose ca-certificates wget -COPY --from=build /out/package-updater /usr/local/bin/ +COPY --from=build /out/ /usr/local/bin/ EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/package-updater"] diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..0833e42 --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,89 @@ +// Command updater-agent reports this machine's Docker services to the +// package-updater master. +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/docker/docker/client" + + "github.com/shcizo/package-updater/internal/agent/config" + "github.com/shcizo/package-updater/internal/agent/conn" + "github.com/shcizo/package-updater/internal/agent/inventory" + "github.com/shcizo/package-updater/internal/logging" +) + +// Build info is injected at link time via -ldflags="-X main.version=...". +var version = "dev" + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return err + } + logger := logging.New(cfg.LogLevel) + + dockerCli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return fmt.Errorf("docker client: %w", err) + } + defer dockerCli.Close() + + collector := inventory.New(dockerCli, cfg.SelfID) + agent := conn.New(conn.Config{ + URL: cfg.MasterURL, Token: cfg.Token, Fingerprint: cfg.MasterKey, Version: version, + }, collector, logger) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go watchLoop(ctx, dockerCli, agent, logger) + logger.Info("agent_starting", "version", version, "master_url", cfg.MasterURL) + return agent.Run(ctx) +} + +// watchLoop keeps a Docker event subscription alive. When the stream breaks +// (daemon restart), it resubscribes and asks for a refresh, because events +// may have been missed in between. It also asks for a full re-inventory every +// 60 seconds, since a Swarm replica dying on another node emits no event here. +func watchLoop(ctx context.Context, src inventory.EventSource, agent *conn.Agent, log *slog.Logger) { + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + agent.Notify() + } + } + }() + + for { + err := inventory.Watch(ctx, src, 2*time.Second, agent.Notify) + if ctx.Err() != nil { + return + } + log.Warn("docker_events_lost", "error", err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + } + agent.Notify() + } +} diff --git a/internal/agent/config/config.go b/internal/agent/config/config.go new file mode 100644 index 0000000..1b918d9 --- /dev/null +++ b/internal/agent/config/config.go @@ -0,0 +1,51 @@ +// Package config loads the agent's configuration from environment variables. +package config + +import ( + "errors" + "os" + "regexp" + "strings" +) + +var masterKeyPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// Config holds the agent's runtime configuration. +type Config struct { + MasterURL string // MASTER_URL, wss://host:port/agent + MasterKey string // MASTER_KEY, fingerprint shown in the master UI + 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 +} + +// Load reads and validates the agent configuration. It fails fast so a +// misconfigured agent never starts dialling. +func Load() (*Config, error) { + cfg := &Config{ + MasterURL: os.Getenv("MASTER_URL"), + MasterKey: os.Getenv("MASTER_KEY"), + Token: os.Getenv("AGENT_TOKEN"), + SelfID: os.Getenv("SELF_ID"), + LogLevel: os.Getenv("LOG_LEVEL"), + } + switch { + case cfg.MasterURL == "": + return nil, errors.New("MASTER_URL is required") + case !strings.HasPrefix(cfg.MasterURL, "wss://"): + return nil, errors.New("MASTER_URL must start with wss://") + case cfg.MasterKey == "": + return nil, errors.New("MASTER_KEY is required") + case !masterKeyPattern.MatchString(cfg.MasterKey): + return nil, errors.New("MASTER_KEY must look like sha256:<64 hex>") + case cfg.Token == "": + return nil, errors.New("AGENT_TOKEN is required") + } + if cfg.SelfID == "" { + cfg.SelfID, _ = os.Hostname() + } + if cfg.LogLevel == "" { + cfg.LogLevel = "info" + } + return cfg, nil +} diff --git a/internal/agent/config/config_test.go b/internal/agent/config/config_test.go new file mode 100644 index 0000000..606018e --- /dev/null +++ b/internal/agent/config/config_test.go @@ -0,0 +1,50 @@ +package config + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const validKey = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +func setValid(t *testing.T) { + t.Setenv("MASTER_URL", "wss://10.0.0.5:8090/agent") + t.Setenv("MASTER_KEY", validKey) + t.Setenv("AGENT_TOKEN", "pua_abc") + t.Setenv("SELF_ID", "") + t.Setenv("LOG_LEVEL", "") +} + +func TestLoad_Valid(t *testing.T) { + setValid(t) + cfg, err := Load() + require.NoError(t, err) + host, _ := os.Hostname() + assert.Equal(t, &Config{ + MasterURL: "wss://10.0.0.5:8090/agent", MasterKey: validKey, Token: "pua_abc", + SelfID: host, LogLevel: "info", + }, cfg) +} + +func TestLoad_Invalid(t *testing.T) { + cases := []struct { + name, key, value, wantErr string + }{ + {"missing url", "MASTER_URL", "", "MASTER_URL is required"}, + {"plain ws", "MASTER_URL", "ws://10.0.0.5:8090/agent", "MASTER_URL must start with wss://"}, + {"missing key", "MASTER_KEY", "", "MASTER_KEY is required"}, + {"malformed key", "MASTER_KEY", "abc", "MASTER_KEY must look like sha256:<64 hex>"}, + {"missing token", "AGENT_TOKEN", "", "AGENT_TOKEN is required"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setValid(t) + t.Setenv(c.key, c.value) + _, err := Load() + assert.ErrorContains(t, err, c.wantErr) + }) + } +}