diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index d3ed22fc8..e19981d77 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -39,6 +39,7 @@ truth for live infrastructure data. 15. `docker-entrypoint.sh` 16. `internal/monitoring/truenas_poller.go` 17. `internal/monitoring/vmware_poller.go` +18. `internal/dockeragent/swarm.go` ## Shared Boundaries @@ -51,6 +52,7 @@ truth for live infrastructure data. 3. Add typed read access through `internal/unifiedresources/views.go` 4. Add unified supplemental ingest through `internal/monitoring/poll_providers.go` 5. Add or change container startup ownership/bootstrap behavior for hosted or managed Pulse runtime mounts through `docker-entrypoint.sh` +6. Add or change Docker Swarm manager task/service runtime collection through `internal/dockeragent/swarm.go` ## Forbidden Paths @@ -62,7 +64,7 @@ truth for live infrastructure data. 1. Update this contract when monitoring truth ownership changes 2. Tighten guardrails when `GetState()`-centric paths are removed -3. Keep discovery-provider, metrics-history, and container bootstrap proof routes explicit in `registry.json` +3. Keep discovery-provider, metrics-history, Docker Swarm collection, and container bootstrap proof routes explicit in `registry.json` 4. Update related read-state or monitor tests when new collector paths land 5. Keep platform ingestion semantics aligned with `docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md`: hybrid is a @@ -238,6 +240,10 @@ boundary. Hosted or managed tenant bootstrap changes must preserve safe startup when immutable read-only mounts are layered into `/etc/pulse`; the entrypoint may not reintroduce ownership mutation against those read-only files during container boot. +That same monitoring boundary now also owns Docker Swarm runtime truth at the +collection seam. `internal/dockeragent/swarm.go` is the canonical manager-side +filter for live Swarm services and tasks, so monitoring consumers do not ingest +historical shutdown tasks as if they were still part of the active runtime. Storage export is now derived from canonical `ReadState.StoragePools()` instead of `GetState().Storage`; `models.Storage` is treated as a boundary diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index fd5c4f7ac..0164dd774 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2929,7 +2929,8 @@ "internal/truenas/" ], "owned_files": [ - "docker-entrypoint.sh" + "docker-entrypoint.sh", + "internal/dockeragent/swarm.go" ], "verification": { "allow_same_subsystem_tests": true, @@ -3036,6 +3037,20 @@ "internal/monitoring/node_memory_sources_test.go" ] }, + { + "id": "docker-swarm-runtime", + "label": "docker swarm runtime proof", + "match_prefixes": [], + "match_files": [ + "internal/dockeragent/swarm.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/dockeragent/swarm_coverage_test.go", + "internal/dockeragent/swarm_test.go" + ] + }, { "id": "container-entrypoint-runtime", "label": "container entrypoint runtime proof", diff --git a/internal/dockeragent/swarm.go b/internal/dockeragent/swarm.go index 03a32dc72..e3cd2d781 100644 --- a/internal/dockeragent/swarm.go +++ b/internal/dockeragent/swarm.go @@ -175,6 +175,7 @@ func (a *Agent) collectSwarmDataFromManager(ctx context.Context, info systemtype var tasks []agentsdocker.Task if includeTasks { taskFilters := filters.NewArgs() + taskFilters.Add("desired-state", string(swarmtypes.TaskStateRunning)) if scope != swarmScopeCluster && info.Swarm.NodeID != "" { taskFilters.Add("node", info.Swarm.NodeID) } @@ -188,6 +189,9 @@ func (a *Agent) collectSwarmDataFromManager(ctx context.Context, info systemtype tasks = make([]agentsdocker.Task, 0, len(taskList)) for i := range taskList { + if !isRuntimeSwarmTask(&taskList[i]) { + continue + } var svc *swarmtypes.Service if ptr, ok := servicePointers[taskList[i].ServiceID]; ok { svc = ptr @@ -217,6 +221,32 @@ func (a *Agent) collectSwarmDataFromManager(ctx context.Context, info systemtype return services, tasks, nil } +func isRuntimeSwarmTask(task *swarmtypes.Task) bool { + if task == nil { + return false + } + if task.DesiredState == swarmtypes.TaskStateRunning { + return true + } + + // Defensive fallback in case the daemon returns an empty desired state for an + // otherwise active task. Terminal tasks should never be retained in runtime state. + switch task.Status.State { + case swarmtypes.TaskStateNew, + swarmtypes.TaskStateAllocated, + swarmtypes.TaskStatePending, + swarmtypes.TaskStateAssigned, + swarmtypes.TaskStateAccepted, + swarmtypes.TaskStatePreparing, + swarmtypes.TaskStateReady, + swarmtypes.TaskStateStarting, + swarmtypes.TaskStateRunning: + return task.DesiredState == "" + default: + return false + } +} + func mapSwarmService(svc *swarmtypes.Service) agentsdocker.Service { service := agentsdocker.Service{ ID: svc.ID, diff --git a/internal/dockeragent/swarm_coverage_test.go b/internal/dockeragent/swarm_coverage_test.go index 8d2f3354a..bd0c7036d 100644 --- a/internal/dockeragent/swarm_coverage_test.go +++ b/internal/dockeragent/swarm_coverage_test.go @@ -216,8 +216,12 @@ func TestCollectSwarmDataFromManager(t *testing.T) { if got := opts.Filters.Get("node"); len(got) != 1 || got[0] != "node1" { t.Fatalf("expected node filter to include node1, got %v", got) } + if got := opts.Filters.Get("desired-state"); len(got) != 1 || got[0] != string(swarmtypes.TaskStateRunning) { + t.Fatalf("expected desired-state filter to include running, got %v", got) + } return []swarmtypes.Task{ {ID: "task1", ServiceID: "svc1", DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, + {ID: "task-old", ServiceID: "svc2", DesiredState: swarmtypes.TaskStateShutdown, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateComplete}}, }, nil }, }, @@ -236,6 +240,9 @@ func TestCollectSwarmDataFromManager(t *testing.T) { if len(tasks) != 1 { t.Fatalf("expected 1 task, got %d", len(tasks)) } + if tasks[0].ID != "task1" { + t.Fatalf("expected running task only, got %#v", tasks) + } if len(services) != 1 { t.Fatalf("expected filtered services, got %d", len(services)) } @@ -375,8 +382,8 @@ func TestCollectSwarmData(t *testing.T) { }, taskListFn: func(context.Context, swarmtypes.TaskListOptions) ([]swarmtypes.Task, error) { return []swarmtypes.Task{ - {ID: "task2", ServiceID: "svc2", Slot: 2}, - {ID: "task1", ServiceID: "svc2", Slot: 1}, + {ID: "task2", ServiceID: "svc2", Slot: 2, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, + {ID: "task1", ServiceID: "svc2", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, }, nil }, }, @@ -494,10 +501,10 @@ func TestCollectSwarmData(t *testing.T) { }, taskListFn: func(context.Context, swarmtypes.TaskListOptions) ([]swarmtypes.Task, error) { return []swarmtypes.Task{ - {ID: "b", ServiceID: "a", Slot: 1}, - {ID: "a", ServiceID: "a", Slot: 1}, - {ID: "c", ServiceID: "a", Slot: 2}, - {ID: "d", ServiceID: "c", Slot: 1}, + {ID: "b", ServiceID: "a", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, + {ID: "a", ServiceID: "a", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, + {ID: "c", ServiceID: "a", Slot: 2, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, + {ID: "d", ServiceID: "c", Slot: 1, DesiredState: swarmtypes.TaskStateRunning, Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}}, }, nil }, }, @@ -524,6 +531,37 @@ func TestCollectSwarmData(t *testing.T) { }) } +func TestIsRuntimeSwarmTask(t *testing.T) { + t.Run("accepts desired running task", func(t *testing.T) { + task := &swarmtypes.Task{ + DesiredState: swarmtypes.TaskStateRunning, + Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateRunning}, + } + if !isRuntimeSwarmTask(task) { + t.Fatal("expected running task to be retained") + } + }) + + t.Run("accepts empty desired state active task as fallback", func(t *testing.T) { + task := &swarmtypes.Task{ + Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStatePreparing}, + } + if !isRuntimeSwarmTask(task) { + t.Fatal("expected active task with empty desired state to be retained") + } + }) + + t.Run("rejects shutdown historical task", func(t *testing.T) { + task := &swarmtypes.Task{ + DesiredState: swarmtypes.TaskStateShutdown, + Status: swarmtypes.TaskStatus{State: swarmtypes.TaskStateComplete}, + } + if isRuntimeSwarmTask(task) { + t.Fatal("expected historical task to be excluded") + } + }) +} + func TestDeriveSwarmTasksFromContainers(t *testing.T) { started := time.Date(2024, 1, 1, 1, 1, 1, 0, time.UTC) finished := started.Add(time.Minute) diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index 789e4eb5b..72879b9b7 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -34,6 +34,30 @@ PLATFORM_CONNECTIONS_WORKSPACE_EXACT_FILES = [ ] +def _contract_reference(contract_path: str, needle: str, runtime_path: str) -> dict: + lines = (REPO_ROOT / contract_path).read_text(encoding="utf-8").splitlines() + current_heading = None + current_heading_line = None + + for line_number, line in enumerate(lines, start=1): + if line.startswith("## "): + current_heading = line + current_heading_line = line_number + if needle in line: + if current_heading is None or current_heading_line is None: + raise AssertionError( + f"reference {needle!r} in {contract_path} has no enclosing heading" + ) + return { + "heading": current_heading, + "path": runtime_path, + "line": line_number, + "heading_line": current_heading_line, + } + + raise AssertionError(f"reference {needle!r} not found in {contract_path}") + + def split_workspace_path(path: str) -> tuple[Path, str]: if ":" not in path: return REPO_ROOT, path @@ -184,6 +208,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): "metrics-hot-path", "metrics-history-runtime", "memory-source-runtime", + "docker-swarm-runtime", "container-entrypoint-runtime", "monitoring-runtime", ], @@ -2823,18 +2848,16 @@ class CanonicalCompletionGuardTest(unittest.TestCase): self.assertEqual( required["docs/release-control/v6/internal/subsystems/monitoring.md"]["matched_reference_details"], [ - { - "heading": "## Canonical Files", - "path": "internal/unifiedresources/views.go", - "line": 36, - "heading_line": 23, - }, - { - "heading": "## Extension Points", - "path": "internal/unifiedresources/views.go", - "line": 51, - "heading_line": 47, - }, + _contract_reference( + "docs/release-control/v6/internal/subsystems/monitoring.md", + "12. `internal/unifiedresources/views.go`", + "internal/unifiedresources/views.go", + ), + _contract_reference( + "docs/release-control/v6/internal/subsystems/monitoring.md", + "3. Add typed read access through `internal/unifiedresources/views.go`", + "internal/unifiedresources/views.go", + ), ], ) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index f3b146c0d..2105e1c51 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4461,6 +4461,27 @@ class SubsystemLookupTest(unittest.TestCase): ], ) + def test_lookup_paths_assigns_docker_swarm_runtime_to_monitoring(self) -> None: + result = lookup_paths(["internal/dockeragent/swarm.go"]) + self.assertEqual(result["unowned_runtime_files"], []) + + file_entry = result["files"][0] + self.assertEqual(file_entry["classification"], "runtime") + self.assertEqual(len(file_entry["matches"]), 1) + + match = file_entry["matches"][0] + self.assertEqual(match["subsystem"], "monitoring") + self.assertEqual(match["contract"], "docs/release-control/v6/internal/subsystems/monitoring.md") + self.assertEqual(match["lane_context"]["lane_id"], "L13") + self.assertEqual(match["verification_requirement"]["id"], "docker-swarm-runtime") + self.assertEqual( + match["verification_requirement"]["exact_files"], + [ + "internal/dockeragent/swarm_coverage_test.go", + "internal/dockeragent/swarm_test.go", + ], + ) + def test_lookup_paths_assigns_system_logs_runtime_owner_to_frontend_primitives(self) -> None: result = lookup_paths( ["frontend-modern/src/components/Settings/useSystemLogsPanelState.ts"] @@ -4631,18 +4652,16 @@ class SubsystemLookupTest(unittest.TestCase): self.assertEqual( monitoring_contract["matched_reference_details"], [ - { - "heading": "## Canonical Files", - "path": "internal/unifiedresources/views.go", - "line": 36, - "heading_line": 23, - }, - { - "heading": "## Extension Points", - "path": "internal/unifiedresources/views.go", - "line": 51, - "heading_line": 47, - }, + _contract_reference( + "docs/release-control/v6/internal/subsystems/monitoring.md", + "12. `internal/unifiedresources/views.go`", + "internal/unifiedresources/views.go", + ), + _contract_reference( + "docs/release-control/v6/internal/subsystems/monitoring.md", + "3. Add typed read access through `internal/unifiedresources/views.go`", + "internal/unifiedresources/views.go", + ), ], ) self.assertEqual(