feat(agent): job runner, compose override executor and swarm executor (#7)

This commit is contained in:
2026-10-12 18:45:44 +02:00
parent 32c30194c4
commit e313365686
18 changed files with 1617 additions and 165 deletions
+64 -23
View File
@@ -3,14 +3,19 @@ package updater
import (
"context"
"fmt"
"io"
"github.com/docker/docker/api/types/swarm"
"github.com/shcizo/package-updater/internal/discovery"
"github.com/docker/docker/api/types/system"
"github.com/shcizo/package-updater/internal/imageref"
"github.com/shcizo/package-updater/internal/protocol"
)
// SwarmDockerClient is the subset of the Docker SDK SwarmExecutor depends
// on. Defined as an interface so tests can supply a fake.
type SwarmDockerClient interface {
// SwarmClient is the subset of the Docker SDK SwarmExecutor depends on.
// Defined as an interface so tests can supply a fake.
type SwarmClient interface {
Info(ctx context.Context) (system.Info, error)
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)
}
@@ -18,35 +23,71 @@ type SwarmDockerClient interface {
// SwarmExecutor updates a Swarm service's image via the Docker API,
// equivalent to `docker service update --image`.
type SwarmExecutor struct {
cli SwarmDockerClient
cli SwarmClient
resolve *Resolver
auth *RegistryAuth
}
// NewSwarmExecutor returns an executor that drives Swarm service updates
// through the Docker API.
func NewSwarmExecutor(cli SwarmDockerClient) *SwarmExecutor {
return &SwarmExecutor{cli: cli}
// through the Docker API. auth may be nil (anonymous registry access).
func NewSwarmExecutor(cli SwarmClient, resolve *Resolver, auth *RegistryAuth) *SwarmExecutor {
return &SwarmExecutor{cli: cli, resolve: resolve, auth: auth}
}
// 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)
// Execute checks that the local daemon is a manager of the job's cluster,
// pins job.Ref to its current digest and updates the service to it. The
// update is addressed by the ID and version of the service that was
// inspected, so it changes exactly that object. Success means the daemon
// accepted the new spec, not that the tasks have converged. It returns the
// pinned ref.
func (e *SwarmExecutor) Execute(ctx context.Context, job protocol.JobStart, out io.Writer) (string, error) {
info, err := e.cli.Info(ctx)
if err != nil {
return "", fmt.Errorf("docker info: %w", err)
}
if !info.Swarm.ControlAvailable {
return "", fmt.Errorf("refused: this agent's daemon is not a swarm manager")
}
actual := ""
if info.Swarm.Cluster != nil {
actual = info.Swarm.Cluster.ID
}
if actual != job.SwarmClusterID {
return "", fmt.Errorf("refused: job is for swarm cluster %s, this daemon is in %s", job.SwarmClusterID, actual)
}
svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.ServiceID, swarm.ServiceInspectOptions{})
svc, _, err := e.cli.ServiceInspectWithRaw(ctx, job.SwarmService, swarm.ServiceInspectOptions{})
if err != nil {
return fmt.Errorf("inspect service %s: %w", job.ServiceID, err)
return "", fmt.Errorf("inspect service %s: %w", job.SwarmService, err)
}
if svc.Spec.Name != job.SwarmService {
return "", fmt.Errorf("inspect service %s: daemon returned service %q", job.SwarmService, svc.Spec.Name)
}
if svc.Spec.TaskTemplate.ContainerSpec == nil {
return "", fmt.Errorf("service %s has no container spec", job.SwarmService)
}
pinned, err := e.resolve.Resolve(ctx, job.Ref)
if err != nil {
return "", err
}
fmt.Fprintf(out, "resolved %s\n", pinned)
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)
spec.TaskTemplate.ContainerSpec.Image = pinned
opts := swarm.ServiceUpdateOptions{QueryRegistry: true}
if enc := e.auth.For(imageref.Parse(job.Ref).Repo); enc != "" {
opts.EncodedRegistryAuth = enc
} else {
opts.RegistryAuthFrom = swarm.RegistryAuthFromSpec
}
return nil
resp, err := e.cli.ServiceUpdate(ctx, svc.ID, svc.Version, spec, opts)
if err != nil {
return "", fmt.Errorf("update service %s: %w", job.SwarmService, err)
}
for _, w := range resp.Warnings {
fmt.Fprintf(out, "warning: %s\n", w)
}
fmt.Fprintf(out, "service %s updated\n", job.SwarmService)
return pinned, nil
}