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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +238 -3
M internal/usecase/deployment/candidate.go +9 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "slices" | |
| 9 | 9 | "sort" | |
| 10 | 10 | "strings" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/zerowrap" | |
| 13 | 14 | ||
| @@ -20,6 +21,9 @@ type startedCandidate struct { | |||
| 20 | 21 | Container *domain.Container | |
| 21 | 22 | TCPBinds map[int]int | |
| 22 | 23 | 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 | 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 | 127 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | |
| 124 | 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 | 134 | binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec)) | |
| 127 | 135 | if err != nil { | |
| 128 | 136 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | |
| 129 | 137 | return startedCandidate{Container: created}, err | |
| 130 | 138 | } | |
| 131 | - | return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds}, nil | |
| 139 | + | return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds, StartedAt: startedAt}, nil | |
| 132 | 140 | } | |
| 133 | 141 | ||
| 134 | 142 | // 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.Container | 21 | Container *domain.Container | ||
| 21 | TCPBinds map[int]int | 22 | TCPBinds map[int]int | ||
| 22 | UDPBinds map[int]int | 23 | 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}, err | 137 | return startedCandidate{Container: created}, err | ||
| 130 | } | 138 | } | ||
| 131 | - | return startedCandidate{Container: created, TCPBinds: binds, UDPBinds: udpBinds}, nil | 139 | + | 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 pinned | 142 | // 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) { | |||
| 81 | 81 | journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending | |
| 82 | 82 | return nil | |
| 83 | 83 | }).Once() | |
| 84 | + | runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe() | |
| 84 | 85 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 85 | 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 | 127 | // The overlap replacement leaves the superseded container untouched. | |
| 127 | 128 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-orphan", Name: "web"}, nil).Once() | |
| 128 | 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 | 131 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-orphan", mock.Anything).Return( | |
| 130 | 132 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 131 | 133 | // The canceled context also refuses the candidate cleanup, so the | |
| @@ -205,6 +207,7 @@ func TestBootRecovery_RemovesUnpublishedCandidateAndKeepsServingGeneration(t *te | |||
| 205 | 207 | order = append(order, "verify-published-generation") | |
| 206 | 208 | return true, nil | |
| 207 | 209 | }).Once() | |
| 210 | + | runtime2.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe() | |
| 208 | 211 | runtime2.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return( | |
| 209 | 212 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 210 | 213 | ||
| @@ -282,6 +285,7 @@ func TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement(t *te | |||
| 282 | 285 | }).Once() | |
| 283 | 286 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |
| 284 | 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 | 289 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-next", mock.Anything).Return( | |
| 286 | 290 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 287 | 291 | ||
| @@ -379,12 +383,14 @@ func TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer(t *testin | |||
| 379 | 383 | seedRevision(t, ctx, store, "intent-0", "", rev) | |
| 380 | 384 | ||
| 381 | 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 | 387 | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |
| 383 | 388 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |
| 384 | 389 | expectNetworkProvision(runtime, "app-blog", 1) | |
| 385 | 390 | runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() | |
| 386 | 391 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuild", Name: "web"}, nil).Once() | |
| 387 | 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 | 394 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuild", mock.Anything).Return( | |
| 389 | 395 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 390 | 396 | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-rebuild", false).Return(nil, assert.AnError).Once() | |
| @@ -581,12 +587,14 @@ func TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive(t *testin | |||
| 581 | 587 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once() | |
| 582 | 588 | runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once() | |
| 583 | 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 | 591 | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |
| 585 | 592 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |
| 586 | 593 | expectNetworkProvision(runtime, "app-blog", 1) | |
| 587 | 594 | runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() | |
| 588 | 595 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once() | |
| 589 | 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 | 598 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return( | |
| 591 | 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 | 634 | seedReplaceableApp(t, ctx, store) | |
| 627 | 635 | ||
| 628 | 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 | 638 | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |
| 630 | 639 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |
| 631 | 640 | expectNetworkProvision(runtime, "app-blog", 1) | |
| 632 | 641 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once() | |
| 633 | 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 | 644 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return( | |
| 635 | 645 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 636 | 646 | ||
| @@ -81,6 +81,7 @@ func TestDeploy_CandidateIsJournaledBeforeItStarts(t *testing.T) { | |||||
| 81 | journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending | 81 | journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending | ||
| 82 | return nil | 82 | 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 the | 133 | // 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, nil | 208 | 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) { | |||
| 166 | 166 | assert.Equal(t, svcSpec.Command, cfg.Entrypoint) && assert.Empty(t, cfg.Cmd) | |
| 167 | 167 | })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 168 | 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 | 170 | // No TCP-capable interfaces (HTTP nil, readiness empty): no backend | |
| 170 | 171 | // binds published, no GetContainerBackendBinds call. | |
| 171 | 172 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |
| @@ -216,6 +217,7 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) { | |||
| 216 | 217 | ||
| 217 | 218 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 218 | 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 | 221 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 220 | 222 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | |
| 221 | 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 | 277 | return &domain.Container{ID: "c-new", Name: "web"}, nil | |
| 276 | 278 | }).Once() | |
| 277 | 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 | 281 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 279 | 282 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | |
| 280 | 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 | 359 | return &domain.Container{ID: "c-new", Name: "web"}, nil | |
| 357 | 360 | }).Once() | |
| 358 | 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 | 363 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 360 | 364 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |
| 361 | 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 | 411 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 408 | 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 | 414 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 410 | 415 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | |
| 411 | 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 | 528 | return &domain.Container{ID: "c-new", Name: "web"}, nil | |
| 524 | 529 | }).Once() | |
| 525 | 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 | 532 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 527 | 533 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | |
| 528 | 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 | 607 | return len(byProto) == 2 | |
| 602 | 608 | })).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 603 | 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 | 611 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.MatchedBy(func(ports []domain.ContainerBackendPort) bool { | |
| 605 | 612 | if len(ports) != 2 { | |
| 606 | 613 | return false | |
| @@ -705,6 +712,7 @@ func TestDeploy_Mockery_InjectsAppEnvAndSecrets(t *testing.T) { | |||
| 705 | 712 | return len(cfg.Env) == 2 | |
| 706 | 713 | })).Return(&domain.Container{ID: "c-worker", Name: "worker"}, nil).Once() | |
| 707 | 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 | 716 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |
| 709 | 717 | App: "blog", Services: map[string]domain.AppEffectiveService{}, | |
| 710 | 718 | }, true, nil) | |
| @@ -762,6 +770,7 @@ func TestStart_RunningContainerRefreshesShiftedBinds(t *testing.T) { | |||
| 762 | 770 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |
| 763 | 771 | runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil) | |
| 764 | 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 | 774 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 766 | 775 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | |
| 767 | 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 | 826 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |
| 818 | 827 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |
| 819 | 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 | 830 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(nil, assert.AnError).Once() | |
| 821 | 831 | ||
| 822 | 832 | traffic := &recordingTraffic{} | |
| @@ -867,6 +877,7 @@ func TestRestart_MixedServiceRefreshesBothProtocols(t *testing.T) { | |||
| 867 | 877 | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |
| 868 | 878 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |
| 869 | 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 | 881 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{ | |
| 871 | 882 | {ContainerPort: 9000, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}, | |
| 872 | 883 | {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 backend | 170 | // 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"}, nil | 277 | 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"}, nil | 359 | 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"}, nil | 528 | 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) == 2 | 607 | 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 false | 613 | return false | ||
| @@ -705,6 +712,7 @@ func TestDeploy_Mockery_InjectsAppEnvAndSecrets(t *testing.T) { | |||||
| 705 | return len(cfg.Env) == 2 | 712 | 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) { | |||
| 65 | 65 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |
| 66 | 66 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 67 | 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 | 69 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 69 | 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 | 180 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |
| 180 | 181 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 181 | 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 | 185 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 183 | 186 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 184 | 187 | ||
| @@ -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) { | |||
| 127 | 127 | return assert.Equal(t, []string{"example.com/gpu=GPU-test-uuid"}, cfg.CDIDevices) | |
| 128 | 128 | })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once() | |
| 129 | 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 | 132 | svc := NewService(Deps{State: state, Runtime: runtime, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()). | |
| 132 | 133 | 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( | |||
| 407 | 407 | result.Services[p.name] = svcResult | |
| 408 | 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 | 413 | if overlap { | |
| 411 | 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 | 799 | RestartUnsafe: singleWriterRequired(p.spec), | |
| 797 | 800 | BackendBinds: candidate.TCPBinds, | |
| 798 | 801 | UDPBackendBinds: candidate.UDPBinds, | |
| 802 | + | VerifiedStartedAt: candidate.StartedAt, | |
| 799 | 803 | } | |
| 800 | 804 | } | |
| 801 | 805 | ||
| @@ -407,6 +407,9 @@ func (s *Service) runServiceStep( | |||||
| 407 | result.Services[p.name] = svcResult | 407 | 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 | |||
| 212 | 212 | s.failTrafficPublication(ctx, &op, err) | |
| 213 | 213 | 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 | 219 | return result, nil | |
| 216 | 220 | } | |
| 217 | 221 | ||
| @@ -287,6 +291,11 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string) | |||
| 287 | 291 | s.failTrafficPublication(ctx, &op, err) | |
| 288 | 292 | 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 | 299 | if len(failures) > 0 { | |
| 291 | 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 | 370 | s.clearServiceBinds(ctx, app, name) | |
| 362 | 371 | return fail(err.Error()) | |
| 363 | 372 | } | |
| 373 | + | // The restarted execution is observed before readiness runs. | |
| 374 | + | startedAt := s.executionStart(ctx, eff.Container) | |
| 364 | 375 | // Ephemeral loopback binds are not guaranteed stable across a runtime | |
| 365 | 376 | // restart: re-inspect before probing so the proxy never dials a stale | |
| 366 | 377 | // bind. | |
| @@ -375,7 +386,7 @@ func (s *Service) restartOneService(ctx context.Context, app, opID, name string, | |||
| 375 | 386 | } | |
| 376 | 387 | step.State = domain.AppStepSucceeded | |
| 377 | 388 | 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 | 392 | // 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 | 782 | svcResult.Error = err.Error() | |
| 772 | 783 | 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 | 788 | return svcResult, nil | |
| 775 | 789 | } | |
| 776 | 790 | ||
| @@ -789,6 +803,7 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef | |||
| 789 | 803 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | |
| 790 | 804 | return | |
| 791 | 805 | } | |
| 806 | + | startedAt := s.executionStart(ctx, eff.Container) | |
| 792 | 807 | binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil) | |
| 793 | 808 | if err != nil { | |
| 794 | 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 | 818 | // follows once, after the loop. | |
| 804 | 819 | step.State = domain.AppStepSucceeded | |
| 805 | 820 | 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 | 824 | // 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, err | 213 | 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, nil | 219 | 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, err | 292 | 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 runtime | 375 | // Ephemeral loopback binds are not guaranteed stable across a runtime | ||
| 365 | // restart: re-inspect before probing so the proxy never dials a stale | 376 | // 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.AppStepSucceeded | 387 | step.State = domain.AppStepSucceeded | ||
| 377 | step.After = eff.Container | 388 | 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, and | 392 | // 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, err | 783 | 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, nil | 788 | 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 | return | 804 | 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.AppStepSucceeded | 819 | step.State = domain.AppStepSucceeded | ||
| 805 | step.After = eff.Container | 820 | 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 restarted | 824 | // 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) { | |||
| 295 | 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 | 324 | // ReconcileRunning is the daemon-owned periodic recovery pass. It never | |
| 299 | 325 | // consults DESIRED to select content, never changes intent, never | |
| 300 | 326 | // 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 never | 324 | // ReconcileRunning is the daemon-owned periodic recovery pass. It never | ||
| 299 | // consults DESIRED to select content, never changes intent, never | 325 | // consults DESIRED to select content, never changes intent, never | ||
| 300 | // pulls, creates, or removes a container, and never starts a | 326 | // 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) { | |||
| 48 | 48 | assert.Equal(t, "web", cfg.Hostname) | |
| 49 | 49 | })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once() | |
| 50 | 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 | 53 | svc := networkTestService(t, state, runtime, | |
| 53 | 54 | NetworkConfig{Prefix: "gordon", Internal: true}, | |
| @@ -77,6 +78,7 @@ func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) { | |||
| 77 | 78 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything). | |
| 78 | 79 | Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once() | |
| 79 | 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 | 83 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | |
| 82 | 84 | _, err := svc.createAndStart(ctx, "blog", "rev-1", | |
| @@ -124,6 +126,7 @@ func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) { | |||
| 124 | 126 | Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once() | |
| 125 | 127 | runtime.EXPECT().ConnectContainerToNetwork(mock.Anything, "c-1", shared).Return(nil).Once() | |
| 126 | 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 | 131 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | |
| 129 | 132 | _, err := svc.createAndStart(ctx, "blog", "rev-1", pinnedService{ | |
| @@ -155,6 +158,7 @@ func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T | |||
| 155 | 158 | return readOnly && !writable && cfg.ReadOnlyVolumes["/config"] == runtimeName | |
| 156 | 159 | })).Return(&domain.Container{ID: "c-1", Name: "web"}, nil).Once() | |
| 157 | 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 | 163 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | |
| 160 | 164 | _, 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"] == runtimeName | 158 | 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 { | |||
| 150 | 150 | store *appstate.Store | |
| 151 | 151 | failWithdraw int | |
| 152 | 152 | failRebuild int | |
| 153 | + | withdrawn []string | |
| 153 | 154 | routed []map[string]map[int]int | |
| 154 | 155 | } | |
| 155 | 156 | ||
| 156 | 157 | func (t *storeTraffic) WithdrawService(ctx context.Context, app, service string) error { | |
| 157 | 158 | t.mu.Lock() | |
| 158 | 159 | defer t.mu.Unlock() | |
| 160 | + | t.withdrawn = append(t.withdrawn, app+"/"+service) | |
| 159 | 161 | active, ok, err := t.store.LoadActive(ctx, app) | |
| 160 | 162 | if err != nil || !ok { | |
| 161 | 163 | return err | |
| @@ -218,6 +220,13 @@ func (t *storeTraffic) allRoutes(service string) []map[int]int { | |||
| 218 | 220 | 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 | 230 | func (t *storeTraffic) rebuildCount() int { | |
| 222 | 231 | t.mu.Lock() | |
| 223 | 232 | defer t.mu.Unlock() | |
| @@ -259,6 +268,7 @@ func overlapDeployMocks(runtime *outmocks.MockContainerRuntime, images *outmocks | |||
| 259 | 268 | expectNetworkProvision(runtime, "app-blog", 1) | |
| 260 | 269 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 261 | 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 | 272 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 263 | 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 | 480 | // The candidate restarted and now publishes a different port. | |
| 471 | 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 | 483 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 473 | 484 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18090, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 474 | 485 | require.NoError(t, svc.ReconcileBoot(ctx)) | |
| @@ -843,3 +854,117 @@ func TestStop_PendingRetirementLoadFailureIsAJournaledFailure(t *testing.T) { | |||
| 843 | 854 | assert.Contains(t, replayErr.Error(), domain.AppOutcomeFailed) | |
| 844 | 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 | + | } | |
| @@ -150,12 +150,14 @@ type storeTraffic struct { | |||||
| 150 | store *appstate.Store | 150 | store *appstate.Store | ||
| 151 | failWithdraw int | 151 | failWithdraw int | ||
| 152 | failRebuild int | 152 | failRebuild int | ||
| 153 | + | withdrawn []string | |||
| 153 | routed []map[string]map[int]int | 154 | 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 err | 163 | return err | ||
| @@ -218,6 +220,13 @@ func (t *storeTraffic) allRoutes(service string) []map[int]int { | |||||
| 218 | return routes | 220 | 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) { | |||
| 271 | 271 | expectNetworkProvision(runtime, "app-blog", 1) | |
| 272 | 272 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 273 | 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 | 275 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | |
| 275 | 276 | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |
| 276 | 277 | 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) { | |||
| 39 | 39 | Spec: readySpec(), | |
| 40 | 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 | 43 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return( | |
| 43 | 44 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 44 | 45 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |
| @@ -71,6 +72,7 @@ func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) { | |||
| 71 | 72 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 72 | 73 | traffic := &stubTraffic{} | |
| 73 | 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 | 76 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return( | |
| 75 | 77 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 76 | 78 | 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) { | |||
| 78 | 78 | recordEvent(&order, "restart") | |
| 79 | 79 | return nil | |
| 80 | 80 | }).Once() | |
| 81 | + | runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe() | |
| 81 | 82 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return( | |
| 82 | 83 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 83 | 84 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |
| @@ -135,6 +136,7 @@ func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing | |||
| 135 | 136 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |
| 136 | 137 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |
| 137 | 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 | 140 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return( | |
| 139 | 141 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 140 | 142 | 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 nil | 79 | 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) { | |||
| 59 | 59 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |
| 60 | 60 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |
| 61 | 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 | 63 | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |
| 63 | 64 | ||
| 64 | 65 | 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( | |||
| 112 | 112 | recordEvent(events, "start-new") | |
| 113 | 113 | return nil | |
| 114 | 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 | 118 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, containerID, mock.Anything).Return( | |
| 116 | 119 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: hostPort, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 117 | 120 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |
| @@ -646,6 +649,7 @@ func TestDeploy_MultiServiceStopsAtFirstFailure(t *testing.T) { | |||
| 646 | 649 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |
| 647 | 650 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-beta", Name: "beta"}, nil).Once() | |
| 648 | 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 | 653 | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-beta", false).Return(nil, assert.AnError).Once() | |
| 650 | 654 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-beta", true).Return(nil).Once() | |
| 651 | 655 | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-beta").Return(nil).Once() | |
| @@ -771,6 +775,7 @@ func TestDeploy_VolumeReplacementPreventsOverlappingWriters(t *testing.T) { | |||
| 771 | 775 | recordEvent(&order, "start-new") | |
| 772 | 776 | return nil | |
| 773 | 777 | }).Once() | |
| 778 | + | runtime.EXPECT().InspectContainer(mock.Anything, "c-new").Return(&domain.Container{ID: "c-new", Status: "running", StartedAt: executionStartedAt}, nil).Maybe() | |
| 774 | 779 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 775 | 780 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 776 | 781 | 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 nil | 113 | 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 nil | 776 | 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 { | |||
| 385 | 385 | After string | |
| 386 | 386 | RestartUnsafe bool | |
| 387 | 387 | 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 | 392 | // SupersededRunning is set by an overlap replacement: the container in | |
| 389 | 393 | // Before still runs beside After and carries a retirement-pending marker | |
| 390 | 394 | // until it is retired. | |
| @@ -385,6 +385,10 @@ type ServiceResult struct { | |||||
| 385 | After string | 385 | After string | ||
| 386 | RestartUnsafe bool | 386 | RestartUnsafe bool | ||
| 387 | Error string | 387 | 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 in | 392 | // SupersededRunning is set by an overlap replacement: the container in | ||
| 389 | // Before still runs beside After and carries a retirement-pending marker | 393 | // 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) { | |||
| 105 | 105 | state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil) | |
| 106 | 106 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once() | |
| 107 | 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 | 109 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-bad", mock.Anything).Return(nil, assert.AnError).Once() | |
| 109 | 110 | // No traffic boundary is wired here, so the fail-closed bind | |
| 110 | 111 | // withdrawal is a no-op and deployment writes no ACTIVE binds itself. | |
| @@ -115,6 +116,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) { | |||
| 115 | 116 | state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe() | |
| 116 | 117 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once() | |
| 117 | 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 | 120 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 119 | 121 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |
| 120 | 122 | // 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 bind | 110 | // 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) { | |||
| 54 | 54 | // the failed switch: no stop, no removal, no claim release. | |
| 55 | 55 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 56 | 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 | 58 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 58 | 59 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 59 | 60 | 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( | |||
| 144 | 144 | expectNetworkProvision(runtime, "app-blog", 1) | |
| 145 | 145 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil) | |
| 146 | 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 | 148 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | |
| 148 | 149 | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |
| 149 | 150 | return rev, saved | |
| @@ -352,6 +353,7 @@ func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) { | |||
| 352 | 353 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |
| 353 | 354 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |
| 354 | 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 | 357 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |
| 356 | 358 | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |
| 357 | 359 | ||
| @@ -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, saved | 150 | 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 | ||||