detached 5f093c2

fix(deployment): record the execution a deploy or restart verified

The first periodic recovery pass after a deploy or restart saw the new container as an unseen execution, withdrew it, and verified it again, which dropped its host for a few tens of milliseconds and defeated the overlap switch. Deploy, restart, rebuild and start now observe the execution start right after the container starts, before readiness, and record it as verified only once the generation is published and routed. A container whose start time changes afterwards is still treated as fresh. Unseen executions at boot behave as before.
commit
5f093c20b4d5ce05724a36a08a16465610913805
author
brice · · 2026-10-16 21:02 UTC
parent
d014001
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +238 -3
M internal/usecase/deployment/candidate.go +9 -1
@@ -8,6 +8,7 @@ import (
88 "slices"
99 "sort"
1010 "strings"
11+ "time"
1112
1213 "github.com/bnema/zerowrap"
1314
@@ -20,6 +21,9 @@ type startedCandidate struct {
2021 Container *domain.Container
2122 TCPBinds map[int]int
2223 UDPBinds map[int]int
24+ // StartedAt is the execution start observed right after the container
25+ // was started, before readiness ran. Zero when it could not be read.
26+ StartedAt time.Time
2327 }
2428
2529 // createAndStart builds the runtime config and starts the replacement.
@@ -123,12 +127,16 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
123127 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
124128 return startedCandidate{Container: created}, fmt.Errorf("deployment: start container: %w", err)
125129 }
130+ // The execution is observed before readiness runs: a native restart
131+ // between readiness and the verified-execution record then shows up as a
132+ // different start time, so it is still treated as a fresh execution.
133+ startedAt := s.executionStart(ctx, created.ID)
126134 binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec))
127135 if err != nil {
128136 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
129137 return startedCandidate{Container: created}, err
130138 }
131- return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds}, nil
139+ return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds, StartedAt: startedAt}, nil
132140 }
133141
134142 // runtimeImageRef resolves the exact runtime image reference of one pinned
@@ -8,6 +8,7 @@ import (
8 "slices"8 "slices"
9 "sort"9 "sort"
10 "strings"10 "strings"
11+ "time"
11 12
12 "github.com/bnema/zerowrap"13 "github.com/bnema/zerowrap"
13 14
@@ -20,6 +21,9 @@ type startedCandidate struct {
20 Container *domain.Container21 Container *domain.Container
21 TCPBinds map[int]int22 TCPBinds map[int]int
22 UDPBinds map[int]int23 UDPBinds map[int]int
24+ // StartedAt is the execution start observed right after the container
25+ // was started, before readiness ran. Zero when it could not be read.
26+ StartedAt time.Time
23 }27 }
24 28
25 // createAndStart builds the runtime config and starts the replacement.29 // createAndStart builds the runtime config and starts the replacement.
@@ -123,12 +127,16 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
123 s.retireCandidate(ctx, app, p.spec.Name, created.ID)127 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
124 return startedCandidate{Container: created}, fmt.Errorf("deployment: start container: %w", err)128 return startedCandidate{Container: created}, fmt.Errorf("deployment: start container: %w", err)
125 }129 }
130+ // The execution is observed before readiness runs: a native restart
131+ // between readiness and the verified-execution record then shows up as a
132+ // different start time, so it is still treated as a fresh execution.
133+ startedAt := s.executionStart(ctx, created.ID)
126 binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec))134 binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec))
127 if err != nil {135 if err != nil {
128 s.retireCandidate(ctx, app, p.spec.Name, created.ID)136 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
129 return startedCandidate{Container: created}, err137 return startedCandidate{Container: created}, err
130 }138 }
131- return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds}, nil139+ return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds, StartedAt: startedAt}, nil
132 }140 }
133 141
134 // runtimeImageRef resolves the exact runtime image reference of one pinned142 // runtimeImageRef resolves the exact runtime image reference of one pinned
M internal/usecase/deployment/crash_recovery_test.go +10 -0
@@ -81,6 +81,7 @@ func TestDeploy_CandidateIsJournaledBeforeItStarts(t *testing.T) {
8181 journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending
8282 return nil
8383 }).Once()
84+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
8485 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
8586 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
8687
@@ -126,6 +127,7 @@ func crashDuringReplacement(t *testing.T, store *appstate.Store, opKey string) {
126127 // The overlap replacement leaves the superseded container untouched.
127128 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-orphan", Name: "web"}, nil).Once()
128129 runtime.EXPECT().StartContainer(mock.Anything, "c-orphan").Return(nil).Once()
130+ runtime.EXPECT().InspectContainer(mock.Anything, "c-orphan").Return(&domain.Container{ID: "c-orphan", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
129131 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-orphan", mock.Anything).Return(
130132 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
131133 // The canceled context also refuses the candidate cleanup, so the
@@ -205,6 +207,7 @@ func TestBootRecovery_RemovesUnpublishedCandidateAndKeepsServingGeneration(t *te
205207 order = append(order, "verify-published-generation")
206208 return true, nil
207209 }).Once()
210+ runtime2.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
208211 runtime2.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
209212 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
210213
@@ -282,6 +285,7 @@ func TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement(t *te
282285 }).Once()
283286 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
284287 runtime.EXPECT().StartContainer(mock.Anything, "c-next").Return(nil).Once()
288+ runtime.EXPECT().InspectContainer(mock.Anything, "c-next").Return(&domain.Container{ID: "c-next", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
285289 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-next", mock.Anything).Return(
286290 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
287291
@@ -379,12 +383,14 @@ func TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer(t *testin
379383 seedRevision(t, ctx, store, "intent-0", "", rev)
380384
381385 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
386+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
382387 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
383388 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
384389 expectNetworkProvision(runtime, "app-blog", 1)
385390 runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
386391 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuild", Name: "web"}, nil).Once()
387392 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuild").Return(nil).Once()
393+ runtime.EXPECT().InspectContainer(mock.Anything, "c-rebuild").Return(&domain.Container{ID: "c-rebuild", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
388394 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuild", mock.Anything).Return(
389395 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
390396 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-rebuild", false).Return(nil, assert.AnError).Once()
@@ -581,12 +587,14 @@ func TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive(t *testin
581587 runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once()
582588 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once()
583589 runtime.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once()
590+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
584591 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
585592 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
586593 expectNetworkProvision(runtime, "app-blog", 1)
587594 runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
588595 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()
589596 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
597+ runtime.EXPECT().InspectContainer(mock.Anything, "c-rebuilt").Return(&domain.Container{ID: "c-rebuilt", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
590598 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
591599 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
592600
@@ -626,11 +634,13 @@ func TestRestart_RebuildsMissingActiveContainer(t *testing.T) {
626634 seedReplaceableApp(t, ctx, store)
627635
628636 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
637+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
629638 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
630639 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
631640 expectNetworkProvision(runtime, "app-blog", 1)
632641 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()
633642 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
643+ runtime.EXPECT().InspectContainer(mock.Anything, "c-rebuilt").Return(&domain.Container{ID: "c-rebuilt", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
634644 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
635645 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
636646
@@ -81,6 +81,7 @@ func TestDeploy_CandidateIsJournaledBeforeItStarts(t *testing.T) {
81 journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending81 journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending
82 return nil82 return nil
83 }).Once()83 }).Once()
84+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
84 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(85 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
85 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()86 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
86 87
@@ -126,6 +127,7 @@ func crashDuringReplacement(t *testing.T, store *appstate.Store, opKey string) {
126 // The overlap replacement leaves the superseded container untouched.127 // The overlap replacement leaves the superseded container untouched.
127 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-orphan", Name: "web"}, nil).Once()128 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-orphan", Name: "web"}, nil).Once()
128 runtime.EXPECT().StartContainer(mock.Anything, "c-orphan").Return(nil).Once()129 runtime.EXPECT().StartContainer(mock.Anything, "c-orphan").Return(nil).Once()
130+ runtime.EXPECT().InspectContainer(mock.Anything, "c-orphan").Return(&domain.Container{ID: "c-orphan", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
129 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-orphan", mock.Anything).Return(131 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-orphan", mock.Anything).Return(
130 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()132 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
131 // The canceled context also refuses the candidate cleanup, so the133 // The canceled context also refuses the candidate cleanup, so the
@@ -205,6 +207,7 @@ func TestBootRecovery_RemovesUnpublishedCandidateAndKeepsServingGeneration(t *te
205 order = append(order, "verify-published-generation")207 order = append(order, "verify-published-generation")
206 return true, nil208 return true, nil
207 }).Once()209 }).Once()
210+ runtime2.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
208 runtime2.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(211 runtime2.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
209 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()212 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
210 213
@@ -282,6 +285,7 @@ func TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement(t *te
282 }).Once()285 }).Once()
283 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()286 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
284 runtime.EXPECT().StartContainer(mock.Anything, "c-next").Return(nil).Once()287 runtime.EXPECT().StartContainer(mock.Anything, "c-next").Return(nil).Once()
288+ runtime.EXPECT().InspectContainer(mock.Anything, "c-next").Return(&domain.Container{ID: "c-next", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
285 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-next", mock.Anything).Return(289 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-next", mock.Anything).Return(
286 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()290 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
287 291
@@ -379,12 +383,14 @@ func TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer(t *testin
379 seedRevision(t, ctx, store, "intent-0", "", rev)383 seedRevision(t, ctx, store, "intent-0", "", rev)
380 384
381 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()385 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
386+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
382 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()387 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
383 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()388 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
384 expectNetworkProvision(runtime, "app-blog", 1)389 expectNetworkProvision(runtime, "app-blog", 1)
385 runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()390 runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
386 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuild", Name: "web"}, nil).Once()391 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuild", Name: "web"}, nil).Once()
387 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuild").Return(nil).Once()392 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuild").Return(nil).Once()
393+ runtime.EXPECT().InspectContainer(mock.Anything, "c-rebuild").Return(&domain.Container{ID: "c-rebuild", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
388 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuild", mock.Anything).Return(394 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuild", mock.Anything).Return(
389 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()395 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
390 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-rebuild", false).Return(nil, assert.AnError).Once()396 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-rebuild", false).Return(nil, assert.AnError).Once()
@@ -581,12 +587,14 @@ func TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive(t *testin
581 runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once()587 runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once()
582 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once()588 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once()
583 runtime.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once()589 runtime.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once()
590+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
584 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()591 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
585 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()592 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
586 expectNetworkProvision(runtime, "app-blog", 1)593 expectNetworkProvision(runtime, "app-blog", 1)
587 runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()594 runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
588 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()595 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()
589 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()596 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
597+ runtime.EXPECT().InspectContainer(mock.Anything, "c-rebuilt").Return(&domain.Container{ID: "c-rebuilt", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
590 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(598 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
591 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()599 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
592 600
@@ -626,11 +634,13 @@ func TestRestart_RebuildsMissingActiveContainer(t *testing.T) {
626 seedReplaceableApp(t, ctx, store)634 seedReplaceableApp(t, ctx, store)
627 635
628 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()636 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
637+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
629 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()638 runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
630 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()639 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
631 expectNetworkProvision(runtime, "app-blog", 1)640 expectNetworkProvision(runtime, "app-blog", 1)
632 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()641 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()
633 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()642 runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
643+ runtime.EXPECT().InspectContainer(mock.Anything, "c-rebuilt").Return(&domain.Container{ID: "c-rebuilt", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
634 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(644 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
635 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()645 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
636 646
M internal/usecase/deployment/deploy_mock_test.go +11 -0
@@ -166,6 +166,7 @@ func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) {
166166 assert.Equal(t, svcSpec.Command, cfg.Entrypoint) && assert.Empty(t, cfg.Cmd)
167167 })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
168168 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
169+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
169170 // No TCP-capable interfaces (HTTP nil, readiness empty): no backend
170171 // binds published, no GetContainerBackendBinds call.
171172 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
@@ -216,6 +217,7 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
216217
217218 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
218219 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
220+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
219221 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
220222 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
221223 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -275,6 +277,7 @@ func TestDeploy_Mockery_HTTPOverlapPublishesBeforeRetiringOldGeneration(t *testi
275277 return &domain.Container{ID: "c-new", Name: "web"}, nil
276278 }).Once()
277279 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
280+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
278281 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
279282 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
280283 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -356,6 +359,7 @@ func TestDeploy_Mockery_HTTPSequentialWhenServiceHasVolume(t *testing.T) {
356359 return &domain.Container{ID: "c-new", Name: "web"}, nil
357360 }).Once()
358361 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
362+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
359363 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
360364 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
361365 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {
@@ -406,6 +410,7 @@ func TestDeploy_Mockery_HTTPReadinessFailureKeepsOldGenerationServing(t *testing
406410
407411 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
408412 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
413+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
409414 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
410415 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
411416 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -523,6 +528,7 @@ func TestDeploy_Mockery_VolumeReplacementMarksUnsafe(t *testing.T) {
523528 return &domain.Container{ID: "c-new", Name: "web"}, nil
524529 }).Once()
525530 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
531+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
526532 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
527533 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
528534 return len(claims) == 1 && claims[0].Port == 19000 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -601,6 +607,7 @@ func TestDeploy_Mockery_MixedTCPUDPPublishesBothLoopbacks(t *testing.T) {
601607 return len(byProto) == 2
602608 })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
603609 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
610+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
604611 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.MatchedBy(func(ports []domain.ContainerBackendPort) bool {
605612 if len(ports) != 2 {
606613 return false
@@ -705,6 +712,7 @@ func TestDeploy_Mockery_InjectsAppEnvAndSecrets(t *testing.T) {
705712 return len(cfg.Env) == 2
706713 })).Return(&domain.Container{ID: "c-worker", Name: "worker"}, nil).Once()
707714 runtime.EXPECT().StartContainer(mock.Anything, mock.Anything).Return(nil)
715+ runtime.EXPECT().InspectContainer(mock.Anything, mock.Anything).Return(&domain.Container{Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
708716 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
709717 App: "blog", Services: map[string]domain.AppEffectiveService{},
710718 }, true, nil)
@@ -762,6 +770,7 @@ func TestStart_RunningContainerRefreshesShiftedBinds(t *testing.T) {
762770 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
763771 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)
764772 // Native restart shifted the ephemeral bind: 32770 -> 32771.
773+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
765774 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
766775 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
767776 return len(claims) == 1 && claims[0].Port == 32771 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-old"
@@ -817,6 +826,7 @@ func TestStart_BindVerificationFailureFailsClosed(t *testing.T) {
817826 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
818827 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
819828 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)
829+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
820830 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(nil, assert.AnError).Once()
821831
822832 traffic := &recordingTraffic{}
@@ -867,6 +877,7 @@ func TestRestart_MixedServiceRefreshesBothProtocols(t *testing.T) {
867877 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
868878 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
869879 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
880+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
870881 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{
871882 {ContainerPort: 9000, HostPort: 32771, Protocol: domain.NetworkProtocolTCP},
872883 {ContainerPort: 9000, HostPort: 32781, Protocol: domain.NetworkProtocolUDP},
@@ -166,6 +166,7 @@ func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) {
166 assert.Equal(t, svcSpec.Command, cfg.Entrypoint) && assert.Empty(t, cfg.Cmd)166 assert.Equal(t, svcSpec.Command, cfg.Entrypoint) && assert.Empty(t, cfg.Cmd)
167 })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()167 })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
168 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()168 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
169+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
169 // No TCP-capable interfaces (HTTP nil, readiness empty): no backend170 // No TCP-capable interfaces (HTTP nil, readiness empty): no backend
170 // binds published, no GetContainerBackendBinds call.171 // binds published, no GetContainerBackendBinds call.
171 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{172 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
@@ -216,6 +217,7 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
216 217
217 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()218 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
218 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()219 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
220+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
219 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()221 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
220 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {222 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
221 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"223 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -275,6 +277,7 @@ func TestDeploy_Mockery_HTTPOverlapPublishesBeforeRetiringOldGeneration(t *testi
275 return &domain.Container{ID: "c-new", Name: "web"}, nil277 return &domain.Container{ID: "c-new", Name: "web"}, nil
276 }).Once()278 }).Once()
277 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()279 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
280+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
278 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()281 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
279 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {282 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
280 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"283 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -356,6 +359,7 @@ func TestDeploy_Mockery_HTTPSequentialWhenServiceHasVolume(t *testing.T) {
356 return &domain.Container{ID: "c-new", Name: "web"}, nil359 return &domain.Container{ID: "c-new", Name: "web"}, nil
357 }).Once()360 }).Once()
358 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()361 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
362+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
359 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()363 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
360 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()364 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
361 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {365 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {
@@ -406,6 +410,7 @@ func TestDeploy_Mockery_HTTPReadinessFailureKeepsOldGenerationServing(t *testing
406 410
407 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()411 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
408 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()412 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
413+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
409 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()414 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
410 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {415 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
411 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"416 return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -523,6 +528,7 @@ func TestDeploy_Mockery_VolumeReplacementMarksUnsafe(t *testing.T) {
523 return &domain.Container{ID: "c-new", Name: "web"}, nil528 return &domain.Container{ID: "c-new", Name: "web"}, nil
524 }).Once()529 }).Once()
525 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()530 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
531+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
526 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()532 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
527 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {533 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
528 return len(claims) == 1 && claims[0].Port == 19000 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"534 return len(claims) == 1 && claims[0].Port == 19000 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
@@ -601,6 +607,7 @@ func TestDeploy_Mockery_MixedTCPUDPPublishesBothLoopbacks(t *testing.T) {
601 return len(byProto) == 2607 return len(byProto) == 2
602 })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()608 })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
603 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()609 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
610+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
604 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.MatchedBy(func(ports []domain.ContainerBackendPort) bool {611 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.MatchedBy(func(ports []domain.ContainerBackendPort) bool {
605 if len(ports) != 2 {612 if len(ports) != 2 {
606 return false613 return false
@@ -705,6 +712,7 @@ func TestDeploy_Mockery_InjectsAppEnvAndSecrets(t *testing.T) {
705 return len(cfg.Env) == 2712 return len(cfg.Env) == 2
706 })).Return(&domain.Container{ID: "c-worker", Name: "worker"}, nil).Once()713 })).Return(&domain.Container{ID: "c-worker", Name: "worker"}, nil).Once()
707 runtime.EXPECT().StartContainer(mock.Anything, mock.Anything).Return(nil)714 runtime.EXPECT().StartContainer(mock.Anything, mock.Anything).Return(nil)
715+ runtime.EXPECT().InspectContainer(mock.Anything, mock.Anything).Return(&domain.Container{Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
708 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{716 state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
709 App: "blog", Services: map[string]domain.AppEffectiveService{},717 App: "blog", Services: map[string]domain.AppEffectiveService{},
710 }, true, nil)718 }, true, nil)
@@ -762,6 +770,7 @@ func TestStart_RunningContainerRefreshesShiftedBinds(t *testing.T) {
762 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()770 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
763 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)771 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)
764 // Native restart shifted the ephemeral bind: 32770 -> 32771.772 // Native restart shifted the ephemeral bind: 32770 -> 32771.
773+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
765 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()774 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
766 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {775 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
767 return len(claims) == 1 && claims[0].Port == 32771 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-old"776 return len(claims) == 1 && claims[0].Port == 32771 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-old"
@@ -817,6 +826,7 @@ func TestStart_BindVerificationFailureFailsClosed(t *testing.T) {
817 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)826 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
818 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()827 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
819 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)828 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)
829+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
820 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(nil, assert.AnError).Once()830 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(nil, assert.AnError).Once()
821 831
822 traffic := &recordingTraffic{}832 traffic := &recordingTraffic{}
@@ -867,6 +877,7 @@ func TestRestart_MixedServiceRefreshesBothProtocols(t *testing.T) {
867 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)877 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
868 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()878 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
869 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()879 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
880+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
870 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{881 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{
871 {ContainerPort: 9000, HostPort: 32771, Protocol: domain.NetworkProtocolTCP},882 {ContainerPort: 9000, HostPort: 32771, Protocol: domain.NetworkProtocolTCP},
872 {ContainerPort: 9000, HostPort: 32781, Protocol: domain.NetworkProtocolUDP},883 {ContainerPort: 9000, HostPort: 32781, Protocol: domain.NetworkProtocolUDP},
M internal/usecase/deployment/deploy_unchanged_test.go +3 -0
@@ -65,6 +65,7 @@ func expectReplacement(runtime *outmocks.MockContainerRuntime) {
6565 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
6666 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
6767 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
68+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
6869 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
6970 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
7071 }
@@ -179,6 +180,8 @@ func TestDeploy_ReplacesWhenTheDigestChanged(t *testing.T) {
179180 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
180181 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
181182 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
183+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
184+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
182185 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
183186 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
184187
@@ -65,6 +65,7 @@ func expectReplacement(runtime *outmocks.MockContainerRuntime) {
65 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()65 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
66 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()66 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
67 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()67 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
68+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
68 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(69 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
69 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()70 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
70 }71 }
@@ -179,6 +180,8 @@ func TestDeploy_ReplacesWhenTheDigestChanged(t *testing.T) {
179 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()180 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
180 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()181 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
181 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()182 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
183+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
184+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
182 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(185 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
183 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()186 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
184 187
M internal/usecase/deployment/device_policy_internal_test.go +1 -0
@@ -127,6 +127,7 @@ func TestCreateAndStart_PassesResolvedCDIIDsToRuntime(t *testing.T) {
127127 return assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, cfg.CDIDevices)
128128 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
129129 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
130+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
130131
131132 svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
132133 WithDevicePolicies(map[string]domain.AppDevicePolicy{
@@ -127,6 +127,7 @@ func TestCreateAndStart_PassesResolvedCDIIDsToRuntime(t *testing.T) {
127 return assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, cfg.CDIDevices)127 return assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, cfg.CDIDevices)
128 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()128 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
129 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()129 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
130+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
130 131
131 svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).132 svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
132 WithDevicePolicies(map[string]domain.AppDevicePolicy{133 WithDevicePolicies(map[string]domain.AppDevicePolicy{
M internal/usecase/deployment/execute.go +4 -0
@@ -407,6 +407,9 @@ func (s *Service) runServiceStep(
407407 result.Services[p.name] = svcResult
408408 return fail(err)
409409 }
410+ // The generation is verified and routed: the periodic pass must not
411+ // mistake its first observation for an unseen execution and withdraw it.
412+ s.recordVerifiedExecution(app, p.name, svcResult)
410413 if overlap {
411414 svcResult = s.retireSuperseded(ctx, app, p.name, before, beforeGrace, svcResult)
412415 }
@@ -796,6 +799,7 @@ func (s *Service) startCandidate(ctx context.Context, app, revision string, p pi
796799 RestartUnsafe: singleWriterRequired(p.spec),
797800 BackendBinds: candidate.TCPBinds,
798801 UDPBackendBinds: candidate.UDPBinds,
802+ VerifiedStartedAt: candidate.StartedAt,
799803 }
800804 }
801805
@@ -407,6 +407,9 @@ func (s *Service) runServiceStep(
407 result.Services[p.name] = svcResult407 result.Services[p.name] = svcResult
408 return fail(err)408 return fail(err)
409 }409 }
410+ // The generation is verified and routed: the periodic pass must not
411+ // mistake its first observation for an unseen execution and withdraw it.
412+ s.recordVerifiedExecution(app, p.name, svcResult)
410 if overlap {413 if overlap {
411 svcResult = s.retireSuperseded(ctx, app, p.name, before, beforeGrace, svcResult)414 svcResult = s.retireSuperseded(ctx, app, p.name, before, beforeGrace, svcResult)
412 }415 }
@@ -796,6 +799,7 @@ func (s *Service) startCandidate(ctx context.Context, app, revision string, p pi
796 RestartUnsafe: singleWriterRequired(p.spec),799 RestartUnsafe: singleWriterRequired(p.spec),
797 BackendBinds: candidate.TCPBinds,800 BackendBinds: candidate.TCPBinds,
798 UDPBackendBinds: candidate.UDPBinds,801 UDPBackendBinds: candidate.UDPBinds,
802+ VerifiedStartedAt: candidate.StartedAt,
799 }803 }
800 }804 }
801 805
A internal/usecase/deployment/execution_start_internal_test.go +8 -0
@@ -0,0 +1,8 @@
1+package deployment
2+
3+import "time"
4+
5+// executionStartedAt is the start time the fakes of the internal tests report
6+// for a freshly started container: deploy and restart observe it right after
7+// the start.
8+var executionStartedAt = time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
@@ -0,0 +1,8 @@
1+package deployment
2+
3+import "time"
4+
5+// executionStartedAt is the start time the fakes of the internal tests report
6+// for a freshly started container: deploy and restart observe it right after
7+// the start.
8+var executionStartedAt = time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
M internal/usecase/deployment/lifecycle.go +17 -2
@@ -212,6 +212,10 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
212212 s.failTrafficPublication(ctx, &op, err)
213213 return result, err
214214 }
215+ // Routing accepted the verified generations: record their executions.
216+ for name, svcResult := range result.Services {
217+ s.recordVerifiedExecution(app, name, svcResult)
218+ }
215219 return result, nil
216220 }
217221
@@ -287,6 +291,11 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
287291 s.failTrafficPublication(ctx, &op, err)
288292 return result, err
289293 }
294+ // Routing accepted the restarted generations: their executions are
295+ // verified. The app lock is held, so no periodic pass ran in between.
296+ for name, svcResult := range result.Services {
297+ s.recordVerifiedExecution(app, name, svcResult)
298+ }
290299 if len(failures) > 0 {
291300 return result, fmt.Errorf("deployment: restart failed for %s: %w", strings.Join(failures, ", "), domain.ErrAppStateConflict)
292301 }
@@ -361,6 +370,8 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string,
361370 s.clearServiceBinds(ctx, app, name)
362371 return fail(err.Error())
363372 }
373+ // The restarted execution is observed before readiness runs.
374+ startedAt := s.executionStart(ctx, eff.Container)
364375 // Ephemeral loopback binds are not guaranteed stable across a runtime
365376 // restart: re-inspect before probing so the proxy never dials a stale
366377 // bind.
@@ -375,7 +386,7 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string,
375386 }
376387 step.State = domain.AppStepSucceeded
377388 step.After = eff.Container
378- return ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}
389+ return ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds, VerifiedStartedAt: startedAt}
379390 }
380391
381392 // Remove withdraws workloads by exact container ID; volumes, secrets, and
@@ -771,6 +782,9 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef
771782 svcResult.Error = err.Error()
772783 return svcResult, err
773784 }
785+ // The rebuilt generation passed readiness and is published; the caller
786+ // applies the graph under the same app lock, before any periodic pass.
787+ s.recordVerifiedExecution(app, name, svcResult)
774788 return svcResult, nil
775789 }
776790
@@ -789,6 +803,7 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
789803 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
790804 return
791805 }
806+ startedAt := s.executionStart(ctx, eff.Container)
792807 binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
793808 if err != nil {
794809 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
@@ -803,7 +818,7 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
803818 // follows once, after the loop.
804819 step.State = domain.AppStepSucceeded
805820 step.After = eff.Container
806- result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}
821+ result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds, VerifiedStartedAt: startedAt}
807822 }
808823
809824 // refreshBackendBinds re-inspects the loopback publishes of a restarted
@@ -212,6 +212,10 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
212 s.failTrafficPublication(ctx, &op, err)212 s.failTrafficPublication(ctx, &op, err)
213 return result, err213 return result, err
214 }214 }
215+ // Routing accepted the verified generations: record their executions.
216+ for name, svcResult := range result.Services {
217+ s.recordVerifiedExecution(app, name, svcResult)
218+ }
215 return result, nil219 return result, nil
216 }220 }
217 221
@@ -287,6 +291,11 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
287 s.failTrafficPublication(ctx, &op, err)291 s.failTrafficPublication(ctx, &op, err)
288 return result, err292 return result, err
289 }293 }
294+ // Routing accepted the restarted generations: their executions are
295+ // verified. The app lock is held, so no periodic pass ran in between.
296+ for name, svcResult := range result.Services {
297+ s.recordVerifiedExecution(app, name, svcResult)
298+ }
290 if len(failures) > 0 {299 if len(failures) > 0 {
291 return result, fmt.Errorf("deployment: restart failed for %s: %w", strings.Join(failures, ", "), domain.ErrAppStateConflict)300 return result, fmt.Errorf("deployment: restart failed for %s: %w", strings.Join(failures, ", "), domain.ErrAppStateConflict)
292 }301 }
@@ -361,6 +370,8 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string,
361 s.clearServiceBinds(ctx, app, name)370 s.clearServiceBinds(ctx, app, name)
362 return fail(err.Error())371 return fail(err.Error())
363 }372 }
373+ // The restarted execution is observed before readiness runs.
374+ startedAt := s.executionStart(ctx, eff.Container)
364 // Ephemeral loopback binds are not guaranteed stable across a runtime375 // Ephemeral loopback binds are not guaranteed stable across a runtime
365 // restart: re-inspect before probing so the proxy never dials a stale376 // restart: re-inspect before probing so the proxy never dials a stale
366 // bind.377 // bind.
@@ -375,7 +386,7 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string,
375 }386 }
376 step.State = domain.AppStepSucceeded387 step.State = domain.AppStepSucceeded
377 step.After = eff.Container388 step.After = eff.Container
378- return ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}389+ return ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds, VerifiedStartedAt: startedAt}
379 }390 }
380 391
381 // Remove withdraws workloads by exact container ID; volumes, secrets, and392 // Remove withdraws workloads by exact container ID; volumes, secrets, and
@@ -771,6 +782,9 @@ func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, ef
771 svcResult.Error = err.Error()782 svcResult.Error = err.Error()
772 return svcResult, err783 return svcResult, err
773 }784 }
785+ // The rebuilt generation passed readiness and is published; the caller
786+ // applies the graph under the same app lock, before any periodic pass.
787+ s.recordVerifiedExecution(app, name, svcResult)
774 return svcResult, nil788 return svcResult, nil
775 }789 }
776 790
@@ -789,6 +803,7 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
789 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())803 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
790 return804 return
791 }805 }
806+ startedAt := s.executionStart(ctx, eff.Container)
792 binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)807 binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
793 if err != nil {808 if err != nil {
794 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())809 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
@@ -803,7 +818,7 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
803 // follows once, after the loop.818 // follows once, after the loop.
804 step.State = domain.AppStepSucceeded819 step.State = domain.AppStepSucceeded
805 step.After = eff.Container820 step.After = eff.Container
806- result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}821+ result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds, VerifiedStartedAt: startedAt}
807 }822 }
808 823
809 // refreshBackendBinds re-inspects the loopback publishes of a restarted824 // refreshBackendBinds re-inspects the loopback publishes of a restarted
M internal/usecase/deployment/monitor.go +26 -0
@@ -295,6 +295,32 @@ func (t *executionTracker) forget(key recoveryKey) {
295295 delete(t.seen, key)
296296 }
297297
298+// executionStart returns the start time of the container's current
299+// execution, or the zero time when it cannot be read: an unknown execution is
300+// simply never recorded as verified, so the periodic pass verifies it.
301+func (s *Service) executionStart(ctx context.Context, containerID string) time.Time {
302+ if s.deps.Runtime == nil || containerID == "" {
303+ return time.Time{}
304+ }
305+ container, err := s.deps.Runtime.InspectContainer(ctx, containerID)
306+ if err != nil || container == nil {
307+ return time.Time{}
308+ }
309+ return container.StartedAt
310+}
311+
312+// recordVerifiedExecution records the execution a deploy or restart verified
313+// and routed, so the first periodic pass after it does not treat the new
314+// generation as an unseen execution, withdraw it, and verify it again. Only a
315+// successful, non-zero observation is recorded; failure paths never are, and
316+// a later native restart still differs from the recorded start time.
317+func (s *Service) recordVerifiedExecution(app, service string, result ServiceResult) {
318+ if result.Result == "failed" || result.After == "" || result.VerifiedStartedAt.IsZero() {
319+ return
320+ }
321+ s.executions.record(recoveryKey{app: app, service: service, container: result.After}, result.VerifiedStartedAt)
322+}
323+
298324 // ReconcileRunning is the daemon-owned periodic recovery pass. It never
299325 // consults DESIRED to select content, never changes intent, never
300326 // pulls, creates, or removes a container, and never starts a
@@ -295,6 +295,32 @@ func (t *executionTracker) forget(key recoveryKey) {
295 delete(t.seen, key)295 delete(t.seen, key)
296 }296 }
297 297
298+// executionStart returns the start time of the container's current
299+// execution, or the zero time when it cannot be read: an unknown execution is
300+// simply never recorded as verified, so the periodic pass verifies it.
301+func (s *Service) executionStart(ctx context.Context, containerID string) time.Time {
302+ if s.deps.Runtime == nil || containerID == "" {
303+ return time.Time{}
304+ }
305+ container, err := s.deps.Runtime.InspectContainer(ctx, containerID)
306+ if err != nil || container == nil {
307+ return time.Time{}
308+ }
309+ return container.StartedAt
310+}
311+
312+// recordVerifiedExecution records the execution a deploy or restart verified
313+// and routed, so the first periodic pass after it does not treat the new
314+// generation as an unseen execution, withdraw it, and verify it again. Only a
315+// successful, non-zero observation is recorded; failure paths never are, and
316+// a later native restart still differs from the recorded start time.
317+func (s *Service) recordVerifiedExecution(app, service string, result ServiceResult) {
318+ if result.Result == "failed" || result.After == "" || result.VerifiedStartedAt.IsZero() {
319+ return
320+ }
321+ s.executions.record(recoveryKey{app: app, service: service, container: result.After}, result.VerifiedStartedAt)
322+}
323+
298 // ReconcileRunning is the daemon-owned periodic recovery pass. It never324 // ReconcileRunning is the daemon-owned periodic recovery pass. It never
299 // consults DESIRED to select content, never changes intent, never325 // consults DESIRED to select content, never changes intent, never
300 // pulls, creates, or removes a container, and never starts a326 // pulls, creates, or removes a container, and never starts a
M internal/usecase/deployment/networks_internal_test.go +4 -0
@@ -48,6 +48,7 @@ func TestCreateAndStart_UsesIncarnationPrivateNetworkAndLimits(t *testing.T) {
4848 assert.Equal(t, "web", cfg.Hostname)
4949 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
5050 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
51+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
5152
5253 svc := networkTestService(t, state, runtime,
5354 NetworkConfig{Prefix: "gordon", Internal: true},
@@ -77,6 +78,7 @@ func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) {
7778 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
7879 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
7980 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
81+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
8082
8183 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
8284 _, err := svc.createAndStart(ctx, "blog", "rev-1",
@@ -124,6 +126,7 @@ func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) {
124126 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
125127 runtime.EXPECT().ConnectContainerToNetwork(mock.Anything, "c-1", shared).Return(nil).Once()
126128 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
129+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
127130
128131 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
129132 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
@@ -155,6 +158,7 @@ func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T
155158 return readOnly && !writable && cfg.ReadOnlyVolumes["/config"] == runtimeName
156159 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
157160 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
161+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
158162
159163 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
160164 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
@@ -48,6 +48,7 @@ func TestCreateAndStart_UsesIncarnationPrivateNetworkAndLimits(t *testing.T) {
48 assert.Equal(t, "web", cfg.Hostname)48 assert.Equal(t, "web", cfg.Hostname)
49 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()49 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
50 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()50 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
51+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
51 52
52 svc := networkTestService(t, state, runtime,53 svc := networkTestService(t, state, runtime,
53 NetworkConfig{Prefix: "gordon", Internal: true},54 NetworkConfig{Prefix: "gordon", Internal: true},
@@ -77,6 +78,7 @@ func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) {
77 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).78 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
78 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()79 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
79 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()80 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
81+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
80 82
81 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})83 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
82 _, err := svc.createAndStart(ctx, "blog", "rev-1",84 _, err := svc.createAndStart(ctx, "blog", "rev-1",
@@ -124,6 +126,7 @@ func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) {
124 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()126 Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
125 runtime.EXPECT().ConnectContainerToNetwork(mock.Anything, "c-1", shared).Return(nil).Once()127 runtime.EXPECT().ConnectContainerToNetwork(mock.Anything, "c-1", shared).Return(nil).Once()
126 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()128 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
129+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
127 130
128 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})131 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
129 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{132 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
@@ -155,6 +158,7 @@ func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T
155 return readOnly && !writable && cfg.ReadOnlyVolumes["/config"] == runtimeName158 return readOnly && !writable && cfg.ReadOnlyVolumes["/config"] == runtimeName
156 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()159 })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once()
157 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()160 runtime.EXPECT().StartContainer(mock.Anything, "c-1").Return(nil).Once()
161+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
158 162
159 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})163 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
160 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{164 _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{
M internal/usecase/deployment/overlap_replacement_test.go +125 -0
@@ -150,12 +150,14 @@ type storeTraffic struct {
150150 store *appstate.Store
151151 failWithdraw int
152152 failRebuild int
153+ withdrawn []string
153154 routed []map[string]map[int]int
154155 }
155156
156157 func (t *storeTraffic) WithdrawService(ctx context.Context, app, service string) error {
157158 t.mu.Lock()
158159 defer t.mu.Unlock()
160+ t.withdrawn = append(t.withdrawn, app+"/"+service)
159161 active, ok, err := t.store.LoadActive(ctx, app)
160162 if err != nil || !ok {
161163 return err
@@ -218,6 +220,13 @@ func (t *storeTraffic) allRoutes(service string) []map[int]int {
218220 return routes
219221 }
220222
223+// withdrawals returns the services withdrawn so far, in order.
224+func (t *storeTraffic) withdrawals() []string {
225+ t.mu.Lock()
226+ defer t.mu.Unlock()
227+ return append([]string(nil), t.withdrawn...)
228+}
229+
221230 func (t *storeTraffic) rebuildCount() int {
222231 t.mu.Lock()
223232 defer t.mu.Unlock()
@@ -259,6 +268,7 @@ func overlapDeployMocks(runtime *outmocks.MockContainerRuntime, images *outmocks
259268 expectNetworkProvision(runtime, "app-blog", 1)
260269 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
261270 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
271+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
262272 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
263273 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
264274 }
@@ -469,6 +479,7 @@ func TestBootRecovery_DoesNotRepublishUnverifiedBindsOfAPublishedCandidate(t *te
469479
470480 // The candidate restarted and now publishes a different port.
471481 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-new").Return(true, nil).Once()
482+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
472483 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
473484 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18090, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
474485 require.NoError(t, svc.ReconcileBoot(ctx))
@@ -843,3 +854,117 @@ func TestStop_PendingRetirementLoadFailureIsAJournaledFailure(t *testing.T) {
843854 assert.Contains(t, replayErr.Error(), domain.AppOutcomeFailed)
844855 runtime.AssertNumberOfCalls(t, "StopContainer", 1)
845856 }
857+
858+// executionStartedAt is the start time the fakes report for a freshly started
859+// container: deploy and restart observe it right after the start.
860+var executionStartedAt = time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
861+
862+// dropInspectExpectations removes the InspectContainer expectations so a test
863+// can report a different execution after a deploy.
864+func dropInspectExpectations(runtime *outmocks.MockContainerRuntime) {
865+ kept := runtime.ExpectedCalls[:0]
866+ for _, call := range runtime.ExpectedCalls {
867+ if call.Method != "InspectContainer" {
868+ kept = append(kept, call)
869+ }
870+ }
871+ runtime.ExpectedCalls = kept
872+}
873+
874+// TestReconcileRunning_DoesNotWithdrawAFreshlyDeployedGeneration is the
875+// regression for a deployed generation being withdrawn by the first periodic
876+// pass: the deploy verified and routed that execution, so the pass must treat
877+// it as already seen. The traffic fake mutates ACTIVE like the real publisher,
878+// so a withdrawal would drop the service's routed binds.
879+func TestReconcileRunning_DoesNotWithdrawAFreshlyDeployedGeneration(t *testing.T) {
880+ ctx := context.Background()
881+ store := newTestStore(t)
882+ runtime := outmocks.NewMockContainerRuntime(t)
883+ images := outmocks.NewMockImageResolver(t)
884+ secrets := outmocks.NewMockSecretProvider(t)
885+ seedServingApp(t, ctx, store, overlapWebSpec(0))
886+ traffic := &storeTraffic{store: store}
887+ svc := overlapService(store, runtime, images, secrets, traffic)
888+
889+ overlapDeployMocks(runtime, images)
890+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
891+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
892+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
893+ require.NoError(t, err)
894+ require.Equal(t, map[int]int{8080: 18081}, traffic.lastRoute("web"))
895+
896+ // The first periodic pass sees the same execution the deploy verified.
897+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c-new").Return("", false, nil)
898+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
899+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil)
900+ require.NoError(t, svc.ReconcileRunning(ctx))
901+
902+ assert.Empty(t, traffic.withdrawals(), "the verified generation is never withdrawn")
903+ for i, routed := range traffic.allRoutes("web") {
904+ assert.Equal(t, map[int]int{8080: 18081}, routed, "graph %d kept routing the service", i)
905+ }
906+ active, _, err := store.LoadActive(ctx, "blog")
907+ require.NoError(t, err)
908+ assert.Equal(t, map[int]int{8080: 18081}, active.Services["web"].BackendBinds)
909+}
910+
911+// TestReconcileRunning_TreatsARestartedExecutionAfterDeployAsFresh proves the
912+// recorded execution is the one observed before readiness: if the container
913+// restarted natively afterwards, its start time differs and the pass withdraws
914+// and verifies it again.
915+func TestReconcileRunning_TreatsARestartedExecutionAfterDeployAsFresh(t *testing.T) {
916+ ctx := context.Background()
917+ store := newTestStore(t)
918+ runtime := outmocks.NewMockContainerRuntime(t)
919+ images := outmocks.NewMockImageResolver(t)
920+ secrets := outmocks.NewMockSecretProvider(t)
921+ seedServingApp(t, ctx, store, overlapWebSpec(0))
922+ traffic := &storeTraffic{store: store}
923+ svc := overlapService(store, runtime, images, secrets, traffic)
924+
925+ overlapDeployMocks(runtime, images)
926+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
927+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
928+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
929+ require.NoError(t, err)
930+
931+ dropInspectExpectations(runtime)
932+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(
933+ &domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt.Add(time.Minute)}, nil)
934+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c-new").Return("", false, nil)
935+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
936+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18090, Protocol: domain.NetworkProtocolTCP}}, nil)
937+ require.NoError(t, svc.ReconcileRunning(ctx))
938+
939+ assert.Equal(t, []string{"blog/web"}, traffic.withdrawals(), "a different execution is withdrawn before it is verified")
940+ assert.Equal(t, map[int]int{8080: 18090}, traffic.lastRoute("web"), "and routed again on its verified binds")
941+}
942+
943+// TestReconcileRunning_DoesNotWithdrawARestartedGeneration covers the restart
944+// path: an in-place restart verified and routed the new execution, so the
945+// first periodic pass keeps it.
946+func TestReconcileRunning_DoesNotWithdrawARestartedGeneration(t *testing.T) {
947+ ctx := context.Background()
948+ store := newTestStore(t)
949+ runtime := outmocks.NewMockContainerRuntime(t)
950+ images := outmocks.NewMockImageResolver(t)
951+ secrets := outmocks.NewMockSecretProvider(t)
952+ seedServingApp(t, ctx, store, overlapWebSpec(0))
953+ traffic := &storeTraffic{store: store}
954+ svc := overlapService(store, runtime, images, secrets, traffic)
955+
956+ restartedAt := executionStartedAt.Add(time.Hour)
957+ runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
958+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(
959+ &domain.Container{ID: "c-old", Status: "running", StartedAt: restartedAt}, nil)
960+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
961+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 17002, Protocol: domain.NetworkProtocolTCP}}, nil)
962+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c-old").Return("", false, nil)
963+ _, err := svc.Restart(ctx, "blog", "web", "")
964+ require.NoError(t, err)
965+ withdrawnByRestart := len(traffic.withdrawals())
966+
967+ require.NoError(t, svc.ReconcileRunning(ctx))
968+ assert.Len(t, traffic.withdrawals(), withdrawnByRestart, "the periodic pass withdraws nothing more")
969+ assert.Equal(t, map[int]int{8080: 17002}, traffic.lastRoute("web"))
970+}
@@ -150,12 +150,14 @@ type storeTraffic struct {
150 store *appstate.Store150 store *appstate.Store
151 failWithdraw int151 failWithdraw int
152 failRebuild int152 failRebuild int
153+ withdrawn []string
153 routed []map[string]map[int]int154 routed []map[string]map[int]int
154 }155 }
155 156
156 func (t *storeTraffic) WithdrawService(ctx context.Context, app, service string) error {157 func (t *storeTraffic) WithdrawService(ctx context.Context, app, service string) error {
157 t.mu.Lock()158 t.mu.Lock()
158 defer t.mu.Unlock()159 defer t.mu.Unlock()
160+ t.withdrawn = append(t.withdrawn, app+"/"+service)
159 active, ok, err := t.store.LoadActive(ctx, app)161 active, ok, err := t.store.LoadActive(ctx, app)
160 if err != nil || !ok {162 if err != nil || !ok {
161 return err163 return err
@@ -218,6 +220,13 @@ func (t *storeTraffic) allRoutes(service string) []map[int]int {
218 return routes220 return routes
219 }221 }
220 222
223+// withdrawals returns the services withdrawn so far, in order.
224+func (t *storeTraffic) withdrawals() []string {
225+ t.mu.Lock()
226+ defer t.mu.Unlock()
227+ return append([]string(nil), t.withdrawn...)
228+}
229+
221 func (t *storeTraffic) rebuildCount() int {230 func (t *storeTraffic) rebuildCount() int {
222 t.mu.Lock()231 t.mu.Lock()
223 defer t.mu.Unlock()232 defer t.mu.Unlock()
@@ -259,6 +268,7 @@ func overlapDeployMocks(runtime *outmocks.MockContainerRuntime, images *outmocks
259 expectNetworkProvision(runtime, "app-blog", 1)268 expectNetworkProvision(runtime, "app-blog", 1)
260 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()269 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
261 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()270 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
271+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
262 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(272 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
263 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()273 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
264 }274 }
@@ -469,6 +479,7 @@ func TestBootRecovery_DoesNotRepublishUnverifiedBindsOfAPublishedCandidate(t *te
469 479
470 // The candidate restarted and now publishes a different port.480 // The candidate restarted and now publishes a different port.
471 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-new").Return(true, nil).Once()481 runtime.EXPECT().IsContainerRunning(mock.Anything, "c-new").Return(true, nil).Once()
482+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
472 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(483 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
473 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18090, Protocol: domain.NetworkProtocolTCP}}, nil).Once()484 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18090, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
474 require.NoError(t, svc.ReconcileBoot(ctx))485 require.NoError(t, svc.ReconcileBoot(ctx))
@@ -843,3 +854,117 @@ func TestStop_PendingRetirementLoadFailureIsAJournaledFailure(t *testing.T) {
843 assert.Contains(t, replayErr.Error(), domain.AppOutcomeFailed)854 assert.Contains(t, replayErr.Error(), domain.AppOutcomeFailed)
844 runtime.AssertNumberOfCalls(t, "StopContainer", 1)855 runtime.AssertNumberOfCalls(t, "StopContainer", 1)
845 }856 }
857+
858+// executionStartedAt is the start time the fakes report for a freshly started
859+// container: deploy and restart observe it right after the start.
860+var executionStartedAt = time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC)
861+
862+// dropInspectExpectations removes the InspectContainer expectations so a test
863+// can report a different execution after a deploy.
864+func dropInspectExpectations(runtime *outmocks.MockContainerRuntime) {
865+ kept := runtime.ExpectedCalls[:0]
866+ for _, call := range runtime.ExpectedCalls {
867+ if call.Method != "InspectContainer" {
868+ kept = append(kept, call)
869+ }
870+ }
871+ runtime.ExpectedCalls = kept
872+}
873+
874+// TestReconcileRunning_DoesNotWithdrawAFreshlyDeployedGeneration is the
875+// regression for a deployed generation being withdrawn by the first periodic
876+// pass: the deploy verified and routed that execution, so the pass must treat
877+// it as already seen. The traffic fake mutates ACTIVE like the real publisher,
878+// so a withdrawal would drop the service's routed binds.
879+func TestReconcileRunning_DoesNotWithdrawAFreshlyDeployedGeneration(t *testing.T) {
880+ ctx := context.Background()
881+ store := newTestStore(t)
882+ runtime := outmocks.NewMockContainerRuntime(t)
883+ images := outmocks.NewMockImageResolver(t)
884+ secrets := outmocks.NewMockSecretProvider(t)
885+ seedServingApp(t, ctx, store, overlapWebSpec(0))
886+ traffic := &storeTraffic{store: store}
887+ svc := overlapService(store, runtime, images, secrets, traffic)
888+
889+ overlapDeployMocks(runtime, images)
890+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
891+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
892+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
893+ require.NoError(t, err)
894+ require.Equal(t, map[int]int{8080: 18081}, traffic.lastRoute("web"))
895+
896+ // The first periodic pass sees the same execution the deploy verified.
897+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c-new").Return("", false, nil)
898+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
899+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil)
900+ require.NoError(t, svc.ReconcileRunning(ctx))
901+
902+ assert.Empty(t, traffic.withdrawals(), "the verified generation is never withdrawn")
903+ for i, routed := range traffic.allRoutes("web") {
904+ assert.Equal(t, map[int]int{8080: 18081}, routed, "graph %d kept routing the service", i)
905+ }
906+ active, _, err := store.LoadActive(ctx, "blog")
907+ require.NoError(t, err)
908+ assert.Equal(t, map[int]int{8080: 18081}, active.Services["web"].BackendBinds)
909+}
910+
911+// TestReconcileRunning_TreatsARestartedExecutionAfterDeployAsFresh proves the
912+// recorded execution is the one observed before readiness: if the container
913+// restarted natively afterwards, its start time differs and the pass withdraws
914+// and verifies it again.
915+func TestReconcileRunning_TreatsARestartedExecutionAfterDeployAsFresh(t *testing.T) {
916+ ctx := context.Background()
917+ store := newTestStore(t)
918+ runtime := outmocks.NewMockContainerRuntime(t)
919+ images := outmocks.NewMockImageResolver(t)
920+ secrets := outmocks.NewMockSecretProvider(t)
921+ seedServingApp(t, ctx, store, overlapWebSpec(0))
922+ traffic := &storeTraffic{store: store}
923+ svc := overlapService(store, runtime, images, secrets, traffic)
924+
925+ overlapDeployMocks(runtime, images)
926+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
927+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
928+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
929+ require.NoError(t, err)
930+
931+ dropInspectExpectations(runtime)
932+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(
933+ &domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt.Add(time.Minute)}, nil)
934+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c-new").Return("", false, nil)
935+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
936+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18090, Protocol: domain.NetworkProtocolTCP}}, nil)
937+ require.NoError(t, svc.ReconcileRunning(ctx))
938+
939+ assert.Equal(t, []string{"blog/web"}, traffic.withdrawals(), "a different execution is withdrawn before it is verified")
940+ assert.Equal(t, map[int]int{8080: 18090}, traffic.lastRoute("web"), "and routed again on its verified binds")
941+}
942+
943+// TestReconcileRunning_DoesNotWithdrawARestartedGeneration covers the restart
944+// path: an in-place restart verified and routed the new execution, so the
945+// first periodic pass keeps it.
946+func TestReconcileRunning_DoesNotWithdrawARestartedGeneration(t *testing.T) {
947+ ctx := context.Background()
948+ store := newTestStore(t)
949+ runtime := outmocks.NewMockContainerRuntime(t)
950+ images := outmocks.NewMockImageResolver(t)
951+ secrets := outmocks.NewMockSecretProvider(t)
952+ seedServingApp(t, ctx, store, overlapWebSpec(0))
953+ traffic := &storeTraffic{store: store}
954+ svc := overlapService(store, runtime, images, secrets, traffic)
955+
956+ restartedAt := executionStartedAt.Add(time.Hour)
957+ runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
958+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(
959+ &domain.Container{ID: "c-old", Status: "running", StartedAt: restartedAt}, nil)
960+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
961+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 17002, Protocol: domain.NetworkProtocolTCP}}, nil)
962+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "c-old").Return("", false, nil)
963+ _, err := svc.Restart(ctx, "blog", "web", "")
964+ require.NoError(t, err)
965+ withdrawnByRestart := len(traffic.withdrawals())
966+
967+ require.NoError(t, svc.ReconcileRunning(ctx))
968+ assert.Len(t, traffic.withdrawals(), withdrawnByRestart, "the periodic pass withdraws nothing more")
969+ assert.Equal(t, map[int]int{8080: 17002}, traffic.lastRoute("web"))
970+}
M internal/usecase/deployment/preflight_gate_test.go +1 -0
@@ -271,6 +271,7 @@ func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
271271 expectNetworkProvision(runtime, "app-blog", 1)
272272 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
273273 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
274+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
274275 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
275276 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
276277 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
@@ -271,6 +271,7 @@ func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
271 expectNetworkProvision(runtime, "app-blog", 1)271 expectNetworkProvision(runtime, "app-blog", 1)
272 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()272 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
273 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()273 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
274+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
274 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)275 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
275 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)276 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
276 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)277 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
M internal/usecase/deployment/publication_internal_test.go +2 -0
@@ -39,6 +39,7 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
3939 Spec: readySpec(),
4040 BackendBinds: map[int]int{8080: 32771},
4141 }
42+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
4243 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
4344 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
4445 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -71,6 +72,7 @@ func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) {
7172 runtime := outmocks.NewMockContainerRuntime(t)
7273 traffic := &stubTraffic{}
7374 eff := domain.AppEffectiveService{Container: "c-1", EffectiveRevision: "rev-1", Spec: readySpec()}
75+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
7476 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
7577 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
7678 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -39,6 +39,7 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
39 Spec: readySpec(),39 Spec: readySpec(),
40 BackendBinds: map[int]int{8080: 32771},40 BackendBinds: map[int]int{8080: 32771},
41 }41 }
42+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
42 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(43 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
43 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()44 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
44 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()45 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -71,6 +72,7 @@ func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) {
71 runtime := outmocks.NewMockContainerRuntime(t)72 runtime := outmocks.NewMockContainerRuntime(t)
72 traffic := &stubTraffic{}73 traffic := &stubTraffic{}
73 eff := domain.AppEffectiveService{Container: "c-1", EffectiveRevision: "rev-1", Spec: readySpec()}74 eff := domain.AppEffectiveService{Container: "c-1", EffectiveRevision: "rev-1", Spec: readySpec()}
75+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
74 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(76 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
75 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()77 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
76 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()78 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
M internal/usecase/deployment/restart_inplace_test.go +2 -0
@@ -78,6 +78,7 @@ func TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes(t *testing.T) {
7878 recordEvent(&order, "restart")
7979 return nil
8080 }).Once()
81+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
8182 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
8283 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
8384 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -135,6 +136,7 @@ func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing
135136 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
136137 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
137138 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
139+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
138140 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
139141 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
140142 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -78,6 +78,7 @@ func TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes(t *testing.T) {
78 recordEvent(&order, "restart")78 recordEvent(&order, "restart")
79 return nil79 return nil
80 }).Once()80 }).Once()
81+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
81 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(82 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
82 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()83 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
83 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()84 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -135,6 +136,7 @@ func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing
135 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)136 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
136 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()137 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
137 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()138 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
139+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
138 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(140 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
139 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()141 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
140 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()142 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
M internal/usecase/deployment/retirement_test.go +1 -0
@@ -59,6 +59,7 @@ func TestLifecycleVerbsPassTheEffectiveStopGrace(t *testing.T) {
5959 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
6060 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
6161 runtime.EXPECT().RestartContainer(mock.Anything, "c-1", grace).Return(nil).Once()
62+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
6263 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
6364
6465 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
@@ -59,6 +59,7 @@ func TestLifecycleVerbsPassTheEffectiveStopGrace(t *testing.T) {
59 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)59 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
60 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()60 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
61 runtime.EXPECT().RestartContainer(mock.Anything, "c-1", grace).Return(nil).Once()61 runtime.EXPECT().RestartContainer(mock.Anything, "c-1", grace).Return(nil).Once()
62+ runtime.EXPECT().InspectContainer(mock.Anything, "c-1").Return(&domain.Container{ID: "c-1", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
62 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)63 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
63 64
64 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())65 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
M internal/usecase/deployment/sequential_replacement_test.go +5 -0
@@ -112,6 +112,9 @@ func expectSequentialCreate(
112112 recordEvent(events, "start-new")
113113 return nil
114114 }).Once()
115+ // Deploy observes the execution start right after the start.
116+ runtime.EXPECT().InspectContainer(mock.Anything, containerID).Return(
117+ &domain.Container{ID: containerID, Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
115118 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, containerID, mock.Anything).Return(
116119 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: hostPort, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
117120 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -646,6 +649,7 @@ func TestDeploy_MultiServiceStopsAtFirstFailure(t *testing.T) {
646649 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
647650 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-beta", Name: "beta"}, nil).Once()
648651 runtime.EXPECT().StartContainer(mock.Anything, "c-beta").Return(nil).Once()
652+ runtime.EXPECT().InspectContainer(mock.Anything, "c-beta").Return(&domain.Container{ID: "c-beta", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
649653 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-beta", false).Return(nil, assert.AnError).Once()
650654 runtime.EXPECT().RemoveContainer(mock.Anything, "c-beta", true).Return(nil).Once()
651655 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-beta").Return(nil).Once()
@@ -771,6 +775,7 @@ func TestDeploy_VolumeReplacementPreventsOverlappingWriters(t *testing.T) {
771775 recordEvent(&order, "start-new")
772776 return nil
773777 }).Once()
778+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
774779 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
775780 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
776781 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()
@@ -112,6 +112,9 @@ func expectSequentialCreate(
112 recordEvent(events, "start-new")112 recordEvent(events, "start-new")
113 return nil113 return nil
114 }).Once()114 }).Once()
115+ // Deploy observes the execution start right after the start.
116+ runtime.EXPECT().InspectContainer(mock.Anything, containerID).Return(
117+ &domain.Container{ID: containerID, Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
115 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, containerID, mock.Anything).Return(118 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, containerID, mock.Anything).Return(
116 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: hostPort, Protocol: domain.NetworkProtocolTCP}}, nil).Once()119 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: hostPort, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
117 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()120 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -646,6 +649,7 @@ func TestDeploy_MultiServiceStopsAtFirstFailure(t *testing.T) {
646 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()649 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
647 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-beta", Name: "beta"}, nil).Once()650 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-beta", Name: "beta"}, nil).Once()
648 runtime.EXPECT().StartContainer(mock.Anything, "c-beta").Return(nil).Once()651 runtime.EXPECT().StartContainer(mock.Anything, "c-beta").Return(nil).Once()
652+ runtime.EXPECT().InspectContainer(mock.Anything, "c-beta").Return(&domain.Container{ID: "c-beta", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
649 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-beta", false).Return(nil, assert.AnError).Once()653 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-beta", false).Return(nil, assert.AnError).Once()
650 runtime.EXPECT().RemoveContainer(mock.Anything, "c-beta", true).Return(nil).Once()654 runtime.EXPECT().RemoveContainer(mock.Anything, "c-beta", true).Return(nil).Once()
651 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-beta").Return(nil).Once()655 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-beta").Return(nil).Once()
@@ -771,6 +775,7 @@ func TestDeploy_VolumeReplacementPreventsOverlappingWriters(t *testing.T) {
771 recordEvent(&order, "start-new")775 recordEvent(&order, "start-new")
772 return nil776 return nil
773 }).Once()777 }).Once()
778+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
774 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(779 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
775 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()780 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
776 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()781 runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()
M internal/usecase/deployment/service.go +4 -0
@@ -385,6 +385,10 @@ type ServiceResult struct {
385385 After string
386386 RestartUnsafe bool
387387 Error string
388+ // VerifiedStartedAt is the execution start of After observed before its
389+ // readiness ran. The caller records it as a verified execution once the
390+ // generation is published and routed. Zero when unknown.
391+ VerifiedStartedAt time.Time
388392 // SupersededRunning is set by an overlap replacement: the container in
389393 // Before still runs beside After and carries a retirement-pending marker
390394 // until it is retired.
@@ -385,6 +385,10 @@ type ServiceResult struct {
385 After string385 After string
386 RestartUnsafe bool386 RestartUnsafe bool
387 Error string387 Error string
388+ // VerifiedStartedAt is the execution start of After observed before its
389+ // readiness ran. The caller records it as a verified execution once the
390+ // generation is published and routed. Zero when unknown.
391+ VerifiedStartedAt time.Time
388 // SupersededRunning is set by an overlap replacement: the container in392 // SupersededRunning is set by an overlap replacement: the container in
389 // Before still runs beside After and carries a retirement-pending marker393 // Before still runs beside After and carries a retirement-pending marker
390 // until it is retired.394 // until it is retired.
M internal/usecase/deployment/service_test.go +2 -0
@@ -105,6 +105,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
105105 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
106106 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()
107107 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-bad").Return(true, nil).Once()
108+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-bad").Return(&domain.Container{ID: "ctr-bad", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
108109 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-bad", mock.Anything).Return(nil, assert.AnError).Once()
109110 // No traffic boundary is wired here, so the fail-closed bind
110111 // withdrawal is a no-op and deployment writes no ACTIVE binds itself.
@@ -115,6 +116,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
115116 state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()
116117 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()
117118 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()
119+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-good").Return(&domain.Container{ID: "ctr-good", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
118120 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
119121 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
120122 // Bind refresh persists the new bind (differs from recorded nil).
@@ -105,6 +105,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
105 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)105 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
106 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()106 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()
107 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-bad").Return(true, nil).Once()107 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-bad").Return(true, nil).Once()
108+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-bad").Return(&domain.Container{ID: "ctr-bad", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
108 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-bad", mock.Anything).Return(nil, assert.AnError).Once()109 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-bad", mock.Anything).Return(nil, assert.AnError).Once()
109 // No traffic boundary is wired here, so the fail-closed bind110 // No traffic boundary is wired here, so the fail-closed bind
110 // withdrawal is a no-op and deployment writes no ACTIVE binds itself.111 // withdrawal is a no-op and deployment writes no ACTIVE binds itself.
@@ -115,6 +116,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
115 state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()116 state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()
116 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()117 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()
117 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()118 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()
119+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-good").Return(&domain.Container{ID: "ctr-good", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
118 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()120 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
119 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()121 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
120 // Bind refresh persists the new bind (differs from recorded nil).122 // Bind refresh persists the new bind (differs from recorded nil).
M internal/usecase/deployment/traffic_failure_test.go +1 -0
@@ -54,6 +54,7 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
5454 // the failed switch: no stop, no removal, no claim release.
5555 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
5656 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
57+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
5758 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
5859 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
5960 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
@@ -54,6 +54,7 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
54 // the failed switch: no stop, no removal, no claim release.54 // the failed switch: no stop, no removal, no claim release.
55 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()55 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
56 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()56 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
57+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
57 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(58 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
58 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()59 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
59 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()60 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
M internal/usecase/deployment/two_phase_deploy_test.go +2 -0
@@ -144,6 +144,7 @@ func expectHeadlessDeploy(
144144 expectNetworkProvision(runtime, "app-blog", 1)
145145 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil)
146146 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil)
147+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
147148 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
148149 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
149150 return rev, saved
@@ -352,6 +353,7 @@ func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) {
352353 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
353354 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
354355 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
356+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
355357 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
356358 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
357359
@@ -144,6 +144,7 @@ func expectHeadlessDeploy(
144 expectNetworkProvision(runtime, "app-blog", 1)144 expectNetworkProvision(runtime, "app-blog", 1)
145 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil)145 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil)
146 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil)146 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil)
147+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
147 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)148 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
148 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)149 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
149 return rev, saved150 return rev, saved
@@ -352,6 +353,7 @@ func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) {
352 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()353 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
353 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()354 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
354 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()355 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
356+ runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
355 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(357 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
356 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()358 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
357 359

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close