diff --git a/internal/master/jobs/jobs.go b/internal/master/jobs/jobs.go index c1bc25b..49086c7 100644 --- a/internal/master/jobs/jobs.go +++ b/internal/master/jobs/jobs.go @@ -76,6 +76,8 @@ func (s *Service) Updatable(svc model.Service) error { return &model.NotUpdatableError{Reason: model.ReasonRemoved} case svc.Self || imageref.Parse(svc.ActualRef).Repo == s.selfRepo: return &model.NotUpdatableError{Reason: model.ReasonSelf} + case svc.Built: + return &model.NotUpdatableError{Reason: model.ReasonBuilt} case svc.Kind == protocol.KindContainer: return &model.NotUpdatableError{Reason: model.ReasonContainer} } diff --git a/internal/master/jobs/jobs_test.go b/internal/master/jobs/jobs_test.go index dbd5412..5e7468a 100644 --- a/internal/master/jobs/jobs_test.go +++ b/internal/master/jobs/jobs_test.go @@ -108,6 +108,7 @@ func TestUpdatable(t *testing.T) { {"flagged self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf}, {"same repo as the agent image", model.Service{Kind: protocol.KindCompose, ActualRef: "gitea.shcizo.se/shcizo/package-updater:1.2.0@sha256:abc"}, model.ReasonSelf}, + {"built from source", model.Service{Kind: protocol.KindCompose, ActualRef: "slusk:1", Built: true}, model.ReasonBuilt}, {"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer}, {"digest without tag", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, model.ReasonNoTag}, {"bare image means latest", model.Service{Kind: protocol.KindCompose, ActualRef: "postgres"}, ""}, diff --git a/internal/master/model/job.go b/internal/master/model/job.go index 5907deb..afd2106 100644 --- a/internal/master/model/job.go +++ b/internal/master/model/job.go @@ -31,6 +31,7 @@ const ( ReasonContainer = "plain containers cannot be updated" ReasonNoTag = "follows no tag" ReasonRemoved = "service no longer exists" + ReasonBuilt = "built from source (build:)" ) // JobStatus is the state of an update job. diff --git a/internal/master/model/model.go b/internal/master/model/model.go index b1a5570..086402d 100644 --- a/internal/master/model/model.go +++ b/internal/master/model/model.go @@ -51,6 +51,7 @@ type Service struct { ActualDigest string Status string Self bool + Built bool // Compose service with a build: section; never updated UpdateMode string FirstSeenAt time.Time UpdatedAt time.Time diff --git a/internal/master/store/migrations/0002_jobs.sql b/internal/master/store/migrations/0002_jobs.sql index eaae8a6..cae7cef 100644 --- a/internal/master/store/migrations/0002_jobs.sql +++ b/internal/master/store/migrations/0002_jobs.sql @@ -1,5 +1,6 @@ ALTER TABLE services ADD COLUMN config_files text NOT NULL DEFAULT ''; ALTER TABLE services ADD COLUMN desired_ref text NOT NULL DEFAULT ''; +ALTER TABLE services ADD COLUMN built boolean NOT NULL DEFAULT false; CREATE TABLE update_jobs ( id bigserial PRIMARY KEY, diff --git a/internal/master/store/services.go b/internal/master/store/services.go index a36ceee..816d2c5 100644 --- a/internal/master/store/services.go +++ b/internal/master/store/services.go @@ -40,13 +40,14 @@ func lockActiveAgent(ctx context.Context, tx pgx.Tx, agentID int64) error { const upsertServiceSQL = ` INSERT INTO services ( kind, location_key, agent_id, compose_project, compose_service, working_dir, config_files, - swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest, + built, swarm_cluster_id, swarm_service, container_name, image_repo, actual_ref, actual_digest, status, self, first_seen_at, updated_at) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $16) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $17) ON CONFLICT (location_key) DO UPDATE SET agent_id = EXCLUDED.agent_id, working_dir = EXCLUDED.working_dir, config_files = EXCLUDED.config_files, + built = EXCLUDED.built, image_repo = EXCLUDED.image_repo, actual_ref = EXCLUDED.actual_ref, actual_digest = EXCLUDED.actual_digest, @@ -62,7 +63,7 @@ func upsertService(ctx context.Context, db execer, agentID int64, clusterID stri } _, err := db.Exec(ctx, upsertServiceSQL, string(svc.Kind), model.LocationKey(agentID, clusterID, svc.Key()), agentID, - svc.ComposeProject, svc.ComposeService, svc.WorkingDir, svc.ConfigFiles, + svc.ComposeProject, svc.ComposeService, svc.WorkingDir, svc.ConfigFiles, svc.Built, clusterCol, svc.SwarmService, svc.ContainerName, imageref.Parse(svc.Image).Repo, svc.Image, svc.Digest, svc.Status, svc.Self, at) @@ -146,7 +147,7 @@ func (s *Store) RemoveService(ctx context.Context, agentID int64, clusterID, age } const serviceColumns = `s.id, s.kind, s.location_key, s.agent_id, COALESCE(a.name, ''), - s.compose_project, s.compose_service, s.working_dir, s.config_files, s.swarm_cluster_id, + s.compose_project, s.compose_service, s.working_dir, s.config_files, s.built, s.swarm_cluster_id, s.swarm_service, s.container_name, s.image_repo, s.desired_ref, s.actual_ref, s.actual_digest, s.status, s.self, s.update_mode, s.first_seen_at, s.updated_at, s.removed_at` @@ -154,7 +155,7 @@ func scanService(row pgx.Row) (model.Service, error) { var m model.Service var kind string err := row.Scan(&m.ID, &kind, &m.LocationKey, &m.AgentID, &m.AgentName, - &m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.ConfigFiles, &m.SwarmClusterID, + &m.ComposeProject, &m.ComposeService, &m.WorkingDir, &m.ConfigFiles, &m.Built, &m.SwarmClusterID, &m.SwarmService, &m.ContainerName, &m.ImageRepo, &m.DesiredRef, &m.ActualRef, &m.ActualDigest, &m.Status, &m.Self, &m.UpdateMode, &m.FirstSeenAt, &m.UpdatedAt, &m.RemovedAt) m.Kind = protocol.Kind(kind) diff --git a/internal/master/store/services_integration_test.go b/internal/master/store/services_integration_test.go index 728c8bc..e0dc4db 100644 --- a/internal/master/store/services_integration_test.go +++ b/internal/master/store/services_integration_test.go @@ -193,3 +193,23 @@ func TestApplySnapshot_StoresConfigFiles(t *testing.T) { require.Len(t, got, 1) assert.Equal(t, withFiles.ConfigFiles, got[0].ConfigFiles) } + +func TestApplySnapshot_StoresBuilt(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + id := newAgent(t, s, "node-2") + built := slusk + built.Built = true + require.NoError(t, s.ApplySnapshot(ctx, id, worker, []protocol.Service{built}, t0)) + + got, err := s.ListServices(ctx) + require.NoError(t, err) + require.Len(t, got, 1) + assert.True(t, got[0].Built) + + // A later report without the flag clears it. + require.NoError(t, s.UpsertService(ctx, id, "", slusk, t0.Add(time.Minute))) + got, err = s.ListServices(ctx) + require.NoError(t, err) + assert.False(t, got[0].Built) +} diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index ec21f14..43b11e8 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -368,7 +368,7 @@ func TestServicePage_ShowsUpdateButton(t *testing.T) { } func TestServicePage_DisabledReason(t *testing.T) { - for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved} { + for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved, model.ReasonBuilt} { t.Run(reason, func(t *testing.T) { srv, _, _, _, jobs := newTestEnv(t) jobs.updatable = &model.NotUpdatableError{Reason: reason}