package updater import ( "context" "fmt" "io" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/system" "github.com/shcizo/package-updater/internal/imageref" "github.com/shcizo/package-updater/internal/protocol" ) // 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) } // SwarmExecutor updates a Swarm service's image via the Docker API, // equivalent to `docker service update --image`. type SwarmExecutor struct { cli SwarmClient resolve *Resolver auth *RegistryAuth } // NewSwarmExecutor returns an executor that drives Swarm service updates // 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 checks that the local daemon is a manager of the job's cluster, // pins job.Ref to its current digest (a ref that has one is used as is) 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.SwarmService, swarm.ServiceInspectOptions{}) if err != nil { 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.Fprintln(out, resolveNote(job.Ref, pinned)) spec := svc.Spec 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 } 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 }