From b2e8f03cc733d9bfd278ba68b1dd41b81d2607fc Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 10:43:52 +0100 Subject: [PATCH 1/6] feat(api): Describe the API from the code and serve the description Anything talking to the agent had to learn what an endpoint accepts by reading the agent's source, so a client could only guess at field names and find out it was wrong from a 400. The CLI's generated commands take arbitrary key=value fields for exactly this reason. The agent now describes itself: routes, path and query parameters, the body each handler binds, the permission each is gated on, and the response where a handler returns a type rather than an inline map. It is read out of the code, so it cannot claim something the code does not do, and a test fails the build if it drifts from the routes. The description is served, so a client asks the instance it is connected to rather than assuming whatever was true when the client was built. Deployments, backups and certificates now answer with declared types, and the fields worth showing as a table are named on those types. A client can lay out results without being taught each endpoint by hand. The rest still answer with maps and remain undescribed until they are converted. --- .gitignore | 1 + go.mod | 2 + internal/api/backup_handlers.go | 4 +- internal/api/openapi.go | 20 + internal/api/openapi.json | 11385 ++++++++++++++++++++++++++++++ internal/api/openapi_test.go | 119 + internal/api/responses.go | 23 + internal/api/server.go | 11 +- internal/backup/types.go | 10 +- pkg/models/certificate.go | 10 +- pkg/models/deployment.go | 6 +- tools/genspec/main.go | 432 ++ tools/genspec/schema.go | 315 + 13 files changed, 12317 insertions(+), 21 deletions(-) create mode 100644 internal/api/openapi.go create mode 100644 internal/api/openapi.json create mode 100644 internal/api/openapi_test.go create mode 100644 internal/api/responses.go create mode 100644 tools/genspec/main.go create mode 100644 tools/genspec/schema.go diff --git a/.gitignore b/.gitignore index f38dd8a..78acfcc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ config.yaml tmp/ temp/ .tmp/ +/genspec diff --git a/go.mod b/go.mod index 3c39a69..1a25cbd 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.41.0 golang.org/x/crypto v0.51.0 golang.org/x/oauth2 v0.36.0 + golang.org/x/tools v0.45.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.47.0 ) @@ -201,6 +202,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/arch v0.18.0 // indirect + golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect diff --git a/internal/api/backup_handlers.go b/internal/api/backup_handlers.go index 3bc92f2..f861ae9 100644 --- a/internal/api/backup_handlers.go +++ b/internal/api/backup_handlers.go @@ -48,7 +48,7 @@ func (s *Server) listBackups(c *gin.Context) { backups = filtered } - c.JSON(http.StatusOK, gin.H{"backups": backups}) + c.JSON(http.StatusOK, BackupListResponse{Backups: backups}) } func (s *Server) getBackup(c *gin.Context) { @@ -140,7 +140,7 @@ func (s *Server) listDeploymentBackups(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"backups": backups}) + c.JSON(http.StatusOK, BackupListResponse{Backups: backups}) } func (s *Server) deleteBackup(c *gin.Context) { diff --git a/internal/api/openapi.go b/internal/api/openapi.go new file mode 100644 index 0000000..0c5922c --- /dev/null +++ b/internal/api/openapi.go @@ -0,0 +1,20 @@ +package api + +import ( + _ "embed" + "net/http" + + "github.com/gin-gonic/gin" +) + +// The description of this agent's own API, generated from its routes and types by +// tools/genspec and checked against them in CI. Serving it means a client can ask the instance +// it is talking to what that instance accepts, rather than assuming whatever was true when the +// client was built. +// +//go:embed openapi.json +var openAPISpec []byte + +func (s *Server) getOpenAPISpec(c *gin.Context) { + c.Data(http.StatusOK, "application/json; charset=utf-8", openAPISpec) +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json new file mode 100644 index 0000000..485678f --- /dev/null +++ b/internal/api/openapi.json @@ -0,0 +1,11385 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "FlatRun Agent API", + "description": "Generated from the agent's routes and the types its handlers bind and return.", + "version": "0.4.0-beta.3" + }, + "paths": { + "/api/agent/update": { + "get": { + "operationId": "get-agent-update", + "parameters": [ + { + "in": "query", + "name": "channel", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/updater.Availability" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "agent" + ], + "x-permission": "settings:read" + }, + "post": { + "operationId": "post-agent-update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.agentUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "agent" + ], + "x-permission": "settings:write" + } + }, + "/api/ai/agents": { + "get": { + "operationId": "get-ai-agents", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "deployments:read" + } + }, + "/api/ai/agents/{name}": { + "delete": { + "operationId": "delete-ai-agents-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "settings:write" + }, + "get": { + "operationId": "get-ai-agents-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "deployments:read" + }, + "put": { + "operationId": "put-ai-agents-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "content": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "content" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "settings:write" + } + }, + "/api/ai/agents/{name}/run": { + "post": { + "operationId": "post-ai-agents-by-name-run", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "dry_run" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "deployments:read" + } + }, + "/api/ai/analyze": { + "post": { + "operationId": "post-ai-analyze", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.assistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "deployments:read" + } + }, + "/api/ai/sessions": { + "get": { + "operationId": "get-ai-sessions", + "parameters": [ + { + "in": "query", + "name": "agent", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "deployments:read" + }, + "post": { + "operationId": "post-ai-sessions", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "auto_run": { + "type": "boolean" + }, + "context": { + "type": "string" + }, + "deployment": { + "type": "string" + }, + "message": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "seed": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "scope", + "deployment", + "auto_run", + "message", + "context", + "seed" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ai" + ], + "x-permission": "deployments:read" + } + }, + "/api/ai/sessions/{id}": { + "delete": { + "operationId": "delete-ai-sessions-by-id", + "tags": [ + "ai" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "get": { + "operationId": "get-ai-sessions-by-id", + "tags": [ + "ai" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/ai/sessions/{id}/approve": { + "post": { + "operationId": "post-ai-sessions-by-id-approve", + "tags": [ + "ai" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "approved": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "x-property-order": [ + "approved" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/ai/sessions/{id}/messages": { + "post": { + "operationId": "post-ai-sessions-by-id-messages", + "tags": [ + "ai" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "x-property-order": [ + "message", + "context" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/ai/status": { + "get": { + "operationId": "get-ai-status", + "tags": [ + "ai" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/apikeys/{id}": { + "delete": { + "operationId": "delete-apikeys-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "apikeys" + ], + "x-permission": "a:pikeysdelete" + }, + "get": { + "operationId": "get-apikeys-by-id", + "tags": [ + "apikeys" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "operationId": "put-apikeys-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "deployments": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "description": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "role": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "name", + "description", + "role", + "permissions", + "deployments", + "expires_in" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "apikeys" + ], + "x-permission": "a:pikeyswrite" + } + }, + "/api/apikeys/{id}/revoke": { + "post": { + "operationId": "post-apikeys-by-id-revoke", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "apikeys" + ], + "x-permission": "a:pikeysdelete" + } + }, + "/api/audit/cleanup": { + "delete": { + "operationId": "delete-audit-cleanup", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "audit" + ], + "x-permission": "settings:write" + } + }, + "/api/audit/events": { + "get": { + "operationId": "get-audit-events", + "parameters": [ + { + "in": "query", + "name": "action", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "actor_id", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "actor_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "client_ip", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "end_time", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "resource_id", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "resource_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "start_time", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "success", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "audit" + ], + "x-permission": "audit:read" + } + }, + "/api/audit/events/{id}": { + "get": { + "operationId": "get-audit-events-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/audit.AuditEvent" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "audit" + ], + "x-permission": "audit:read" + } + }, + "/api/audit/export": { + "post": { + "operationId": "post-audit-export", + "parameters": [ + { + "in": "query", + "name": "format", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "action": { + "type": "string" + }, + "actor_id": { + "type": "string" + }, + "actor_type": { + "type": "string" + }, + "end_time": { + "type": "string" + }, + "format": { + "type": "string" + }, + "limit": { + "type": "integer" + }, + "resource_type": { + "type": "string" + }, + "start_time": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "format", + "actor_id", + "actor_type", + "action", + "resource_type", + "start_time", + "end_time", + "limit" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "audit" + ], + "x-permission": "audit:read" + } + }, + "/api/audit/stats": { + "get": { + "operationId": "get-audit-stats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/audit.AuditStats" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "audit" + ], + "x-permission": "audit:read" + } + }, + "/api/auth/login": { + "post": { + "operationId": "post-auth-login", + "tags": [ + "auth" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "x-property-order": [ + "api_key", + "username", + "password" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/status": { + "get": { + "operationId": "get-auth-status", + "tags": [ + "auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/auth/validate": { + "get": { + "operationId": "get-auth-validate", + "tags": [ + "auth" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/backup-destinations": { + "get": { + "operationId": "get-backup-destinations", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backup-destinations" + ], + "x-permission": "backups:read" + } + }, + "/api/backup-destinations/test": { + "post": { + "operationId": "post-backup-destinations-test", + "parameters": [ + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/config.BackupDestination" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backup-destinations" + ], + "x-permission": "backups:write" + } + }, + "/api/backups": { + "get": { + "operationId": "get-backups", + "parameters": [ + { + "in": "query", + "name": "deployment", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.BackupListResponse" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:read" + }, + "post": { + "operationId": "post-backups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.CreateBackupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:write" + } + }, + "/api/backups/jobs": { + "get": { + "operationId": "get-backups-jobs", + "parameters": [ + { + "in": "query", + "name": "deployment", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:read" + } + }, + "/api/backups/jobs/{id}": { + "get": { + "operationId": "get-backups-jobs-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:read" + } + }, + "/api/backups/{id}": { + "delete": { + "operationId": "delete-backups-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:delete" + }, + "get": { + "operationId": "get-backups-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:read" + } + }, + "/api/backups/{id}/download": { + "get": { + "operationId": "get-backups-by-id-download", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:read" + } + }, + "/api/backups/{id}/restore": { + "post": { + "operationId": "post-backups-by-id-restore", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/backup.RestoreBackupRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "backups" + ], + "x-permission": "backups:write" + } + }, + "/api/certificates": { + "get": { + "operationId": "get-certificates", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.CertificateListResponse" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:read" + }, + "post": { + "operationId": "post-certificates", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "deployment": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "required": [ + "domain" + ], + "type": "object", + "x-property-order": [ + "domain", + "deployment" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:write" + } + }, + "/api/certificates/renew": { + "post": { + "operationId": "post-certificates-renew", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:write" + } + }, + "/api/certificates/{domain}": { + "delete": { + "operationId": "delete-certificates-by-domain", + "parameters": [ + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "force", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:delete" + }, + "get": { + "operationId": "get-certificates-by-domain", + "parameters": [ + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:read" + } + }, + "/api/certificates/{domain}/auto-renew": { + "patch": { + "operationId": "patch-certificates-by-domain-auto-renew", + "parameters": [ + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "auto_renew": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "auto_renew" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:write" + } + }, + "/api/certificates/{domain}/renew": { + "post": { + "operationId": "post-certificates-by-domain-renew", + "parameters": [ + { + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "force", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "certificates" + ], + "x-permission": "certificates:write" + } + }, + "/api/cluster/accept": { + "post": { + "operationId": "post-cluster-accept", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "callback_url": { + "type": "string" + }, + "invite_token": { + "type": "string" + }, + "peer_url": { + "type": "string" + } + }, + "required": [ + "invite_token", + "peer_url" + ], + "type": "object", + "x-property-order": [ + "invite_token", + "peer_url", + "callback_url" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + } + }, + "/api/cluster/deployments": { + "get": { + "operationId": "get-cluster-deployments", + "tags": [ + "cluster" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cluster.AggregatedResponse" + } + } + } + } + } + } + }, + "/api/cluster/exchange": { + "post": { + "operationId": "post-cluster-exchange", + "tags": [ + "cluster" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.exchangeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.exchangeResponse" + } + } + } + } + } + } + }, + "/api/cluster/invite": { + "post": { + "operationId": "post-cluster-invite", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + } + }, + "/api/cluster/peers": { + "get": { + "operationId": "get-cluster-peers", + "tags": [ + "cluster" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/cluster/peers/{name}": { + "delete": { + "operationId": "delete-cluster-peers-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "cluster" + ], + "x-permission": "cluster:write" + } + }, + "/api/cluster/stats": { + "get": { + "operationId": "get-cluster-stats", + "tags": [ + "cluster" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cluster.AggregatedResponse" + } + } + } + } + } + } + }, + "/api/cluster/status": { + "get": { + "operationId": "get-cluster-status", + "tags": [ + "cluster" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/compose/update": { + "post": { + "operationId": "post-compose-update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ComposeUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "compose" + ], + "x-permission": "deployments:write" + } + }, + "/api/config": { + "get": { + "operationId": "get-config", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "config" + ], + "x-permission": "config:read" + } + }, + "/api/config/*key": { + "get": { + "operationId": "get-config-*key", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "config" + ], + "x-permission": "config:read" + }, + "put": { + "operationId": "put-config-*key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "value": {} + }, + "type": "object", + "x-property-order": [ + "value" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "config" + ], + "x-permission": "config:write" + } + }, + "/api/containers": { + "get": { + "operationId": "get-containers", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:read" + } + }, + "/api/containers/stats": { + "get": { + "operationId": "get-containers-stats", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:read" + } + }, + "/api/containers/{id}": { + "delete": { + "operationId": "delete-containers-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:delete" + } + }, + "/api/containers/{id}/exec": { + "get": { + "operationId": "get-containers-by-id-exec", + "tags": [ + "containers" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "operationId": "post-containers-by-id-exec", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "args": { + "items": { + "type": "string" + }, + "type": "array" + }, + "command": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "command", + "args" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:write" + } + }, + "/api/containers/{id}/logs": { + "get": { + "operationId": "get-containers-by-id-logs", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tail", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:read" + } + }, + "/api/containers/{id}/resources": { + "get": { + "operationId": "get-containers-by-id-resources", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:read" + }, + "put": { + "operationId": "put-containers-by-id-resources", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/docker.ResourceUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:write" + } + }, + "/api/containers/{id}/restart": { + "post": { + "operationId": "post-containers-by-id-restart", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:write" + } + }, + "/api/containers/{id}/start": { + "post": { + "operationId": "post-containers-by-id-start", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:write" + } + }, + "/api/containers/{id}/stats": { + "get": { + "operationId": "get-containers-by-id-stats", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:read" + } + }, + "/api/containers/{id}/stop": { + "post": { + "operationId": "post-containers-by-id-stop", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "containers" + ], + "x-permission": "containers:write" + } + }, + "/api/credentials": { + "get": { + "operationId": "get-credentials", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "credentials" + ], + "x-permission": "registries:read" + }, + "post": { + "operationId": "post-credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "registry_type_slug": { + "type": "string" + }, + "registry_url": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "name", + "registry_type_slug", + "username", + "password" + ], + "type": "object", + "x-property-order": [ + "name", + "registry_type_slug", + "registry_url", + "username", + "password", + "email", + "is_default" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "credentials" + ], + "x-permission": "registries:write" + } + }, + "/api/credentials/{id}": { + "delete": { + "operationId": "delete-credentials-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "credentials" + ], + "x-permission": "registries:delete" + }, + "get": { + "operationId": "get-credentials-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "credentials" + ], + "x-permission": "registries:read" + }, + "put": { + "operationId": "put-credentials-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "registry_url": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "name", + "registry_url", + "username", + "password", + "email", + "is_default" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "credentials" + ], + "x-permission": "registries:write" + } + }, + "/api/credentials/{id}/test": { + "post": { + "operationId": "post-credentials-by-id-test", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "credentials" + ], + "x-permission": "registries:read" + } + }, + "/api/dashboards": { + "get": { + "operationId": "get-dashboards", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "dashboards" + ], + "x-permission": "deployments:read" + }, + "post": { + "operationId": "post-dashboards", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dashboards.Dashboard" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dashboards.Dashboard" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "dashboards" + ], + "x-permission": "deployments:write" + } + }, + "/api/dashboards/{id}": { + "delete": { + "operationId": "delete-dashboards-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "dashboards" + ], + "x-permission": "deployments:write" + }, + "get": { + "operationId": "get-dashboards-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dashboards.Dashboard" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "dashboards" + ], + "x-permission": "deployments:read" + } + }, + "/api/databases/create": { + "post": { + "operationId": "post-databases-create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "db_name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "db_name" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "db_name" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:write" + } + }, + "/api/databases/delete": { + "post": { + "operationId": "post-databases-delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "db_name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "db_name" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "db_name" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:write" + } + }, + "/api/databases/list": { + "post": { + "operationId": "post-databases-list", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/database.ConnectionConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/privileges/grant": { + "post": { + "operationId": "post-databases-privileges-grant", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "target_database": { + "type": "string" + }, + "target_host": { + "type": "string" + }, + "target_username": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "target_username", + "target_database" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "target_username", + "target_database", + "target_host" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:write" + } + }, + "/api/databases/query": { + "post": { + "operationId": "post-databases-query", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "query": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "database", + "query" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database", + "query" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/database.QueryResult" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:write" + } + }, + "/api/databases/tables": { + "post": { + "operationId": "post-databases-tables", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "database" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/tables/data": { + "post": { + "operationId": "post-databases-tables-data", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "table": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "database", + "table" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database", + "table", + "limit", + "offset" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/database.QueryResult" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/tables/schema": { + "post": { + "operationId": "post-databases-tables-schema", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "table": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "database", + "table" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database", + "table" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/database.TableSchema" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/test": { + "post": { + "operationId": "post-databases-test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/database.ConnectionConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/users": { + "post": { + "operationId": "post-databases-users", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/database.ConnectionConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/users/by-database": { + "post": { + "operationId": "post-databases-users-by-database", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "database" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:read" + } + }, + "/api/databases/users/create": { + "post": { + "operationId": "post-databases-users-create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "target_host": { + "type": "string" + }, + "target_password": { + "type": "string" + }, + "target_username": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "target_username", + "target_password" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "target_username", + "target_password", + "target_host" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:write" + } + }, + "/api/databases/users/delete": { + "post": { + "operationId": "post-databases-users-delete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "target_host": { + "type": "string" + }, + "target_username": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "target_username" + ], + "type": "object", + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "target_username", + "target_host" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "databases" + ], + "x-permission": "databases:write" + } + }, + "/api/deployments": { + "get": { + "operationId": "get-deployments", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.DeploymentListResponse" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + }, + "post": { + "operationId": "post-deployments", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "auto_start": { + "type": "boolean" + }, + "compose_content": { + "type": "string" + }, + "container_port": { + "type": "integer" + }, + "databases": { + "items": { + "$ref": "#/components/schemas/api.DatabaseConfigRequest" + }, + "type": "array" + }, + "env_vars": { + "items": { + "$ref": "#/components/schemas/api.EnvVar" + }, + "type": "array" + }, + "existing_database_container": { + "type": "string" + }, + "host_port": { + "type": "string" + }, + "image": { + "type": "string" + }, + "map_ports": { + "type": "boolean" + }, + "metadata": { + "$ref": "#/components/schemas/models.ServiceMetadata" + }, + "name": { + "type": "string" + }, + "ports": { + "items": { + "$ref": "#/components/schemas/api.PortConfig" + }, + "type": "array" + }, + "registry_credential": { + "properties": { + "credential_id": { + "type": "string" + }, + "credential_name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "registry_type_slug": { + "type": "string" + }, + "registry_url": { + "type": "string" + }, + "save_credential": { + "type": "boolean" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "credential_id", + "username", + "password", + "save_credential", + "credential_name", + "registry_type_slug", + "registry_url" + ] + }, + "seed_mounts": { + "items": { + "type": "string" + }, + "type": "array" + }, + "service_credentials": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "source": { + "$ref": "#/components/schemas/api.deploymentSource" + }, + "template_id": { + "type": "string" + }, + "use_shared_database": { + "type": "boolean" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-property-order": [ + "name", + "image", + "compose_content", + "template_id", + "metadata", + "env_vars", + "container_port", + "map_ports", + "host_port", + "ports", + "auto_start", + "use_shared_database", + "existing_database_container", + "databases", + "registry_credential", + "service_credentials", + "seed_mounts", + "source" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}": { + "delete": { + "operationId": "delete-deployments-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "delete_database", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "delete_ssl", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "delete_vhost", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:delete" + }, + "get": { + "operationId": "get-deployments-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + }, + "put": { + "operationId": "put-deployments-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "compose_content": { + "type": "string" + } + }, + "required": [ + "compose_content" + ], + "type": "object", + "x-property-order": [ + "compose_content" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/actions/{actionId}": { + "post": { + "operationId": "post-deployments-by-name-actions-by-actionId", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "actionId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/ai/analyze": { + "post": { + "operationId": "post-deployments-by-name-ai-analyze", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.assistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/backup-config": { + "get": { + "operationId": "get-deployments-by-name-backup-config", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:read" + }, + "put": { + "operationId": "put-deployments-by-name-backup-config", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.BackupSpec" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:write" + } + }, + "/api/deployments/{name}/backups": { + "get": { + "operationId": "get-deployments-by-name-backups", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.BackupListResponse" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:read" + }, + "post": { + "operationId": "post-deployments-by-name-backups", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "backups:write" + } + }, + "/api/deployments/{name}/certificates/renew": { + "post": { + "operationId": "post-deployments-by-name-certificates-renew", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "certificates:write" + } + }, + "/api/deployments/{name}/compose": { + "get": { + "operationId": "get-deployments-by-name-compose", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/compose/mount": { + "post": { + "operationId": "post-deployments-by-name-compose-mount", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "read_only": { + "type": "boolean" + }, + "selinux": { + "type": "string" + }, + "service_name": { + "type": "string" + }, + "source_path": { + "type": "string" + }, + "target_path": { + "type": "string" + } + }, + "required": [ + "source_path", + "target_path", + "service_name" + ], + "type": "object", + "x-property-order": [ + "source_path", + "target_path", + "service_name", + "read_only", + "selinux" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/compose/unmount": { + "post": { + "operationId": "post-deployments-by-name-compose-unmount", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "service_name": { + "type": "string" + }, + "source_path": { + "type": "string" + }, + "target_path": { + "type": "string" + } + }, + "required": [ + "source_path", + "target_path", + "service_name" + ], + "type": "object", + "x-property-order": [ + "source_path", + "target_path", + "service_name" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/container-files/{service}": { + "get": { + "operationId": "get-deployments-by-name-container-files-by-service", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/container-files/{service}/materialize": { + "post": { + "operationId": "post-deployments-by-name-container-files-by-service-materialize", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container_path": { + "type": "string" + }, + "host_path": { + "type": "string" + } + }, + "required": [ + "container_path" + ], + "type": "object", + "x-property-order": [ + "container_path", + "host_path" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/deploy": { + "post": { + "operationId": "post-deployments-by-name-deploy", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "action": { + "type": "string" + }, + "cleanup": { + "type": "boolean" + }, + "only_latest": { + "type": "boolean" + }, + "pull": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "action", + "pull", + "only_latest", + "cleanup" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/domains": { + "get": { + "operationId": "get-deployments-by-name-domains", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + }, + "post": { + "operationId": "post-deployments-by-name-domains", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.DomainConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/domains/{domainId}": { + "delete": { + "operationId": "delete-deployments-by-name-domains-by-domainId", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "domainId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + }, + "put": { + "operationId": "put-deployments-by-name-domains-by-domainId", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "domainId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.DomainConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/env": { + "get": { + "operationId": "get-deployments-by-name-env", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + }, + "put": { + "operationId": "put-deployments-by-name-env", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "env_vars": { + "items": { + "$ref": "#/components/schemas/api.EnvVar" + }, + "type": "array" + } + }, + "type": "object", + "x-property-order": [ + "env_vars" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/files": { + "get": { + "operationId": "get-deployments-by-name-files", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/files-info": { + "get": { + "operationId": "get-deployments-by-name-files-info", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/files/*path": { + "delete": { + "operationId": "delete-deployments-by-name-files-*path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:delete" + }, + "get": { + "operationId": "get-deployments-by-name-files-*path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "info", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "list", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/files.FileInfo" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + }, + "post": { + "operationId": "post-deployments-by-name-files-*path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/images": { + "get": { + "operationId": "get-deployments-by-name-images", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/images/cleanup": { + "post": { + "operationId": "post-deployments-by-name-images-cleanup", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "dry_run" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "images:write" + } + }, + "/api/deployments/{name}/jobs/active": { + "get": { + "operationId": "get-deployments-by-name-jobs-active", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.JobSnapshot" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/jobs/{jobId}": { + "get": { + "operationId": "get-deployments-by-name-jobs-by-jobId", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "jobId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.JobSnapshot" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/log-sources": { + "get": { + "operationId": "get-deployments-by-name-log-sources", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + }, + "put": { + "operationId": "put-deployments-by-name-log-sources", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "sources": { + "items": { + "$ref": "#/components/schemas/models.LogSource" + }, + "type": "array" + } + }, + "type": "object", + "x-property-order": [ + "sources" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/logs": { + "delete": { + "operationId": "delete-deployments-by-name-logs", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "service", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + }, + "get": { + "operationId": "get-deployments-by-name-logs", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "filter", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "service", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "source", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tail", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/metadata": { + "put": { + "operationId": "put-deployments-by-name-metadata", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/mkdir/*path": { + "post": { + "operationId": "post-deployments-by-name-mkdir-*path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/permissions/*path": { + "put": { + "operationId": "put-deployments-by-name-permissions-*path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "mode": { + "type": "integer" + } + }, + "required": [ + "mode" + ], + "type": "object", + "x-property-order": [ + "mode" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/protected-mode": { + "put": { + "operationId": "put-deployments-by-name-protected-mode", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.ProtectedModeConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/pull": { + "post": { + "operationId": "post-deployments-by-name-pull", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "cleanup": { + "type": "boolean" + }, + "only_latest": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "only_latest", + "cleanup" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/rebuild": { + "post": { + "operationId": "post-deployments-by-name-rebuild", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/resources": { + "get": { + "operationId": "get-deployments-by-name-resources", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/restart": { + "post": { + "operationId": "post-deployments-by-name-restart", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/security": { + "get": { + "operationId": "get-deployments-by-name-security", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "security:read" + }, + "put": { + "operationId": "put-deployments-by-name-security", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/models.DeploymentSecurityConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "security:write" + } + }, + "/api/deployments/{name}/security/events": { + "get": { + "operationId": "get-deployments-by-name-security-events", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "security:read" + } + }, + "/api/deployments/{name}/services": { + "get": { + "operationId": "get-deployments-by-name-services", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/services/{service}/job": { + "post": { + "operationId": "post-deployments-by-name-services-by-service-job", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "action": { + "type": "string" + }, + "force_recreate": { + "type": "boolean" + }, + "fresh_pull": { + "type": "boolean" + }, + "no_cache": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "action", + "force_recreate", + "no_cache", + "fresh_pull" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/services/{service}/pull": { + "post": { + "operationId": "post-deployments-by-name-services-by-service-pull", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/services/{service}/rebuild": { + "post": { + "operationId": "post-deployments-by-name-services-by-service-rebuild", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/services/{service}/restart": { + "post": { + "operationId": "post-deployments-by-name-services-by-service-restart", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/services/{service}/start": { + "post": { + "operationId": "post-deployments-by-name-services-by-service-start", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/services/{service}/stop": { + "post": { + "operationId": "post-deployments-by-name-services-by-service-stop", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "service", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/serving": { + "get": { + "operationId": "get-deployments-by-name-serving", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/ssl/disable": { + "post": { + "operationId": "post-deployments-by-name-ssl-disable", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/start": { + "post": { + "operationId": "post-deployments-by-name-start", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/stats": { + "get": { + "operationId": "get-deployments-by-name-stats", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/stop": { + "post": { + "operationId": "post-deployments-by-name-stop", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/touch/*path": { + "post": { + "operationId": "post-deployments-by-name-touch-*path", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:write" + } + }, + "/api/deployments/{name}/traffic": { + "get": { + "operationId": "get-deployments-by-name-traffic", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "deployments:read" + } + }, + "/api/deployments/{name}/users": { + "get": { + "operationId": "get-deployments-by-name-users", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "deployments" + ], + "x-permission": "users:read" + } + }, + "/api/dns/providers": { + "get": { + "operationId": "get-dns-providers", + "tags": [ + "dns" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/health": { + "get": { + "operationId": "get-health", + "tags": [ + "health" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/images": { + "get": { + "operationId": "get-images", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "images" + ], + "x-permission": "images:read" + } + }, + "/api/images/cleanup": { + "post": { + "operationId": "post-images-cleanup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "dry_run" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "images" + ], + "x-permission": "images:delete" + } + }, + "/api/images/pull": { + "post": { + "operationId": "post-images-pull", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "credential_id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-property-order": [ + "name", + "credential_id" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "images" + ], + "x-permission": "images:write" + } + }, + "/api/images/{id}": { + "delete": { + "operationId": "delete-images-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "images" + ], + "x-permission": "images:delete" + } + }, + "/api/infrastructure": { + "get": { + "operationId": "get-infrastructure", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:read" + } + }, + "/api/infrastructure/migrate/{name}": { + "post": { + "operationId": "post-infrastructure-migrate-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:write" + } + }, + "/api/infrastructure/stats": { + "get": { + "operationId": "get-infrastructure-stats", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:read" + } + }, + "/api/infrastructure/{name}": { + "get": { + "operationId": "get-infrastructure-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:read" + } + }, + "/api/infrastructure/{name}/logs": { + "get": { + "operationId": "get-infrastructure-by-name-logs", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tail", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:read" + } + }, + "/api/infrastructure/{name}/restart": { + "post": { + "operationId": "post-infrastructure-by-name-restart", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:write" + } + }, + "/api/infrastructure/{name}/start": { + "post": { + "operationId": "post-infrastructure-by-name-start", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:write" + } + }, + "/api/infrastructure/{name}/stop": { + "post": { + "operationId": "post-infrastructure-by-name-stop", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "infrastructure" + ], + "x-permission": "infrastructure:write" + } + }, + "/api/networks": { + "get": { + "operationId": "get-networks", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "networks" + ], + "x-permission": "networks:read" + }, + "post": { + "operationId": "post-networks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "driver": { + "type": "string" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-property-order": [ + "name", + "driver", + "labels" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "networks" + ], + "x-permission": "networks:write" + } + }, + "/api/networks/{name}": { + "delete": { + "operationId": "delete-networks-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "networks" + ], + "x-permission": "networks:delete" + } + }, + "/api/networks/{name}/connect": { + "post": { + "operationId": "post-networks-by-name-connect", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + } + }, + "required": [ + "container" + ], + "type": "object", + "x-property-order": [ + "container" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "networks" + ], + "x-permission": "networks:write" + } + }, + "/api/networks/{name}/disconnect": { + "post": { + "operationId": "post-networks-by-name-disconnect", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "container": { + "type": "string" + } + }, + "required": [ + "container" + ], + "type": "object", + "x-property-order": [ + "container" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "networks" + ], + "x-permission": "networks:write" + } + }, + "/api/notifications/targets": { + "get": { + "operationId": "get-notifications-targets", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/notify.Config" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "notifications" + ], + "x-permission": "settings:read" + }, + "put": { + "operationId": "put-notifications-targets", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/notify.Config" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/notify.Config" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "notifications" + ], + "x-permission": "settings:write" + } + }, + "/api/notifications/test": { + "post": { + "operationId": "post-notifications-test", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "url": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "url" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "notifications" + ], + "x-permission": "settings:write" + } + }, + "/api/object-stores/provision-managed": { + "post": { + "operationId": "post-object-stores-provision-managed", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.provisionManagedObjectStoreRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:write" + } + }, + "/api/object-stores/{name}/attach": { + "post": { + "operationId": "post-object-stores-by-name-attach", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "deployment": { + "type": "string" + }, + "prefix": { + "type": "string" + } + }, + "required": [ + "deployment" + ], + "type": "object", + "x-property-order": [ + "deployment", + "prefix" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "deployments:write" + } + }, + "/api/object-stores/{name}/buckets": { + "get": { + "operationId": "get-object-stores-by-name-buckets", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:read" + }, + "post": { + "operationId": "post-object-stores-by-name-buckets", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "bucket": { + "type": "string" + } + }, + "required": [ + "bucket" + ], + "type": "object", + "x-property-order": [ + "bucket" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:write" + } + }, + "/api/object-stores/{name}/buckets/{bucket}": { + "delete": { + "operationId": "delete-object-stores-by-name-buckets-by-bucket", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "bucket", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:delete" + } + }, + "/api/object-stores/{name}/objects": { + "delete": { + "operationId": "delete-object-stores-by-name-objects", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "bucket", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "key", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:write" + }, + "get": { + "operationId": "get-object-stores-by-name-objects", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "prefix", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "token", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:read" + }, + "post": { + "operationId": "post-object-stores-by-name-objects", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:write" + } + }, + "/api/object-stores/{name}/objects/download": { + "get": { + "operationId": "get-object-stores-by-name-objects-download", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "inline", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "key", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:read" + } + }, + "/api/object-stores/{name}/replicate": { + "post": { + "operationId": "post-object-stores-by-name-replicate", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "target": { + "type": "string" + } + }, + "required": [ + "target" + ], + "type": "object", + "x-property-order": [ + "target" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "object-stores" + ], + "x-permission": "backups:write" + } + }, + "/api/openapi.json": { + "get": { + "operationId": "get-openapi.json", + "tags": [ + "openapi.json" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/plans": { + "get": { + "operationId": "get-plans", + "tags": [ + "plans" + ], + "parameters": [ + { + "name": "deployment", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "resource_type", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/plans/{id}": { + "delete": { + "operationId": "delete-plans-by-id", + "tags": [ + "plans" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "get": { + "operationId": "get-plans-by-id", + "tags": [ + "plans" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "include_sensitive", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/plans/{id}/apply": { + "post": { + "operationId": "post-plans-by-id-apply", + "tags": [ + "plans" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/plugins": { + "get": { + "operationId": "get-plugins", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "plugins" + ], + "x-permission": "templates:read" + } + }, + "/api/plugins/{name}": { + "get": { + "operationId": "get-plugins-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "plugins" + ], + "x-permission": "templates:read" + } + }, + "/api/plugins/{name}/deployments": { + "post": { + "operationId": "post-plugins-by-name-deployments", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "config": { + "additionalProperties": {}, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-property-order": [ + "name", + "config" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "plugins" + ], + "x-permission": "templates:write" + } + }, + "/api/ports": { + "get": { + "operationId": "get-ports", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ports" + ], + "x-permission": "system:read" + } + }, + "/api/ports/{pid}/kill": { + "post": { + "operationId": "post-ports-by-pid-kill", + "parameters": [ + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "ports" + ], + "x-permission": "system:write" + } + }, + "/api/proxy/setup/{name}": { + "post": { + "operationId": "post-proxy-setup-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "proxy" + ], + "x-permission": "certificates:write" + } + }, + "/api/proxy/status/{name}": { + "get": { + "operationId": "get-proxy-status-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "proxy" + ], + "x-permission": "certificates:read" + } + }, + "/api/proxy/sync": { + "post": { + "operationId": "post-proxy-sync", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "proxy" + ], + "x-permission": "certificates:write" + } + }, + "/api/proxy/vhosts": { + "get": { + "operationId": "get-proxy-vhosts", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "proxy" + ], + "x-permission": "certificates:read" + } + }, + "/api/proxy/{name}": { + "delete": { + "operationId": "delete-proxy-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "proxy" + ], + "x-permission": "certificates:delete" + } + }, + "/api/registries": { + "get": { + "operationId": "get-registries", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "registries" + ], + "x-permission": "registries:read" + }, + "post": { + "operationId": "post-registries", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "auth_type": { + "type": "string" + }, + "docs_url": { + "type": "string" + }, + "login_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url_patterns": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "url_patterns" + ], + "type": "object", + "x-property-order": [ + "name", + "url_patterns", + "auth_type", + "login_url", + "docs_url" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "registries" + ], + "x-permission": "registries:write" + } + }, + "/api/registries/{slug}": { + "delete": { + "operationId": "delete-registries-by-slug", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "registries" + ], + "x-permission": "registries:delete" + }, + "get": { + "operationId": "get-registries-by-slug", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "registries" + ], + "x-permission": "registries:read" + }, + "put": { + "operationId": "put-registries-by-slug", + "parameters": [ + { + "in": "path", + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "auth_type": { + "type": "string" + }, + "docs_url": { + "type": "string" + }, + "login_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url_patterns": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object", + "x-property-order": [ + "name", + "url_patterns", + "auth_type", + "login_url", + "docs_url" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "registries" + ], + "x-permission": "registries:write" + } + }, + "/api/scheduler/executions": { + "get": { + "operationId": "get-scheduler-executions", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:read" + } + }, + "/api/scheduler/tasks": { + "get": { + "operationId": "get-scheduler-tasks", + "parameters": [ + { + "in": "query", + "name": "deployment", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:read" + }, + "post": { + "operationId": "post-scheduler-tasks", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/scheduler.CreateTaskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:write" + } + }, + "/api/scheduler/tasks/{id}": { + "delete": { + "operationId": "delete-scheduler-tasks-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:delete" + }, + "get": { + "operationId": "get-scheduler-tasks-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:read" + }, + "put": { + "operationId": "put-scheduler-tasks-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/scheduler.UpdateTaskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:write" + } + }, + "/api/scheduler/tasks/{id}/executions": { + "get": { + "operationId": "get-scheduler-tasks-by-id-executions", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:read" + } + }, + "/api/scheduler/tasks/{id}/run": { + "post": { + "operationId": "post-scheduler-tasks-by-id-run", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "scheduler" + ], + "x-permission": "scheduler:write" + } + }, + "/api/security/blocked-ips": { + "get": { + "operationId": "get-security-blocked-ips", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + }, + "post": { + "operationId": "post-security-blocked-ips", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "duration": { + "type": "integer" + }, + "ip": { + "type": "string" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "ip" + ], + "type": "object", + "x-property-order": [ + "ip", + "reason", + "duration" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/blocked-ips/{ip}": { + "delete": { + "operationId": "delete-security-blocked-ips-by-ip", + "parameters": [ + { + "in": "path", + "name": "ip", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/cleanup": { + "post": { + "operationId": "post-security-cleanup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "days": { + "type": "integer" + } + }, + "type": "object", + "x-property-order": [ + "days" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/events": { + "get": { + "operationId": "get-security-events", + "parameters": [ + { + "in": "query", + "name": "deployment", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "end_time", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "event_type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "severity", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "source_ip", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "start_time", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + } + }, + "/api/security/events/{id}": { + "get": { + "operationId": "get-security-events-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + } + }, + "/api/security/health": { + "get": { + "operationId": "get-security-health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/infra.SecurityHealthCheck" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + } + }, + "/api/security/ips/{ip}/events": { + "get": { + "operationId": "get-security-ips-by-ip-events", + "parameters": [ + { + "in": "path", + "name": "ip", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + } + }, + "/api/security/protected-routes": { + "get": { + "operationId": "get-security-protected-routes", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + }, + "post": { + "operationId": "post-security-protected-routes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/security.ProtectedRoute" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/protected-routes/{id}": { + "delete": { + "operationId": "delete-security-protected-routes-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + }, + "put": { + "operationId": "put-security-protected-routes-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/security.ProtectedRoute" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/realtime-capture": { + "get": { + "operationId": "get-security-realtime-capture", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + }, + "put": { + "operationId": "put-security-realtime-capture", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object", + "x-property-order": [ + "enabled" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/refresh": { + "post": { + "operationId": "post-security-refresh", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/infra.RefreshSecurityScriptsResult" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/stats": { + "get": { + "operationId": "get-security-stats", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + } + }, + "/api/security/whitelist": { + "get": { + "operationId": "get-security-whitelist", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:read" + }, + "post": { + "operationId": "post-security-whitelist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "reason": { + "type": "string" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "value", + "type" + ], + "type": "object", + "x-property-order": [ + "value", + "type", + "reason" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/security/whitelist/{id}": { + "delete": { + "operationId": "delete-security-whitelist-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "security" + ], + "x-permission": "security:write" + } + }, + "/api/server/info": { + "get": { + "operationId": "get-server-info", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "server" + ], + "x-permission": "system:read" + } + }, + "/api/server/network-health": { + "get": { + "operationId": "get-server-network-health", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "server" + ], + "x-permission": "system:read" + } + }, + "/api/settings": { + "get": { + "operationId": "get-settings", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "settings" + ], + "x-permission": "settings:read" + }, + "put": { + "operationId": "put-settings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "certbot": { + "properties": { + "certs_path": { + "type": "string" + }, + "dns_provider": { + "type": "string" + }, + "email": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "image": { + "type": "string" + }, + "staging": { + "type": "boolean" + }, + "webroot_path": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "enabled", + "image", + "email", + "staging", + "certs_path", + "webroot_path", + "dns_provider" + ] + }, + "domain": { + "properties": { + "auto_ssl": { + "type": "boolean" + }, + "auto_subdomain": { + "type": "boolean" + }, + "default_domain": { + "type": "string" + }, + "subdomain_style": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "default_domain", + "auto_subdomain", + "auto_ssl", + "subdomain_style" + ] + }, + "infrastructure": { + "properties": { + "database": { + "properties": { + "container": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "root_password": { + "type": "string" + }, + "root_user": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "enabled", + "type", + "container", + "host", + "port", + "root_user", + "root_password" + ] + }, + "default_database_network": { + "type": "string" + }, + "default_proxy_network": { + "type": "string" + }, + "redis": { + "properties": { + "container": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "type": "object", + "x-property-order": [ + "enabled", + "container", + "host", + "port", + "password" + ] + } + }, + "type": "object", + "x-property-order": [ + "default_proxy_network", + "default_database_network", + "database", + "redis" + ] + }, + "nginx": { + "properties": { + "config_path": { + "type": "string" + }, + "container_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "external": { + "type": "boolean" + }, + "image": { + "type": "string" + }, + "reject_unknown_domains": { + "type": "boolean" + }, + "reload_command": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "enabled", + "image", + "container_name", + "config_path", + "reload_command", + "external", + "reject_unknown_domains" + ] + }, + "security": { + "properties": { + "auto_block_duration": { + "type": "string" + }, + "auto_block_enabled": { + "type": "boolean" + }, + "auto_block_threshold": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "rate_threshold": { + "type": "integer" + }, + "realtime_capture": { + "type": "boolean" + }, + "retention_days": { + "type": "integer" + }, + "scan_interval": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "enabled", + "realtime_capture", + "scan_interval", + "retention_days", + "rate_threshold", + "auto_block_enabled", + "auto_block_threshold", + "auto_block_duration" + ] + }, + "system_terminal": { + "properties": { + "protected_mode": { + "$ref": "#/components/schemas/models.ProtectedModeConfig" + } + }, + "type": "object", + "x-property-order": [ + "protected_mode" + ] + } + }, + "type": "object", + "x-property-order": [ + "domain", + "nginx", + "certbot", + "infrastructure", + "security", + "system_terminal" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "settings" + ], + "x-permission": "settings:write" + } + }, + "/api/settings/security": { + "put": { + "operationId": "put-settings-security", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "auth_failure_threshold": { + "type": "integer" + }, + "auto_block_duration": { + "type": "string" + }, + "auto_block_enabled": { + "type": "boolean" + }, + "auto_block_threshold": { + "type": "integer" + }, + "detection_window": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "not_found_threshold": { + "type": "integer" + }, + "rate_threshold": { + "type": "integer" + }, + "realtime_capture": { + "type": "boolean" + }, + "repeated_hits_threshold": { + "type": "integer" + }, + "retention_days": { + "type": "integer" + }, + "scan_interval": { + "type": "string" + }, + "unique_paths_threshold": { + "type": "integer" + } + }, + "type": "object", + "x-property-order": [ + "enabled", + "realtime_capture", + "scan_interval", + "retention_days", + "rate_threshold", + "auto_block_enabled", + "auto_block_threshold", + "auto_block_duration", + "detection_window", + "not_found_threshold", + "auth_failure_threshold", + "unique_paths_threshold", + "repeated_hits_threshold" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "settings" + ], + "x-permission": "settings:write" + } + }, + "/api/setup/authentication": { + "post": { + "operationId": "post-setup-authentication", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/setup/complete": { + "post": { + "operationId": "post-setup-complete", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/setup/info": { + "get": { + "operationId": "get-setup-info", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/setup/settings": { + "post": { + "operationId": "post-setup-settings", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/setup/status": { + "get": { + "operationId": "get-setup-status", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dns.PowerDNSStatus" + } + } + } + } + } + } + }, + "/api/setup/validate": { + "post": { + "operationId": "post-setup-validate", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/setup/verify-dns": { + "get": { + "operationId": "get-setup-verify-dns", + "tags": [ + "setup" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/source-credentials": { + "get": { + "operationId": "get-source-credentials", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "source-credentials" + ], + "x-permission": "deployments:read" + }, + "post": { + "operationId": "post-source-credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + }, + "token": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "name", + "token" + ], + "type": "object", + "x-property-order": [ + "name", + "username", + "token" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "source-credentials" + ], + "x-permission": "deployments:write" + } + }, + "/api/source-credentials/{id}": { + "delete": { + "operationId": "delete-source-credentials-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "source-credentials" + ], + "x-permission": "deployments:write" + } + }, + "/api/stats": { + "get": { + "operationId": "get-stats", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "stats" + ], + "x-permission": "deployments:read" + } + }, + "/api/storage-credentials": { + "get": { + "operationId": "get-storage-credentials", + "parameters": [ + { + "in": "query", + "name": "kind", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "storage-credentials" + ], + "x-permission": "backups:read" + }, + "post": { + "operationId": "post-storage-credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "kind" + ], + "type": "object", + "x-property-order": [ + "name", + "kind", + "data" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "storage-credentials" + ], + "x-permission": "backups:write" + } + }, + "/api/storage-credentials/{id}": { + "delete": { + "operationId": "delete-storage-credentials-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "storage-credentials" + ], + "x-permission": "backups:delete" + }, + "put": { + "operationId": "put-storage-credentials-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "name", + "data" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "storage-credentials" + ], + "x-permission": "backups:write" + } + }, + "/api/subdomain/generate": { + "get": { + "operationId": "get-subdomain-generate", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "subdomain" + ], + "x-permission": "deployments:read" + } + }, + "/api/system/files": { + "get": { + "operationId": "get-system-files", + "parameters": [ + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + } + }, + "/api/system/files-info": { + "get": { + "operationId": "get-system-files-info", + "parameters": [ + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "usage", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + } + }, + "/api/system/files/*path": { + "delete": { + "operationId": "delete-system-files-*path", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + }, + "get": { + "operationId": "get-system-files-*path", + "parameters": [ + { + "in": "query", + "name": "info", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "list", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/files.FileInfo" + } + } + }, + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + }, + "post": { + "operationId": "post-system-files-*path", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + } + }, + "/api/system/logs": { + "delete": { + "operationId": "delete-system-logs", + "parameters": [ + { + "in": "query", + "name": "source", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "infrastructure:write" + }, + "get": { + "operationId": "get-system-logs", + "parameters": [ + { + "in": "query", + "name": "source", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tail", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "infrastructure:read" + } + }, + "/api/system/logs/sources": { + "get": { + "operationId": "get-system-logs-sources", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "infrastructure:read" + } + }, + "/api/system/mkdir/*path": { + "post": { + "operationId": "post-system-mkdir-*path", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + } + }, + "/api/system/permissions/*path": { + "put": { + "operationId": "put-system-permissions-*path", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "mode": { + "type": "integer" + } + }, + "required": [ + "mode" + ], + "type": "object", + "x-property-order": [ + "mode" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + } + }, + "/api/system/services": { + "get": { + "operationId": "get-system-services", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:read" + } + }, + "/api/system/services/{name}/restart": { + "post": { + "operationId": "post-system-services-by-name-restart", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:write" + } + }, + "/api/system/services/{name}/start": { + "post": { + "operationId": "post-system-services-by-name-start", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:write" + } + }, + "/api/system/services/{name}/stop": { + "post": { + "operationId": "post-system-services-by-name-stop", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:write" + } + }, + "/api/system/terminal": { + "get": { + "operationId": "get-system-terminal", + "tags": [ + "system" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/system/touch/*path": { + "post": { + "operationId": "post-system-touch-*path", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "system" + ], + "x-permission": "system:files" + } + }, + "/api/templates": { + "get": { + "operationId": "get-templates", + "parameters": [ + { + "in": "query", + "name": "type", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:read" + } + }, + "/api/templates/categories": { + "get": { + "operationId": "get-templates-categories", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:read" + } + }, + "/api/templates/infra/{name}/compose": { + "get": { + "operationId": "get-templates-infra-by-name-compose", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:read" + } + }, + "/api/templates/infra/{name}/generate": { + "post": { + "operationId": "post-templates-infra-by-name-generate", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:write" + } + }, + "/api/templates/refresh": { + "post": { + "operationId": "post-templates-refresh", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:write" + } + }, + "/api/templates/{id}/compose": { + "get": { + "operationId": "get-templates-by-id-compose", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "name", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:read" + } + }, + "/api/templates/{id}/generate": { + "post": { + "operationId": "post-templates-by-id-generate", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/api.ComposeGenerateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "templates" + ], + "x-permission": "templates:write" + } + }, + "/api/traffic/cleanup": { + "post": { + "operationId": "post-traffic-cleanup", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "days": { + "type": "integer" + } + }, + "type": "object", + "x-property-order": [ + "days" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "traffic" + ], + "x-permission": "traffic:write" + } + }, + "/api/traffic/logs": { + "get": { + "operationId": "get-traffic-logs", + "parameters": [ + { + "in": "query", + "name": "deployment", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "end_time", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "method", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "path", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "source_ip", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "start_time", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status_code", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status_group", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "traffic" + ], + "x-permission": "traffic:read" + } + }, + "/api/traffic/stats": { + "get": { + "operationId": "get-traffic-stats", + "parameters": [ + { + "in": "query", + "name": "deployment", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "traffic" + ], + "x-permission": "traffic:read" + } + }, + "/api/traffic/unknown-domains": { + "get": { + "operationId": "get-traffic-unknown-domains", + "parameters": [ + { + "in": "query", + "name": "since", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "traffic" + ], + "x-permission": "traffic:read" + } + }, + "/api/users/me": { + "get": { + "operationId": "get-users-me", + "tags": [ + "users" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "operationId": "put-users-me", + "tags": [ + "users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + }, + "x-property-order": [ + "email" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/users/me/password": { + "put": { + "operationId": "put-users-me-password", + "tags": [ + "users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "current_password": { + "type": "string" + }, + "new_password": { + "type": "string" + } + }, + "x-property-order": [ + "current_password", + "new_password" + ], + "required": [ + "current_password", + "new_password" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/users/{id}": { + "delete": { + "operationId": "delete-users-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "users" + ], + "x-permission": "users:delete" + }, + "get": { + "operationId": "get-users-by-id", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "put": { + "operationId": "put-users-by-id", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object", + "x-property-order": [ + "username", + "email", + "role", + "permissions", + "is_active" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "users" + ], + "x-permission": "users:write" + } + }, + "/api/users/{id}/deployments": { + "get": { + "operationId": "get-users-by-id-deployments", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "operationId": "post-users-by-id-deployments", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "access_level": { + "type": "string" + }, + "deployment_name": { + "type": "string" + } + }, + "required": [ + "deployment_name", + "access_level" + ], + "type": "object", + "x-property-order": [ + "deployment_name", + "access_level" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "users" + ], + "x-permission": "users:write" + } + }, + "/api/users/{id}/deployments/{name}": { + "delete": { + "operationId": "delete-users-by-id-deployments-by-name", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "users" + ], + "x-permission": "users:write" + }, + "put": { + "operationId": "put-users-by-id-deployments-by-name", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "access_level": { + "type": "string" + } + }, + "required": [ + "access_level" + ], + "type": "object", + "x-property-order": [ + "access_level" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "users" + ], + "x-permission": "users:write" + } + }, + "/api/volumes": { + "get": { + "operationId": "get-volumes", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "volumes" + ], + "x-permission": "volumes:read" + }, + "post": { + "operationId": "post-volumes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "driver": { + "type": "string" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-property-order": [ + "name", + "driver", + "labels" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "volumes" + ], + "x-permission": "volumes:write" + } + }, + "/api/volumes/prune": { + "post": { + "operationId": "post-volumes-prune", + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "volumes" + ], + "x-permission": "volumes:write" + } + }, + "/api/volumes/{name}": { + "delete": { + "operationId": "delete-volumes-by-name", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "volumes" + ], + "x-permission": "volumes:delete" + } + } + }, + "components": { + "schemas": { + "api.BackupListResponse": { + "type": "object", + "properties": { + "backups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.Backup" + } + } + }, + "x-property-order": [ + "backups" + ] + }, + "api.CertificateListResponse": { + "type": "object", + "properties": { + "certificates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.Certificate" + } + } + }, + "x-property-order": [ + "certificates" + ] + }, + "api.ComposeGenerateRequest": { + "type": "object", + "properties": { + "container_port": { + "type": "integer" + }, + "host_port": { + "type": "string" + }, + "image": { + "type": "string" + }, + "map_ports": { + "type": "boolean" + }, + "mounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/api.MountSelection" + } + }, + "name": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "image", + "container_port", + "map_ports", + "host_port", + "mounts" + ], + "required": [ + "name" + ] + }, + "api.ComposeUpdateRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "database": { + "$ref": "#/components/schemas/api.DatabaseConfig" + }, + "mounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/api.MountSelection" + } + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/api.PortConfig" + } + } + }, + "x-property-order": [ + "content", + "ports", + "mounts", + "database" + ], + "required": [ + "content" + ] + }, + "api.DatabaseConfig": { + "type": "object", + "properties": { + "existing_container": { + "type": "string" + }, + "external_host": { + "type": "string" + }, + "external_port": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + }, + "root_password": { + "type": "string" + }, + "type": { + "type": "string" + }, + "user": { + "type": "string" + } + }, + "x-property-order": [ + "type", + "mode", + "name", + "user", + "password", + "root_password", + "existing_container", + "external_host", + "external_port" + ] + }, + "api.DatabaseConfigRequest": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "database_name": { + "type": "string" + }, + "env_prefix": { + "type": "string" + }, + "existing_container": { + "type": "string" + }, + "external_host": { + "type": "string" + }, + "external_port": { + "type": "integer" + }, + "mode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "service": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "x-property-order": [ + "alias", + "type", + "mode", + "service", + "existing_container", + "external_host", + "external_port", + "database_name", + "username", + "password", + "env_prefix" + ] + }, + "api.DeploymentListResponse": { + "type": "object", + "properties": { + "deployments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.Deployment" + } + }, + "path": { + "type": "string" + } + }, + "x-property-order": [ + "deployments", + "path" + ] + }, + "api.EnvVar": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "x-property-order": [ + "key", + "value" + ] + }, + "api.JobSnapshot": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "deployment": { + "type": "string" + }, + "error": { + "type": "string" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "string" + }, + "service": { + "type": "string" + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "deployment", + "service", + "action", + "status", + "output", + "lines", + "error", + "started_at", + "finished_at" + ] + }, + "api.MountSelection": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "enabled", + "type" + ] + }, + "api.PortConfig": { + "type": "object", + "properties": { + "container": { + "type": "integer" + }, + "container_port": { + "type": "integer" + }, + "host": { + "type": "string" + }, + "host_port": { + "type": "string" + } + }, + "x-property-order": [ + "container_port", + "container", + "host_port", + "host" + ] + }, + "api.agentUpdateRequest": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "force": { + "type": "boolean" + }, + "restart": { + "type": "boolean" + } + }, + "x-property-order": [ + "channel", + "force", + "restart" + ] + }, + "api.assistRequest": { + "type": "object", + "properties": { + "intent": { + "type": "string" + }, + "question": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/api.assistSource" + } + } + }, + "x-property-order": [ + "intent", + "sources", + "question" + ] + }, + "api.assistSource": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "label": { + "type": "string" + }, + "tail": { + "type": "integer" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "type", + "label", + "content", + "tail" + ] + }, + "api.deploymentSource": { + "type": "object", + "properties": { + "branch": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "subpath": { + "type": "string" + }, + "token": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "x-property-order": [ + "type", + "ref", + "branch", + "subpath", + "credential_id", + "username", + "token" + ] + }, + "api.exchangeRequest": { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "invite_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "x-property-order": [ + "invite_token", + "url", + "api_key", + "name" + ] + }, + "api.exchangeResponse": { + "type": "object", + "properties": { + "api_key": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "x-property-order": [ + "api_key", + "name" + ] + }, + "api.provisionManagedObjectStoreRequest": { + "type": "object", + "properties": { + "access_key": { + "type": "string" + }, + "access_key_env": { + "type": "string" + }, + "api_port": { + "type": "integer" + }, + "bucket": { + "type": "string" + }, + "deployment": { + "type": "string" + }, + "region": { + "type": "string" + }, + "secret_key": { + "type": "string" + }, + "secret_key_env": { + "type": "string" + }, + "store_name": { + "type": "string" + }, + "use_path_style": { + "type": "boolean" + } + }, + "x-property-order": [ + "deployment", + "store_name", + "bucket", + "access_key_env", + "secret_key_env", + "access_key", + "secret_key", + "api_port", + "region", + "use_path_style" + ], + "required": [ + "deployment" + ] + }, + "audit.ActorStats": { + "type": "object", + "properties": { + "actor_id": { + "type": "string" + }, + "actor_type": { + "type": "string" + }, + "event_count": { + "type": "integer" + }, + "last_seen": { + "type": "string" + } + }, + "x-property-order": [ + "actor_id", + "actor_type", + "event_count", + "last_seen" + ] + }, + "audit.AuditEvent": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "actor_id": { + "type": "string" + }, + "actor_name": { + "type": "string" + }, + "actor_type": { + "type": "string" + }, + "api_key_prefix": { + "type": "string" + }, + "client_ip": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "metadata": { + "type": "string" + }, + "method": { + "type": "string" + }, + "path": { + "type": "string" + }, + "request_body": { + "type": "string" + }, + "request_id": { + "type": "string" + }, + "resource_id": { + "type": "string" + }, + "resource_type": { + "type": "string" + }, + "response_status": { + "type": "integer" + }, + "response_time_ms": { + "type": "integer" + }, + "success": { + "type": "boolean" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "user_agent": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "event_id", + "timestamp", + "actor_type", + "actor_id", + "actor_name", + "api_key_prefix", + "action", + "method", + "path", + "resource_type", + "resource_id", + "client_ip", + "user_agent", + "request_id", + "request_body", + "response_status", + "response_time_ms", + "success", + "error_message", + "metadata" + ] + }, + "audit.AuditStats": { + "type": "object", + "properties": { + "by_action": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "by_actor_type": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "by_resource_type": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "events_trend": { + "type": "array", + "items": { + "$ref": "#/components/schemas/audit.TrendPoint" + } + }, + "failure_count": { + "type": "integer" + }, + "last_24_hours": { + "type": "integer" + }, + "last_7_days": { + "type": "integer" + }, + "top_actors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/audit.ActorStats" + } + }, + "total_events": { + "type": "integer" + } + }, + "x-property-order": [ + "total_events", + "last_24_hours", + "last_7_days", + "by_action", + "by_actor_type", + "by_resource_type", + "failure_count", + "top_actors", + "events_trend" + ] + }, + "audit.TrendPoint": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "string" + } + }, + "x-property-order": [ + "date", + "count" + ] + }, + "backup.Backup": { + "type": "object", + "properties": { + "completed_at": { + "type": "string", + "format": "date-time" + }, + "components": { + "type": "array", + "items": { + "type": "string" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "deployment_name": { + "type": "string" + }, + "error": { + "type": "string" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + }, + "locations": { + "type": "array", + "items": { + "type": "string" + } + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "status": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "deployment_name", + "status", + "size", + "path", + "components", + "error", + "created_at", + "completed_at", + "expires_at", + "locations" + ], + "x-columns": [ + "id", + "deployment_name", + "status", + "size", + "created_at" + ] + }, + "backup.CreateBackupRequest": { + "type": "object", + "properties": { + "deployment_name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "x-property-order": [ + "deployment_name", + "description" + ], + "required": [ + "deployment_name" + ] + }, + "backup.RestoreBackupRequest": { + "type": "object", + "properties": { + "backup_id": { + "type": "string" + }, + "deployment_name": { + "type": "string" + }, + "restore_data": { + "type": "boolean" + }, + "restore_db": { + "type": "boolean" + }, + "stop_first": { + "type": "boolean" + } + }, + "x-property-order": [ + "backup_id", + "deployment_name", + "restore_data", + "restore_db", + "stop_first" + ], + "required": [ + "backup_id" + ] + }, + "cluster.AggregatedResponse": { + "type": "object", + "properties": { + "servers": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/cluster.ServerResult" + } + } + }, + "x-property-order": [ + "servers" + ] + }, + "cluster.ServerResult": { + "type": "object", + "properties": { + "data": { + "type": "string", + "format": "byte" + }, + "error": { + "type": "string" + }, + "name": { + "type": "string" + }, + "online": { + "type": "boolean" + } + }, + "x-property-order": [ + "name", + "online", + "data", + "error" + ] + }, + "config.BackupDestination": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "credential_id": { + "type": "string" + }, + "deployment": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "region": { + "type": "string" + }, + "type": { + "type": "string" + }, + "use_path_style": { + "type": "boolean" + } + }, + "x-property-order": [ + "name", + "type", + "kind", + "deployment", + "endpoint", + "region", + "bucket", + "prefix", + "credential_id", + "use_path_style", + "enabled" + ] + }, + "dashboards.Dashboard": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "panels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dashboards.Panel" + } + } + }, + "x-property-order": [ + "id", + "name", + "panels" + ] + }, + "dashboards.Panel": { + "type": "object", + "properties": { + "deployment": { + "type": "string" + }, + "id": { + "type": "string" + }, + "series": { + "type": "string" + }, + "source": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "width": { + "type": "integer" + } + }, + "x-property-order": [ + "id", + "title", + "source", + "series", + "deployment", + "type", + "width" + ] + }, + "database.ColumnSchema": { + "type": "object", + "properties": { + "default": {}, + "extra": { + "type": "string" + }, + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nullable": { + "type": "boolean" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "type", + "nullable", + "default", + "key", + "extra" + ] + }, + "database.ConnectionConfig": { + "type": "object", + "properties": { + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "host": { + "type": "string" + }, + "password": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "x-property-order": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container" + ] + }, + "database.IndexSchema": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "primary": { + "type": "boolean" + }, + "unique": { + "type": "boolean" + } + }, + "x-property-order": [ + "name", + "columns", + "unique", + "primary" + ] + }, + "database.QueryResult": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "count": { + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": {} + } + } + }, + "x-property-order": [ + "columns", + "rows", + "count" + ] + }, + "database.TableSchema": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/database.ColumnSchema" + } + }, + "indexes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/database.IndexSchema" + } + } + }, + "x-property-order": [ + "columns", + "indexes" + ] + }, + "dns.PowerDNSStatus": { + "type": "object", + "properties": { + "running": { + "type": "boolean" + }, + "version": { + "type": "string" + } + }, + "x-property-order": [ + "running", + "version" + ] + }, + "docker.ResourceUpdate": { + "type": "object", + "properties": { + "cpu_shares": { + "type": "integer" + }, + "cpus": { + "type": "number" + }, + "memory_limit": { + "type": "integer" + }, + "memory_swap": { + "type": "integer" + } + }, + "x-property-order": [ + "memory_limit", + "memory_swap", + "cpus", + "cpu_shares" + ] + }, + "files.FileInfo": { + "type": "object", + "properties": { + "child_count": { + "type": "integer" + }, + "is_dir": { + "type": "boolean" + }, + "mod_time": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "permissions": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "x-property-order": [ + "name", + "path", + "size", + "is_dir", + "mod_time", + "permissions", + "child_count" + ] + }, + "infra.RefreshSecurityScriptsResult": { + "type": "object", + "properties": { + "agent_ip": { + "type": "string" + }, + "agent_port": { + "type": "integer" + }, + "container_recreated": { + "type": "boolean" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "lua_written": { + "type": "boolean" + }, + "nginx_conf_written": { + "type": "boolean" + }, + "nginx_reloaded": { + "type": "boolean" + }, + "success": { + "type": "boolean" + }, + "vhosts_updated": { + "type": "array", + "items": { + "type": "string" + } + }, + "volumes_modified": { + "type": "boolean" + } + }, + "x-property-order": [ + "success", + "agent_ip", + "agent_port", + "nginx_conf_written", + "lua_written", + "volumes_modified", + "container_recreated", + "nginx_reloaded", + "vhosts_updated", + "errors" + ] + }, + "infra.SecurityHealthCheck": { + "type": "object", + "properties": { + "checks": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "details": { + "type": "object", + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "type": "string" + } + }, + "recommendations": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + }, + "x-property-order": [ + "status", + "checks", + "issues", + "recommendations", + "details" + ] + }, + "models.BackupHookSpec": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "service": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + }, + "x-property-order": [ + "service", + "command", + "timeout" + ] + }, + "models.BackupSpec": { + "type": "object", + "properties": { + "container_paths": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.ContainerBackupPath" + } + }, + "databases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.DatabaseBackupSpec" + } + }, + "exclude_patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "post_hooks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.BackupHookSpec" + } + }, + "pre_hooks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.BackupHookSpec" + } + } + }, + "x-property-order": [ + "container_paths", + "databases", + "pre_hooks", + "post_hooks", + "exclude_patterns" + ] + }, + "models.Certificate": { + "type": "object", + "properties": { + "auto_renew": { + "type": "boolean" + }, + "days_left": { + "type": "integer" + }, + "deployment_id": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "not_after": { + "type": "string", + "format": "date-time" + }, + "not_before": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "x-property-order": [ + "domain", + "issuer", + "not_before", + "not_after", + "days_left", + "status", + "path", + "auto_renew", + "deployment_id" + ], + "x-columns": [ + "domain", + "issuer", + "days_left", + "status", + "auto_renew" + ] + }, + "models.ContainerBackupPath": { + "type": "object", + "properties": { + "container_path": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "service": { + "type": "string" + } + }, + "x-property-order": [ + "service", + "container_path", + "description", + "required" + ] + }, + "models.DatabaseBackupSpec": { + "type": "object", + "properties": { + "all_databases": { + "type": "boolean" + }, + "container": { + "type": "string" + }, + "database": { + "type": "string" + }, + "database_env": { + "type": "string" + }, + "host": { + "type": "string" + }, + "host_env": { + "type": "string" + }, + "password": { + "type": "string" + }, + "password_env": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "port_env": { + "type": "string" + }, + "service": { + "type": "string" + }, + "type": { + "type": "string" + }, + "user": { + "type": "string" + }, + "user_env": { + "type": "string" + } + }, + "x-property-order": [ + "service", + "type", + "container", + "all_databases", + "host_env", + "port_env", + "user_env", + "password_env", + "database_env", + "host", + "port", + "user", + "password", + "database" + ] + }, + "models.DatabaseConfig": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "container": { + "type": "string" + }, + "database_name": { + "type": "string" + }, + "env_prefix": { + "type": "string" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_shared": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "service": { + "type": "string" + }, + "type": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "alias", + "type", + "mode", + "service", + "host", + "port", + "container", + "database_name", + "username", + "env_prefix", + "is_shared" + ] + }, + "models.Deployment": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "$ref": "#/components/schemas/models.ServiceMetadata" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.Service" + } + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "x-property-order": [ + "name", + "path", + "status", + "created_at", + "updated_at", + "services", + "metadata" + ], + "x-columns": [ + "name", + "status", + "created_at" + ] + }, + "models.DeploymentRateLimit": { + "type": "object", + "properties": { + "burst": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "rate": { + "type": "integer" + } + }, + "x-property-order": [ + "path", + "rate", + "burst", + "enabled" + ] + }, + "models.DeploymentSecurityConfig": { + "type": "object", + "properties": { + "blocked_ips": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "protected_paths": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.ProtectedPath" + } + }, + "rate_limits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.DeploymentRateLimit" + } + } + }, + "x-property-order": [ + "enabled", + "blocked_ips", + "protected_paths", + "rate_limits" + ] + }, + "models.DomainConfig": { + "type": "object", + "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "container_port": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "string" + }, + "path_prefix": { + "type": "string" + }, + "proxy_timeout": { + "type": "integer" + }, + "route_only_aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "service": { + "type": "string" + }, + "ssl": { + "$ref": "#/components/schemas/models.SSLConfig" + }, + "static_cache": { + "type": "boolean" + }, + "strip_prefix": { + "type": "boolean" + } + }, + "x-property-order": [ + "id", + "service", + "container_port", + "domain", + "path_prefix", + "strip_prefix", + "ssl", + "aliases", + "route_only_aliases", + "proxy_timeout", + "static_cache" + ] + }, + "models.HealthCheckConfig": { + "type": "object", + "properties": { + "interval": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "x-property-order": [ + "path", + "interval" + ] + }, + "models.LogSource": { + "type": "object", + "properties": { + "builtin": { + "type": "boolean" + }, + "format": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "service": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "name", + "type", + "service", + "path", + "format", + "builtin" + ] + }, + "models.NetworkingConfig": { + "type": "object", + "properties": { + "container_port": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "expose": { + "type": "boolean" + }, + "protocol": { + "type": "string" + }, + "proxy_type": { + "type": "string" + }, + "service": { + "type": "string" + } + }, + "x-property-order": [ + "expose", + "domain", + "service", + "container_port", + "protocol", + "proxy_type" + ] + }, + "models.ProtectedCommandRule": { + "type": "object", + "properties": { + "case_sensitive": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "match": { + "type": "string" + }, + "name": { + "type": "string" + }, + "pattern": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "name", + "match", + "pattern", + "case_sensitive", + "description" + ] + }, + "models.ProtectedModeConfig": { + "type": "object", + "properties": { + "blocked_actions": { + "type": "array", + "items": { + "type": "string" + } + }, + "blocked_command_rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.ProtectedCommandRule" + } + }, + "disable_terminal": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "x-property-order": [ + "enabled", + "blocked_actions", + "blocked_command_rules", + "disable_terminal" + ] + }, + "models.ProtectedPath": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "pattern": { + "type": "string" + } + }, + "x-property-order": [ + "pattern", + "enabled" + ] + }, + "models.QuickAction": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "description": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "service": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "name", + "command", + "description", + "icon", + "service" + ] + }, + "models.SSLConfig": { + "type": "object", + "properties": { + "auto_cert": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "x-property-order": [ + "enabled", + "auto_cert" + ] + }, + "models.Service": { + "type": "object", + "properties": { + "container_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "health": { + "type": "string" + }, + "image": { + "type": "string" + }, + "is_primary": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "networks": { + "type": "array", + "items": { + "type": "string" + } + }, + "ports": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "container_id", + "image", + "status", + "health", + "ports", + "networks", + "is_primary", + "created_at" + ] + }, + "models.ServiceMetadata": { + "type": "object", + "properties": { + "backup": { + "$ref": "#/components/schemas/models.BackupSpec" + }, + "credential_id": { + "type": "string" + }, + "databases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.DatabaseConfig" + } + }, + "domains": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.DomainConfig" + } + }, + "healthcheck": { + "$ref": "#/components/schemas/models.HealthCheckConfig" + }, + "kind": { + "type": "string" + }, + "log_sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.LogSource" + } + }, + "name": { + "type": "string" + }, + "networking": { + "$ref": "#/components/schemas/models.NetworkingConfig" + }, + "primary_service": { + "type": "string" + }, + "protected_mode": { + "$ref": "#/components/schemas/models.ProtectedModeConfig" + }, + "quick_actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.QuickAction" + } + }, + "require_plan": { + "type": "boolean" + }, + "security": { + "$ref": "#/components/schemas/models.DeploymentSecurityConfig" + }, + "service_credentials": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "ssl": { + "$ref": "#/components/schemas/models.SSLConfig" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "type", + "kind", + "log_sources", + "primary_service", + "networking", + "ssl", + "healthcheck", + "quick_actions", + "security", + "backup", + "protected_mode", + "require_plan", + "credential_id", + "service_credentials", + "domains", + "databases" + ] + }, + "notify.Config": { + "type": "object", + "properties": { + "targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/notify.Target" + } + } + }, + "x-property-order": [ + "targets" + ] + }, + "notify.Target": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "x-property-order": [ + "id", + "name", + "url", + "enabled" + ] + }, + "scheduler.AgentTaskConfig": { + "type": "object", + "properties": { + "agent_name": { + "type": "string" + } + }, + "x-property-order": [ + "agent_name" + ] + }, + "scheduler.BackupTaskConfig": { + "type": "object", + "properties": { + "retention_count": { + "type": "integer" + }, + "storage_path": { + "type": "string" + } + }, + "x-property-order": [ + "retention_count", + "storage_path" + ] + }, + "scheduler.CommandTaskConfig": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "service": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + }, + "x-property-order": [ + "service", + "command", + "timeout" + ] + }, + "scheduler.CreateTaskRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/scheduler.TaskConfig" + }, + "cron_expr": { + "type": "string" + }, + "deployment_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "type", + "deployment_name", + "cron_expr", + "enabled", + "config" + ], + "required": [ + "name", + "type", + "deployment_name", + "cron_expr" + ] + }, + "scheduler.TaskConfig": { + "type": "object", + "properties": { + "agent_config": { + "$ref": "#/components/schemas/scheduler.AgentTaskConfig" + }, + "backup_config": { + "$ref": "#/components/schemas/scheduler.BackupTaskConfig" + }, + "command_config": { + "$ref": "#/components/schemas/scheduler.CommandTaskConfig" + } + }, + "x-property-order": [ + "backup_config", + "command_config", + "agent_config" + ] + }, + "scheduler.UpdateTaskRequest": { + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/scheduler.TaskConfig" + }, + "cron_expr": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "cron_expr", + "enabled", + "config" + ] + }, + "security.ProtectedRoute": { + "type": "object", + "properties": { + "block_duration": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "path_pattern": { + "type": "string" + }, + "rate_limit": { + "type": "integer" + } + }, + "x-property-order": [ + "id", + "path_pattern", + "rate_limit", + "block_duration", + "enabled", + "created_at" + ] + }, + "updater.Availability": { + "type": "object", + "properties": { + "channel": { + "type": "string" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/updater.ReleaseInfo" + } + }, + "update_available": { + "type": "boolean" + } + }, + "x-property-order": [ + "current_version", + "channel", + "latest_version", + "update_available", + "releases" + ] + }, + "updater.ReleaseInfo": { + "type": "object", + "properties": { + "changelog": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "x-property-order": [ + "version", + "prerelease", + "published_at", + "changelog" + ] + } + } + } +} diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go new file mode 100644 index 0000000..f42c670 --- /dev/null +++ b/internal/api/openapi_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func loadSpec(t *testing.T) map[string]any { + t.Helper() + var spec map[string]any + if err := json.Unmarshal(openAPISpec, &spec); err != nil { + t.Fatalf("the embedded spec must be valid JSON: %v", err) + } + return spec +} + +func TestOpenAPISpecIsServed(t *testing.T) { + gin.SetMode(gin.TestMode) + server := &Server{} + router := gin.New() + router.GET("/openapi.json", server.getOpenAPISpec) + + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.json", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var spec map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil { + t.Fatalf("the served spec must be valid JSON: %v", err) + } + if spec["openapi"] != "3.1.0" { + t.Errorf("openapi = %v", spec["openapi"]) + } +} + +// A spec that describes an endpoint the agent does not serve, or misses one it does, is worse +// than no spec, since a caller trusts it. +func TestOpenAPISpecMatchesTheRoutes(t *testing.T) { + if testing.Short() { + t.Skip("reruns the generator") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain unavailable") + } + + root, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + regenerated := filepath.Join(t.TempDir(), "openapi.json") + cmd := exec.Command("go", "run", "./tools/genspec", "-o", regenerated) + cmd.Dir = root + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("regenerating the spec failed: %v: %s", err, out) + } + + fresh, err := os.ReadFile(regenerated) + if err != nil { + t.Fatal(err) + } + if string(fresh) != string(openAPISpec) { + t.Error("the committed spec is out of date; run: go run ./tools/genspec -o internal/api/openapi.json") + } +} + +func TestOpenAPISpecCarriesWhatACallerNeeds(t *testing.T) { + spec := loadSpec(t) + paths, ok := spec["paths"].(map[string]any) + if !ok || len(paths) == 0 { + t.Fatal("the spec describes no paths") + } + + // A request body, so a caller knows what to send rather than guessing field names. + backups, ok := paths["/api/backups"].(map[string]any) + if !ok { + t.Fatal("/api/backups is missing") + } + post, ok := backups["post"].(map[string]any) + if !ok { + t.Fatal("POST /api/backups is missing") + } + body, ok := post["requestBody"].(map[string]any) + if !ok { + t.Fatal("POST /api/backups has no request body") + } + ref := body["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"] + if ref == nil { + t.Fatal("the request body has no schema") + } + + schemas := spec["components"].(map[string]any)["schemas"].(map[string]any) + name := ref.(string)[len("#/components/schemas/"):] + schema, ok := schemas[name].(map[string]any) + if !ok { + t.Fatalf("%s is referenced but not described", name) + } + properties, ok := schema["properties"].(map[string]any) + if !ok || properties["deployment_name"] == nil { + t.Fatalf("the schema should carry the fields the handler binds, got %v", schema) + } + required, ok := schema["required"].([]any) + if !ok || len(required) == 0 { + t.Error("a field tagged binding:required should be required in the spec") + } + + // The permission, so a caller can tell what a key needs before it is refused. + if post["x-permission"] == nil { + t.Error("the operation should carry the permission it is gated on") + } +} diff --git a/internal/api/responses.go b/internal/api/responses.go new file mode 100644 index 0000000..3d35d92 --- /dev/null +++ b/internal/api/responses.go @@ -0,0 +1,23 @@ +package api + +import ( + "github.com/flatrun/agent/internal/backup" + "github.com/flatrun/agent/pkg/models" +) + +// Responses declared as types rather than inline maps, so the generated spec describes what an +// endpoint answers with and a client can lay it out without being told how. The `cli` tag names +// the fields worth a column; everything else is still in the payload for whoever wants it. + +type DeploymentListResponse struct { + Deployments []models.Deployment `json:"deployments"` + Path string `json:"path"` +} + +type BackupListResponse struct { + Backups []backup.Backup `json:"backups"` +} + +type CertificateListResponse struct { + Certificates []models.Certificate `json:"certificates"` +} diff --git a/internal/api/server.go b/internal/api/server.go index 44f2bb3..4cbb3c4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -418,6 +418,7 @@ func (s *Server) setupRoutes() { api := s.router.Group("/api") { api.GET("/health", s.healthCheck) + api.GET("/openapi.json", s.getOpenAPISpec) api.GET("/auth/status", s.authMiddleware.GetAuthStatus) api.POST("/auth/login", s.authMiddleware.Login) api.GET("/auth/validate", s.authMiddleware.ValidateToken) @@ -975,9 +976,9 @@ func (s *Server) listDeployments(c *gin.Context) { deployments = filtered } - c.JSON(http.StatusOK, gin.H{ - "deployments": deployments, - "path": s.manager.BasePath(), + c.JSON(http.StatusOK, DeploymentListResponse{ + Deployments: deployments, + Path: s.manager.BasePath(), }) } @@ -5033,9 +5034,7 @@ func (s *Server) listCertificates(c *gin.Context) { s.annotateCertificatesWithDeployment(certificates) - c.JSON(http.StatusOK, gin.H{ - "certificates": certificates, - }) + c.JSON(http.StatusOK, CertificateListResponse{Certificates: certificates}) } func (s *Server) annotateCertificatesWithDeployment(certs []models.Certificate) { diff --git a/internal/backup/types.go b/internal/backup/types.go index b57925a..9d1b441 100644 --- a/internal/backup/types.go +++ b/internal/backup/types.go @@ -21,14 +21,14 @@ type DatabaseSpec = models.DatabaseBackupSpec type HookSpec = models.BackupHookSpec type Backup struct { - ID string `json:"id"` - DeploymentName string `json:"deployment_name"` - Status BackupStatus `json:"status"` - Size int64 `json:"size"` + ID string `json:"id" cli:"column"` + DeploymentName string `json:"deployment_name" cli:"column"` + Status BackupStatus `json:"status" cli:"column"` + Size int64 `json:"size" cli:"column"` Path string `json:"path"` Components []string `json:"components"` Error string `json:"error,omitempty"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `json:"created_at" cli:"column"` CompletedAt *time.Time `json:"completed_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` // Locations lists where this backup exists: "local" and/or remote diff --git a/pkg/models/certificate.go b/pkg/models/certificate.go index 7598890..744718e 100644 --- a/pkg/models/certificate.go +++ b/pkg/models/certificate.go @@ -3,13 +3,13 @@ package models import "time" type Certificate struct { - Domain string `json:"domain"` - Issuer string `json:"issuer"` + Domain string `json:"domain" cli:"column"` + Issuer string `json:"issuer" cli:"column"` NotBefore time.Time `json:"not_before"` NotAfter time.Time `json:"not_after"` - DaysLeft int `json:"days_left"` - Status string `json:"status"` + DaysLeft int `json:"days_left" cli:"column"` + Status string `json:"status" cli:"column"` Path string `json:"path"` - AutoRenew bool `json:"auto_renew"` + AutoRenew bool `json:"auto_renew" cli:"column"` DeploymentID string `json:"deployment_id,omitempty"` } diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index afa9761..aedb2f1 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -3,10 +3,10 @@ package models import "time" type Deployment struct { - Name string `json:"name"` + Name string `json:"name" cli:"column"` Path string `json:"path"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` + Status string `json:"status" cli:"column"` + CreatedAt time.Time `json:"created_at" cli:"column"` UpdatedAt time.Time `json:"updated_at"` Services []Service `json:"services,omitempty"` Metadata *ServiceMetadata `json:"metadata,omitempty"` diff --git a/tools/genspec/main.go b/tools/genspec/main.go new file mode 100644 index 0000000..a97d0b1 --- /dev/null +++ b/tools/genspec/main.go @@ -0,0 +1,432 @@ +// Command genspec writes the agent's OpenAPI description by reading the agent. +// +// Nothing here is annotated: the routes come from the router, the request body of an endpoint +// comes from whatever its handler binds, and the response comes from whatever typed value it +// writes. A spec written that way cannot say something the code does not, which is the whole +// reason for generating it rather than maintaining one by hand. +// +// go run ./tools/genspec -o internal/api/openapi.json +package main + +import ( + "encoding/json" + "flag" + "fmt" + "go/ast" + "go/types" + "log" + "os" + "regexp" + "sort" + "strconv" + "strings" + + "golang.org/x/tools/go/packages" +) + +func main() { + root := flag.String("root", ".", "agent module root") + out := flag.String("o", "", "write here instead of stdout") + flag.Parse() + + spec, err := build(*root) + if err != nil { + log.Fatal(err) + } + + encoded, err := json.MarshalIndent(spec, "", " ") + if err != nil { + log.Fatal(err) + } + encoded = append(encoded, '\n') + + if *out == "" { + _, _ = os.Stdout.Write(encoded) + return + } + if err := os.WriteFile(*out, encoded, 0644); err != nil { + log.Fatal(err) + } +} + +// route is one registration read out of the router. +type route struct { + Method string + Path string + Handler string + Permission string + Group string +} + +// groupPrefix is what each router group prepends to the paths registered on it. +var groupPrefix = map[string]string{ + "api": "", + "protected": "", + "setupGroup": "/setup", + "guarded": "/setup", + "usersGroup": "/users", + "apiKeysGroup": "/apikeys", + "dnsGroup": "/dns", + "clusterGroup": "/cluster", +} + +// Endpoints the agent's own components call, not part of the interface it offers. +var skipPrefixes = []string{"/internal", "/_internal", "/security/events/ingest", "/traffic/ingest"} + +var routePattern = regexp.MustCompile(`\b(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*"([^"]+)"(.*)`) +var permPattern = regexp.MustCompile(`auth\.(Perm\w+)`) +var handlerPattern = regexp.MustCompile(`\.(\w+)\s*\)\s*$`) + +func build(root string) (*openAPI, error) { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | + packages.NeedTypesInfo | packages.NeedDeps | packages.NeedImports, + Dir: root, + } + pkgs, err := packages.Load(cfg, "./internal/api", "./internal/auth") + if err != nil { + return nil, err + } + var api *packages.Package + for _, pkg := range pkgs { + if len(pkg.Errors) > 0 { + return nil, fmt.Errorf("loading %s: %v", pkg.PkgPath, pkg.Errors[0]) + } + if strings.HasSuffix(pkg.PkgPath, "/internal/api") { + api = pkg + } + } + if api == nil || len(api.Syntax) == 0 { + return nil, fmt.Errorf("no packages loaded from %s", root) + } + + routes, err := readRoutes(api) + if err != nil { + return nil, err + } + + handlers := indexHandlers(pkgs) + schemas := &schemaSet{byName: map[string]*schema{}, seen: map[string]bool{}} + + spec := &openAPI{ + OpenAPI: "3.1.0", + Info: info{ + Title: "FlatRun Agent API", + Description: "Generated from the agent's routes and the types its handlers bind and return.", + Version: readVersion(root), + }, + Paths: map[string]map[string]*operation{}, + Components: components{Schemas: schemas.byName}, + } + + for _, r := range routes { + op := &operation{ + OperationID: operationID(r), + Tags: []string{familyOf(r.Path)}, + Responses: map[string]response{"200": {Description: "Success"}}, + } + if r.Permission != "" { + op.Extensions = map[string]any{"x-permission": permissionValue(r.Permission)} + } + for _, param := range pathParams(r.Path) { + op.Parameters = append(op.Parameters, parameter{ + Name: param, In: "path", Required: true, + Schema: &schema{Type: "string"}, + }) + } + + if fn := handlers[r.Handler]; fn != nil { + if bound := boundRequestType(fn.pkg, fn.decl); bound != nil { + if ref := schemas.add(bound); ref != nil { + op.RequestBody = &requestBody{ + Required: true, + Content: map[string]mediaType{"application/json": {Schema: ref}}, + } + } + } + if returned := typedResponse(fn.pkg, fn.decl); returned != nil { + if ref := schemas.add(returned); ref != nil { + op.Responses["200"] = response{ + Description: "Success", + Content: map[string]mediaType{"application/json": {Schema: ref}}, + } + } + } + for _, q := range queryParams(fn.pkg, fn.decl) { + op.Parameters = append(op.Parameters, parameter{ + Name: q, In: "query", Schema: &schema{Type: "string"}, + }) + } + } + + path := openAPIPath(r.Path) + if spec.Paths[path] == nil { + spec.Paths[path] = map[string]*operation{} + } + spec.Paths[path][strings.ToLower(r.Method)] = op + } + + return spec, nil +} + +func readVersion(root string) string { + raw, err := os.ReadFile(root + "/VERSION") + if err != nil { + return "0.0.0" + } + return strings.TrimSpace(string(raw)) +} + +// readRoutes reads the registrations out of the router's source. The router is built at runtime +// against a live host, so its table cannot be read by calling it. +func readRoutes(api *packages.Package) ([]route, error) { + var file string + for _, f := range api.GoFiles { + if strings.HasSuffix(f, "server.go") { + file = f + } + } + if file == "" { + return nil, fmt.Errorf("server.go not found in internal/api") + } + raw, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var routes []route + seen := map[string]bool{} + for _, line := range strings.Split(string(raw), "\n") { + match := routePattern.FindStringSubmatch(line) + if match == nil { + continue + } + group, method, path, rest := match[1], match[2], match[3], match[4] + prefix, known := groupPrefix[group] + if !known { + continue + } + full := prefix + path + if skip(full) { + continue + } + key := method + " " + full + if seen[key] { + continue + } + seen[key] = true + + r := route{Method: method, Path: full, Group: group} + if m := permPattern.FindStringSubmatch(rest); m != nil { + r.Permission = m[1] + } + if m := handlerPattern.FindStringSubmatch(strings.TrimSpace(rest)); m != nil { + r.Handler = m[1] + } + routes = append(routes, r) + } + sort.Slice(routes, func(i, j int) bool { + if routes[i].Path != routes[j].Path { + return routes[i].Path < routes[j].Path + } + return routes[i].Method < routes[j].Method + }) + return routes, nil +} + +func skip(path string) bool { + for _, prefix := range skipPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + // A websocket carries no JSON body or response to describe. + return strings.HasSuffix(path, "/stream") || strings.HasSuffix(path, "/interactive") +} + +// handler is a method that serves a route, with the package it came from so its types resolve. +type handler struct { + decl *ast.FuncDecl + pkg *packages.Package +} + +func indexHandlers(pkgs []*packages.Package) map[string]*handler { + handlers := map[string]*handler{} + for _, pkg := range pkgs { + for _, file := range pkg.Syntax { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil { + continue + } + // The API package wins a name it shares with another, since that is where a + // route's handler lives unless it is delegated. + if existing, taken := handlers[fn.Name.Name]; taken && + strings.HasSuffix(existing.pkg.PkgPath, "/internal/api") { + continue + } + handlers[fn.Name.Name] = &handler{decl: fn, pkg: pkg} + } + } + } + return handlers +} + +// boundRequestType is the type a handler binds the request body into. +func boundRequestType(api *packages.Package, fn *ast.FuncDecl) types.Type { + var found types.Type + ast.Inspect(fn, func(n ast.Node) bool { + if found != nil { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (sel.Sel.Name != "ShouldBindJSON" && sel.Sel.Name != "BindJSON") { + return true + } + if len(call.Args) != 1 { + return true + } + unary, ok := call.Args[0].(*ast.UnaryExpr) + if !ok { + return true + } + if t := api.TypesInfo.TypeOf(unary.X); t != nil { + found = t + } + return false + }) + return found +} + +// typedResponse is the type a handler writes on success, when it writes one rather than an +// inline map. A handler answering with gin.H describes nothing, and is left undescribed. +func typedResponse(api *packages.Package, fn *ast.FuncDecl) types.Type { + var found types.Type + ast.Inspect(fn, func(n ast.Node) bool { + if found != nil { + return false + } + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) != 2 { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "JSON" { + return true + } + if !isStatusOK(call.Args[0]) { + return true + } + t := api.TypesInfo.TypeOf(call.Args[1]) + if t == nil || isGinH(t) { + return true + } + found = t + return false + }) + return found +} + +func isStatusOK(expr ast.Expr) bool { + sel, ok := expr.(*ast.SelectorExpr) + return ok && sel.Sel.Name == "StatusOK" +} + +func isGinH(t types.Type) bool { + named, ok := t.(*types.Named) + if !ok { + return false + } + return named.Obj().Name() == "H" && strings.HasSuffix(named.Obj().Pkg().Path(), "gin") +} + +// queryParams are the query keys a handler reads, which is what makes them checkable by a caller +// rather than something to be discovered by trial. +func queryParams(api *packages.Package, fn *ast.FuncDecl) []string { + seen := map[string]bool{} + var names []string + ast.Inspect(fn, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + if sel.Sel.Name != "Query" && sel.Sel.Name != "DefaultQuery" { + return true + } + lit, ok := call.Args[0].(*ast.BasicLit) + if !ok { + return true + } + name, err := strconv.Unquote(lit.Value) + if err != nil || seen[name] { + return true + } + seen[name] = true + names = append(names, name) + return true + }) + sort.Strings(names) + return names +} + +func pathParams(path string) []string { + var params []string + for _, segment := range strings.Split(strings.Trim(path, "/"), "/") { + if strings.HasPrefix(segment, ":") { + params = append(params, strings.TrimPrefix(segment, ":")) + } + } + return params +} + +func openAPIPath(path string) string { + segments := strings.Split(path, "/") + for i, segment := range segments { + if strings.HasPrefix(segment, ":") { + segments[i] = "{" + strings.TrimPrefix(segment, ":") + "}" + } + } + return "/api" + strings.Join(segments, "/") +} + +func familyOf(path string) string { + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return "root" + } + return strings.Split(trimmed, "/")[0] +} + +func operationID(r route) string { + parts := []string{strings.ToLower(r.Method)} + for _, segment := range strings.Split(strings.Trim(r.Path, "/"), "/") { + if segment == "" { + continue + } + if strings.HasPrefix(segment, ":") { + parts = append(parts, "by-"+strings.TrimPrefix(segment, ":")) + continue + } + parts = append(parts, segment) + } + return strings.Join(parts, "-") +} + +func permissionValue(constant string) string { + // PermDeploymentsWrite reads as deployments:write, which is the string the agent checks. + trimmed := strings.TrimPrefix(constant, "Perm") + for i := 1; i < len(trimmed); i++ { + if trimmed[i] >= 'A' && trimmed[i] <= 'Z' { + return strings.ToLower(trimmed[:i]) + ":" + strings.ToLower(trimmed[i:]) + } + } + return strings.ToLower(trimmed) +} diff --git a/tools/genspec/schema.go b/tools/genspec/schema.go new file mode 100644 index 0000000..76e5213 --- /dev/null +++ b/tools/genspec/schema.go @@ -0,0 +1,315 @@ +package main + +import ( + "encoding/json" + "go/types" + "strings" +) + +type openAPI struct { + OpenAPI string `json:"openapi"` + Info info `json:"info"` + Paths map[string]map[string]*operation `json:"paths"` + Components components `json:"components"` +} + +type info struct { + Title string `json:"title"` + Description string `json:"description"` + Version string `json:"version"` +} + +type components struct { + Schemas map[string]*schema `json:"schemas"` +} + +type operation struct { + OperationID string `json:"operationId"` + Tags []string `json:"tags,omitempty"` + Parameters []parameter `json:"parameters,omitempty"` + RequestBody *requestBody `json:"requestBody,omitempty"` + Responses map[string]response `json:"responses"` + Extensions map[string]any `json:"-"` +} + +// MarshalJSON writes the extensions inline, which is where OpenAPI expects x- keys. +func (o operation) MarshalJSON() ([]byte, error) { + type plain operation + encoded, err := json.Marshal(plain(o)) + if err != nil { + return nil, err + } + if len(o.Extensions) == 0 { + return encoded, nil + } + var merged map[string]any + if err := json.Unmarshal(encoded, &merged); err != nil { + return nil, err + } + for key, value := range o.Extensions { + merged[key] = value + } + return json.Marshal(merged) +} + +type parameter struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required,omitempty"` + Schema *schema `json:"schema,omitempty"` +} + +type requestBody struct { + Required bool `json:"required,omitempty"` + Content map[string]mediaType `json:"content"` +} + +type response struct { + Description string `json:"description"` + Content map[string]mediaType `json:"content,omitempty"` +} + +type mediaType struct { + Schema *schema `json:"schema,omitempty"` +} + +type schema struct { + Ref string `json:"$ref,omitempty"` + Type string `json:"type,omitempty"` + Format string `json:"format,omitempty"` + Items *schema `json:"items,omitempty"` + Properties map[string]*schema `json:"properties,omitempty"` + // PropertyOrder is the order the fields are declared in, which is the order a caller reads + // them in and the order the CLI lays out columns. JSON objects have none of their own. + PropertyOrder []string `json:"x-property-order,omitempty"` + // Columns are the fields worth showing when a row of this is printed as a table, named on + // the type so the choice lives with the data rather than in every client. + Columns []string `json:"x-columns,omitempty"` + Required []string `json:"required,omitempty"` + Description string `json:"description,omitempty"` + AdditionalProperties *schema `json:"additionalProperties,omitempty"` +} + +// schemaSet collects the named types the spec refers to, so a type used by twenty endpoints is +// described once. +type schemaSet struct { + byName map[string]*schema + seen map[string]bool +} + +func (s *schemaSet) add(t types.Type) *schema { + built := s.build(t, 0) + if built == nil { + return nil + } + return built +} + +func (s *schemaSet) build(t types.Type, depth int) *schema { + if t == nil || depth > 12 { + return nil + } + + switch typed := t.(type) { + case *types.Pointer: + return s.build(typed.Elem(), depth) + case *types.Named: + return s.named(typed, depth) + case *types.Alias: + return s.build(types.Unalias(typed), depth) + case *types.Basic: + return basicSchema(typed) + case *types.Slice: + if isByteSlice(typed) { + return &schema{Type: "string", Format: "byte"} + } + return &schema{Type: "array", Items: s.build(typed.Elem(), depth+1)} + case *types.Array: + return &schema{Type: "array", Items: s.build(typed.Elem(), depth+1)} + case *types.Map: + return &schema{Type: "object", AdditionalProperties: s.build(typed.Elem(), depth+1)} + case *types.Struct: + return s.structSchema(typed, depth) + case *types.Interface: + return &schema{} + } + return nil +} + +func (s *schemaSet) named(t *types.Named, depth int) *schema { + obj := t.Obj() + if obj.Pkg() == nil { + return s.build(t.Underlying(), depth) + } + + // time.Time is a struct, but nobody wants its fields. + if obj.Pkg().Path() == "time" && obj.Name() == "Time" { + return &schema{Type: "string", Format: "date-time"} + } + if obj.Pkg().Path() == "time" && obj.Name() == "Duration" { + return &schema{Type: "string", Description: "Duration, such as 30s or 1h0m0s"} + } + + if _, ok := t.Underlying().(*types.Struct); !ok { + return s.build(t.Underlying(), depth) + } + + name := schemaName(obj.Pkg().Path(), obj.Name()) + if !s.seen[name] { + s.seen[name] = true + // Registered before its fields are walked, so a type holding itself terminates. + s.byName[name] = &schema{Type: "object"} + built := s.structSchema(t.Underlying().(*types.Struct), depth+1) + if built != nil { + s.byName[name] = built + } + } + return &schema{Ref: "#/components/schemas/" + name} +} + +func (s *schemaSet) structSchema(t *types.Struct, depth int) *schema { + out := &schema{Type: "object", Properties: map[string]*schema{}} + s.fields(t, depth, out) + if len(out.Properties) == 0 { + return &schema{Type: "object"} + } + return out +} + +func (s *schemaSet) fields(t *types.Struct, depth int, out *schema) { + for i := 0; i < t.NumFields(); i++ { + field := t.Field(i) + tag := parseTag(t.Tag(i)) + + if field.Embedded() && tag.name == "" { + // An embedded struct's fields belong to the outer object. + if embedded, ok := underlyingStruct(field.Type()); ok { + s.fields(embedded, depth, out) + continue + } + } + if !field.Exported() || tag.skip { + continue + } + + name := tag.name + if name == "" { + name = field.Name() + } + built := s.build(field.Type(), depth+1) + if built == nil { + continue + } + if tag.column { + out.Columns = append(out.Columns, name) + } + out.Properties[name] = built + out.PropertyOrder = append(out.PropertyOrder, name) + if tag.required { + out.Required = append(out.Required, name) + } + } +} + +func underlyingStruct(t types.Type) (*types.Struct, bool) { + switch typed := t.(type) { + case *types.Pointer: + return underlyingStruct(typed.Elem()) + case *types.Named: + st, ok := typed.Underlying().(*types.Struct) + return st, ok + case *types.Struct: + return typed, true + } + return nil, false +} + +type fieldTag struct { + name string + skip bool + required bool + column bool +} + +func parseTag(raw string) fieldTag { + tag := fieldTag{} + jsonTag := structTag(raw, "json") + if jsonTag == "-" { + tag.skip = true + return tag + } + if jsonTag != "" { + tag.name = strings.Split(jsonTag, ",")[0] + } + if strings.Contains(structTag(raw, "binding"), "required") { + tag.required = true + } + if strings.Contains(structTag(raw, "cli"), "column") { + tag.column = true + } + return tag +} + +// structTag reads one key out of a raw struct tag without reflect, which needs a live value. +func structTag(raw, key string) string { + for raw != "" { + i := 0 + for i < len(raw) && raw[i] == ' ' { + i++ + } + raw = raw[i:] + if raw == "" { + break + } + i = 0 + for i < len(raw) && raw[i] > ' ' && raw[i] != ':' && raw[i] != '"' { + i++ + } + if i+1 >= len(raw) || raw[i] != ':' || raw[i+1] != '"' { + break + } + name := raw[:i] + raw = raw[i+1:] + + i = 1 + for i < len(raw) && raw[i] != '"' { + if raw[i] == '\\' { + i++ + } + i++ + } + if i >= len(raw) { + break + } + value := raw[1:i] + raw = raw[i+1:] + if name == key { + return value + } + } + return "" +} + +func basicSchema(t *types.Basic) *schema { + switch { + case t.Info()&types.IsBoolean != 0: + return &schema{Type: "boolean"} + case t.Info()&types.IsInteger != 0: + return &schema{Type: "integer"} + case t.Info()&types.IsFloat != 0: + return &schema{Type: "number"} + case t.Info()&types.IsString != 0: + return &schema{Type: "string"} + } + return &schema{} +} + +func isByteSlice(t *types.Slice) bool { + basic, ok := t.Elem().(*types.Basic) + return ok && basic.Kind() == types.Byte +} + +func schemaName(pkgPath, name string) string { + parts := strings.Split(pkgPath, "/") + return parts[len(parts)-1] + "." + name +} From 33a3fb9ff80fdbed208d5cd433bf33c0a8588f24 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 13:15:57 +0100 Subject: [PATCH 2/6] test(api): Check the description resolves and names every operation A generated description can be current and still be unusable: a reference pointing at nothing, an operation without an identifier, a path a client cannot reach. Each of those parses as JSON and breaks whoever trusts it. --- internal/api/openapi_test.go | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index f42c670..3fba820 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" @@ -72,6 +73,76 @@ func TestOpenAPISpecMatchesTheRoutes(t *testing.T) { } } +// A description a client cannot resolve is worse than none, since the client trusts it. These +// are the faults a generator can introduce that still produce parseable JSON. +func TestOpenAPISpecIsStructurallySound(t *testing.T) { + spec := loadSpec(t) + + schemas, ok := spec["components"].(map[string]any)["schemas"].(map[string]any) + if !ok { + t.Fatal("the spec describes no schemas") + } + + var refs []string + var walk func(node any) + walk = func(node any) { + switch typed := node.(type) { + case map[string]any: + if ref, ok := typed["$ref"].(string); ok { + refs = append(refs, ref) + } + for _, value := range typed { + walk(value) + } + case []any: + for _, value := range typed { + walk(value) + } + } + } + walk(spec) + + if len(refs) == 0 { + t.Fatal("no schema is referenced, so nothing describes a body") + } + for _, ref := range refs { + name := strings.TrimPrefix(ref, "#/components/schemas/") + if name == ref { + t.Errorf("%s does not point into the schemas", ref) + continue + } + if _, ok := schemas[name]; !ok { + t.Errorf("%s is referenced but not described", ref) + } + } + + seen := map[string]string{} + for path, methods := range spec["paths"].(map[string]any) { + if !strings.HasPrefix(path, "/api/") { + t.Errorf("%s is not reachable: every route is served under /api", path) + } + for method, raw := range methods.(map[string]any) { + op, ok := raw.(map[string]any) + if !ok { + t.Errorf("%s %s is not an operation", method, path) + continue + } + id, _ := op["operationId"].(string) + if id == "" { + t.Errorf("%s %s has no operationId, which client generators key on", method, path) + continue + } + if previous, clash := seen[id]; clash { + t.Errorf("operationId %s is used by both %s and %s %s", id, previous, method, path) + } + seen[id] = method + " " + path + if op["responses"] == nil { + t.Errorf("%s %s describes no response", method, path) + } + } + } +} + func TestOpenAPISpecCarriesWhatACallerNeeds(t *testing.T) { spec := loadSpec(t) paths, ok := spec["paths"].(map[string]any) From c6350d425fabc487a2dc948c8256a9a5076999ee Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 23:25:05 +0100 Subject: [PATCH 3/6] refactor(api): Answer in shapes rather than in a type per resource A response type named after its resource tells a client nothing it did not already know, so each one had to be learned separately. A collection is a collection whatever it holds: there is now one shape for a list, one for a single thing, and one for a report of what happened, and the generated description says which an endpoint answers in. A client renders any collection the same way, and an endpoint converted tomorrow renders without a line of client code. The fields worth showing as columns are every scalar the row declares, in the order it declares them, rather than a list repeated on each type; a field that is never worth a column says so once. Collections keep answering under their old name alongside the new one, so nothing reading the current shape breaks while it moves. --- internal/api/backup_handlers.go | 4 +- internal/api/openapi.json | 982 ++++++++++++++++++++++++++++++-- internal/api/render.go | 46 ++ internal/api/render_test.go | 41 ++ internal/api/responses.go | 23 - internal/api/server.go | 7 +- internal/backup/types.go | 12 +- pkg/models/certificate.go | 12 +- pkg/models/deployment.go | 8 +- tools/genspec/schema.go | 69 ++- 10 files changed, 1092 insertions(+), 112 deletions(-) create mode 100644 internal/api/render.go create mode 100644 internal/api/render_test.go delete mode 100644 internal/api/responses.go diff --git a/internal/api/backup_handlers.go b/internal/api/backup_handlers.go index f861ae9..d35949d 100644 --- a/internal/api/backup_handlers.go +++ b/internal/api/backup_handlers.go @@ -48,7 +48,7 @@ func (s *Server) listBackups(c *gin.Context) { backups = filtered } - c.JSON(http.StatusOK, BackupListResponse{Backups: backups}) + c.JSON(http.StatusOK, NewList(backups, "backups")) } func (s *Server) getBackup(c *gin.Context) { @@ -140,7 +140,7 @@ func (s *Server) listDeploymentBackups(c *gin.Context) { return } - c.JSON(http.StatusOK, BackupListResponse{Backups: backups}) + c.JSON(http.StatusOK, NewList(backups, "backups")) } func (s *Server) deleteBackup(c *gin.Context) { diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 485678f..8e507e2 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -139,6 +139,9 @@ } }, "type": "object", + "x-columns": [ + "content" + ], "x-property-order": [ "content" ] @@ -181,6 +184,9 @@ } }, "type": "object", + "x-columns": [ + "dry_run" + ], "x-property-order": [ "dry_run" ] @@ -273,6 +279,14 @@ } }, "type": "object", + "x-columns": [ + "scope", + "deployment", + "auto_run", + "message", + "context", + "seed" + ], "x-property-order": [ "scope", "deployment", @@ -418,6 +432,10 @@ "x-property-order": [ "message", "context" + ], + "x-columns": [ + "message", + "context" ] } } @@ -530,6 +548,12 @@ } }, "type": "object", + "x-columns": [ + "name", + "description", + "role", + "expires_in" + ], "x-property-order": [ "name", "description", @@ -759,6 +783,16 @@ } }, "type": "object", + "x-columns": [ + "format", + "actor_id", + "actor_type", + "action", + "resource_type", + "start_time", + "end_time", + "limit" + ], "x-property-order": [ "format", "actor_id", @@ -833,6 +867,11 @@ "api_key", "username", "password" + ], + "x-columns": [ + "api_key", + "username", + "password" ] } } @@ -942,7 +981,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/api.BackupListResponse" + "$ref": "#/components/schemas/api.ListOfBackup" } } }, @@ -1143,7 +1182,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/api.CertificateListResponse" + "$ref": "#/components/schemas/api.ListOfCertificate" } } }, @@ -1173,6 +1212,10 @@ "domain" ], "type": "object", + "x-columns": [ + "domain", + "deployment" + ], "x-property-order": [ "domain", "deployment" @@ -1283,6 +1326,9 @@ } }, "type": "object", + "x-columns": [ + "auto_renew" + ], "x-property-order": [ "auto_renew" ] @@ -1356,6 +1402,11 @@ "peer_url" ], "type": "object", + "x-columns": [ + "invite_token", + "peer_url", + "callback_url" + ], "x-property-order": [ "invite_token", "peer_url", @@ -1693,6 +1744,9 @@ } }, "type": "object", + "x-columns": [ + "command" + ], "x-property-order": [ "command", "args" @@ -1945,6 +1999,15 @@ "password" ], "type": "object", + "x-columns": [ + "name", + "registry_type_slug", + "registry_url", + "username", + "password", + "email", + "is_default" + ], "x-property-order": [ "name", "registry_type_slug", @@ -2052,6 +2115,14 @@ } }, "type": "object", + "x-columns": [ + "name", + "registry_url", + "username", + "password", + "email", + "is_default" + ], "x-property-order": [ "name", "registry_url", @@ -2233,6 +2304,16 @@ "db_name" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "db_name" + ], "x-property-order": [ "type", "host", @@ -2296,6 +2377,16 @@ "db_name" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "db_name" + ], "x-property-order": [ "type", "host", @@ -2390,6 +2481,18 @@ "target_database" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "target_username", + "target_database", + "target_host" + ], "x-property-order": [ "type", "host", @@ -2456,6 +2559,17 @@ "query" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database", + "query" + ], "x-property-order": [ "type", "host", @@ -2524,6 +2638,16 @@ "database" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database" + ], "x-property-order": [ "type", "host", @@ -2594,6 +2718,19 @@ "table" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database", + "table", + "limit", + "offset" + ], "x-property-order": [ "type", "host", @@ -2668,6 +2805,17 @@ "table" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database", + "table" + ], "x-property-order": [ "type", "host", @@ -2784,6 +2932,16 @@ "database" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "database" + ], "x-property-order": [ "type", "host", @@ -2854,6 +3012,18 @@ "target_password" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "target_username", + "target_password", + "target_host" + ], "x-property-order": [ "type", "host", @@ -2922,6 +3092,17 @@ "target_username" ], "type": "object", + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container", + "target_username", + "target_host" + ], "x-property-order": [ "type", "host", @@ -2957,7 +3138,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/api.DeploymentListResponse" + "$ref": "#/components/schemas/api.ListOfDeployment" } } }, @@ -3046,6 +3227,15 @@ } }, "type": "object", + "x-columns": [ + "credential_id", + "username", + "password", + "save_credential", + "credential_name", + "registry_type_slug", + "registry_url" + ], "x-property-order": [ "credential_id", "username", @@ -3082,6 +3272,18 @@ "name" ], "type": "object", + "x-columns": [ + "name", + "image", + "compose_content", + "template_id", + "container_port", + "map_ports", + "host_port", + "auto_start", + "use_shared_database", + "existing_database_container" + ], "x-property-order": [ "name", "image", @@ -3209,6 +3411,9 @@ "compose_content" ], "type": "object", + "x-columns": [ + "compose_content" + ], "x-property-order": [ "compose_content" ] @@ -3375,7 +3580,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/api.BackupListResponse" + "$ref": "#/components/schemas/api.ListOfBackup" } } }, @@ -3498,6 +3703,13 @@ "service_name" ], "type": "object", + "x-columns": [ + "source_path", + "target_path", + "service_name", + "read_only", + "selinux" + ], "x-property-order": [ "source_path", "target_path", @@ -3555,6 +3767,11 @@ "service_name" ], "type": "object", + "x-columns": [ + "source_path", + "target_path", + "service_name" + ], "x-property-order": [ "source_path", "target_path", @@ -3652,6 +3869,10 @@ "container_path" ], "type": "object", + "x-columns": [ + "container_path", + "host_path" + ], "x-property-order": [ "container_path", "host_path" @@ -3704,6 +3925,12 @@ } }, "type": "object", + "x-columns": [ + "action", + "pull", + "only_latest", + "cleanup" + ], "x-property-order": [ "action", "pull", @@ -4112,6 +4339,9 @@ } }, "type": "object", + "x-columns": [ + "dry_run" + ], "x-property-order": [ "dry_run" ] @@ -4430,6 +4660,9 @@ "mode" ], "type": "object", + "x-columns": [ + "mode" + ], "x-property-order": [ "mode" ] @@ -4509,6 +4742,10 @@ } }, "type": "object", + "x-columns": [ + "only_latest", + "cleanup" + ], "x-property-order": [ "only_latest", "cleanup" @@ -4752,6 +4989,12 @@ } }, "type": "object", + "x-columns": [ + "action", + "force_recreate", + "no_cache", + "fresh_pull" + ], "x-property-order": [ "action", "force_recreate", @@ -5193,6 +5436,9 @@ } }, "type": "object", + "x-columns": [ + "dry_run" + ], "x-property-order": [ "dry_run" ] @@ -5231,6 +5477,10 @@ "name" ], "type": "object", + "x-columns": [ + "name", + "credential_id" + ], "x-property-order": [ "name", "credential_id" @@ -5491,6 +5741,10 @@ "name" ], "type": "object", + "x-columns": [ + "name", + "driver" + ], "x-property-order": [ "name", "driver", @@ -5562,6 +5816,9 @@ "container" ], "type": "object", + "x-columns": [ + "container" + ], "x-property-order": [ "container" ] @@ -5607,6 +5864,9 @@ "container" ], "type": "object", + "x-columns": [ + "container" + ], "x-property-order": [ "container" ] @@ -5689,6 +5949,9 @@ } }, "type": "object", + "x-columns": [ + "url" + ], "x-property-order": [ "url" ] @@ -5761,6 +6024,10 @@ "deployment" ], "type": "object", + "x-columns": [ + "deployment", + "prefix" + ], "x-property-order": [ "deployment", "prefix" @@ -5829,6 +6096,9 @@ "bucket" ], "type": "object", + "x-columns": [ + "bucket" + ], "x-property-order": [ "bucket" ] @@ -6047,6 +6317,9 @@ "target" ], "type": "object", + "x-columns": [ + "target" + ], "x-property-order": [ "target" ] @@ -6257,6 +6530,9 @@ "name" ], "type": "object", + "x-columns": [ + "name" + ], "x-property-order": [ "name", "config" @@ -6459,6 +6735,12 @@ "url_patterns" ], "type": "object", + "x-columns": [ + "name", + "auth_type", + "login_url", + "docs_url" + ], "x-property-order": [ "name", "url_patterns", @@ -6564,6 +6846,12 @@ } }, "type": "object", + "x-columns": [ + "name", + "auth_type", + "login_url", + "docs_url" + ], "x-property-order": [ "name", "url_patterns", @@ -6822,6 +7110,11 @@ "ip" ], "type": "object", + "x-columns": [ + "ip", + "reason", + "duration" + ], "x-property-order": [ "ip", "reason", @@ -6880,6 +7173,9 @@ } }, "type": "object", + "x-columns": [ + "days" + ], "x-property-order": [ "days" ] @@ -7157,6 +7453,9 @@ } }, "type": "object", + "x-columns": [ + "enabled" + ], "x-property-order": [ "enabled" ] @@ -7246,6 +7545,11 @@ "type" ], "type": "object", + "x-columns": [ + "value", + "type", + "reason" + ], "x-property-order": [ "value", "type", @@ -7364,7 +7668,7 @@ } }, "type": "object", - "x-property-order": [ + "x-columns": [ "enabled", "image", "email", @@ -7372,11 +7676,20 @@ "certs_path", "webroot_path", "dns_provider" - ] - }, - "domain": { - "properties": { - "auto_ssl": { + ], + "x-property-order": [ + "enabled", + "image", + "email", + "staging", + "certs_path", + "webroot_path", + "dns_provider" + ] + }, + "domain": { + "properties": { + "auto_ssl": { "type": "boolean" }, "auto_subdomain": { @@ -7390,6 +7703,12 @@ } }, "type": "object", + "x-columns": [ + "default_domain", + "auto_subdomain", + "auto_ssl", + "subdomain_style" + ], "x-property-order": [ "default_domain", "auto_subdomain", @@ -7424,6 +7743,15 @@ } }, "type": "object", + "x-columns": [ + "enabled", + "type", + "container", + "host", + "port", + "root_user", + "root_password" + ], "x-property-order": [ "enabled", "type", @@ -7459,6 +7787,13 @@ } }, "type": "object", + "x-columns": [ + "enabled", + "container", + "host", + "port", + "password" + ], "x-property-order": [ "enabled", "container", @@ -7469,6 +7804,10 @@ } }, "type": "object", + "x-columns": [ + "default_proxy_network", + "default_database_network" + ], "x-property-order": [ "default_proxy_network", "default_database_network", @@ -7501,6 +7840,15 @@ } }, "type": "object", + "x-columns": [ + "enabled", + "image", + "container_name", + "config_path", + "reload_command", + "external", + "reject_unknown_domains" + ], "x-property-order": [ "enabled", "image", @@ -7539,6 +7887,16 @@ } }, "type": "object", + "x-columns": [ + "enabled", + "realtime_capture", + "scan_interval", + "retention_days", + "rate_threshold", + "auto_block_enabled", + "auto_block_threshold", + "auto_block_duration" + ], "x-property-order": [ "enabled", "realtime_capture", @@ -7636,6 +7994,21 @@ } }, "type": "object", + "x-columns": [ + "enabled", + "realtime_capture", + "scan_interval", + "retention_days", + "rate_threshold", + "auto_block_enabled", + "auto_block_threshold", + "auto_block_duration", + "detection_window", + "not_found_threshold", + "auth_failure_threshold", + "unique_paths_threshold", + "repeated_hits_threshold" + ], "x-property-order": [ "enabled", "realtime_capture", @@ -7800,6 +8173,11 @@ "token" ], "type": "object", + "x-columns": [ + "name", + "username", + "token" + ], "x-property-order": [ "name", "username", @@ -7906,6 +8284,10 @@ "kind" ], "type": "object", + "x-columns": [ + "name", + "kind" + ], "x-property-order": [ "name", "kind", @@ -7978,6 +8360,9 @@ } }, "type": "object", + "x-columns": [ + "name" + ], "x-property-order": [ "name", "data" @@ -8221,6 +8606,9 @@ "mode" ], "type": "object", + "x-columns": [ + "mode" + ], "x-property-order": [ "mode" ] @@ -8530,6 +8918,9 @@ } }, "type": "object", + "x-columns": [ + "days" + ], "x-property-order": [ "days" ] @@ -8718,6 +9109,9 @@ }, "x-property-order": [ "email" + ], + "x-columns": [ + "email" ] } } @@ -8754,6 +9148,10 @@ "current_password", "new_password" ], + "x-columns": [ + "current_password", + "new_password" + ], "required": [ "current_password", "new_password" @@ -8850,6 +9248,12 @@ } }, "type": "object", + "x-columns": [ + "username", + "email", + "role", + "is_active" + ], "x-property-order": [ "username", "email", @@ -8924,6 +9328,10 @@ "access_level" ], "type": "object", + "x-columns": [ + "deployment_name", + "access_level" + ], "x-property-order": [ "deployment_name", "access_level" @@ -9008,6 +9416,9 @@ "access_level" ], "type": "object", + "x-columns": [ + "access_level" + ], "x-property-order": [ "access_level" ] @@ -9064,6 +9475,10 @@ "name" ], "type": "object", + "x-columns": [ + "name", + "driver" + ], "x-property-order": [ "name", "driver", @@ -9126,34 +9541,6 @@ }, "components": { "schemas": { - "api.BackupListResponse": { - "type": "object", - "properties": { - "backups": { - "type": "array", - "items": { - "$ref": "#/components/schemas/backup.Backup" - } - } - }, - "x-property-order": [ - "backups" - ] - }, - "api.CertificateListResponse": { - "type": "object", - "properties": { - "certificates": { - "type": "array", - "items": { - "$ref": "#/components/schemas/models.Certificate" - } - } - }, - "x-property-order": [ - "certificates" - ] - }, "api.ComposeGenerateRequest": { "type": "object", "properties": { @@ -9187,6 +9574,13 @@ "host_port", "mounts" ], + "x-columns": [ + "name", + "image", + "container_port", + "map_ports", + "host_port" + ], "required": [ "name" ] @@ -9219,6 +9613,9 @@ "mounts", "database" ], + "x-columns": [ + "content" + ], "required": [ "content" ] @@ -9264,6 +9661,17 @@ "existing_container", "external_host", "external_port" + ], + "x-columns": [ + "type", + "mode", + "name", + "user", + "password", + "root_password", + "existing_container", + "external_host", + "external_port" ] }, "api.DatabaseConfigRequest": { @@ -9315,24 +9723,19 @@ "username", "password", "env_prefix" - ] - }, - "api.DeploymentListResponse": { - "type": "object", - "properties": { - "deployments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/models.Deployment" - } - }, - "path": { - "type": "string" - } - }, - "x-property-order": [ - "deployments", - "path" + ], + "x-columns": [ + "alias", + "type", + "mode", + "service", + "existing_container", + "external_host", + "external_port", + "database_name", + "username", + "password", + "env_prefix" ] }, "api.EnvVar": { @@ -9348,6 +9751,10 @@ "x-property-order": [ "key", "value" + ], + "x-columns": [ + "key", + "value" ] }, "api.JobSnapshot": { @@ -9400,8 +9807,85 @@ "error", "started_at", "finished_at" + ], + "x-columns": [ + "id", + "deployment", + "service", + "action", + "status", + "output", + "error", + "started_at", + "finished_at" ] }, + "api.ListOfBackup": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/backup.Backup" + } + }, + "total": { + "type": "integer" + } + }, + "x-property-order": [ + "items", + "total" + ], + "x-columns": [ + "total" + ], + "x-render": "list" + }, + "api.ListOfCertificate": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.Certificate" + } + }, + "total": { + "type": "integer" + } + }, + "x-property-order": [ + "items", + "total" + ], + "x-columns": [ + "total" + ], + "x-render": "list" + }, + "api.ListOfDeployment": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/models.Deployment" + } + }, + "total": { + "type": "integer" + } + }, + "x-property-order": [ + "items", + "total" + ], + "x-columns": [ + "total" + ], + "x-render": "list" + }, "api.MountSelection": { "type": "object", "properties": { @@ -9419,6 +9903,11 @@ "id", "enabled", "type" + ], + "x-columns": [ + "id", + "enabled", + "type" ] }, "api.PortConfig": { @@ -9442,6 +9931,12 @@ "container", "host_port", "host" + ], + "x-columns": [ + "container_port", + "container", + "host_port", + "host" ] }, "api.agentUpdateRequest": { @@ -9461,6 +9956,11 @@ "channel", "force", "restart" + ], + "x-columns": [ + "channel", + "force", + "restart" ] }, "api.assistRequest": { @@ -9483,6 +9983,10 @@ "intent", "sources", "question" + ], + "x-columns": [ + "intent", + "question" ] }, "api.assistSource": { @@ -9506,6 +10010,12 @@ "label", "content", "tail" + ], + "x-columns": [ + "type", + "label", + "content", + "tail" ] }, "api.deploymentSource": { @@ -9541,6 +10051,15 @@ "credential_id", "username", "token" + ], + "x-columns": [ + "type", + "ref", + "branch", + "subpath", + "credential_id", + "username", + "token" ] }, "api.exchangeRequest": { @@ -9564,7 +10083,13 @@ "url", "api_key", "name" - ] + ], + "x-columns": [ + "invite_token", + "url", + "api_key", + "name" + ] }, "api.exchangeResponse": { "type": "object", @@ -9579,6 +10104,10 @@ "x-property-order": [ "api_key", "name" + ], + "x-columns": [ + "api_key", + "name" ] }, "api.provisionManagedObjectStoreRequest": { @@ -9627,6 +10156,18 @@ "region", "use_path_style" ], + "x-columns": [ + "deployment", + "store_name", + "bucket", + "access_key_env", + "secret_key_env", + "access_key", + "secret_key", + "api_port", + "region", + "use_path_style" + ], "required": [ "deployment" ] @@ -9652,6 +10193,12 @@ "actor_type", "event_count", "last_seen" + ], + "x-columns": [ + "actor_id", + "actor_type", + "event_count", + "last_seen" ] }, "audit.AuditEvent": { @@ -9744,6 +10291,29 @@ "success", "error_message", "metadata" + ], + "x-columns": [ + "id", + "event_id", + "timestamp", + "actor_type", + "actor_id", + "actor_name", + "api_key_prefix", + "action", + "method", + "path", + "resource_type", + "resource_id", + "client_ip", + "user_agent", + "request_id", + "request_body", + "response_status", + "response_time_ms", + "success", + "error_message", + "metadata" ] }, "audit.AuditStats": { @@ -9802,6 +10372,12 @@ "failure_count", "top_actors", "events_trend" + ], + "x-columns": [ + "total_events", + "last_24_hours", + "last_7_days", + "failure_count" ] }, "audit.TrendPoint": { @@ -9817,6 +10393,10 @@ "x-property-order": [ "date", "count" + ], + "x-columns": [ + "date", + "count" ] }, "backup.Backup": { @@ -9883,7 +10463,10 @@ "deployment_name", "status", "size", - "created_at" + "error", + "created_at", + "completed_at", + "expires_at" ] }, "backup.CreateBackupRequest": { @@ -9900,6 +10483,10 @@ "deployment_name", "description" ], + "x-columns": [ + "deployment_name", + "description" + ], "required": [ "deployment_name" ] @@ -9930,6 +10517,13 @@ "restore_db", "stop_first" ], + "x-columns": [ + "backup_id", + "deployment_name", + "restore_data", + "restore_db", + "stop_first" + ], "required": [ "backup_id" ] @@ -9970,6 +10564,12 @@ "online", "data", "error" + ], + "x-columns": [ + "name", + "online", + "data", + "error" ] }, "config.BackupDestination": { @@ -10021,6 +10621,19 @@ "credential_id", "use_path_style", "enabled" + ], + "x-columns": [ + "name", + "type", + "kind", + "deployment", + "endpoint", + "region", + "bucket", + "prefix", + "credential_id", + "use_path_style", + "enabled" ] }, "dashboards.Dashboard": { @@ -10043,6 +10656,10 @@ "id", "name", "panels" + ], + "x-columns": [ + "id", + "name" ] }, "dashboards.Panel": { @@ -10078,6 +10695,15 @@ "deployment", "type", "width" + ], + "x-columns": [ + "id", + "title", + "source", + "series", + "deployment", + "type", + "width" ] }, "database.ColumnSchema": { @@ -10107,6 +10733,13 @@ "default", "key", "extra" + ], + "x-columns": [ + "name", + "type", + "nullable", + "key", + "extra" ] }, "database.ConnectionConfig": { @@ -10142,6 +10775,15 @@ "password", "database", "container" + ], + "x-columns": [ + "type", + "host", + "port", + "username", + "password", + "database", + "container" ] }, "database.IndexSchema": { @@ -10168,6 +10810,11 @@ "columns", "unique", "primary" + ], + "x-columns": [ + "name", + "unique", + "primary" ] }, "database.QueryResult": { @@ -10194,6 +10841,9 @@ "columns", "rows", "count" + ], + "x-columns": [ + "count" ] }, "database.TableSchema": { @@ -10230,6 +10880,10 @@ "x-property-order": [ "running", "version" + ], + "x-columns": [ + "running", + "version" ] }, "docker.ResourceUpdate": { @@ -10253,6 +10907,12 @@ "memory_swap", "cpus", "cpu_shares" + ], + "x-columns": [ + "memory_limit", + "memory_swap", + "cpus", + "cpu_shares" ] }, "files.FileInfo": { @@ -10289,6 +10949,15 @@ "mod_time", "permissions", "child_count" + ], + "x-columns": [ + "name", + "path", + "size", + "is_dir", + "mod_time", + "permissions", + "child_count" ] }, "infra.RefreshSecurityScriptsResult": { @@ -10342,6 +11011,16 @@ "nginx_reloaded", "vhosts_updated", "errors" + ], + "x-columns": [ + "success", + "agent_ip", + "agent_port", + "nginx_conf_written", + "lua_written", + "volumes_modified", + "container_recreated", + "nginx_reloaded" ] }, "infra.SecurityHealthCheck": { @@ -10379,6 +11058,9 @@ "issues", "recommendations", "details" + ], + "x-columns": [ + "status" ] }, "models.BackupHookSpec": { @@ -10398,6 +11080,11 @@ "service", "command", "timeout" + ], + "x-columns": [ + "service", + "command", + "timeout" ] }, "models.BackupSpec": { @@ -10489,9 +11176,12 @@ "x-columns": [ "domain", "issuer", + "not_before", + "not_after", "days_left", "status", - "auto_renew" + "auto_renew", + "deployment_id" ] }, "models.ContainerBackupPath": { @@ -10515,6 +11205,12 @@ "container_path", "description", "required" + ], + "x-columns": [ + "service", + "container_path", + "description", + "required" ] }, "models.DatabaseBackupSpec": { @@ -10578,6 +11274,22 @@ "user", "password", "database" + ], + "x-columns": [ + "service", + "type", + "container", + "all_databases", + "host_env", + "port_env", + "user_env", + "password_env", + "database_env", + "host", + "port", + "user", + "password", + "database" ] }, "models.DatabaseConfig": { @@ -10633,6 +11345,20 @@ "username", "env_prefix", "is_shared" + ], + "x-columns": [ + "id", + "alias", + "type", + "mode", + "service", + "host", + "port", + "container", + "database_name", + "username", + "env_prefix", + "is_shared" ] }, "models.Deployment": { @@ -10677,7 +11403,8 @@ "x-columns": [ "name", "status", - "created_at" + "created_at", + "updated_at" ] }, "models.DeploymentRateLimit": { @@ -10701,6 +11428,12 @@ "rate", "burst", "enabled" + ], + "x-columns": [ + "path", + "rate", + "burst", + "enabled" ] }, "models.DeploymentSecurityConfig": { @@ -10733,6 +11466,9 @@ "blocked_ips", "protected_paths", "rate_limits" + ], + "x-columns": [ + "enabled" ] }, "models.DomainConfig": { @@ -10790,6 +11526,16 @@ "route_only_aliases", "proxy_timeout", "static_cache" + ], + "x-columns": [ + "id", + "service", + "container_port", + "domain", + "path_prefix", + "strip_prefix", + "proxy_timeout", + "static_cache" ] }, "models.HealthCheckConfig": { @@ -10805,6 +11551,10 @@ "x-property-order": [ "path", "interval" + ], + "x-columns": [ + "path", + "interval" ] }, "models.LogSource": { @@ -10840,6 +11590,15 @@ "path", "format", "builtin" + ], + "x-columns": [ + "id", + "name", + "type", + "service", + "path", + "format", + "builtin" ] }, "models.NetworkingConfig": { @@ -10871,6 +11630,14 @@ "container_port", "protocol", "proxy_type" + ], + "x-columns": [ + "expose", + "domain", + "service", + "container_port", + "protocol", + "proxy_type" ] }, "models.ProtectedCommandRule": { @@ -10902,6 +11669,14 @@ "pattern", "case_sensitive", "description" + ], + "x-columns": [ + "id", + "name", + "match", + "pattern", + "case_sensitive", + "description" ] }, "models.ProtectedModeConfig": { @@ -10931,6 +11706,10 @@ "blocked_actions", "blocked_command_rules", "disable_terminal" + ], + "x-columns": [ + "enabled", + "disable_terminal" ] }, "models.ProtectedPath": { @@ -10946,6 +11725,10 @@ "x-property-order": [ "pattern", "enabled" + ], + "x-columns": [ + "pattern", + "enabled" ] }, "models.QuickAction": { @@ -10977,6 +11760,14 @@ "description", "icon", "service" + ], + "x-columns": [ + "id", + "name", + "command", + "description", + "icon", + "service" ] }, "models.SSLConfig": { @@ -10992,6 +11783,10 @@ "x-property-order": [ "enabled", "auto_cert" + ], + "x-columns": [ + "enabled", + "auto_cert" ] }, "models.Service": { @@ -11042,6 +11837,15 @@ "networks", "is_primary", "created_at" + ], + "x-columns": [ + "name", + "container_id", + "image", + "status", + "health", + "is_primary", + "created_at" ] }, "models.ServiceMetadata": { @@ -11132,6 +11936,14 @@ "service_credentials", "domains", "databases" + ], + "x-columns": [ + "name", + "type", + "kind", + "primary_service", + "require_plan", + "credential_id" ] }, "notify.Config": { @@ -11169,6 +11981,12 @@ "name", "url", "enabled" + ], + "x-columns": [ + "id", + "name", + "url", + "enabled" ] }, "scheduler.AgentTaskConfig": { @@ -11180,6 +11998,9 @@ }, "x-property-order": [ "agent_name" + ], + "x-columns": [ + "agent_name" ] }, "scheduler.BackupTaskConfig": { @@ -11195,6 +12016,10 @@ "x-property-order": [ "retention_count", "storage_path" + ], + "x-columns": [ + "retention_count", + "storage_path" ] }, "scheduler.CommandTaskConfig": { @@ -11214,6 +12039,11 @@ "service", "command", "timeout" + ], + "x-columns": [ + "service", + "command", + "timeout" ] }, "scheduler.CreateTaskRequest": { @@ -11246,6 +12076,13 @@ "enabled", "config" ], + "x-columns": [ + "name", + "type", + "deployment_name", + "cron_expr", + "enabled" + ], "required": [ "name", "type", @@ -11293,6 +12130,11 @@ "cron_expr", "enabled", "config" + ], + "x-columns": [ + "name", + "cron_expr", + "enabled" ] }, "security.ProtectedRoute": { @@ -11325,6 +12167,14 @@ "block_duration", "enabled", "created_at" + ], + "x-columns": [ + "id", + "path_pattern", + "rate_limit", + "block_duration", + "enabled", + "created_at" ] }, "updater.Availability": { @@ -11355,6 +12205,12 @@ "latest_version", "update_available", "releases" + ], + "x-columns": [ + "current_version", + "channel", + "latest_version", + "update_available" ] }, "updater.ReleaseInfo": { @@ -11378,6 +12234,12 @@ "prerelease", "published_at", "changelog" + ], + "x-columns": [ + "version", + "prerelease", + "published_at", + "changelog" ] } } diff --git a/internal/api/render.go b/internal/api/render.go new file mode 100644 index 0000000..4d8a222 --- /dev/null +++ b/internal/api/render.go @@ -0,0 +1,46 @@ +package api + +import "encoding/json" + +// The shapes an endpoint answers in. They say how an answer is presented, not what it is about: +// a collection is a collection whether it holds deployments or certificates, so a client can lay +// any of them out without being taught the resource first. The generated description carries the +// shape, which is what a client switches on. + +// List is every collection. Items is the whole answer; a client showing a table takes its rows +// from there and its columns from the item's own type. +type List[T any] struct { + Items []T `json:"items"` + Total int `json:"total"` + + // legacy is the name this collection used to answer under, written alongside items until + // the clients reading it have moved. It carries no type of its own and is not described. + legacy string +} + +// NewList answers with a collection. The legacy name may be empty for anything new, which is +// where every collection ends up. +func NewList[T any](items []T, legacy string) List[T] { + if items == nil { + items = []T{} + } + return List[T]{Items: items, Total: len(items), legacy: legacy} +} + +func (l List[T]) MarshalJSON() ([]byte, error) { + out := map[string]any{"items": l.Items, "total": l.Total} + if l.legacy != "" { + out[l.legacy] = l.Items + } + return json.Marshal(out) +} + +// Item is one thing, presented as its fields rather than as a row. +type Item[T any] struct { + Item T `json:"item"` +} + +// Message is an answer that only reports what happened. +type Message struct { + Message string `json:"message"` +} diff --git a/internal/api/render_test.go b/internal/api/render_test.go new file mode 100644 index 0000000..3a00321 --- /dev/null +++ b/internal/api/render_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestListAnswersBothNames(t *testing.T) { + raw, err := json.Marshal(NewList([]string{"a", "b"}, "deployments")) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded["total"] != float64(2) { + t.Errorf("total = %v", decoded["total"]) + } + if items, ok := decoded["items"].([]any); !ok || len(items) != 2 { + t.Errorf("items = %v", decoded["items"]) + } + // Older clients read the resource name, and keep working until they are moved off it. + if legacy, ok := decoded["deployments"].([]any); !ok || len(legacy) != 2 { + t.Errorf("deployments = %v", decoded["deployments"]) + } +} + +func TestListWithoutALegacyNameAnswersOnlyTheShape(t *testing.T) { + raw, err := json.Marshal(NewList([]int{1}, "")) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded) != 2 { + t.Errorf("expected items and total only, got %v", decoded) + } +} diff --git a/internal/api/responses.go b/internal/api/responses.go deleted file mode 100644 index 3d35d92..0000000 --- a/internal/api/responses.go +++ /dev/null @@ -1,23 +0,0 @@ -package api - -import ( - "github.com/flatrun/agent/internal/backup" - "github.com/flatrun/agent/pkg/models" -) - -// Responses declared as types rather than inline maps, so the generated spec describes what an -// endpoint answers with and a client can lay it out without being told how. The `cli` tag names -// the fields worth a column; everything else is still in the payload for whoever wants it. - -type DeploymentListResponse struct { - Deployments []models.Deployment `json:"deployments"` - Path string `json:"path"` -} - -type BackupListResponse struct { - Backups []backup.Backup `json:"backups"` -} - -type CertificateListResponse struct { - Certificates []models.Certificate `json:"certificates"` -} diff --git a/internal/api/server.go b/internal/api/server.go index 4cbb3c4..3ab1575 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -976,10 +976,7 @@ func (s *Server) listDeployments(c *gin.Context) { deployments = filtered } - c.JSON(http.StatusOK, DeploymentListResponse{ - Deployments: deployments, - Path: s.manager.BasePath(), - }) + c.JSON(http.StatusOK, NewList(deployments, "deployments")) } func (s *Server) getDeployment(c *gin.Context) { @@ -5034,7 +5031,7 @@ func (s *Server) listCertificates(c *gin.Context) { s.annotateCertificatesWithDeployment(certificates) - c.JSON(http.StatusOK, CertificateListResponse{Certificates: certificates}) + c.JSON(http.StatusOK, NewList(certificates, "certificates")) } func (s *Server) annotateCertificatesWithDeployment(certs []models.Certificate) { diff --git a/internal/backup/types.go b/internal/backup/types.go index 9d1b441..17908da 100644 --- a/internal/backup/types.go +++ b/internal/backup/types.go @@ -21,14 +21,14 @@ type DatabaseSpec = models.DatabaseBackupSpec type HookSpec = models.BackupHookSpec type Backup struct { - ID string `json:"id" cli:"column"` - DeploymentName string `json:"deployment_name" cli:"column"` - Status BackupStatus `json:"status" cli:"column"` - Size int64 `json:"size" cli:"column"` - Path string `json:"path"` + ID string `json:"id"` + DeploymentName string `json:"deployment_name"` + Status BackupStatus `json:"status"` + Size int64 `json:"size"` + Path string `json:"path" cli:"-"` Components []string `json:"components"` Error string `json:"error,omitempty"` - CreatedAt time.Time `json:"created_at" cli:"column"` + CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` // Locations lists where this backup exists: "local" and/or remote diff --git a/pkg/models/certificate.go b/pkg/models/certificate.go index 744718e..a030261 100644 --- a/pkg/models/certificate.go +++ b/pkg/models/certificate.go @@ -3,13 +3,13 @@ package models import "time" type Certificate struct { - Domain string `json:"domain" cli:"column"` - Issuer string `json:"issuer" cli:"column"` + Domain string `json:"domain"` + Issuer string `json:"issuer"` NotBefore time.Time `json:"not_before"` NotAfter time.Time `json:"not_after"` - DaysLeft int `json:"days_left" cli:"column"` - Status string `json:"status" cli:"column"` - Path string `json:"path"` - AutoRenew bool `json:"auto_renew" cli:"column"` + DaysLeft int `json:"days_left"` + Status string `json:"status"` + Path string `json:"path" cli:"-"` + AutoRenew bool `json:"auto_renew"` DeploymentID string `json:"deployment_id,omitempty"` } diff --git a/pkg/models/deployment.go b/pkg/models/deployment.go index aedb2f1..1adaaa6 100644 --- a/pkg/models/deployment.go +++ b/pkg/models/deployment.go @@ -3,10 +3,10 @@ package models import "time" type Deployment struct { - Name string `json:"name" cli:"column"` - Path string `json:"path"` - Status string `json:"status" cli:"column"` - CreatedAt time.Time `json:"created_at" cli:"column"` + Name string `json:"name"` + Path string `json:"path" cli:"-"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Services []Service `json:"services,omitempty"` Metadata *ServiceMetadata `json:"metadata,omitempty"` diff --git a/tools/genspec/schema.go b/tools/genspec/schema.go index 76e5213..0e1e627 100644 --- a/tools/genspec/schema.go +++ b/tools/genspec/schema.go @@ -84,7 +84,10 @@ type schema struct { PropertyOrder []string `json:"x-property-order,omitempty"` // Columns are the fields worth showing when a row of this is printed as a table, named on // the type so the choice lives with the data rather than in every client. - Columns []string `json:"x-columns,omitempty"` + Columns []string `json:"x-columns,omitempty"` + // Render is how an answer of this shape is presented: a list of rows, one thing, or a + // report of what happened. A client switches on this rather than on the resource. + Render string `json:"x-render,omitempty"` Required []string `json:"required,omitempty"` Description string `json:"description,omitempty"` AdditionalProperties *schema `json:"additionalProperties,omitempty"` @@ -154,13 +157,14 @@ func (s *schemaSet) named(t *types.Named, depth int) *schema { return s.build(t.Underlying(), depth) } - name := schemaName(obj.Pkg().Path(), obj.Name()) + name := instantiatedName(t) if !s.seen[name] { s.seen[name] = true // Registered before its fields are walked, so a type holding itself terminates. s.byName[name] = &schema{Type: "object"} built := s.structSchema(t.Underlying().(*types.Struct), depth+1) if built != nil { + built.Render = renderKind(obj.Name()) s.byName[name] = built } } @@ -200,7 +204,7 @@ func (s *schemaSet) fields(t *types.Struct, depth int, out *schema) { if built == nil { continue } - if tag.column { + if isScalar(built) && !tag.hidden { out.Columns = append(out.Columns, name) } out.Properties[name] = built @@ -228,7 +232,7 @@ type fieldTag struct { name string skip bool required bool - column bool + hidden bool } func parseTag(raw string) fieldTag { @@ -244,8 +248,8 @@ func parseTag(raw string) fieldTag { if strings.Contains(structTag(raw, "binding"), "required") { tag.required = true } - if strings.Contains(structTag(raw, "cli"), "column") { - tag.column = true + if strings.TrimSpace(structTag(raw, "cli")) == "-" { + tag.hidden = true } return tag } @@ -309,6 +313,59 @@ func isByteSlice(t *types.Slice) bool { return ok && basic.Kind() == types.Byte } +// isScalar reports whether a value fits in a table cell. Anything nested is still in the answer, +// it just cannot be a column. +func isScalar(s *schema) bool { + switch s.Type { + case "string", "integer", "number", "boolean": + return true + } + return false +} + +// instantiatedName keeps generic shapes apart: a list of deployments and a list of backups are +// the same type but not the same schema. +func instantiatedName(t *types.Named) string { + obj := t.Obj() + name := schemaName(obj.Pkg().Path(), obj.Name()) + args := t.TypeArgs() + if args == nil || args.Len() == 0 { + return name + } + parts := make([]string, 0, args.Len()) + for i := 0; i < args.Len(); i++ { + parts = append(parts, argName(args.At(i))) + } + return name + "Of" + strings.Join(parts, "And") +} + +func argName(t types.Type) string { + switch typed := t.(type) { + case *types.Pointer: + return argName(typed.Elem()) + case *types.Slice: + return argName(typed.Elem()) + "s" + case *types.Named: + return typed.Obj().Name() + case *types.Basic: + return strings.ToUpper(typed.Name()[:1]) + typed.Name()[1:] + } + return "Value" +} + +// renderKind maps the shapes an answer can take onto what a client does with them. +func renderKind(typeName string) string { + switch typeName { + case "List": + return "list" + case "Item": + return "item" + case "Message": + return "message" + } + return "" +} + func schemaName(pkgPath, name string) string { parts := strings.Split(pkgPath, "/") return parts[len(parts)-1] + "." + name From 02f8c53358a773499d4eb9a22d4f13ad0a7213a6 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 13 Aug 2026 23:51:00 +0100 Subject: [PATCH 4/6] refactor: Cut comments back to the ones carrying a decision --- internal/api/openapi.go | 6 ++---- internal/api/render.go | 16 ++++------------ tools/genspec/main.go | 26 +++++++++----------------- tools/genspec/schema.go | 23 +++++++---------------- 4 files changed, 22 insertions(+), 49 deletions(-) diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 0c5922c..e086a59 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -7,10 +7,8 @@ import ( "github.com/gin-gonic/gin" ) -// The description of this agent's own API, generated from its routes and types by -// tools/genspec and checked against them in CI. Serving it means a client can ask the instance -// it is talking to what that instance accepts, rather than assuming whatever was true when the -// client was built. +// Generated by tools/genspec and checked against the routes in CI. Serving it lets a client ask +// the instance it is talking to what that instance accepts. // //go:embed openapi.json var openAPISpec []byte diff --git a/internal/api/render.go b/internal/api/render.go index 4d8a222..c025ca6 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -2,24 +2,18 @@ package api import "encoding/json" -// The shapes an endpoint answers in. They say how an answer is presented, not what it is about: -// a collection is a collection whether it holds deployments or certificates, so a client can lay -// any of them out without being taught the resource first. The generated description carries the -// shape, which is what a client switches on. +// The shapes an endpoint answers in. A client switches on the shape, so it lays out a collection +// of anything without being taught the resource. -// List is every collection. Items is the whole answer; a client showing a table takes its rows -// from there and its columns from the item's own type. type List[T any] struct { Items []T `json:"items"` Total int `json:"total"` - // legacy is the name this collection used to answer under, written alongside items until - // the clients reading it have moved. It carries no type of its own and is not described. + // The name this collection used to answer under, written alongside items until the clients + // reading it have moved. legacy string } -// NewList answers with a collection. The legacy name may be empty for anything new, which is -// where every collection ends up. func NewList[T any](items []T, legacy string) List[T] { if items == nil { items = []T{} @@ -35,12 +29,10 @@ func (l List[T]) MarshalJSON() ([]byte, error) { return json.Marshal(out) } -// Item is one thing, presented as its fields rather than as a row. type Item[T any] struct { Item T `json:"item"` } -// Message is an answer that only reports what happened. type Message struct { Message string `json:"message"` } diff --git a/tools/genspec/main.go b/tools/genspec/main.go index a97d0b1..ff33664 100644 --- a/tools/genspec/main.go +++ b/tools/genspec/main.go @@ -1,9 +1,6 @@ -// Command genspec writes the agent's OpenAPI description by reading the agent. -// -// Nothing here is annotated: the routes come from the router, the request body of an endpoint -// comes from whatever its handler binds, and the response comes from whatever typed value it -// writes. A spec written that way cannot say something the code does not, which is the whole -// reason for generating it rather than maintaining one by hand. +// Command genspec writes the agent's OpenAPI description by reading the agent: routes from the +// router, bodies from whatever each handler binds, responses from whatever typed value it writes. +// Nothing is annotated, so the description cannot claim something the code does not do. // // go run ./tools/genspec -o internal/api/openapi.json package main @@ -177,8 +174,7 @@ func readVersion(root string) string { return strings.TrimSpace(string(raw)) } -// readRoutes reads the registrations out of the router's source. The router is built at runtime -// against a live host, so its table cannot be read by calling it. +// readRoutes reads the registrations out of the source, since building the router needs a live host. func readRoutes(api *packages.Package) ([]route, error) { var file string for _, f := range api.GoFiles { @@ -244,7 +240,6 @@ func skip(path string) bool { return strings.HasSuffix(path, "/stream") || strings.HasSuffix(path, "/interactive") } -// handler is a method that serves a route, with the package it came from so its types resolve. type handler struct { decl *ast.FuncDecl pkg *packages.Package @@ -259,8 +254,7 @@ func indexHandlers(pkgs []*packages.Package) map[string]*handler { if !ok || fn.Recv == nil { continue } - // The API package wins a name it shares with another, since that is where a - // route's handler lives unless it is delegated. + // The API package wins a shared name: that is where a route's handler lives. if existing, taken := handlers[fn.Name.Name]; taken && strings.HasSuffix(existing.pkg.PkgPath, "/internal/api") { continue @@ -272,7 +266,6 @@ func indexHandlers(pkgs []*packages.Package) map[string]*handler { return handlers } -// boundRequestType is the type a handler binds the request body into. func boundRequestType(api *packages.Package, fn *ast.FuncDecl) types.Type { var found types.Type ast.Inspect(fn, func(n ast.Node) bool { @@ -302,8 +295,8 @@ func boundRequestType(api *packages.Package, fn *ast.FuncDecl) types.Type { return found } -// typedResponse is the type a handler writes on success, when it writes one rather than an -// inline map. A handler answering with gin.H describes nothing, and is left undescribed. +// typedResponse is what a handler writes on success. A handler answering with gin.H describes +// nothing and is left undescribed. func typedResponse(api *packages.Package, fn *ast.FuncDecl) types.Type { var found types.Type ast.Inspect(fn, func(n ast.Node) bool { @@ -344,8 +337,7 @@ func isGinH(t types.Type) bool { return named.Obj().Name() == "H" && strings.HasSuffix(named.Obj().Pkg().Path(), "gin") } -// queryParams are the query keys a handler reads, which is what makes them checkable by a caller -// rather than something to be discovered by trial. +// queryParams are the query keys a handler reads. func queryParams(api *packages.Package, fn *ast.FuncDecl) []string { seen := map[string]bool{} var names []string @@ -421,7 +413,7 @@ func operationID(r route) string { } func permissionValue(constant string) string { - // PermDeploymentsWrite reads as deployments:write, which is the string the agent checks. + // PermDeploymentsWrite reads as deployments:write. trimmed := strings.TrimPrefix(constant, "Perm") for i := 1; i < len(trimmed); i++ { if trimmed[i] >= 'A' && trimmed[i] <= 'Z' { diff --git a/tools/genspec/schema.go b/tools/genspec/schema.go index 0e1e627..3b46b5e 100644 --- a/tools/genspec/schema.go +++ b/tools/genspec/schema.go @@ -32,7 +32,7 @@ type operation struct { Extensions map[string]any `json:"-"` } -// MarshalJSON writes the extensions inline, which is where OpenAPI expects x- keys. +// MarshalJSON writes the extensions inline, where OpenAPI expects x- keys. func (o operation) MarshalJSON() ([]byte, error) { type plain operation encoded, err := json.Marshal(plain(o)) @@ -79,22 +79,16 @@ type schema struct { Format string `json:"format,omitempty"` Items *schema `json:"items,omitempty"` Properties map[string]*schema `json:"properties,omitempty"` - // PropertyOrder is the order the fields are declared in, which is the order a caller reads - // them in and the order the CLI lays out columns. JSON objects have none of their own. - PropertyOrder []string `json:"x-property-order,omitempty"` - // Columns are the fields worth showing when a row of this is printed as a table, named on - // the type so the choice lives with the data rather than in every client. - Columns []string `json:"x-columns,omitempty"` - // Render is how an answer of this shape is presented: a list of rows, one thing, or a - // report of what happened. A client switches on this rather than on the resource. + // JSON objects have no order of their own, and declaration order is the order to read in. + PropertyOrder []string `json:"x-property-order,omitempty"` + Columns []string `json:"x-columns,omitempty"` Render string `json:"x-render,omitempty"` Required []string `json:"required,omitempty"` Description string `json:"description,omitempty"` AdditionalProperties *schema `json:"additionalProperties,omitempty"` } -// schemaSet collects the named types the spec refers to, so a type used by twenty endpoints is -// described once. +// schemaSet describes a type once however many endpoints use it. type schemaSet struct { byName map[string]*schema seen map[string]bool @@ -313,8 +307,7 @@ func isByteSlice(t *types.Slice) bool { return ok && basic.Kind() == types.Byte } -// isScalar reports whether a value fits in a table cell. Anything nested is still in the answer, -// it just cannot be a column. +// isScalar reports whether a value fits in a table cell. func isScalar(s *schema) bool { switch s.Type { case "string", "integer", "number", "boolean": @@ -323,8 +316,7 @@ func isScalar(s *schema) bool { return false } -// instantiatedName keeps generic shapes apart: a list of deployments and a list of backups are -// the same type but not the same schema. +// instantiatedName keeps a list of deployments and a list of backups as separate schemas. func instantiatedName(t *types.Named) string { obj := t.Obj() name := schemaName(obj.Pkg().Path(), obj.Name()) @@ -353,7 +345,6 @@ func argName(t types.Type) string { return "Value" } -// renderKind maps the shapes an answer can take onto what a client does with them. func renderKind(typeName string) string { switch typeName { case "List": From baa7b2f7f9e956408ba66894b27454227c3eed5a Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 14 Aug 2026 00:41:26 +0100 Subject: [PATCH 5/6] fix(api): Describe permissions and wildcard paths as they really are Two endpoints' permissions were being invented from the name of a constant rather than read from it, so the ones with an acronym came out mangled and no client could match them against a key. They are now read from the value the agent itself checks. Nine paths kept the router's wildcard notation, which OpenAPI has no notion of, so they named no path a client could build and left the segment carrying the filename undescribed. --- internal/api/openapi.json | 178 ++++++++++++++++++++++++++++++----- internal/api/openapi_test.go | 37 ++++++++ tools/genspec/main.go | 51 ++++++---- tools/genspec/schema.go | 49 ++-------- 4 files changed, 230 insertions(+), 85 deletions(-) diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 8e507e2..bccd182 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -482,7 +482,7 @@ "tags": [ "apikeys" ], - "x-permission": "a:pikeysdelete" + "x-permission": "apikeys:delete" }, "get": { "operationId": "get-apikeys-by-id", @@ -575,7 +575,7 @@ "tags": [ "apikeys" ], - "x-permission": "a:pikeyswrite" + "x-permission": "apikeys:write" } }, "/api/apikeys/{id}/revoke": { @@ -599,7 +599,7 @@ "tags": [ "apikeys" ], - "x-permission": "a:pikeysdelete" + "x-permission": "apikeys:delete" } }, "/api/audit/cleanup": { @@ -1600,9 +1600,19 @@ "x-permission": "config:read" } }, - "/api/config/*key": { + "/api/config/{key}": { "get": { - "operationId": "get-config-*key", + "operationId": "get-config-by-key", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success" @@ -1614,7 +1624,17 @@ "x-permission": "config:read" }, "put": { - "operationId": "put-config-*key", + "operationId": "put-config-by-key", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -4203,9 +4223,9 @@ "x-permission": "deployments:read" } }, - "/api/deployments/{name}/files/*path": { + "/api/deployments/{name}/files/{path}": { "delete": { - "operationId": "delete-deployments-by-name-files-*path", + "operationId": "delete-deployments-by-name-files-by-path", "parameters": [ { "in": "path", @@ -4214,6 +4234,14 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -4227,7 +4255,7 @@ "x-permission": "deployments:delete" }, "get": { - "operationId": "get-deployments-by-name-files-*path", + "operationId": "get-deployments-by-name-files-by-path", "parameters": [ { "in": "path", @@ -4237,6 +4265,14 @@ "type": "string" } }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + }, { "in": "query", "name": "info", @@ -4270,7 +4306,7 @@ "x-permission": "deployments:read" }, "post": { - "operationId": "post-deployments-by-name-files-*path", + "operationId": "post-deployments-by-name-files-by-path", "parameters": [ { "in": "path", @@ -4279,6 +4315,14 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -4610,9 +4654,9 @@ "x-permission": "deployments:write" } }, - "/api/deployments/{name}/mkdir/*path": { + "/api/deployments/{name}/mkdir/{path}": { "post": { - "operationId": "post-deployments-by-name-mkdir-*path", + "operationId": "post-deployments-by-name-mkdir-by-path", "parameters": [ { "in": "path", @@ -4621,6 +4665,14 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -4634,9 +4686,9 @@ "x-permission": "deployments:write" } }, - "/api/deployments/{name}/permissions/*path": { + "/api/deployments/{name}/permissions/{path}": { "put": { - "operationId": "put-deployments-by-name-permissions-*path", + "operationId": "put-deployments-by-name-permissions-by-path", "parameters": [ { "in": "path", @@ -4645,6 +4697,14 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } } ], "requestBody": { @@ -5304,9 +5364,9 @@ "x-permission": "deployments:write" } }, - "/api/deployments/{name}/touch/*path": { + "/api/deployments/{name}/touch/{path}": { "post": { - "operationId": "post-deployments-by-name-touch-*path", + "operationId": "post-deployments-by-name-touch-by-path", "parameters": [ { "in": "path", @@ -5315,6 +5375,14 @@ "schema": { "type": "string" } + }, + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } } ], "responses": { @@ -8450,9 +8518,19 @@ "x-permission": "system:files" } }, - "/api/system/files/*path": { + "/api/system/files/{path}": { "delete": { - "operationId": "delete-system-files-*path", + "operationId": "delete-system-files-by-path", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success" @@ -8464,8 +8542,16 @@ "x-permission": "system:files" }, "get": { - "operationId": "get-system-files-*path", + "operationId": "get-system-files-by-path", "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + }, { "in": "query", "name": "info", @@ -8499,7 +8585,17 @@ "x-permission": "system:files" }, "post": { - "operationId": "post-system-files-*path", + "operationId": "post-system-files-by-path", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success" @@ -8576,9 +8672,19 @@ "x-permission": "infrastructure:read" } }, - "/api/system/mkdir/*path": { + "/api/system/mkdir/{path}": { "post": { - "operationId": "post-system-mkdir-*path", + "operationId": "post-system-mkdir-by-path", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success" @@ -8590,9 +8696,19 @@ "x-permission": "system:files" } }, - "/api/system/permissions/*path": { + "/api/system/permissions/{path}": { "put": { - "operationId": "put-system-permissions-*path", + "operationId": "put-system-permissions-by-path", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -8727,9 +8843,19 @@ } } }, - "/api/system/touch/*path": { + "/api/system/touch/{path}": { "post": { - "operationId": "post-system-touch-*path", + "operationId": "post-system-touch-by-path", + "parameters": [ + { + "in": "path", + "name": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success" diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index 3fba820..fd51d57 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" @@ -143,6 +144,42 @@ func TestOpenAPISpecIsStructurallySound(t *testing.T) { } } +// A permission is a string the agent checks, not a rewording of a constant's name, and a path +// OpenAPI cannot express is a path no client can call. +func TestOpenAPISpecNamesPermissionsAndPathsExactly(t *testing.T) { + spec := loadSpec(t) + + for path, methods := range spec["paths"].(map[string]any) { + if strings.ContainsAny(path, "*:") { + t.Errorf("%s is not a path a client can build", path) + } + for _, raw := range methods.(map[string]any) { + op := raw.(map[string]any) + permission, ok := op["x-permission"].(string) + if !ok { + continue + } + resource, _, found := strings.Cut(permission, ":") + if !found || len(resource) < 3 { + t.Errorf("%s reads as a mangled permission on %s", permission, path) + } + } + } + + // The wildcard segment carries the rest of the path, and a caller has to be told about it. + files, ok := spec["paths"].(map[string]any)["/api/deployments/{name}/files/{path}"].(map[string]any) + if !ok { + t.Fatal("the file endpoint is missing, so wildcards are not being translated") + } + var named []string + for _, param := range files["get"].(map[string]any)["parameters"].([]any) { + named = append(named, param.(map[string]any)["name"].(string)) + } + if !slices.Contains(named, "path") { + t.Errorf("the wildcard is not described as a parameter, got %v", named) + } +} + func TestOpenAPISpecCarriesWhatACallerNeeds(t *testing.T) { spec := loadSpec(t) paths, ok := spec["paths"].(map[string]any) diff --git a/tools/genspec/main.go b/tools/genspec/main.go index ff33664..3e9b9bb 100644 --- a/tools/genspec/main.go +++ b/tools/genspec/main.go @@ -13,6 +13,7 @@ import ( "go/types" "log" "os" + "path/filepath" "regexp" "sort" "strconv" @@ -103,6 +104,7 @@ func build(root string) (*openAPI, error) { } handlers := indexHandlers(pkgs) + permissions := indexPermissions(pkgs) schemas := &schemaSet{byName: map[string]*schema{}, seen: map[string]bool{}} spec := &openAPI{ @@ -123,7 +125,7 @@ func build(root string) (*openAPI, error) { Responses: map[string]response{"200": {Description: "Success"}}, } if r.Permission != "" { - op.Extensions = map[string]any{"x-permission": permissionValue(r.Permission)} + op.Extensions = map[string]any{"x-permission": permissions[r.Permission]} } for _, param := range pathParams(r.Path) { op.Parameters = append(op.Parameters, parameter{ @@ -167,7 +169,7 @@ func build(root string) (*openAPI, error) { } func readVersion(root string) string { - raw, err := os.ReadFile(root + "/VERSION") + raw, err := os.ReadFile(filepath.Join(root, "VERSION")) if err != nil { return "0.0.0" } @@ -334,7 +336,7 @@ func isGinH(t types.Type) bool { if !ok { return false } - return named.Obj().Name() == "H" && strings.HasSuffix(named.Obj().Pkg().Path(), "gin") + return named.Obj().Name() == "H" && named.Obj().Pkg().Path() == "github.com/gin-gonic/gin" } // queryParams are the query keys a handler reads. @@ -369,11 +371,19 @@ func queryParams(api *packages.Package, fn *ast.FuncDecl) []string { return names } +// A route parameter is either :name or *name, the second matching the rest of the path. +func paramName(segment string) (string, bool) { + if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "*") { + return segment[1:], true + } + return "", false +} + func pathParams(path string) []string { var params []string for _, segment := range strings.Split(strings.Trim(path, "/"), "/") { - if strings.HasPrefix(segment, ":") { - params = append(params, strings.TrimPrefix(segment, ":")) + if name, ok := paramName(segment); ok { + params = append(params, name) } } return params @@ -382,8 +392,8 @@ func pathParams(path string) []string { func openAPIPath(path string) string { segments := strings.Split(path, "/") for i, segment := range segments { - if strings.HasPrefix(segment, ":") { - segments[i] = "{" + strings.TrimPrefix(segment, ":") + "}" + if name, ok := paramName(segment); ok { + segments[i] = "{" + name + "}" } } return "/api" + strings.Join(segments, "/") @@ -403,8 +413,8 @@ func operationID(r route) string { if segment == "" { continue } - if strings.HasPrefix(segment, ":") { - parts = append(parts, "by-"+strings.TrimPrefix(segment, ":")) + if name, ok := paramName(segment); ok { + parts = append(parts, "by-"+name) continue } parts = append(parts, segment) @@ -412,13 +422,22 @@ func operationID(r route) string { return strings.Join(parts, "-") } -func permissionValue(constant string) string { - // PermDeploymentsWrite reads as deployments:write. - trimmed := strings.TrimPrefix(constant, "Perm") - for i := 1; i < len(trimmed); i++ { - if trimmed[i] >= 'A' && trimmed[i] <= 'Z' { - return strings.ToLower(trimmed[:i]) + ":" + strings.ToLower(trimmed[i:]) +// indexPermissions reads what each Perm constant is actually set to, rather than deriving it from +// the name: PermAPIKeysWrite is "apikeys:write", which no reading of the name produces. +func indexPermissions(pkgs []*packages.Package) map[string]string { + values := map[string]string{} + for _, pkg := range pkgs { + if pkg.Types == nil { + continue + } + scope := pkg.Types.Scope() + for _, name := range scope.Names() { + constant, ok := scope.Lookup(name).(*types.Const) + if !ok || !strings.HasPrefix(name, "Perm") || constant.Val() == nil { + continue + } + values[name] = strings.Trim(constant.Val().String(), `"`) } } - return strings.ToLower(trimmed) + return values } diff --git a/tools/genspec/schema.go b/tools/genspec/schema.go index 3b46b5e..5079e33 100644 --- a/tools/genspec/schema.go +++ b/tools/genspec/schema.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "go/types" + "reflect" "strings" ) @@ -231,7 +232,9 @@ type fieldTag struct { func parseTag(raw string) fieldTag { tag := fieldTag{} - jsonTag := structTag(raw, "json") + parsed := reflect.StructTag(raw) + + jsonTag := parsed.Get("json") if jsonTag == "-" { tag.skip = true return tag @@ -239,55 +242,15 @@ func parseTag(raw string) fieldTag { if jsonTag != "" { tag.name = strings.Split(jsonTag, ",")[0] } - if strings.Contains(structTag(raw, "binding"), "required") { + if strings.Contains(parsed.Get("binding"), "required") { tag.required = true } - if strings.TrimSpace(structTag(raw, "cli")) == "-" { + if strings.TrimSpace(parsed.Get("cli")) == "-" { tag.hidden = true } return tag } -// structTag reads one key out of a raw struct tag without reflect, which needs a live value. -func structTag(raw, key string) string { - for raw != "" { - i := 0 - for i < len(raw) && raw[i] == ' ' { - i++ - } - raw = raw[i:] - if raw == "" { - break - } - i = 0 - for i < len(raw) && raw[i] > ' ' && raw[i] != ':' && raw[i] != '"' { - i++ - } - if i+1 >= len(raw) || raw[i] != ':' || raw[i+1] != '"' { - break - } - name := raw[:i] - raw = raw[i+1:] - - i = 1 - for i < len(raw) && raw[i] != '"' { - if raw[i] == '\\' { - i++ - } - i++ - } - if i >= len(raw) { - break - } - value := raw[1:i] - raw = raw[i+1:] - if name == key { - return value - } - } - return "" -} - func basicSchema(t *types.Basic) *schema { switch { case t.Info()&types.IsBoolean != 0: From 18ab0ac1bcd46ee389a4ed38d7411931f3ef6b77 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 15 Aug 2026 02:15:56 +0100 Subject: [PATCH 6/6] fix(api): Read the routes from the router instead of matching its text Nine endpoints were missing from the description entirely, because the proxy DNS handlers register their own routes in a function of their own and the extraction only looked at one function's source text. Four more were missing because they register on a group under an empty path. Reading the router as code rather than as lines also removes three ways the description could go quietly wrong: a new router group nobody added to a lookup table, a trailing comment on a registration, and two handlers on different types sharing a method name, where one endpoint's body was described with another's. Answers with 201 or 202 are now described as well as 200, a body reaches the description whether it is bound by address or through a pointer already held, and a type from the universe scope no longer crashes the run. The deployments list keeps answering with the path it always did, and a collection can now say how many exist rather than how many it is carrying. --- internal/api/openapi.json | 494 +++++++++++++++++++++++++++++++++++- internal/api/render.go | 24 +- internal/api/render_test.go | 28 ++ internal/api/server.go | 2 +- tools/genspec/main.go | 343 +++++++++++++++++-------- 5 files changed, 780 insertions(+), 111 deletions(-) diff --git a/internal/api/openapi.json b/internal/api/openapi.json index bccd182..6680232 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -461,6 +461,89 @@ } } }, + "/api/apikeys": { + "get": { + "operationId": "get-apikeys", + "tags": [ + "apikeys" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "operationId": "post-apikeys", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "deployments": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "description": { + "type": "string" + }, + "expires_in": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "role": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + }, + "required": [ + "name" + ], + "type": "object", + "x-columns": [ + "name", + "description", + "role", + "expires_in", + "user_id" + ], + "x-property-order": [ + "name", + "description", + "role", + "permissions", + "deployments", + "expires_in", + "user_id" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "apikeys" + ], + "x-permission": "apikeys:write" + } + }, "/api/apikeys/{id}": { "delete": { "operationId": "delete-apikeys-by-id", @@ -5451,6 +5534,206 @@ "x-permission": "users:read" } }, + "/api/dns/powerdns/disable": { + "post": { + "operationId": "post-dns-powerdns-disable", + "tags": [ + "dns" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/dns/powerdns/enable": { + "post": { + "operationId": "post-dns-powerdns-enable", + "tags": [ + "dns" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/dns/powerdns/restart": { + "post": { + "operationId": "post-dns-powerdns-restart", + "tags": [ + "dns" + ], + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/api/dns/powerdns/status": { + "get": { + "operationId": "get-dns-powerdns-status", + "tags": [ + "dns" + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dns.PowerDNSStatus" + } + } + } + } + } + } + }, + "/api/dns/powerdns/zones": { + "get": { + "operationId": "get-dns-powerdns-zones", + "tags": [ + "dns" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "operationId": "post-dns-powerdns-zones", + "tags": [ + "dns" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dns.ZoneCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dns.Zone" + } + } + } + } + } + } + }, + "/api/dns/powerdns/zones/{zoneId}": { + "delete": { + "operationId": "delete-dns-powerdns-zones-by-zoneId", + "tags": [ + "dns" + ], + "parameters": [ + { + "name": "zoneId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "get": { + "operationId": "get-dns-powerdns-zones-by-zoneId", + "tags": [ + "dns" + ], + "parameters": [ + { + "name": "zoneId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dns.Zone" + } + } + } + } + } + }, + "patch": { + "operationId": "patch-dns-powerdns-zones-by-zoneId", + "tags": [ + "dns" + ], + "parameters": [ + { + "name": "zoneId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "rrsets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dns.RRSet" + } + } + }, + "x-property-order": [ + "rrsets" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/dns.Zone" + } + } + } + } + } + } + }, "/api/dns/providers": { "get": { "operationId": "get-dns-providers", @@ -8168,14 +8451,7 @@ ], "responses": { "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/dns.PowerDNSStatus" - } - } - } + "description": "Success" } } } @@ -9205,6 +9481,79 @@ "x-permission": "traffic:read" } }, + "/api/users": { + "get": { + "operationId": "get-users", + "tags": [ + "users" + ], + "responses": { + "200": { + "description": "Success" + } + } + }, + "post": { + "operationId": "post-users", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "permissions": { + "items": { + "type": "string" + }, + "type": "array" + }, + "role": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "password", + "role" + ], + "type": "object", + "x-columns": [ + "username", + "email", + "password", + "role" + ], + "x-property-order": [ + "username", + "email", + "password", + "role", + "permissions" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Success" + } + }, + "tags": [ + "users" + ], + "x-permission": "users:write" + } + }, "/api/users/me": { "get": { "operationId": "get-users-me", @@ -11012,6 +11361,135 @@ "version" ] }, + "dns.RRSet": { + "type": "object", + "properties": { + "changetype": { + "type": "string" + }, + "name": { + "type": "string" + }, + "records": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dns.Record" + } + }, + "ttl": { + "type": "integer" + }, + "type": { + "type": "string" + } + }, + "x-property-order": [ + "name", + "type", + "ttl", + "changetype", + "records" + ], + "x-columns": [ + "name", + "type", + "ttl", + "changetype" + ] + }, + "dns.Record": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "disabled": { + "type": "boolean" + } + }, + "x-property-order": [ + "content", + "disabled" + ], + "x-columns": [ + "content", + "disabled" + ] + }, + "dns.Zone": { + "type": "object", + "properties": { + "dnssec": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nameservers": { + "type": "array", + "items": { + "type": "string" + } + }, + "rrsets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dns.RRSet" + } + }, + "serial": { + "type": "integer" + } + }, + "x-property-order": [ + "id", + "name", + "kind", + "serial", + "dnssec", + "rrsets", + "nameservers" + ], + "x-columns": [ + "id", + "name", + "kind", + "serial", + "dnssec" + ] + }, + "dns.ZoneCreate": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nameservers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "x-property-order": [ + "name", + "kind", + "nameservers" + ], + "x-columns": [ + "name", + "kind" + ] + }, "docker.ResourceUpdate": { "type": "object", "properties": { diff --git a/internal/api/render.go b/internal/api/render.go index c025ca6..836b3ca 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -7,11 +7,13 @@ import "encoding/json" type List[T any] struct { Items []T `json:"items"` + // Total is what exists, which is more than Items holds once anything is paged. Total int `json:"total"` - // The name this collection used to answer under, written alongside items until the clients - // reading it have moved. + // The name this collection used to answer under, and any field it answered alongside, + // written out until the clients reading them have moved. legacy string + extras map[string]any } func NewList[T any](items []T, legacy string) List[T] { @@ -21,11 +23,29 @@ func NewList[T any](items []T, legacy string) List[T] { return List[T]{Items: items, Total: len(items), legacy: legacy} } +// OfTotal says how many exist when Items is one page of them. +func (l List[T]) OfTotal(total int) List[T] { + l.Total = total + return l +} + +// Also carries a field this collection answered with before it took this shape. +func (l List[T]) Also(name string, value any) List[T] { + if l.extras == nil { + l.extras = map[string]any{} + } + l.extras[name] = value + return l +} + func (l List[T]) MarshalJSON() ([]byte, error) { out := map[string]any{"items": l.Items, "total": l.Total} if l.legacy != "" { out[l.legacy] = l.Items } + for name, value := range l.extras { + out[name] = value + } return json.Marshal(out) } diff --git a/internal/api/render_test.go b/internal/api/render_test.go index 3a00321..276306d 100644 --- a/internal/api/render_test.go +++ b/internal/api/render_test.go @@ -39,3 +39,31 @@ func TestListWithoutALegacyNameAnswersOnlyTheShape(t *testing.T) { t.Errorf("expected items and total only, got %v", decoded) } } + +func TestListKeepsAFieldItUsedToAnswerWith(t *testing.T) { + raw, err := json.Marshal(NewList([]string{"shop"}, "deployments").Also("path", "/srv/apps")) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded["path"] != "/srv/apps" { + t.Errorf("path = %v", decoded["path"]) + } +} + +func TestListReportsWhatExistsNotWhatItHolds(t *testing.T) { + raw, err := json.Marshal(NewList([]string{"a", "b"}, "").OfTotal(97)) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if decoded["total"] != float64(97) { + t.Errorf("a page of 2 out of 97 should report 97, got %v", decoded["total"]) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 3ab1575..257407b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -976,7 +976,7 @@ func (s *Server) listDeployments(c *gin.Context) { deployments = filtered } - c.JSON(http.StatusOK, NewList(deployments, "deployments")) + c.JSON(http.StatusOK, NewList(deployments, "deployments").Also("path", s.manager.BasePath())) } func (s *Server) getDeployment(c *gin.Context) { diff --git a/tools/genspec/main.go b/tools/genspec/main.go index 3e9b9bb..4f4e038 100644 --- a/tools/genspec/main.go +++ b/tools/genspec/main.go @@ -14,7 +14,6 @@ import ( "log" "os" "path/filepath" - "regexp" "sort" "strconv" "strings" @@ -47,34 +46,11 @@ func main() { } } -// route is one registration read out of the router. -type route struct { - Method string - Path string - Handler string - Permission string - Group string -} - -// groupPrefix is what each router group prepends to the paths registered on it. -var groupPrefix = map[string]string{ - "api": "", - "protected": "", - "setupGroup": "/setup", - "guarded": "/setup", - "usersGroup": "/users", - "apiKeysGroup": "/apikeys", - "dnsGroup": "/dns", - "clusterGroup": "/cluster", +type handler struct { + decl *ast.FuncDecl + pkg *packages.Package } -// Endpoints the agent's own components call, not part of the interface it offers. -var skipPrefixes = []string{"/internal", "/_internal", "/security/events/ingest", "/traffic/ingest"} - -var routePattern = regexp.MustCompile(`\b(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*"([^"]+)"(.*)`) -var permPattern = regexp.MustCompile(`auth\.(Perm\w+)`) -var handlerPattern = regexp.MustCompile(`\.(\w+)\s*\)\s*$`) - func build(root string) (*openAPI, error) { cfg := &packages.Config{ Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | @@ -98,12 +74,13 @@ func build(root string) (*openAPI, error) { return nil, fmt.Errorf("no packages loaded from %s", root) } - routes, err := readRoutes(api) + handlers := indexHandlers(pkgs) + + routes, err := readRoutes(api, handlers) if err != nil { return nil, err } - handlers := indexHandlers(pkgs) permissions := indexPermissions(pkgs) schemas := &schemaSet{byName: map[string]*schema{}, seen: map[string]bool{}} @@ -143,9 +120,12 @@ func build(root string) (*openAPI, error) { } } } - if returned := typedResponse(fn.pkg, fn.decl); returned != nil { + if returned, status := typedResponse(fn.pkg, fn.decl); returned != nil { if ref := schemas.add(returned); ref != nil { - op.Responses["200"] = response{ + if status != "200" { + delete(op.Responses, "200") + } + op.Responses[status] = response{ Description: "Success", Content: map[string]mediaType{"application/json": {Schema: ref}}, } @@ -176,53 +156,65 @@ func readVersion(root string) string { return strings.TrimSpace(string(raw)) } -// readRoutes reads the registrations out of the source, since building the router needs a live host. -func readRoutes(api *packages.Package) ([]route, error) { - var file string - for _, f := range api.GoFiles { - if strings.HasSuffix(f, "server.go") { - file = f - } - } - if file == "" { - return nil, fmt.Errorf("server.go not found in internal/api") - } - raw, err := os.ReadFile(file) - if err != nil { - return nil, err +// route is one registration read out of the router. +type route struct { + Method string + Path string + Handler string + Permission string +} + +// Endpoints the agent's own components call, not part of the interface it offers. +var skipPrefixes = []string{"/internal", "/_internal", "/security/events/ingest", "/traffic/ingest"} + +var httpMethods = map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true, "PATCH": true} + +// readRoutes walks the router setup rather than matching its text, so a new group, a trailing +// comment or two handlers sharing a method name cannot silently drop an endpoint. +func readRoutes(api *packages.Package, handlers map[string]*handler) ([]route, error) { + setup := findFunc(api, "setupRoutes") + if setup == nil { + return nil, fmt.Errorf("setupRoutes not found in internal/api") } var routes []route seen := map[string]bool{} - for _, line := range strings.Split(string(raw), "\n") { - match := routePattern.FindStringSubmatch(line) - if match == nil { - continue - } - group, method, path, rest := match[1], match[2], match[3], match[4] - prefix, known := groupPrefix[group] - if !known { - continue - } - full := prefix + path - if skip(full) { - continue - } - key := method + " " + full - if seen[key] { - continue - } - seen[key] = true - r := route{Method: method, Path: full, Group: group} - if m := permPattern.FindStringSubmatch(rest); m != nil { - r.Permission = m[1] - } - if m := handlerPattern.FindStringSubmatch(strings.TrimSpace(rest)); m != nil { - r.Handler = m[1] - } - routes = append(routes, r) + // A function handed a router group registers routes under whatever prefix the caller gave + // it, so following the group into it is the only way those endpoints are seen at all. + var walk func(fn *ast.FuncDecl, prefixes map[string]string, depth int) + walk = func(fn *ast.FuncDecl, prefixes map[string]string, depth int) { + if fn == nil || depth > 4 { + return + } + ast.Inspect(fn, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.AssignStmt: + if name, prefix, ok := groupAssignment(node, prefixes); ok { + prefixes[name] = prefix + } + case *ast.CallExpr: + if r, ok := routeCall(api, node, prefixes); ok { + if skip(r.Path) { + return true + } + key := r.Method + " " + r.Path + if seen[key] { + return true + } + seen[key] = true + routes = append(routes, r) + return true + } + if target, bound, ok := delegated(api, handlers, node, prefixes); ok { + walk(target, bound, depth+1) + } + } + return true + }) } + walk(setup, map[string]string{}, 0) + sort.Slice(routes, func(i, j int) bool { if routes[i].Path != routes[j].Path { return routes[i].Path < routes[j].Path @@ -232,9 +224,165 @@ func readRoutes(api *packages.Package) ([]route, error) { return routes, nil } +// delegated resolves a call that hands a router group to another function, returning that +// function and the prefix its parameter stands for. +func delegated(api *packages.Package, handlers map[string]*handler, call *ast.CallExpr, prefixes map[string]string) (*ast.FuncDecl, map[string]string, bool) { + index := -1 + prefix := "" + for i, arg := range call.Args { + ident, ok := arg.(*ast.Ident) + if !ok { + continue + } + if known, ok := prefixes[ident.Name]; ok { + index, prefix = i, known + break + } + } + if index < 0 { + return nil, nil, false + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return nil, nil, false + } + fn, ok := api.TypesInfo.Uses[sel.Sel].(*types.Func) + if !ok { + return nil, nil, false + } + signature, ok := fn.Type().(*types.Signature) + if !ok || signature.Recv() == nil { + return nil, nil, false + } + target, ok := handlers[receiverName(signature.Recv().Type())+"."+fn.Name()] + if !ok || target.decl.Type.Params == nil { + return nil, nil, false + } + + // The name the group goes by inside the function it was handed to. + position := 0 + for _, field := range target.decl.Type.Params.List { + for _, name := range field.Names { + if position == index { + return target.decl, map[string]string{name.Name: prefix}, true + } + position++ + } + } + return nil, nil, false +} + +// groupAssignment reads `x := parent.Group("/prefix")`, which is how every path prefix is set. +func groupAssignment(stmt *ast.AssignStmt, prefixes map[string]string) (string, string, bool) { + if len(stmt.Lhs) != 1 || len(stmt.Rhs) != 1 { + return "", "", false + } + name, ok := stmt.Lhs[0].(*ast.Ident) + if !ok { + return "", "", false + } + call, ok := stmt.Rhs[0].(*ast.CallExpr) + if !ok { + return "", "", false + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Group" || len(call.Args) == 0 { + return "", "", false + } + segment, ok := stringLiteral(call.Args[0]) + if !ok { + return "", "", false + } + // The parent's own prefix, when the group hangs off another group. + parent := "" + if ident, ok := sel.X.(*ast.Ident); ok { + parent = prefixes[ident.Name] + } + return name.Name, parent + segment, true +} + +// routeCall reads `group.GET("/path", middleware..., handler)`. +func routeCall(api *packages.Package, call *ast.CallExpr, prefixes map[string]string) (route, bool) { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !httpMethods[sel.Sel.Name] || len(call.Args) < 2 { + return route{}, false + } + group, ok := sel.X.(*ast.Ident) + if !ok { + return route{}, false + } + prefix, known := prefixes[group.Name] + if !known { + return route{}, false + } + path, ok := stringLiteral(call.Args[0]) + if !ok { + return route{}, false + } + + r := route{Method: sel.Sel.Name, Path: prefix + path} + r.Handler = handlerKey(api, call.Args[len(call.Args)-1]) + ast.Inspect(call, func(n ast.Node) bool { + if ident, ok := n.(*ast.Ident); ok && strings.HasPrefix(ident.Name, "Perm") { + r.Permission = ident.Name + } + return true + }) + return r, true +} + +// handlerKey names a handler by its receiver as well as its method, since Server and half a dozen +// managers each have a Delete. +func handlerKey(api *packages.Package, expr ast.Expr) string { + sel, ok := expr.(*ast.SelectorExpr) + if !ok { + return "" + } + fn, ok := api.TypesInfo.Uses[sel.Sel].(*types.Func) + if !ok { + return sel.Sel.Name + } + signature, ok := fn.Type().(*types.Signature) + if !ok || signature.Recv() == nil { + return fn.Name() + } + return receiverName(signature.Recv().Type()) + "." + fn.Name() +} + +func receiverName(t types.Type) string { + if pointer, ok := t.(*types.Pointer); ok { + t = pointer.Elem() + } + if named, ok := t.(*types.Named); ok { + return named.Obj().Name() + } + return "" +} + +func findFunc(pkg *packages.Package, name string) *ast.FuncDecl { + for _, file := range pkg.Syntax { + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name { + return fn + } + } + } + return nil +} + +func stringLiteral(expr ast.Expr) (string, bool) { + lit, ok := expr.(*ast.BasicLit) + if !ok { + return "", false + } + value, err := strconv.Unquote(lit.Value) + return value, err == nil +} + func skip(path string) bool { for _, prefix := range skipPrefixes { - if strings.HasPrefix(path, prefix) { + if strings.HasPrefix(strings.TrimPrefix(path, "/api"), prefix) { return true } } @@ -242,26 +390,17 @@ func skip(path string) bool { return strings.HasSuffix(path, "/stream") || strings.HasSuffix(path, "/interactive") } -type handler struct { - decl *ast.FuncDecl - pkg *packages.Package -} - func indexHandlers(pkgs []*packages.Package) map[string]*handler { handlers := map[string]*handler{} for _, pkg := range pkgs { for _, file := range pkg.Syntax { for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Recv == nil { - continue - } - // The API package wins a shared name: that is where a route's handler lives. - if existing, taken := handlers[fn.Name.Name]; taken && - strings.HasSuffix(existing.pkg.PkgPath, "/internal/api") { + if !ok || fn.Recv == nil || len(fn.Recv.List) == 0 { continue } - handlers[fn.Name.Name] = &handler{decl: fn, pkg: pkg} + recv := pkg.TypesInfo.TypeOf(fn.Recv.List[0].Type) + handlers[receiverName(recv)+"."+fn.Name.Name] = &handler{decl: fn, pkg: pkg} } } } @@ -285,11 +424,7 @@ func boundRequestType(api *packages.Package, fn *ast.FuncDecl) types.Type { if len(call.Args) != 1 { return true } - unary, ok := call.Args[0].(*ast.UnaryExpr) - if !ok { - return true - } - if t := api.TypesInfo.TypeOf(unary.X); t != nil { + if t := api.TypesInfo.TypeOf(call.Args[0]); t != nil { found = t } return false @@ -299,8 +434,9 @@ func boundRequestType(api *packages.Package, fn *ast.FuncDecl) types.Type { // typedResponse is what a handler writes on success. A handler answering with gin.H describes // nothing and is left undescribed. -func typedResponse(api *packages.Package, fn *ast.FuncDecl) types.Type { +func typedResponse(api *packages.Package, fn *ast.FuncDecl) (types.Type, string) { var found types.Type + code := "200" ast.Inspect(fn, func(n ast.Node) bool { if found != nil { return false @@ -313,27 +449,34 @@ func typedResponse(api *packages.Package, fn *ast.FuncDecl) types.Type { if !ok || sel.Sel.Name != "JSON" { return true } - if !isStatusOK(call.Args[0]) { + status, ok := successStatus(call.Args[0]) + if !ok { return true } t := api.TypesInfo.TypeOf(call.Args[1]) if t == nil || isGinH(t) { return true } - found = t + found, code = t, status return false }) - return found + return found, code } -func isStatusOK(expr ast.Expr) bool { +var successStatuses = map[string]string{"StatusOK": "200", "StatusCreated": "201", "StatusAccepted": "202"} + +func successStatus(expr ast.Expr) (string, bool) { sel, ok := expr.(*ast.SelectorExpr) - return ok && sel.Sel.Name == "StatusOK" + if !ok { + return "", false + } + code, ok := successStatuses[sel.Sel.Name] + return code, ok } func isGinH(t types.Type) bool { named, ok := t.(*types.Named) - if !ok { + if !ok || named.Obj().Pkg() == nil { return false } return named.Obj().Name() == "H" && named.Obj().Pkg().Path() == "github.com/gin-gonic/gin" @@ -396,11 +539,11 @@ func openAPIPath(path string) string { segments[i] = "{" + name + "}" } } - return "/api" + strings.Join(segments, "/") + return strings.Join(segments, "/") } func familyOf(path string) string { - trimmed := strings.Trim(path, "/") + trimmed := strings.Trim(strings.TrimPrefix(path, "/api"), "/") if trimmed == "" { return "root" } @@ -409,7 +552,7 @@ func familyOf(path string) string { func operationID(r route) string { parts := []string{strings.ToLower(r.Method)} - for _, segment := range strings.Split(strings.Trim(r.Path, "/"), "/") { + for _, segment := range strings.Split(strings.Trim(strings.TrimPrefix(r.Path, "/api"), "/"), "/") { if segment == "" { continue }