From 8269e390649ec3ce509fa198e4c1867f1036f696 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:21:11 -0300 Subject: [PATCH 1/4] feat(api): admin agent config preview, revision history and revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteenth layer of the agent remote-configuration stack (split from #465): POST /api/admin/agents/{id}/config/preview validates a candidate overlay and shows, per instance, the redacted effective config, its diff, classified changes and whether the agent would apply it (bounded to 50 instances / 16 MiB of reported config, validated first; omitted-instances counts the rest; needs agent:configure); GET …/config/revisions lists revisions (paginated, newest first); POST …/revisions/{rev}/revert saves an old overlay as a new revision. Co-Authored-By: Claude Opus 5.5 --- docs/docs.go | 425 ++++++++++++++++++ docs/swagger.json | 425 ++++++++++++++++++ docs/swagger.yaml | 295 ++++++++++++ internal/api/handler/agent_config.go | 257 ++++++++++- .../agent_config_admin_integration_test.go | 348 ++++++++++++++ internal/api/handler/agent_config_test.go | 47 ++ 6 files changed, 1796 insertions(+), 1 deletion(-) diff --git a/docs/docs.go b/docs/docs.go index 00e45741..b8d201c2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -190,6 +190,158 @@ const docTemplate = `{ ] } }, + "/admin/agents/{id}/config/preview": { + "post": { + "description": "Validates a candidate overlay without saving it and shows, per reporting instance (fresh and stale), the redacted effective config, its diff against the instance's current effective config, the classified changes and whether the agent would apply it. validated marks the instances a save validates against; only their errors block a save. errors are the problems the overlay introduces; warnings are problems already in the instance's own file (present in Merge(base, {})), which never block a save or force invalid-config (R59). Validation problems are returned in the 200 body; when the overlay itself is invalid (overlay-errors), instances is empty. At most 50 instances and 16 MiB of reported config are previewed (validated instances first, then newest first); omitted-instances counts the rest. A save still validates against every validated instance. Needs agent:configure.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "Preview an agent configuration overlay", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Candidate overlay", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.agentConfigPreviewRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_configPreviewResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "415": { + "description": "Unsupported Media Type", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/agents/{id}/config/revisions": { + "get": { + "description": "Revisions newest first, without overlays.", + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "List an agent's configuration revisions", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page (default 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 50, max 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_agentConfigRevisionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/agents/{id}/config/revisions/{rev}": { "get": { "description": "The overlay is verbatim for callers that also hold agent:configure and redacted (secret-like keys and values become ••••) for every other caller, as on GET config.", @@ -255,6 +407,125 @@ const docTemplate = `{ ] } }, + "/admin/agents/{id}/config/revisions/{rev}/revert": { + "post": { + "description": "Creates the next revision with the overlay of revision :rev (revert-of records it). Same If-Match, validation and authorization rules as PUT. The body is optional.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "Revert an agent's configuration to an earlier revision", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Revision to restore", + "name": "rev", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Current revision, e.g. \\", + "name": "If-Match", + "in": "header", + "required": true + }, + { + "description": "Optional comment", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.agentConfigRevertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_agentConfigRevisionResponse" + } + }, + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_agentConfigRevisionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "415": { + "description": "Unsupported Media Type", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/ai-diagnostics/runs": { "get": { "description": "Lists dashboard suggestion runs across all SSPs, newest first, with optional status and SSP filters.", @@ -34719,6 +34990,24 @@ const docTemplate = `{ } } }, + "agentconfig.DiffEntry": { + "type": "object", + "properties": { + "from": { + "type": "object" + }, + "op": { + "description": "add | remove | replace", + "type": "string" + }, + "path": { + "type": "string" + }, + "to": { + "type": "object" + } + } + }, "agentconfig.FieldError": { "type": "object", "properties": { @@ -37102,6 +37391,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_configPreviewResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.configPreviewResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -39070,6 +39372,14 @@ const docTemplate = `{ } } }, + "handler.agentConfigPreviewRequest": { + "type": "object", + "properties": { + "overlay": { + "type": "object" + } + } + }, "handler.agentConfigPutRequest": { "type": "object", "properties": { @@ -39081,6 +39391,14 @@ const docTemplate = `{ } } }, + "handler.agentConfigRevertRequest": { + "type": "object", + "properties": { + "comment": { + "type": "string" + } + } + }, "handler.agentConfigRevisionResponse": { "type": "object", "properties": { @@ -39230,6 +39548,33 @@ const docTemplate = `{ } } }, + "handler.configPreviewResponse": { + "type": "object", + "properties": { + "desired-revision": { + "type": "integer" + }, + "instances": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.instancePreview" + } + }, + "omitted-instances": { + "description": "OmittedInstances counts the instances with a reported base the preview bounds left\nout (at most agentcfg.PreviewMaxInstances instances and PreviewMaxConfigBytes of\nreported config; validated instances first, then newest first).", + "type": "integer" + }, + "overlay-errors": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + }, + "standalone": { + "type": "boolean" + } + } + }, "handler.configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -39620,6 +39965,63 @@ const docTemplate = `{ } } }, + "handler.instancePreview": { + "type": "object", + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.Change" + } + }, + "diff-vs-current": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.DiffEntry" + } + }, + "effective": { + "type": "object" + }, + "errors": { + "description": "introduced by the overlay (R59)", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + }, + "hostname": { + "type": "string" + }, + "instance-id": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "validated": { + "description": "R48: PUT validates against this instance; its errors block a save", + "type": "boolean" + }, + "warnings": { + "description": "already in Merge(base, {}): from the host file, non-blocking (R59)", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + }, + "will-apply": { + "type": "boolean" + }, + "will-apply-reason": { + "description": "mode-off|mode-report|unsafe-changes|forbidden-changes|invalid-config", + "type": "string" + } + } + }, "handler.milestoneResponse": { "type": "object", "properties": { @@ -50870,6 +51272,29 @@ const docTemplate = `{ } } }, + "service.ListResponse-handler_agentConfigRevisionResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.agentConfigRevisionResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_riskResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index d3b3c663..d791b2ba 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -184,6 +184,158 @@ ] } }, + "/admin/agents/{id}/config/preview": { + "post": { + "description": "Validates a candidate overlay without saving it and shows, per reporting instance (fresh and stale), the redacted effective config, its diff against the instance's current effective config, the classified changes and whether the agent would apply it. validated marks the instances a save validates against; only their errors block a save. errors are the problems the overlay introduces; warnings are problems already in the instance's own file (present in Merge(base, {})), which never block a save or force invalid-config (R59). Validation problems are returned in the 200 body; when the overlay itself is invalid (overlay-errors), instances is empty. At most 50 instances and 16 MiB of reported config are previewed (validated instances first, then newest first); omitted-instances counts the rest. A save still validates against every validated instance. Needs agent:configure.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "Preview an agent configuration overlay", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Candidate overlay", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.agentConfigPreviewRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_configPreviewResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "415": { + "description": "Unsupported Media Type", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/agents/{id}/config/revisions": { + "get": { + "description": "Revisions newest first, without overlays.", + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "List an agent's configuration revisions", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Page (default 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size (default 50, max 100)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/service.ListResponse-handler_agentConfigRevisionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/agents/{id}/config/revisions/{rev}": { "get": { "description": "The overlay is verbatim for callers that also hold agent:configure and redacted (secret-like keys and values become ••••) for every other caller, as on GET config.", @@ -249,6 +401,125 @@ ] } }, + "/admin/agents/{id}/config/revisions/{rev}/revert": { + "post": { + "description": "Creates the next revision with the overlay of revision :rev (revert-of records it). Same If-Match, validation and authorization rules as PUT. The body is optional.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agent Configuration" + ], + "summary": "Revert an agent's configuration to an earlier revision", + "parameters": [ + { + "type": "string", + "description": "Agent ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Revision to restore", + "name": "rev", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Current revision, e.g. \\", + "name": "If-Match", + "in": "header", + "required": true + }, + { + "description": "Optional comment", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/handler.agentConfigRevertRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_agentConfigRevisionResponse" + } + }, + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_agentConfigRevisionResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "415": { + "description": "Unsupported Media Type", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "428": { + "description": "Precondition Required", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/ai-diagnostics/runs": { "get": { "description": "Lists dashboard suggestion runs across all SSPs, newest first, with optional status and SSP filters.", @@ -34713,6 +34984,24 @@ } } }, + "agentconfig.DiffEntry": { + "type": "object", + "properties": { + "from": { + "type": "object" + }, + "op": { + "description": "add | remove | replace", + "type": "string" + }, + "path": { + "type": "string" + }, + "to": { + "type": "object" + } + } + }, "agentconfig.FieldError": { "type": "object", "properties": { @@ -37096,6 +37385,19 @@ } } }, + "handler.GenericDataResponse-handler_configPreviewResponse": { + "type": "object", + "properties": { + "data": { + "description": "Wrapped response data", + "allOf": [ + { + "$ref": "#/definitions/handler.configPreviewResponse" + } + ] + } + } + }, "handler.GenericDataResponse-handler_configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -39064,6 +39366,14 @@ } } }, + "handler.agentConfigPreviewRequest": { + "type": "object", + "properties": { + "overlay": { + "type": "object" + } + } + }, "handler.agentConfigPutRequest": { "type": "object", "properties": { @@ -39075,6 +39385,14 @@ } } }, + "handler.agentConfigRevertRequest": { + "type": "object", + "properties": { + "comment": { + "type": "string" + } + } + }, "handler.agentConfigRevisionResponse": { "type": "object", "properties": { @@ -39224,6 +39542,33 @@ } } }, + "handler.configPreviewResponse": { + "type": "object", + "properties": { + "desired-revision": { + "type": "integer" + }, + "instances": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.instancePreview" + } + }, + "omitted-instances": { + "description": "OmittedInstances counts the instances with a reported base the preview bounds left\nout (at most agentcfg.PreviewMaxInstances instances and PreviewMaxConfigBytes of\nreported config; validated instances first, then newest first).", + "type": "integer" + }, + "overlay-errors": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + }, + "standalone": { + "type": "boolean" + } + } + }, "handler.configuredSystemDestinationResponse": { "type": "object", "properties": { @@ -39614,6 +39959,63 @@ } } }, + "handler.instancePreview": { + "type": "object", + "properties": { + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.Change" + } + }, + "diff-vs-current": { + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.DiffEntry" + } + }, + "effective": { + "type": "object" + }, + "errors": { + "description": "introduced by the overlay (R59)", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + }, + "hostname": { + "type": "string" + }, + "instance-id": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "validated": { + "description": "R48: PUT validates against this instance; its errors block a save", + "type": "boolean" + }, + "warnings": { + "description": "already in Merge(base, {}): from the host file, non-blocking (R59)", + "type": "array", + "items": { + "$ref": "#/definitions/agentconfig.FieldError" + } + }, + "will-apply": { + "type": "boolean" + }, + "will-apply-reason": { + "description": "mode-off|mode-report|unsafe-changes|forbidden-changes|invalid-config", + "type": "string" + } + } + }, "handler.milestoneResponse": { "type": "object", "properties": { @@ -50864,6 +51266,29 @@ } } }, + "service.ListResponse-handler_agentConfigRevisionResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/handler.agentConfigRevisionResponse" + } + }, + "limit": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + } + } + }, "service.ListResponse-handler_riskResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 4bab4023..931557e9 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -15,6 +15,18 @@ definitions: description: the source / env name that triggered the class type: string type: object + agentconfig.DiffEntry: + properties: + from: + type: object + op: + description: add | remove | replace + type: string + path: + type: string + to: + type: object + type: object agentconfig.FieldError: properties: code: @@ -1671,6 +1683,13 @@ definitions: - $ref: '#/definitions/handler.catalogLinkResponse' description: Wrapped response data type: object + handler.GenericDataResponse-handler_configPreviewResponse: + properties: + data: + allOf: + - $ref: '#/definitions/handler.configPreviewResponse' + description: Wrapped response data + type: object handler.GenericDataResponse-handler_configuredSystemDestinationResponse: properties: data: @@ -2833,6 +2852,11 @@ definitions: subject-id: type: string type: object + handler.agentConfigPreviewRequest: + properties: + overlay: + type: object + type: object handler.agentConfigPutRequest: properties: comment: @@ -2840,6 +2864,11 @@ definitions: overlay: type: object type: object + handler.agentConfigRevertRequest: + properties: + comment: + type: string + type: object handler.agentConfigRevisionResponse: properties: agent-id: @@ -2945,6 +2974,27 @@ definitions: targetControlId: type: string type: object + handler.configPreviewResponse: + properties: + desired-revision: + type: integer + instances: + items: + $ref: '#/definitions/handler.instancePreview' + type: array + omitted-instances: + description: |- + OmittedInstances counts the instances with a reported base the preview bounds left + out (at most agentcfg.PreviewMaxInstances instances and PreviewMaxConfigBytes of + reported config; validated instances first, then newest first). + type: integer + overlay-errors: + items: + $ref: '#/definitions/agentconfig.FieldError' + type: array + standalone: + type: boolean + type: object handler.configuredSystemDestinationResponse: properties: destinationTarget: @@ -3220,6 +3270,47 @@ definitions: updatedAt: type: string type: object + handler.instancePreview: + properties: + changes: + items: + $ref: '#/definitions/agentconfig.Change' + type: array + diff-vs-current: + items: + $ref: '#/definitions/agentconfig.DiffEntry' + type: array + effective: + type: object + errors: + description: introduced by the overlay (R59) + items: + $ref: '#/definitions/agentconfig.FieldError' + type: array + hostname: + type: string + instance-id: + type: string + mode: + type: string + stale: + type: boolean + validated: + description: 'R48: PUT validates against this instance; its errors block a + save' + type: boolean + warnings: + description: 'already in Merge(base, {}): from the host file, non-blocking + (R59)' + items: + $ref: '#/definitions/agentconfig.FieldError' + type: array + will-apply: + type: boolean + will-apply-reason: + description: mode-off|mode-report|unsafe-changes|forbidden-changes|invalid-config + type: string + type: object handler.milestoneResponse: properties: completionDate: @@ -10792,6 +10883,21 @@ definitions: totalPages: type: integer type: object + service.ListResponse-handler_agentConfigRevisionResponse: + properties: + data: + items: + $ref: '#/definitions/handler.agentConfigRevisionResponse' + type: array + limit: + type: integer + page: + type: integer + total: + type: integer + totalPages: + type: integer + type: object service.ListResponse-handler_riskResponse: properties: data: @@ -12740,6 +12846,115 @@ paths: summary: Save an agent's configuration overlay tags: - Agent Configuration + /admin/agents/{id}/config/preview: + post: + consumes: + - application/json + description: Validates a candidate overlay without saving it and shows, per + reporting instance (fresh and stale), the redacted effective config, its diff + against the instance's current effective config, the classified changes and + whether the agent would apply it. validated marks the instances a save validates + against; only their errors block a save. errors are the problems the overlay + introduces; warnings are problems already in the instance's own file (present + in Merge(base, {})), which never block a save or force invalid-config (R59). + Validation problems are returned in the 200 body; when the overlay itself + is invalid (overlay-errors), instances is empty. At most 50 instances and + 16 MiB of reported config are previewed (validated instances first, then newest + first); omitted-instances counts the rest. A save still validates against + every validated instance. Needs agent:configure. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Candidate overlay + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.agentConfigPreviewRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_configPreviewResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/api.Error' + "415": + description: Unsupported Media Type + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Preview an agent configuration overlay + tags: + - Agent Configuration + /admin/agents/{id}/config/revisions: + get: + description: Revisions newest first, without overlays. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Page (default 1) + in: query + name: page + type: integer + - description: Page size (default 50, max 100) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/service.ListResponse-handler_agentConfigRevisionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: List an agent's configuration revisions + tags: + - Agent Configuration /admin/agents/{id}/config/revisions/{rev}: get: description: The overlay is verbatim for callers that also hold agent:configure @@ -12784,6 +12999,86 @@ paths: summary: Get one configuration revision tags: - Agent Configuration + /admin/agents/{id}/config/revisions/{rev}/revert: + post: + consumes: + - application/json + description: Creates the next revision with the overlay of revision :rev (revert-of + records it). Same If-Match, validation and authorization rules as PUT. The + body is optional. + parameters: + - description: Agent ID + in: path + name: id + required: true + type: string + - description: Revision to restore + in: path + name: rev + required: true + type: integer + - description: Current revision, e.g. \ + in: header + name: If-Match + required: true + type: string + - description: Optional comment + in: body + name: body + schema: + $ref: '#/definitions/handler.agentConfigRevertRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_agentConfigRevisionResponse' + "201": + description: Created + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_agentConfigRevisionResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "403": + description: Forbidden + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "409": + description: Conflict + schema: + $ref: '#/definitions/api.Error' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/api.Error' + "415": + description: Unsupported Media Type + schema: + $ref: '#/definitions/api.Error' + "422": + description: Unprocessable Entity + schema: + $ref: '#/definitions/api.Error' + "428": + description: Precondition Required + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Revert an agent's configuration to an earlier revision + tags: + - Agent Configuration /admin/ai-diagnostics/runs: get: description: Lists dashboard suggestion runs across all SSPs, newest first, diff --git a/internal/api/handler/agent_config.go b/internal/api/handler/agent_config.go index 0dc78ff0..cab9face 100644 --- a/internal/api/handler/agent_config.go +++ b/internal/api/handler/agent_config.go @@ -15,6 +15,7 @@ import ( "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/authn" "github.com/compliance-framework/api/internal/authz" + "github.com/compliance-framework/api/internal/service" "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/service/relational/agentcfg" "github.com/compliance-framework/api/pkg/agentconfig" @@ -51,12 +52,15 @@ func NewAgentConfigHandler(sugar *zap.SugaredLogger, db *gorm.DB, svc *agentcfg. } // Register mounts the routes on an /admin/agents group of their own (so they inherit no -// group guard). Writes need agent:configure. +// group guard). Writes and preview need agent:configure. func (h *AgentConfigHandler) Register(g *echo.Group, guard middleware.ResourceGuard) { write := guard.Do(authz.ActionConfigure) g.GET("/:id/config", h.Get, guard.Read()) g.PUT("/:id/config", h.Put, write) + g.POST("/:id/config/preview", h.Preview, write) + g.GET("/:id/config/revisions", h.ListRevisions, guard.Read()) g.GET("/:id/config/revisions/:rev", h.GetRevision, guard.Read()) + g.POST("/:id/config/revisions/:rev/revert", h.Revert, write) } // ---- DTOs (A4.4) ---- @@ -72,6 +76,32 @@ type agentConfigRevisionResponse struct { RevertOf *int64 `json:"revert-of"` } +type configPreviewResponse struct { + DesiredRevision int64 `json:"desired-revision"` + Standalone bool `json:"standalone"` + OverlayErrors []agentconfig.FieldError `json:"overlay-errors"` + Instances []instancePreview `json:"instances"` + // OmittedInstances counts the instances with a reported base the preview bounds left + // out (at most agentcfg.PreviewMaxInstances instances and PreviewMaxConfigBytes of + // reported config; validated instances first, then newest first). + OmittedInstances int64 `json:"omitted-instances"` +} + +type instancePreview struct { + InstanceID string `json:"instance-id"` + Hostname *string `json:"hostname"` + Mode string `json:"mode"` + Stale bool `json:"stale"` + Validated bool `json:"validated"` // R48: PUT validates against this instance; its errors block a save + Effective json.RawMessage `json:"effective" swaggertype:"object"` + DiffVsCurrent []agentconfig.DiffEntry `json:"diff-vs-current"` + Errors []agentconfig.FieldError `json:"errors"` // introduced by the overlay (R59) + Warnings []agentconfig.FieldError `json:"warnings"` // already in Merge(base, {}): from the host file, non-blocking (R59) + Changes []agentconfig.Change `json:"changes"` + WillApply bool `json:"will-apply"` + WillApplyReason string `json:"will-apply-reason,omitempty"` // mode-off|mode-report|unsafe-changes|forbidden-changes|invalid-config +} + // instanceValidationErrors groups the errors of one validated instance in a 422 body. // Errors are the ones the overlay introduces (they block); Warnings are already present in // Merge(base, {}), i.e. they come from the host file, and do not block (R59). @@ -88,6 +118,16 @@ type agentConfigPutRequest struct { Comment *string `json:"comment"` } +// agentConfigRevertRequest is the (optional) revert body. +type agentConfigRevertRequest struct { + Comment *string `json:"comment"` +} + +// agentConfigPreviewRequest is the preview body. +type agentConfigPreviewRequest struct { + Overlay json.RawMessage `json:"overlay" swaggertype:"object"` +} + // ---- Handlers ---- // Get godoc @@ -171,6 +211,67 @@ func (h *AgentConfigHandler) Put(ctx echo.Context) error { return h.save(ctx, agent, expected, req.Overlay, comment, nil) } +// Revert godoc +// +// @Summary Revert an agent's configuration to an earlier revision +// @Description Creates the next revision with the overlay of revision :rev (revert-of records it). Same If-Match, validation and authorization rules as PUT. The body is optional. +// @Tags Agent Configuration +// @Accept json +// @Produce json +// @Param id path string true "Agent ID" +// @Param rev path integer true "Revision to restore" +// @Param If-Match header string true "Current revision, e.g. \"7\"" +// @Param body body handler.agentConfigRevertRequest false "Optional comment" +// @Success 200 {object} handler.GenericDataResponse[handler.agentConfigRevisionResponse] +// @Success 201 {object} handler.GenericDataResponse[handler.agentConfigRevisionResponse] +// @Failure 400 {object} api.Error +// @Failure 403 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 409 {object} api.Error +// @Failure 413 {object} api.Error +// @Failure 415 {object} api.Error +// @Failure 422 {object} api.Error +// @Failure 428 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/agents/{id}/config/revisions/{rev}/revert [post] +func (h *AgentConfigHandler) Revert(ctx echo.Context) error { + agent, errResp := h.resolveAgent(ctx) + if agent == nil { + return errResp + } + revNumber, err := parseRevisionParam(ctx.Param("rev")) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + target, err := h.svc.GetRevision(ctx.Request().Context(), *agent.ID, revNumber) + if errors.Is(err, agentcfg.ErrNotFound) { + return ctx.JSON(http.StatusNotFound, api.NotFoundCustomMsg("revision not found")) + } + if err != nil { + return h.internalError(ctx, "load revision", err) + } + expected, ok := agentconfig.ParseRevisionIfMatch(ctx.Request().Header.Get(headerIfMatch)) + if !ok { + return preconditionRequired(ctx) + } + body, bodyErr := readJSONBody(ctx, agentConfigBodyLimit) + if bodyErr != nil { + return bodyErr.respond(ctx) + } + var req agentConfigRevertRequest + if len(bytes.TrimSpace(body)) > 0 { + if err := decodeStrict(body, &req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + } + comment, err := normalizeComment(req.Comment) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return h.save(ctx, agent, expected, json.RawMessage(target.Overlay), comment, &revNumber) +} + // save implements PUT/revert steps 3-7 (A4.3). func (h *AgentConfigHandler) save(ctx echo.Context, agent *relational.Agent, expected int64, overlay json.RawMessage, comment *string, revertOf *int64) error { reqCtx := ctx.Request().Context() @@ -234,6 +335,114 @@ func (h *AgentConfigHandler) save(ctx echo.Context, agent *relational.Agent, exp return ctx.JSON(http.StatusCreated, GenericDataResponse[agentConfigRevisionResponse]{Data: revisionResponse(agentID, rev, true)}) } +// Preview godoc +// +// @Summary Preview an agent configuration overlay +// @Description Validates a candidate overlay without saving it and shows, per reporting instance (fresh and stale), the redacted effective config, its diff against the instance's current effective config, the classified changes and whether the agent would apply it. validated marks the instances a save validates against; only their errors block a save. errors are the problems the overlay introduces; warnings are problems already in the instance's own file (present in Merge(base, {})), which never block a save or force invalid-config (R59). Validation problems are returned in the 200 body; when the overlay itself is invalid (overlay-errors), instances is empty. At most 50 instances and 16 MiB of reported config are previewed (validated instances first, then newest first); omitted-instances counts the rest. A save still validates against every validated instance. Needs agent:configure. +// @Tags Agent Configuration +// @Accept json +// @Produce json +// @Param id path string true "Agent ID" +// @Param body body handler.agentConfigPreviewRequest true "Candidate overlay" +// @Success 200 {object} handler.GenericDataResponse[handler.configPreviewResponse] +// @Failure 400 {object} api.Error +// @Failure 403 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 413 {object} api.Error +// @Failure 415 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/agents/{id}/config/preview [post] +func (h *AgentConfigHandler) Preview(ctx echo.Context) error { + agent, errResp := h.resolveAgent(ctx) + if agent == nil { + return errResp + } + body, bodyErr := readJSONBody(ctx, agentConfigBodyLimit) + if bodyErr != nil { + return bodyErr.respond(ctx) + } + var req agentConfigPreviewRequest + if err := decodeStrict(body, &req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if isNullOrEmpty(req.Overlay) { + return ctx.JSON(http.StatusBadRequest, api.NewError(errors.New("overlay is required"))) + } + reqCtx := ctx.Request().Context() + agentID := *agent.ID + + desired, err := h.svc.CurrentRevisionNumber(reqCtx, agentID) + if err != nil { + return h.internalError(ctx, "load agent configuration", err) + } + set, err := h.svc.PreviewBases(reqCtx, agentID) + if err != nil { + return h.internalError(ctx, "load instances", err) + } + // The validation set is what a save validates against (R48); none means standalone. + standalone := len(set.Validation) == 0 + + result := validateCandidate(req.Overlay, set.Validation) + resp := configPreviewResponse{ + DesiredRevision: desired, + Standalone: standalone, + OverlayErrors: nonNil(result.overlay), + Instances: []instancePreview{}, + OmittedInstances: set.Omitted, + } + // An overlay that is invalid on its own (including over MaxOverlayBytes) is not + // previewed per instance: it cannot be saved, and the per-instance work is costly. + if len(result.overlay) > 0 { + return ctx.JSON(http.StatusOK, GenericDataResponse[configPreviewResponse]{Data: resp}) + } + resp.Instances = make([]instancePreview, 0, len(set.Instances)) + for _, b := range set.Instances { + resp.Instances = append(resp.Instances, previewInstance(b, req.Overlay)) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[configPreviewResponse]{Data: resp}) +} + +// previewInstance computes one instance's preview of a valid overlay. +func previewInstance(b agentcfg.InstanceBase, overlay json.RawMessage) instancePreview { + p := instancePreview{ + InstanceID: b.Instance.InstanceID.String(), + Hostname: b.Instance.Hostname, + Mode: b.Instance.Mode, + Stale: b.Stale, + Validated: b.Validated, + DiffVsCurrent: []agentconfig.DiffEntry{}, + Errors: []agentconfig.FieldError{}, + Warnings: []agentconfig.FieldError{}, + Changes: []agentconfig.Change{}, + } + eff, introduced, fileOrigin := splitIntroduced(b.Base, overlay) + p.Errors = append(p.Errors, introduced...) + p.Warnings = append(p.Warnings, fileOrigin...) + if eff != nil { + if raw, err := agentconfig.CanonicalJSON(agentconfig.Redact(*eff)); err == nil { + p.Effective = raw + if diff, err := agentconfig.DiffJSON(b.Instance.EffectiveConfig, raw); err == nil && diff != nil { + p.DiffVsCurrent = diff + } + } + if changes, err := agentconfig.Classify(b.Base, overlay, b.Remote); err == nil { + if changes != nil { + p.Changes = changes + } + } else { + p.Errors = append(p.Errors, agentconfig.FieldError{Path: "", Code: agentconfig.FieldCodeParse, Message: err.Error()}) + } + } + p.WillApply, p.WillApplyReason = agentconfig.WillApply(b.Remote, p.Changes) + // Only errors the overlay introduces force invalid-config; file-origin warnings do not, + // since the agent only warns about them (R34, R41, R59). + if len(p.Errors) > 0 { + p.WillApply, p.WillApplyReason = false, agentconfig.ReasonInvalidConfig + } + return p +} + // candidateResult is the outcome of the candidate validation pipeline (A4.2). type candidateResult struct { overlay []agentconfig.FieldError @@ -338,6 +547,52 @@ func mergeAndValidate(base agentconfig.Config, overlay json.RawMessage) (*agentc return &eff, nil } +// ListRevisions godoc +// +// @Summary List an agent's configuration revisions +// @Description Revisions newest first, without overlays. +// @Tags Agent Configuration +// @Produce json +// @Param id path string true "Agent ID" +// @Param page query integer false "Page (default 1)" +// @Param limit query integer false "Page size (default 50, max 100)" +// @Success 200 {object} service.ListResponse[handler.agentConfigRevisionResponse] +// @Failure 400 {object} api.Error +// @Failure 403 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/agents/{id}/config/revisions [get] +func (h *AgentConfigHandler) ListRevisions(ctx echo.Context) error { + agent, errResp := h.resolveAgent(ctx) + if agent == nil { + return errResp + } + params, err := service.NewPaginationConfig().ParseParams(ctx) + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + rows, total, err := h.svc.ListRevisions(ctx.Request().Context(), *agent.ID, *params) + if err != nil { + return h.internalError(ctx, "list revisions", err) + } + items := make([]agentConfigRevisionResponse, 0, len(rows)) + for _, r := range rows { + createdAt := r.CreatedAt.UTC() + createdBy := r.CreatedBy + items = append(items, agentConfigRevisionResponse{ + AgentID: r.AgentID.String(), + Revision: r.Revision, + OverlaySize: r.OverlaySize, + Comment: r.Comment, + CreatedBy: &createdBy, + CreatedAt: &createdAt, + RevertOf: r.RevertOf, + }) + } + return ctx.JSON(http.StatusOK, service.NewListResponse(items, total, params.Page, params.Limit)) +} + // GetRevision godoc // // @Summary Get one configuration revision diff --git a/internal/api/handler/agent_config_admin_integration_test.go b/internal/api/handler/agent_config_admin_integration_test.go index be512d5a..57db4818 100644 --- a/internal/api/handler/agent_config_admin_integration_test.go +++ b/internal/api/handler/agent_config_admin_integration_test.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/middleware" @@ -266,6 +267,14 @@ func (s *AgentConfigAdminIntegrationSuite) report(agentID uuid.UUID, mode string return instanceID } +// makeStale moves an instance's last_seen_at (and reported_at) into the past. +func (s *AgentConfigAdminIntegrationSuite) makeStale(instanceID uuid.UUID, age time.Duration) { + at := time.Now().UTC().Add(-age) + s.Require().NoError(s.DB.Exec( + "UPDATE ccf_agent_instances SET last_seen_at = ?, reported_at = ? WHERE instance_id = ?", at, at, instanceID, + ).Error) +} + // ---- GET /config ---- func (s *AgentConfigAdminIntegrationSuite) TestGetConfigRevisionZero() { @@ -299,6 +308,29 @@ func (s *AgentConfigAdminIntegrationSuite) TestGetConfigAfterSave() { s.Equal("dummy@example.com", *got.CreatedBy) } +// The PUT response shows the stored revision: the same overlay bytes and size as GET and +// the revision list. +func (s *AgentConfigAdminIntegrationSuite) TestPutResponseMatchesReads() { + rec := s.put(s.server, s.token, `"0"`, `{"verbosity":1}`) + s.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + put := acaData[agentConfigRevisionResponse](s, rec) + + rec = s.call(http.MethodGet, s.path("/config"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + get := acaData[agentConfigRevisionResponse](s, rec) + s.Equal(get.OverlaySize, put.OverlaySize) + s.Equal(string(get.Overlay), string(put.Overlay)) + + rec = s.call(http.MethodGet, s.path("/config/revisions"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var list struct { + Data []agentConfigRevisionResponse `json:"data"` + } + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &list)) + s.Require().Len(list.Data, 1) + s.Equal(put.OverlaySize, list.Data[0].OverlaySize) +} + func (s *AgentConfigAdminIntegrationSuite) TestGetConfigBadAndUnknownAgent() { rec := s.call(http.MethodGet, "/api/admin/agents/not-a-uuid/config", nil) s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) @@ -401,6 +433,45 @@ func (s *AgentConfigAdminIntegrationSuite) TestPutValidatesAgainstFreshInstances s.Equal(agentconfig.FieldCodeRequired, errs.Instances[0].Errors[0].Code) } +func (s *AgentConfigAdminIntegrationSuite) TestPutFallsBackToLatestStaleInstance() { + // Only a stale apply-mode instance: it is still validated against (R48), so a plugin + // without a source is rejected. + older := s.report(*s.agent.ID, agentconfig.ModeApplySafe, nil) + s.makeStale(older, 2*time.Hour) + + overlay := `{"plugins":{"other":{"schedule":"*/5 * * * *"}}}` + errs := s.unprocessable(s.put(s.server, s.token, `"0"`, overlay)) + s.Require().Len(errs.Instances, 1) + s.Equal(older.String(), errs.Instances[0].InstanceID) + + // A schedule-only patch of a plugin in the stale base is fine. + s.save(`"0"`, `{"plugins":{"ssh":{"schedule":"*/10 * * * *"}}}`, 1) + + // A more recently reported (still stale) instance whose base defines "other" becomes the + // only validation base, so the same overlay now saves. + schedule := "@hourly" + newer := s.report(*s.agent.ID, agentconfig.ModeApplyAll, func(r *agentconfig.Report) { + base := acaBase(agentconfig.ModeApplyAll, map[string]*agentconfig.Plugin{ + "other": {Source: "ghcr.io/vendor/other:v1", Schedule: &schedule}, + }) + r.Base, r.Effective = base, base + }) + s.makeStale(newer, time.Hour) + s.save(`"1"`, overlay, 2) + + // Preview marks only the fallback instance as validated. + rec := s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(overlay)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview := acaData[configPreviewResponse](s, rec) + s.False(preview.Standalone) + validated := map[string]bool{} + for _, inst := range preview.Instances { + s.True(inst.Stale, inst.InstanceID) + validated[inst.InstanceID] = inst.Validated + } + s.Equal(map[string]bool{older.String(): false, newer.String(): true}, validated) +} + func (s *AgentConfigAdminIntegrationSuite) TestPutStandaloneWithoutInstances() { // With no reporting instance only overlay-level checks run, so a plugin without a source // saves (it cannot be merged against anything). @@ -447,10 +518,287 @@ func (s *AgentConfigAdminIntegrationSuite) TestPutBodyHandling() { // ---- Revert ---- +func (s *AgentConfigAdminIntegrationSuite) TestRevert() { + s.save(`"0"`, `{"verbosity":1}`, 1) + s.save(`"1"`, `{"verbosity":2}`, 2) + revert := s.path("/config/revisions/1/revert") + + // Missing If-Match => 428. + rec := s.call(http.MethodPost, revert, nil) + s.Require().Equal(http.StatusPreconditionRequired, rec.Code, rec.Body.String()) + + // Stale If-Match => 409. + rec = s.call(http.MethodPost, revert, nil, "If-Match", `"1"`) + s.Require().Equal(http.StatusConflict, rec.Code, rec.Body.String()) + s.JSONEq(`{"errors":{"body":"configuration revision conflict","current-revision":2}}`, rec.Body.String()) + + // Unknown revision => 404; non-numeric => 400. + rec = s.call(http.MethodPost, s.path("/config/revisions/99/revert"), nil, "If-Match", `"2"`) + s.Equal(http.StatusNotFound, rec.Code, rec.Body.String()) + rec = s.call(http.MethodPost, s.path("/config/revisions/abc/revert"), nil, "If-Match", `"2"`) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + + // Empty body with the current If-Match => 201, revision 3, revert-of 1. + rec = s.call(http.MethodPost, revert, nil, "If-Match", `"2"`) + s.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + s.Equal(`"3"`, rec.Header().Get("ETag")) + got := acaData[agentConfigRevisionResponse](s, rec) + s.Equal(int64(3), got.Revision) + s.Require().NotNil(got.RevertOf) + s.Equal(int64(1), *got.RevertOf) + s.JSONEq(`{"verbosity":1}`, string(got.Overlay)) + + // A revert with a comment body. + rec = s.call(http.MethodPost, s.path("/config/revisions/2/revert"), []byte(`{"comment":"back to 2"}`), "If-Match", `"3"`) + s.Require().Equal(http.StatusCreated, rec.Code, rec.Body.String()) + got = acaData[agentConfigRevisionResponse](s, rec) + s.Equal(int64(4), got.Revision) + s.Require().NotNil(got.Comment) + s.Equal("back to 2", *got.Comment) + + // Reverting to an overlay equal to the current one is a no-op (R14) => 200. + rec = s.call(http.MethodPost, s.path("/config/revisions/2/revert"), nil, "If-Match", `"4"`) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + s.Equal(int64(4), s.revisionCount(*s.agent.ID)) +} + // ---- Revisions ---- +func (s *AgentConfigAdminIntegrationSuite) TestRevisions() { + s.save(`"0"`, `{"verbosity":1}`, 1) + s.save(`"1"`, `{"verbosity":2}`, 2) + s.save(`"2"`, `{"verbosity":0}`, 3) + + rec := s.call(http.MethodGet, s.path("/config/revisions"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var raw map[string]json.RawMessage + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &raw)) + for _, k := range []string{"data", "total", "page", "limit", "totalPages"} { + s.Contains(raw, k) + } + var items []map[string]json.RawMessage + s.Require().NoError(json.Unmarshal(raw["data"], &items)) + s.Require().Len(items, 3) + for i, item := range items { + s.JSONEq(fmt.Sprint(3-i), string(item["revision"]), "newest first") + s.NotContains(item, "overlay", "lists omit the overlay (R12)") + s.Contains(item, "overlay-size") + } + s.JSONEq(`3`, string(raw["total"])) + + rec = s.call(http.MethodGet, s.path("/config/revisions?page=2&limit=1"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var page struct { + Data []agentConfigRevisionResponse `json:"data"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"totalPages"` + } + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &page)) + s.Require().Len(page.Data, 1) + s.Equal(int64(2), page.Data[0].Revision) + s.Equal(int64(3), page.Total) + s.Equal(2, page.Page) + s.Equal(1, page.Limit) + s.Equal(3, page.TotalPages) + + rec = s.call(http.MethodGet, s.path("/config/revisions?page=0"), nil) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + + // One revision, with its overlay. + rec = s.call(http.MethodGet, s.path("/config/revisions/2"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + got := acaData[agentConfigRevisionResponse](s, rec) + s.Equal(int64(2), got.Revision) + s.JSONEq(`{"verbosity":2}`, string(got.Overlay)) + + rec = s.call(http.MethodGet, s.path("/config/revisions/99"), nil) + s.Equal(http.StatusNotFound, rec.Code, rec.Body.String()) + rec = s.call(http.MethodGet, s.path("/config/revisions/abc"), nil) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + rec = s.call(http.MethodGet, s.path("/config/revisions/0"), nil) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) +} + +func (s *AgentConfigAdminIntegrationSuite) TestRevisionsEmptyList() { + rec := s.call(http.MethodGet, s.path("/config/revisions"), nil) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var raw map[string]json.RawMessage + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &raw)) + s.JSONEq(`[]`, string(raw["data"])) + s.JSONEq(`0`, string(raw["total"])) +} + // ---- Preview ---- +func (s *AgentConfigAdminIntegrationSuite) TestPreviewWillApply() { + safe := s.report(*s.agent.ID, agentconfig.ModeApplySafe, nil) + all := s.report(*s.agent.ID, agentconfig.ModeApplyAll, nil) + reportOnly := s.report(*s.agent.ID, agentconfig.ModeReport, nil) + s.save(`"0"`, `{"verbosity":1}`, 1) + + // A new plugin from an untrusted OCI source. + overlay := `{"plugins":{"newp":{"source":"ghcr.io/other/newp:v1","schedule":"@hourly"}}}` + rec := s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(overlay)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview := acaData[configPreviewResponse](s, rec) + s.Equal(int64(1), preview.DesiredRevision) + s.False(preview.Standalone) + s.Empty(preview.OverlayErrors) + + byID := map[string]instancePreview{} + for _, inst := range preview.Instances { + byID[inst.InstanceID] = inst + } + s.Require().Len(byID, 3) + + p := byID[safe.String()] + s.True(p.Validated) + s.False(p.WillApply) + s.Equal(agentconfig.ReasonUnsafeChanges, p.WillApplyReason) + s.NotEmpty(p.Changes) + s.NotEmpty(p.DiffVsCurrent) + s.NotEmpty(p.Effective) + + p = byID[all.String()] + s.True(p.Validated) + s.True(p.WillApply) + s.Empty(p.WillApplyReason) + + p = byID[reportOnly.String()] + s.False(p.Validated, "report-mode instances are not validated against") + s.False(p.WillApply) + s.Equal(agentconfig.WillApplyReasonModeReport, p.WillApplyReason) + + // Nothing was saved. + s.Equal(int64(1), s.revisionCount(*s.agent.ID)) +} + +func (s *AgentConfigAdminIntegrationSuite) TestPreviewStandaloneAndSlices() { + rec := s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{"verbosity":1}`)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + s.JSONEq(`{"data":{"desired-revision":0,"standalone":true,"overlay-errors":[],"instances":[],"omitted-instances":0}}`, rec.Body.String()) + + // An unchanged overlay on a fresh instance: every slice is [] rather than null. + s.report(*s.agent.ID, agentconfig.ModeApplySafe, nil) + rec = s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{}`)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + var body struct { + Data struct { + Standalone bool `json:"standalone"` + Instances []map[string]json.RawMessage `json:"instances"` + } `json:"data"` + } + s.Require().NoError(json.Unmarshal(rec.Body.Bytes(), &body)) + s.False(body.Data.Standalone) + s.Require().Len(body.Data.Instances, 1) + inst := body.Data.Instances[0] + for _, k := range []string{"diff-vs-current", "errors", "warnings", "changes"} { + s.JSONEq(`[]`, string(inst[k]), k) + } + s.JSONEq(`true`, string(inst["will-apply"])) + s.NotContains(inst, "will-apply-reason") +} + +func (s *AgentConfigAdminIntegrationSuite) TestPreviewInvalidOverlay() { + s.report(*s.agent.ID, agentconfig.ModeApplySafe, nil) + s.report(*s.agent.ID, agentconfig.ModeApplyAll, nil) + + rec := s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{"api":{"url":"http://evil"}}`)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview := acaData[configPreviewResponse](s, rec) + s.Require().NotEmpty(preview.OverlayErrors) + s.Equal("/api", preview.OverlayErrors[0].Path) + s.Equal(agentconfig.FieldCodeLockedKey, preview.OverlayErrors[0].Code) + s.NotNil(preview.Instances) + s.Empty(preview.Instances, "an invalid overlay is not previewed per instance") + s.Contains(rec.Body.String(), `"instances":[]`) + + // Over MaxOverlayBytes (but within the body limit): overlay errors only. + big := `{"plugins":{"ssh":{"labels":{"x":"` + strings.Repeat("a", agentconfig.MaxOverlayBytes) + `"}}}}` + rec = s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(big)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview = acaData[configPreviewResponse](s, rec) + s.Require().NotEmpty(preview.OverlayErrors) + s.Equal(agentconfig.FieldCodeSize, preview.OverlayErrors[0].Code) + s.Empty(preview.Instances) + + // Instance-level errors (no source) also force invalid-config, and are listed per instance. + rec = s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{"plugins":{"newp":{"schedule":"* * * * *"}}}`)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview = acaData[configPreviewResponse](s, rec) + s.Empty(preview.OverlayErrors) + for _, inst := range preview.Instances { + s.NotEmpty(inst.Errors) + s.Equal(agentconfig.ReasonInvalidConfig, inst.WillApplyReason) + } + + // Body problems are still 400 / 415. + rec = s.call(http.MethodPost, s.path("/config/preview"), []byte(`{"overlay":{},"x":1}`)) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + rec = s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{}`), echo.HeaderContentType, "text/plain") + s.Equal(http.StatusUnsupportedMediaType, rec.Code, rec.Body.String()) +} + +// R59: errors already present in Merge(base, {}) come from the host file. They are +// non-blocking warnings; only errors the overlay introduces block a save or force +// invalid-config. +func (s *AgentConfigAdminIntegrationSuite) TestFileOriginErrorsDoNotBlock() { + badCron := "not a cron" + instance := s.report(*s.agent.ID, agentconfig.ModeApplySafe, func(r *agentconfig.Report) { + base := acaBase(agentconfig.ModeApplySafe, map[string]*agentconfig.Plugin{ + "x": {Source: acaVendorPlugin, Schedule: &badCron}, + }) + r.Base, r.Effective = base, base + }) + + // An unrelated overlay saves. + s.save(`"0"`, `{"verbosity":1}`, 1) + + // Preview shows the file error as a warning and the instance still applies. + rec := s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{"verbosity":2}`)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview := acaData[configPreviewResponse](s, rec) + s.Require().Len(preview.Instances, 1) + p := preview.Instances[0] + s.Equal(instance.String(), p.InstanceID) + s.True(p.Validated) + s.Empty(p.Errors) + s.NotNil(p.Errors) + s.Require().Len(p.Warnings, 1) + s.Equal("/plugins/x/schedule", p.Warnings[0].Path) + s.Equal(agentconfig.FieldCodeCron, p.Warnings[0].Code) + s.True(p.WillApply) + s.Empty(p.WillApplyReason) + + // A new bad cron in the overlay is still refused (caught on the overlay itself). + rec = s.put(s.server, s.token, `"1"`, `{"plugins":{"ssh":{"schedule":"also bad"}}}`) + body := s.unprocessable(rec) + s.Require().NotEmpty(body.Overlay) + s.Equal("/plugins/ssh/schedule", body.Overlay[0].Path) + + // An error that only appears once merged is introduced: 422, listed per instance with + // the file-origin error as a warning. + rec = s.put(s.server, s.token, `"1"`, `{"plugins":{"newp":{"schedule":"* * * * *"}}}`) + body = s.unprocessable(rec) + s.Empty(body.Overlay) + s.Require().Len(body.Instances, 1) + s.Require().NotEmpty(body.Instances[0].Errors) + s.Equal("/plugins/newp/source", body.Instances[0].Errors[0].Path) + s.Require().Len(body.Instances[0].Warnings, 1) + s.Equal("/plugins/x/schedule", body.Instances[0].Warnings[0].Path) + + // Preview agrees: an introduced error forces invalid-config. + rec = s.call(http.MethodPost, s.path("/config/preview"), acaPutBody(`{"plugins":{"newp":{"schedule":"* * * * *"}}}`)) + s.Require().Equal(http.StatusOK, rec.Code, rec.Body.String()) + preview = acaData[configPreviewResponse](s, rec) + s.Require().Len(preview.Instances, 1) + s.False(preview.Instances[0].WillApply) + s.Equal(agentconfig.ReasonInvalidConfig, preview.Instances[0].WillApplyReason) + s.Equal(int64(1), s.revisionCount(*s.agent.ID)) +} + // ---- Instances ---- // ---- Agent deletion ---- diff --git a/internal/api/handler/agent_config_test.go b/internal/api/handler/agent_config_test.go index 4655281c..c7e33be3 100644 --- a/internal/api/handler/agent_config_test.go +++ b/internal/api/handler/agent_config_test.go @@ -65,6 +65,53 @@ func TestSplitIntroduced(t *testing.T) { } } +func TestValidateCandidateAndPreview_FileOriginErrorsDoNotBlock(t *testing.T) { + base := agentcfg.InstanceBase{ + Instance: relational.AgentInstance{InstanceID: uuid.New(), Mode: agentconfig.ModeApplySafe}, + Base: badCronBase(), + Remote: agentconfig.RemoteConfig{Mode: agentconfig.ModeApplySafe}, + Validated: true, + } + + ok := json.RawMessage(`{"verbosity":1}`) + r := validateCandidate(ok, []agentcfg.InstanceBase{base}) + assert.False(t, r.blocking(), "file-origin errors must not block a save") + assert.Empty(t, r.instances) + + p := previewInstance(base, ok) + assert.Empty(t, p.Errors) + require.Len(t, p.Warnings, 1) + assert.Equal(t, "/plugins/x/schedule", p.Warnings[0].Path) + assert.Equal(t, agentconfig.FieldCodeCron, p.Warnings[0].Code) + assert.NotEqual(t, agentconfig.ReasonInvalidConfig, p.WillApplyReason) + + // Valid on its own (so it reaches the per-instance step), invalid once merged. + bad := json.RawMessage(`{"plugins":{"y":{"source":null}}}`) + r = validateCandidate(bad, []agentcfg.InstanceBase{base}) + assert.Empty(t, r.overlay) + assert.True(t, r.blocking()) + require.Len(t, r.instances, 1) + assert.Equal(t, []string{"/plugins/y/source"}, fieldPaths(r.instances[0].Errors)) + assert.Equal(t, []string{"/plugins/x/schedule"}, fieldPaths(r.instances[0].Warnings)) + + p = previewInstance(base, bad) + assert.Equal(t, []string{"/plugins/y/source"}, fieldPaths(p.Errors)) + assert.Equal(t, []string{"/plugins/x/schedule"}, fieldPaths(p.Warnings)) + assert.False(t, p.WillApply) + assert.Equal(t, agentconfig.ReasonInvalidConfig, p.WillApplyReason) +} + +func TestPreviewInstance_WarningsNeverNull(t *testing.T) { + base := agentcfg.InstanceBase{ + Instance: relational.AgentInstance{InstanceID: uuid.New(), Mode: agentconfig.ModeApplySafe}, + Base: agentconfig.Config{}, + Remote: agentconfig.RemoteConfig{Mode: agentconfig.ModeApplySafe}, + } + raw, err := json.Marshal(previewInstance(base, json.RawMessage(`{"verbosity":1}`))) + require.NoError(t, err) + assert.Contains(t, string(raw), `"warnings":[]`) +} + func fieldPaths(errs []agentconfig.FieldError) []string { if len(errs) == 0 { return nil From f823596a0eee6148057b57428b90b711db3fbd87 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:04:03 -0300 Subject: [PATCH 2/4] fix(api): keep preview within its bounds for validated instances PreviewBases loaded and decoded every validated instance's base through ValidationBases, and Preview validated the overlay against all of them only to discard the per-instance results, so the 50-instance / 16 MiB preview bound did not cover the validation set. Preview now validates the overlay alone, and PreviewBases reads the validation set as instance ids only. Co-Authored-By: Claude Opus 5.5 --- internal/api/handler/agent_config.go | 6 ++-- .../service/relational/agentcfg/service.go | 29 ++++++++++++------- .../agentcfg/service_integration_test.go | 22 ++++++++++++-- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/internal/api/handler/agent_config.go b/internal/api/handler/agent_config.go index cab9face..024dd748 100644 --- a/internal/api/handler/agent_config.go +++ b/internal/api/handler/agent_config.go @@ -381,9 +381,11 @@ func (h *AgentConfigHandler) Preview(ctx echo.Context) error { return h.internalError(ctx, "load instances", err) } // The validation set is what a save validates against (R48); none means standalone. - standalone := len(set.Validation) == 0 + standalone := set.Validated == 0 - result := validateCandidate(req.Overlay, set.Validation) + // Only the overlay is validated here: each previewed instance gets its own errors from + // previewInstance, so the validation set's bases are never loaded (preview bounds, R14). + result := validateCandidate(req.Overlay, nil) resp := configPreviewResponse{ DesiredRevision: desired, Standalone: standalone, diff --git a/internal/service/relational/agentcfg/service.go b/internal/service/relational/agentcfg/service.go index c08f3f14..6c137f68 100644 --- a/internal/service/relational/agentcfg/service.go +++ b/internal/service/relational/agentcfg/service.go @@ -796,6 +796,13 @@ func instancesWithKey(members []validationMember, key string) []uuid.UUID { return ids } +// validationRows loads the given columns of the ValidationBases set (findValidationSet). +func (s *Service) validationRows(ctx context.Context, agentID uuid.UUID, columns []string) ([]relational.AgentInstance, error) { + var rows []relational.AgentInstance + err := s.findValidationSet(s.db.WithContext(ctx), agentID, s.now(), columns, &rows) + return rows, err +} + // Preview bounds (R14): a preview shows at most PreviewMaxInstances instances and decodes // at most PreviewMaxConfigBytes of reported base+effective config, so one agent credential // cannot make a single preview cost minutes of CPU by reporting many large instances. @@ -806,8 +813,9 @@ const ( // PreviewSet is what a preview works on. type PreviewSet struct { - // Validation is ValidationBases: the set a save validates against (R48), in full. - Validation []InstanceBase + // Validated is the size of the ValidationBases set, the instances a save validates + // against (R48). Their configs are not loaded: preview only shows them. + Validated int // Instances are the instances the preview shows, each marked Validated when it is in // Validation: the validated ones first, then the others, newest first, within // PreviewMaxInstances and PreviewMaxConfigBytes. @@ -816,17 +824,18 @@ type PreviewSet struct { Omitted int64 } -// PreviewBases returns the validation set and the bounded list of instances with a -// reported base (fresh and stale, flagged) a preview shows. Only the selected instances' -// configs are loaded. +// PreviewBases returns the size of the validation set and the bounded list of instances with +// a reported base (fresh and stale, flagged) a preview shows. Only the selected instances' +// configs are loaded; the validation set is read as instance ids only, so the cost stays +// within PreviewMaxInstances and PreviewMaxConfigBytes however many instances validate. func (s *Service) PreviewBases(ctx context.Context, agentID uuid.UUID) (PreviewSet, error) { - validation, _, err := s.ValidationBases(ctx, agentID) + validation, err := s.validationRows(ctx, agentID, []string{"instance_id"}) if err != nil { return PreviewSet{}, err } - validated := map[uuid.UUID]bool{} - for _, b := range validation { - validated[b.Instance.InstanceID] = true + validated := make(map[uuid.UUID]bool, len(validation)) + for _, row := range validation { + validated[row.InstanceID] = true } type candidate struct { @@ -864,7 +873,7 @@ func (s *Service) PreviewBases(ctx context.Context, agentID uuid.UUID) (PreviewS picked = append(picked, c.InstanceID) budget -= c.ConfigSize } - set := PreviewSet{Validation: validation, Omitted: int64(len(candidates) - len(picked))} + set := PreviewSet{Validated: len(validation), Omitted: int64(len(candidates) - len(picked))} if len(picked) == 0 { return set, nil } diff --git a/internal/service/relational/agentcfg/service_integration_test.go b/internal/service/relational/agentcfg/service_integration_test.go index 850b578f..d0d52089 100644 --- a/internal/service/relational/agentcfg/service_integration_test.go +++ b/internal/service/relational/agentcfg/service_integration_test.go @@ -911,7 +911,7 @@ func (s *AgentCfgServiceIntegrationSuite) TestPreviewBases() { set, err := s.svc.PreviewBases(s.ctx, f.agentID) s.Require().NoError(err) s.Zero(set.Omitted) - s.Len(set.Validation, 2) + s.Equal(2, set.Validated) m := byInstance(set.Instances) s.Require().Len(m, 4, "every instance with a base; base-less excluded") s.NotContains(m, f.noBase) @@ -956,7 +956,7 @@ func (s *AgentCfgServiceIntegrationSuite) TestPreviewBasesBounded() { set, err := s.svc.PreviewBases(s.ctx, agentID) s.Require().NoError(err) - s.Len(set.Validation, 1) + s.Equal(1, set.Validated) s.Require().Len(set.Instances, agentcfg.PreviewMaxInstances) s.EqualValues(6, set.Omitted) s.Equal(validated, set.Instances[0].Instance.InstanceID, "validated instances first") @@ -965,6 +965,24 @@ func (s *AgentCfgServiceIntegrationSuite) TestPreviewBasesBounded() { s.False(set.Instances[1].Validated) } +// More validated (fresh apply-mode) instances than the preview bound: only the bound is +// shown, every one still counts as validated, and the rest are omitted. +func (s *AgentCfgServiceIntegrationSuite) TestPreviewBasesBoundsValidatedInstances() { + agentID := s.newAgent("preview-bounded-validated") + for i := range agentcfg.PreviewMaxInstances + 5 { + s.Require().NoError(s.reportAt(s.svc, s.now.Add(-time.Duration(i+1)*time.Second), agentID, uuid.New(), applyReport(agentconfig.ModeApplySafe, baseConfig))) + } + + set, err := s.svc.PreviewBases(s.ctx, agentID) + s.Require().NoError(err) + s.Equal(agentcfg.PreviewMaxInstances+5, set.Validated) + s.Require().Len(set.Instances, agentcfg.PreviewMaxInstances) + s.EqualValues(5, set.Omitted) + for _, b := range set.Instances { + s.True(b.Validated) + } +} + func (s *AgentCfgServiceIntegrationSuite) TestDeleteInstancesForAgentKeepsRevisions() { agentA := s.newAgent("delete-a") agentB := s.newAgent("delete-b") From cbc8a1d4af81b7ae840b606a2230f787fc462508 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:05:41 -0300 Subject: [PATCH 3/4] docs(agentcfg): drop the removed Validation field from PreviewSet docs Co-Authored-By: Claude Opus 5.5 --- internal/service/relational/agentcfg/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/service/relational/agentcfg/service.go b/internal/service/relational/agentcfg/service.go index 6c137f68..c4b7189a 100644 --- a/internal/service/relational/agentcfg/service.go +++ b/internal/service/relational/agentcfg/service.go @@ -817,7 +817,7 @@ type PreviewSet struct { // against (R48). Their configs are not loaded: preview only shows them. Validated int // Instances are the instances the preview shows, each marked Validated when it is in - // Validation: the validated ones first, then the others, newest first, within + // the validation set: the validated ones first, then the others, newest first, within // PreviewMaxInstances and PreviewMaxConfigBytes. Instances []InstanceBase // Omitted counts the instances with a reported base the bounds left out. From c6b7821c021a4e62ad760c7d31e64f212be68473 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:40:35 -0300 Subject: [PATCH 4/4] test(api): a NUL in a revert comment is a 400 Revert shares normalizeComment with PUT, which now rejects a NUL character; cover the revert route so it cannot regress to a 500 from the insert. Co-Authored-By: Claude Opus 5.5 --- ..._config_revert_regression_integration_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 internal/api/handler/agent_config_revert_regression_integration_test.go diff --git a/internal/api/handler/agent_config_revert_regression_integration_test.go b/internal/api/handler/agent_config_revert_regression_integration_test.go new file mode 100644 index 00000000..e503dd59 --- /dev/null +++ b/internal/api/handler/agent_config_revert_regression_integration_test.go @@ -0,0 +1,16 @@ +//go:build integration + +package handler + +import "net/http" + +// Regression (review #481/#482, fp a042387242aa): a NUL in a revert comment is a 400 (not a +// 500 from the insert), and no revision is created. +func (s *AgentConfigAdminIntegrationSuite) TestRegressionRevertCommentNULIsBadRequest() { + s.save(`"0"`, `{"verbosity":1}`, 1) + s.save(`"1"`, `{"verbosity":2}`, 2) + rec := s.send(s.server, s.token, http.MethodPost, s.path("/config/revisions/1/revert"), + []byte(`{"comment":"a\u0000b"}`), "If-Match", `"2"`) + s.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + s.Equal(int64(2), s.revisionCount(*s.agent.ID)) +}