diff --git a/internal/updater/swarm_executor.go b/internal/updater/swarm_executor.go new file mode 100644 index 0000000..a007792 --- /dev/null +++ b/internal/updater/swarm_executor.go @@ -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 +} diff --git a/internal/updater/swarm_executor_test.go b/internal/updater/swarm_executor_test.go new file mode 100644 index 0000000..253d139 --- /dev/null +++ b/internal/updater/swarm_executor_test.go @@ -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) +}