feat(agent): add updater-agent binary and ship it in the image
This commit is contained in:
+9
-7
@@ -8,15 +8,17 @@ COPY . .
|
|||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG COMMIT=unknown
|
ARG COMMIT=unknown
|
||||||
ARG BUILD_TIME=unknown
|
ARG BUILD_TIME=unknown
|
||||||
RUN CGO_ENABLED=0 go build \
|
RUN for cmd in server:package-updater agent:updater-agent; do \
|
||||||
-ldflags="-s -w \
|
CGO_ENABLED=0 go build \
|
||||||
-X main.version=${VERSION} \
|
-ldflags="-s -w \
|
||||||
-X main.commit=${COMMIT} \
|
-X main.version=${VERSION} \
|
||||||
-X main.buildTime=${BUILD_TIME}" \
|
-X main.commit=${COMMIT} \
|
||||||
-o /out/package-updater ./cmd/server
|
-X main.buildTime=${BUILD_TIME}" \
|
||||||
|
-o /out/${cmd#*:} ./cmd/${cmd%%:*} || exit 1; \
|
||||||
|
done
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
RUN apk add --no-cache docker-cli docker-cli-compose ca-certificates wget
|
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
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/usr/local/bin/package-updater"]
|
ENTRYPOINT ["/usr/local/bin/package-updater"]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user