feat: add Docker Swarm support (MODE=swarm) #4
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user