mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 00:22:28 +00:00
3c778e2b26
v6.1.0-rc.1 retired the legacy update endpoints before a replacement existed, so the UI's Update button failed with an internal-jargon 410 (issue #1564). This lands the replacement end to end: update_container is a typed agentexec operation with its own strict codec, durable receipts, and a request digest bound to the image digest the plan observed; the unified agent bridges execution to the Docker module's existing pull/backup/recreate/verify/rollback implementation (which now reports rollback attempt and outcome); and the container action executor plans, dispatches, and reconciles the operation with declared backup/rollback compensation truth. Containers advertise an admin-approval update capability while an image update with a stated current digest is detected. The legacy endpoints stay retired but return actionable copy. Proven live against a Colima daemon: single-container update, the issue-1564 shared-network-namespace update, and the full UI journey (Update button, governed review, approve, run) all completed with the namespace preserved and the backup retained.
266 lines
12 KiB
Go
266 lines
12 KiB
Go
package mutationregistry
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type routeClassification struct {
|
|
MutationID string
|
|
}
|
|
|
|
var infrastructureRouteCatalog = map[string]routeClassification{
|
|
"POST /api/actions/plan": {MutationID: "action.api.plan"},
|
|
"GET /api/actions/pending": {},
|
|
"GET /api/actions": {},
|
|
"GET /api/actions/{id}": {},
|
|
"POST /api/actions/{id}/decision": {MutationID: "action.api.decision"},
|
|
"POST /api/actions/{id}/execute": {MutationID: "action.api.execute"},
|
|
"/api/agents/docker/report": {},
|
|
"/api/agents/docker/commands/": {},
|
|
"/api/agents/docker/runtimes/": {MutationID: "docker.api.runtime-stop"},
|
|
"/api/agents/docker/containers/update": {MutationID: "docker.api.update-container"},
|
|
"/api/updates/check": {},
|
|
"/api/updates/apply": {MutationID: "admin.pulse.self-update"},
|
|
"/api/updates/rollback": {MutationID: "admin.pulse.self-update"},
|
|
"/api/updates/status": {},
|
|
"/api/updates/stream": {},
|
|
"/api/updates/plan": {},
|
|
"/api/updates/history": {},
|
|
"/api/updates/history/entry": {},
|
|
"/api/updates/release-notes": {},
|
|
"/api/ai/run-command": {MutationID: "legacy.api.run-command"},
|
|
"/api/ai/remediation/plans": {},
|
|
"/api/ai/remediation/plan": {},
|
|
"/api/ai/remediation/approve": {MutationID: "legacy.enterprise.remediation-approve"},
|
|
"/api/ai/remediation/execute": {MutationID: "legacy.enterprise.remediation-execute"},
|
|
"/api/ai/remediation/rollback": {MutationID: "legacy.enterprise.remediation-rollback"},
|
|
}
|
|
|
|
// TestInfrastructureAPIRoutesResolveToRegistry scans the real router sources,
|
|
// not a duplicate ID inventory. Its scope is deliberately limited to the
|
|
// infrastructure mutation namespaces below; settings/metadata CRUD is outside
|
|
// M1 and is not claimed as infrastructure enumeration.
|
|
func TestInfrastructureAPIRoutesResolveToRegistry(t *testing.T) {
|
|
root := repoRoot(t)
|
|
files := []string{
|
|
"internal/api/router_routes_monitoring.go",
|
|
"internal/api/router_routes_registration.go",
|
|
"internal/api/router_routes_ai_relay.go",
|
|
}
|
|
routeRE := regexp.MustCompile(`"(?:(GET|POST|PUT|DELETE) )?(/api/[^"]+)"`)
|
|
found := map[string]bool{}
|
|
for _, relative := range files {
|
|
data, err := os.ReadFile(filepath.Join(root, relative))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, match := range routeRE.FindAllStringSubmatch(string(data), -1) {
|
|
method, route := match[1], match[2]
|
|
if !auditedInfrastructurePrefix(route) {
|
|
continue
|
|
}
|
|
key := routeCatalogKey(method, route)
|
|
found[key] = true
|
|
classification, classified := infrastructureRouteCatalog[key]
|
|
if !classified {
|
|
t.Errorf("runtime infrastructure route %q is unclassified", key)
|
|
continue
|
|
}
|
|
if classification.MutationID != "" {
|
|
if err := AuditRuntimeCandidates([]RuntimeCandidate{{Surface: "http:" + key, MutationID: classification.MutationID}}); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for key := range infrastructureRouteCatalog {
|
|
if !found[key] {
|
|
t.Errorf("classified route %q is no longer registered", key)
|
|
}
|
|
}
|
|
// The shared Docker gateway contains a second mutation sub-route.
|
|
dockerSource, _ := os.ReadFile(filepath.Join(root, "internal/api/docker_agents.go"))
|
|
if !strings.Contains(string(dockerSource), `"/update-all"`) {
|
|
t.Fatal("Docker update-all handler candidate not found")
|
|
}
|
|
if err := AuditRuntimeCandidates([]RuntimeCandidate{{Surface: "http:/api/agents/docker/runtimes/#update-all", MutationID: "docker.api.update-all"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func routeCatalogKey(method, route string) string {
|
|
if strings.TrimSpace(method) == "" {
|
|
return route
|
|
}
|
|
return method + " " + route
|
|
}
|
|
|
|
func auditedInfrastructurePrefix(route string) bool {
|
|
if route == "/api/actions" {
|
|
return true
|
|
}
|
|
for _, prefix := range []string{"/api/actions/", "/api/agents/docker/", "/api/updates/", "/api/ai/remediation/", "/api/ai/run-command"} {
|
|
if strings.HasPrefix(route, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestActionRouteMethodAuthorityIsExactAndLookalikesFailClosed(t *testing.T) {
|
|
tests := []struct {
|
|
method, route, mutationID string
|
|
classified bool
|
|
}{
|
|
{method: "GET", route: "/api/actions/{id}", classified: true},
|
|
{method: "POST", route: "/api/actions/{id}/execute", mutationID: "action.api.execute", classified: true},
|
|
{method: "DELETE", route: "/api/actions/{id}"},
|
|
{method: "POST", route: "/api/actions/{id}"},
|
|
{method: "GET", route: "/api/actions/{id}/execute"},
|
|
{method: "POST", route: "/api/actions/{id}/execute-copy"},
|
|
}
|
|
for _, tt := range tests {
|
|
key := routeCatalogKey(tt.method, tt.route)
|
|
classification, ok := infrastructureRouteCatalog[key]
|
|
if ok != tt.classified {
|
|
t.Errorf("route %q classified=%v, want %v", key, ok, tt.classified)
|
|
continue
|
|
}
|
|
if ok && classification.MutationID != tt.mutationID {
|
|
t.Errorf("route %q mutation=%q, want %q", key, classification.MutationID, tt.mutationID)
|
|
}
|
|
}
|
|
}
|
|
|
|
type transportClassification struct {
|
|
Role TransportRole
|
|
MutationID string
|
|
DurableAuthorityID string
|
|
}
|
|
|
|
func TestTransportCommandCatalogsResolveToRegistry(t *testing.T) {
|
|
root := repoRoot(t)
|
|
docker := scanNamedStringConstants(t, filepath.Join(root, "internal/monitoring/docker_commands.go"), `DockerCommandType\w+`)
|
|
dockerCatalog := map[string]transportClassification{
|
|
"DockerCommandTypeStop": {Role: TransportRoleMutationRequest, MutationID: "transport.docker.runtime-stop"},
|
|
"DockerCommandTypeUpdateContainer": {Role: TransportRoleMutationRequest, MutationID: "transport.docker.update-container"},
|
|
"DockerCommandTypeUpdateAll": {Role: TransportRoleMutationRequest, MutationID: "transport.docker.update-all"},
|
|
"DockerCommandTypeCheckUpdates": {Role: TransportRoleProtocol},
|
|
}
|
|
auditConstantCatalog(t, docker, dockerCatalog)
|
|
|
|
agent := scanNamedStringConstants(t, filepath.Join(root, "internal/agentexec/types.go"), `MsgType\w+`)
|
|
agentCatalog := map[string]transportClassification{
|
|
"MsgTypeAgentRegister": {Role: TransportRoleProtocol}, "MsgTypeAgentPing": {Role: TransportRoleProtocol},
|
|
"MsgTypeCommandResult": {Role: TransportRoleOperationResult},
|
|
"MsgTypeHostStorageCleanupResult": {Role: TransportRoleOperationResult}, "MsgTypeHostUpdateResult": {Role: TransportRoleOperationResult},
|
|
"MsgTypeDockerContainerLifecycleResult": {Role: TransportRoleOperationResult},
|
|
"MsgTypeDockerContainerUpdateResult": {Role: TransportRoleOperationResult},
|
|
"MsgTypeOperationQueryResult": {Role: TransportRoleOperationReceipt},
|
|
"MsgTypeRegistered": {Role: TransportRoleProtocol}, "MsgTypePong": {Role: TransportRoleProtocol},
|
|
"MsgTypeExecuteCmd": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.raw-command", DurableAuthorityID: "assistant.resource-action"},
|
|
"MsgTypeHostStorageCleanup": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.host-package-cache-cleanup", DurableAuthorityID: "resource.host.package-cache-cleanup"},
|
|
"MsgTypeReadFile": {Role: TransportRoleProtocol},
|
|
"MsgTypeHostUpdate": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.host-package-update", DurableAuthorityID: "resource.host.package-update"},
|
|
"MsgTypeDockerContainerLifecycle": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.docker-container-lifecycle", DurableAuthorityID: "resource.docker.container-lifecycle"},
|
|
"MsgTypeDockerContainerUpdate": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.docker-container-update", DurableAuthorityID: "resource.docker.container-update"},
|
|
"MsgTypeOperationQuery": {Role: TransportRoleOperationQuery},
|
|
"MsgTypeDeployPreflight": {Role: TransportRoleAdministrativeRequest, MutationID: "transport.agent.deploy-preflight"},
|
|
"MsgTypeDeployInstall": {Role: TransportRoleAdministrativeRequest, MutationID: "transport.agent.deploy-install"},
|
|
"MsgTypeDeployCancelJob": {Role: TransportRoleAdministrativeRequest, MutationID: "transport.agent.deploy-cancel"},
|
|
"MsgTypeDeployProgress": {Role: TransportRoleOperationResult},
|
|
}
|
|
auditConstantCatalog(t, agent, agentCatalog)
|
|
}
|
|
|
|
func TestNonAdmittingTransportMessagesCannotCarryDispatchAuthority(t *testing.T) {
|
|
for _, role := range []TransportRole{TransportRoleOperationQuery, TransportRoleOperationResult, TransportRoleOperationReceipt, TransportRoleProtocol} {
|
|
err := AuditTransportSurfaces([]TransportSurface{{
|
|
Name: "lookalike", WireValue: "docker_container_lifecycle_result_retry", Role: role,
|
|
MutationID: "transport.agent.docker-container-lifecycle", DurableAuthorityID: "resource.docker.container-lifecycle",
|
|
}})
|
|
if err == nil {
|
|
t.Errorf("non-admitting role %q accepted dispatch authority", role)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnknownTransportLookalikeFailsClosed(t *testing.T) {
|
|
actual := map[string]string{"MsgTypeDockerContainerLifecycle": "docker_container_lifecycle", "MsgTypeDockerContainerLifecycleRetry": "docker_container_lifecycle_retry"}
|
|
catalog := map[string]transportClassification{
|
|
"MsgTypeDockerContainerLifecycle": {Role: TransportRoleMutationRequest, MutationID: "transport.agent.docker-container-lifecycle", DurableAuthorityID: "resource.docker.container-lifecycle"},
|
|
}
|
|
if unknown := unclassifiedTransportConstants(actual, catalog); len(unknown) != 1 || unknown[0] != `MsgTypeDockerContainerLifecycleRetry="docker_container_lifecycle_retry"` {
|
|
t.Fatalf("unknown lookalike classification = %v", unknown)
|
|
}
|
|
}
|
|
|
|
func TestPatrolJobRegistrationResolvesToRegistry(t *testing.T) {
|
|
root := repoRoot(t)
|
|
router, _ := os.ReadFile(filepath.Join(root, "internal/api/router.go"))
|
|
broker, _ := os.ReadFile(filepath.Join(root, "internal/api/patrol_action_broker.go"))
|
|
if !strings.Contains(string(router), "SetActionBrokerFactory") || !strings.Contains(string(broker), "PlanWithOptions") {
|
|
t.Fatal("Patrol action job registration is no longer mechanically discoverable")
|
|
}
|
|
if err := AuditRuntimeCandidates([]RuntimeCandidate{{Surface: "job:patrol-action-broker", MutationID: "patrol.typed-action"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func scanNamedStringConstants(t *testing.T, path, namePattern string) map[string]string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
re := regexp.MustCompile(`(?m)^\s*(` + namePattern + `)(?:\s+\w+)?\s*=\s*"([^"]+)"`)
|
|
out := map[string]string{}
|
|
for _, match := range re.FindAllStringSubmatch(string(data), -1) {
|
|
out[match[1]] = match[2]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func auditConstantCatalog(t *testing.T, actual map[string]string, catalog map[string]transportClassification) {
|
|
t.Helper()
|
|
var surfaces []TransportSurface
|
|
for name, value := range actual {
|
|
classification, ok := catalog[name]
|
|
if !ok {
|
|
t.Errorf("transport command %s=%q is unclassified", name, value)
|
|
continue
|
|
}
|
|
surfaces = append(surfaces, TransportSurface{Name: name, WireValue: value, Role: classification.Role, MutationID: classification.MutationID, DurableAuthorityID: classification.DurableAuthorityID})
|
|
}
|
|
if err := AuditTransportSurfaces(surfaces); err != nil {
|
|
t.Error(err)
|
|
}
|
|
var stale []string
|
|
for name := range catalog {
|
|
if _, ok := actual[name]; !ok {
|
|
stale = append(stale, name)
|
|
}
|
|
}
|
|
sort.Strings(stale)
|
|
if len(stale) > 0 {
|
|
t.Errorf("classified transport constants no longer exist: %v", stale)
|
|
}
|
|
}
|
|
|
|
func unclassifiedTransportConstants(actual map[string]string, catalog map[string]transportClassification) []string {
|
|
var unknown []string
|
|
for name, value := range actual {
|
|
if _, ok := catalog[name]; !ok {
|
|
unknown = append(unknown, fmt.Sprintf(`%s=%q`, name, value))
|
|
}
|
|
}
|
|
sort.Strings(unknown)
|
|
return unknown
|
|
}
|