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..024dd748 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,116 @@ 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 := set.Validated == 0 + + // 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, + 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 +549,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_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)) +} 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 diff --git a/internal/service/relational/agentcfg/service.go b/internal/service/relational/agentcfg/service.go index c08f3f14..c4b7189a 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,27 +813,29 @@ 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 + // 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. 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")