diff --git a/.gitignore b/.gitignore index 81527be..134c1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ bun.lock data/db data/.encryption_key .last-synced-gitea +.hermes/ diff --git a/drizzle-pg/0012_add_git_stack_branch.sql b/drizzle-pg/0012_add_git_stack_branch.sql new file mode 100644 index 0000000..c723178 --- /dev/null +++ b/drizzle-pg/0012_add_git_stack_branch.sql @@ -0,0 +1 @@ +ALTER TABLE "git_stacks" ADD COLUMN "branch" text; diff --git a/drizzle-pg/meta/0012_snapshot.json b/drizzle-pg/meta/0012_snapshot.json new file mode 100644 index 0000000..1c94c04 --- /dev/null +++ b/drizzle-pg/meta/0012_snapshot.json @@ -0,0 +1,3489 @@ +{ + "id": "0012_add_git_stack_branch", + "prevId": "5f9fe4e6-7799-406c-a232-c72726c6db27", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_tokens_token_prefix_idx": { + "name": "api_tokens_token_prefix_idx", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_environment_id_environments_id_fk": { + "name": "audit_logs_environment_id_environments_id_fk", + "tableFrom": "audit_logs", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_settings": { + "name": "auth_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auth_enabled": { + "name": "auth_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "default_provider": { + "name": "default_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_update_settings": { + "name": "auto_update_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'daily'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vulnerability_criteria": { + "name": "vulnerability_criteria", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'never'" + }, + "last_checked": { + "name": "last_checked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_updated": { + "name": "last_updated", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auto_update_settings_environment_id_environments_id_fk": { + "name": "auto_update_settings_environment_id_environments_id_fk", + "tableFrom": "auto_update_settings", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auto_update_settings_environment_id_container_name_unique": { + "name": "auto_update_settings_environment_id_container_name_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "container_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_configs": { + "name": "backup_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "all_volumes": { + "name": "all_volumes", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "selected_volumes": { + "name": "selected_volumes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_before_backup": { + "name": "stop_before_backup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention": { + "name": "retention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_configs_environment_id_environments_id_fk": { + "name": "backup_configs_environment_id_environments_id_fk", + "tableFrom": "backup_configs", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_configs_destination_id_backup_destinations_id_fk": { + "name": "backup_configs_destination_id_backup_destinations_id_fk", + "tableFrom": "backup_configs", + "tableTo": "backup_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backup_destinations": { + "name": "backup_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flags": { + "name": "flags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host_path": { + "name": "host_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policies": { + "name": "policies", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "backup_destinations_name_unique": { + "name": "backup_destinations_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.config_sets": { + "name": "config_sets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ports": { + "name": "ports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "volumes": { + "name": "volumes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "network_mode": { + "name": "network_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'bridge'" + }, + "restart_policy": { + "name": "restart_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'no'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "config_sets_name_unique": { + "name": "config_sets_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.container_events": { + "name": "container_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_attributes": { + "name": "actor_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "container_events_env_timestamp_idx": { + "name": "container_events_env_timestamp_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "container_events_environment_id_environments_id_fk": { + "name": "container_events_environment_id_environments_id_fk", + "tableFrom": "container_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_notifications": { + "name": "environment_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_notifications_environment_id_environments_id_fk": { + "name": "environment_notifications_environment_id_environments_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_notifications_notification_id_notification_settings_id_fk": { + "name": "environment_notifications_notification_id_notification_settings_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "notification_settings", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_notifications_environment_id_notification_id_unique": { + "name": "environment_notifications_environment_id_notification_id_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "notification_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2375 + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'http'" + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_cert": { + "name": "tls_cert", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_key": { + "name": "tls_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_skip_verify": { + "name": "tls_skip_verify", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'globe'" + }, + "collect_activity": { + "name": "collect_activity", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "collect_metrics": { + "name": "collect_metrics", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "highlight_changes": { + "name": "highlight_changes", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'socket'" + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'/var/run/docker.sock'" + }, + "hawser_token": { + "name": "hawser_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_last_seen": { + "name": "hawser_last_seen", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hawser_agent_id": { + "name": "hawser_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_agent_name": { + "name": "hawser_agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_version": { + "name": "hawser_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hawser_capabilities": { + "name": "hawser_capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environments_name_unique": { + "name": "environments_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_credentials": { + "name": "git_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_private_key": { + "name": "ssh_private_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ssh_passphrase": { + "name": "ssh_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_credentials_name_unique": { + "name": "git_credentials_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_repositories": { + "name": "git_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'main'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'docker-compose.yml'" + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync": { + "name": "last_sync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "git_repositories_credential_id_git_credentials_id_fk": { + "name": "git_repositories_credential_id_git_credentials_id_fk", + "tableFrom": "git_repositories", + "tableTo": "git_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_repositories_name_unique": { + "name": "git_repositories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_stacks": { + "name": "git_stacks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'docker-compose.yml'" + }, + "env_file_path": { + "name": "env_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_update": { + "name": "auto_update", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_dir": { + "name": "context_dir", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_on_deploy": { + "name": "build_on_deploy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "no_build_cache": { + "name": "no_build_cache", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "repull_images": { + "name": "repull_images", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "force_redeploy": { + "name": "force_redeploy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_sync": { + "name": "last_sync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "synced_files": { + "name": "synced_files", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "git_stacks_environment_id_environments_id_fk": { + "name": "git_stacks_environment_id_environments_id_fk", + "tableFrom": "git_stacks", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_stacks_repository_id_git_repositories_id_fk": { + "name": "git_stacks_repository_id_git_repositories_id_fk", + "tableFrom": "git_stacks", + "tableTo": "git_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_stacks_stack_name_environment_id_unique": { + "name": "git_stacks_stack_name_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stack_name", + "environment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hawser_tokens": { + "name": "hawser_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "hawser_tokens_environment_id_environments_id_fk": { + "name": "hawser_tokens_environment_id_environments_id_fk", + "tableFrom": "hawser_tokens", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hawser_tokens_token_unique": { + "name": "hawser_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.host_metrics": { + "name": "host_metrics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "memory_percent": { + "name": "memory_percent", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "memory_used": { + "name": "memory_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_total": { + "name": "memory_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "host_metrics_env_timestamp_idx": { + "name": "host_metrics_env_timestamp_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "host_metrics_environment_id_environments_id_fk": { + "name": "host_metrics_environment_id_environments_id_fk", + "tableFrom": "host_metrics", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ldap_config": { + "name": "ldap_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bind_password": { + "name": "bind_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'(uid={{username}})'" + }, + "username_attribute": { + "name": "username_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'uid'" + }, + "email_attribute": { + "name": "email_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'mail'" + }, + "display_name_attribute": { + "name": "display_name_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'cn'" + }, + "group_base_dn": { + "name": "group_base_dn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_filter": { + "name": "group_filter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_group": { + "name": "admin_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_enabled": { + "name": "tls_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oidc_config": { + "name": "oidc_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'openid profile email'" + }, + "username_claim": { + "name": "username_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'preferred_username'" + }, + "email_claim": { + "name": "email_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'email'" + }, + "display_name_claim": { + "name": "display_name_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'name'" + }, + "admin_claim": { + "name": "admin_claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_value": { + "name": "admin_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role_mappings_claim": { + "name": "role_mappings_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'groups'" + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_container_updates": { + "name": "pending_container_updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_image": { + "name": "current_image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "has_image_update": { + "name": "has_image_update", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "newer_version": { + "name": "newer_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pending_container_updates_environment_id_environments_id_fk": { + "name": "pending_container_updates_environment_id_environments_id_fk", + "tableFrom": "pending_container_updates", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_container_updates_environment_id_container_id_unique": { + "name": "pending_container_updates_environment_id_container_id_unique", + "nullsNotDistinct": false, + "columns": [ + "environment_id", + "container_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registries": { + "name": "registries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "registries_name_unique": { + "name": "registries_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_ids": { + "name": "environment_ids", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.schedule_executions": { + "name": "schedule_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs": { + "name": "logs", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "schedule_executions_type_id_idx": { + "name": "schedule_executions_type_id_idx", + "columns": [ + { + "expression": "schedule_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "schedule_executions_environment_id_environments_id_fk": { + "name": "schedule_executions_environment_id_environments_id_fk", + "tableFrom": "schedule_executions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_providers": { + "name": "secret_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "secret_providers_name_unique": { + "name": "secret_providers_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stack_environment_variables": { + "name": "stack_environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stack_environment_variables_environment_id_environments_id_fk": { + "name": "stack_environment_variables_environment_id_environments_id_fk", + "tableFrom": "stack_environment_variables", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stack_environment_variables_stack_name_environment_id_key_unique": { + "name": "stack_environment_variables_stack_name_environment_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "stack_name", + "environment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stack_events": { + "name": "stack_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "stack_events_environment_id_environments_id_fk": { + "name": "stack_events_environment_id_environments_id_fk", + "tableFrom": "stack_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stack_sources": { + "name": "stack_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "git_repository_id": { + "name": "git_repository_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "git_stack_id": { + "name": "git_stack_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_path": { + "name": "env_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provider_id": { + "name": "secret_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "injected_secret_keys": { + "name": "injected_secret_keys", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stack_sources_environment_id_environments_id_fk": { + "name": "stack_sources_environment_id_environments_id_fk", + "tableFrom": "stack_sources", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stack_sources_git_repository_id_git_repositories_id_fk": { + "name": "stack_sources_git_repository_id_git_repositories_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_repositories", + "columnsFrom": [ + "git_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "stack_sources_git_stack_id_git_stacks_id_fk": { + "name": "stack_sources_git_stack_id_git_stacks_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_stacks", + "columnsFrom": [ + "git_stack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "stack_sources_secret_provider_id_secret_providers_id_fk": { + "name": "stack_sources_secret_provider_id_secret_providers_id_fk", + "tableFrom": "stack_sources", + "tableTo": "secret_providers", + "columnsFrom": [ + "secret_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stack_sources_stack_name_environment_id_unique": { + "name": "stack_sources_stack_name_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stack_name", + "environment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_sources": { + "name": "template_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "builtin": { + "name": "builtin", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "template_sources_source_id_unique": { + "name": "template_sources_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_environment_id_environments_id_fk": { + "name": "user_preferences_environment_id_environments_id_fk", + "tableFrom": "user_preferences", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_environment_id_key_unique": { + "name": "user_preferences_user_id_environment_id_key_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "environment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_environment_id_environments_id_fk": { + "name": "user_roles_environment_id_environments_id_fk", + "tableFrom": "user_roles", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_roles_user_id_role_id_environment_id_unique": { + "name": "user_roles_user_id_role_id_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "role_id", + "environment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "mfa_enabled": { + "name": "mfa_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "mfa_secret": { + "name": "mfa_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vulnerability_scans": { + "name": "vulnerability_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_name": { + "name": "image_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scanner": { + "name": "scanner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scanned_at": { + "name": "scanned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scan_duration": { + "name": "scan_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "medium_count": { + "name": "medium_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "low_count": { + "name": "low_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "negligible_count": { + "name": "negligible_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "unknown_count": { + "name": "unknown_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "vulnerabilities": { + "name": "vulnerabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "vulnerability_scans_env_image_idx": { + "name": "vulnerability_scans_env_image_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vulnerability_scans_environment_id_environments_id_fk": { + "name": "vulnerability_scans_environment_id_environments_id_fk", + "tableFrom": "vulnerability_scans", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index f66840b..5783498 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1786892847678, "tag": "0011_pending_updates_semver", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1786967313223, + "tag": "0012_add_git_stack_branch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/0012_add_git_stack_branch.sql b/drizzle/0012_add_git_stack_branch.sql new file mode 100644 index 0000000..cbe2eaf --- /dev/null +++ b/drizzle/0012_add_git_stack_branch.sql @@ -0,0 +1 @@ +ALTER TABLE `git_stacks` ADD `branch` text; diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..2e7319e --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,3649 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0012_add_git_stack_branch", + "prevId": "c0095b38-49f2-46e5-bca7-68643b496a28", + "tables": { + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "api_tokens_token_hash_unique": { + "name": "api_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + }, + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_tokens_token_prefix_idx": { + "name": "api_tokens_token_prefix_idx", + "columns": [ + "token_prefix" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_environment_id_environments_id_fk": { + "name": "audit_logs_environment_id_environments_id_fk", + "tableFrom": "audit_logs", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_settings": { + "name": "auth_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "auth_enabled": { + "name": "auth_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "default_provider": { + "name": "default_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'local'" + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 86400 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auto_update_settings": { + "name": "auto_update_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'daily'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vulnerability_criteria": { + "name": "vulnerability_criteria", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'never'" + }, + "last_checked": { + "name": "last_checked", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "auto_update_settings_environment_id_container_name_unique": { + "name": "auto_update_settings_environment_id_container_name_unique", + "columns": [ + "environment_id", + "container_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "auto_update_settings_environment_id_environments_id_fk": { + "name": "auto_update_settings_environment_id_environments_id_fk", + "tableFrom": "auto_update_settings", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_configs": { + "name": "backup_configs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'container'" + }, + "target_name": { + "name": "target_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "all_volumes": { + "name": "all_volumes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "selected_volumes": { + "name": "selected_volumes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stop_before_backup": { + "name": "stop_before_backup", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retention": { + "name": "retention", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "options": { + "name": "options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_configs_environment_id_environments_id_fk": { + "name": "backup_configs_environment_id_environments_id_fk", + "tableFrom": "backup_configs", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_configs_destination_id_backup_destinations_id_fk": { + "name": "backup_configs_destination_id_backup_destinations_id_fk", + "tableFrom": "backup_configs", + "tableTo": "backup_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_destinations": { + "name": "backup_destinations", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "flags": { + "name": "flags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host_path": { + "name": "host_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policies": { + "name": "policies", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_test_at": { + "name": "last_test_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_test_status": { + "name": "last_test_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "backup_destinations_name_unique": { + "name": "backup_destinations_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config_sets": { + "name": "config_sets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_vars": { + "name": "env_vars", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ports": { + "name": "ports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "volumes": { + "name": "volumes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "network_mode": { + "name": "network_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'bridge'" + }, + "restart_policy": { + "name": "restart_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'no'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "config_sets_name_unique": { + "name": "config_sets_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "container_events": { + "name": "container_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_attributes": { + "name": "actor_attributes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "container_events_env_timestamp_idx": { + "name": "container_events_env_timestamp_idx", + "columns": [ + "environment_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "container_events_environment_id_environments_id_fk": { + "name": "container_events_environment_id_environments_id_fk", + "tableFrom": "container_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_notifications": { + "name": "environment_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "environment_notifications_environment_id_notification_id_unique": { + "name": "environment_notifications_environment_id_notification_id_unique", + "columns": [ + "environment_id", + "notification_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "environment_notifications_environment_id_environments_id_fk": { + "name": "environment_notifications_environment_id_environments_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_notifications_notification_id_notification_settings_id_fk": { + "name": "environment_notifications_notification_id_notification_settings_id_fk", + "tableFrom": "environment_notifications", + "tableTo": "notification_settings", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environments": { + "name": "environments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 2375 + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'http'" + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_cert": { + "name": "tls_cert", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_key": { + "name": "tls_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_skip_verify": { + "name": "tls_skip_verify", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'globe'" + }, + "collect_activity": { + "name": "collect_activity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "collect_metrics": { + "name": "collect_metrics", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "highlight_changes": { + "name": "highlight_changes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_type": { + "name": "connection_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'socket'" + }, + "socket_path": { + "name": "socket_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'/var/run/docker.sock'" + }, + "hawser_token": { + "name": "hawser_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_last_seen": { + "name": "hawser_last_seen", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_agent_id": { + "name": "hawser_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_agent_name": { + "name": "hawser_agent_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_version": { + "name": "hawser_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hawser_capabilities": { + "name": "hawser_capabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_credentials": { + "name": "git_credentials", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_private_key": { + "name": "ssh_private_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ssh_passphrase": { + "name": "ssh_passphrase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "git_credentials_name_unique": { + "name": "git_credentials_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_repositories": { + "name": "git_repositories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'main'" + }, + "credential_id": { + "name": "credential_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'docker-compose.yml'" + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync": { + "name": "last_sync", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "git_repositories_name_unique": { + "name": "git_repositories_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "git_repositories_credential_id_git_credentials_id_fk": { + "name": "git_repositories_credential_id_git_credentials_id_fk", + "tableFrom": "git_repositories", + "tableTo": "git_credentials", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "git_stacks": { + "name": "git_stacks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'docker-compose.yml'" + }, + "env_file_path": { + "name": "env_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_update": { + "name": "auto_update", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "auto_update_schedule": { + "name": "auto_update_schedule", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'daily'" + }, + "auto_update_cron": { + "name": "auto_update_cron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0 3 * * *'" + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_dir": { + "name": "context_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_on_deploy": { + "name": "build_on_deploy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "no_build_cache": { + "name": "no_build_cache", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "repull_images": { + "name": "repull_images", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_redeploy": { + "name": "force_redeploy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "last_sync": { + "name": "last_sync", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_commit": { + "name": "last_commit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "synced_files": { + "name": "synced_files", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "git_stacks_stack_name_environment_id_unique": { + "name": "git_stacks_stack_name_environment_id_unique", + "columns": [ + "stack_name", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "git_stacks_environment_id_environments_id_fk": { + "name": "git_stacks_environment_id_environments_id_fk", + "tableFrom": "git_stacks", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_stacks_repository_id_git_repositories_id_fk": { + "name": "git_stacks_repository_id_git_repositories_id_fk", + "tableFrom": "git_stacks", + "tableTo": "git_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hawser_tokens": { + "name": "hawser_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_used": { + "name": "last_used", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "hawser_tokens_token_unique": { + "name": "hawser_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "hawser_tokens_environment_id_environments_id_fk": { + "name": "hawser_tokens_environment_id_environments_id_fk", + "tableFrom": "hawser_tokens", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "host_metrics": { + "name": "host_metrics", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_percent": { + "name": "cpu_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_percent": { + "name": "memory_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_used": { + "name": "memory_used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_total": { + "name": "memory_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "host_metrics_env_timestamp_idx": { + "name": "host_metrics_env_timestamp_idx", + "columns": [ + "environment_id", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "host_metrics_environment_id_environments_id_fk": { + "name": "host_metrics_environment_id_environments_id_fk", + "tableFrom": "host_metrics", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ldap_config": { + "name": "ldap_config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "server_url": { + "name": "server_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bind_password": { + "name": "bind_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'(uid={{username}})'" + }, + "username_attribute": { + "name": "username_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'uid'" + }, + "email_attribute": { + "name": "email_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'mail'" + }, + "display_name_attribute": { + "name": "display_name_attribute", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'cn'" + }, + "group_base_dn": { + "name": "group_base_dn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_filter": { + "name": "group_filter", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_group": { + "name": "admin_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_enabled": { + "name": "tls_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "tls_ca": { + "name": "tls_ca", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_settings": { + "name": "notification_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_types": { + "name": "event_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oidc_config": { + "name": "oidc_config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "issuer_url": { + "name": "issuer_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'openid profile email'" + }, + "username_claim": { + "name": "username_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'preferred_username'" + }, + "email_claim": { + "name": "email_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'email'" + }, + "display_name_claim": { + "name": "display_name_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'name'" + }, + "admin_claim": { + "name": "admin_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_value": { + "name": "admin_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role_mappings_claim": { + "name": "role_mappings_claim", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'groups'" + }, + "role_mappings": { + "name": "role_mappings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_container_updates": { + "name": "pending_container_updates", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_image": { + "name": "current_image", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_image_update": { + "name": "has_image_update", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "newer_version": { + "name": "newer_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pending_container_updates_environment_id_container_id_unique": { + "name": "pending_container_updates_environment_id_container_id_unique", + "columns": [ + "environment_id", + "container_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pending_container_updates_environment_id_environments_id_fk": { + "name": "pending_container_updates_environment_id_environments_id_fk", + "tableFrom": "pending_container_updates", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "registries": { + "name": "registries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "registries_name_unique": { + "name": "registries_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_ids": { + "name": "environment_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_executions": { + "name": "schedule_executions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_type": { + "name": "schedule_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_name": { + "name": "entity_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_at": { + "name": "triggered_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logs": { + "name": "logs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "schedule_executions_type_id_idx": { + "name": "schedule_executions_type_id_idx", + "columns": [ + "schedule_type", + "schedule_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_executions_environment_id_environments_id_fk": { + "name": "schedule_executions_environment_id_environments_id_fk", + "tableFrom": "schedule_executions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "secret_providers": { + "name": "secret_providers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "secret_providers_name_unique": { + "name": "secret_providers_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stack_environment_variables": { + "name": "stack_environment_variables", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_secret": { + "name": "is_secret", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "stack_environment_variables_stack_name_environment_id_key_unique": { + "name": "stack_environment_variables_stack_name_environment_id_key_unique", + "columns": [ + "stack_name", + "environment_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "stack_environment_variables_environment_id_environments_id_fk": { + "name": "stack_environment_variables_environment_id_environments_id_fk", + "tableFrom": "stack_environment_variables", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stack_events": { + "name": "stack_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "stack_events_environment_id_environments_id_fk": { + "name": "stack_events_environment_id_environments_id_fk", + "tableFrom": "stack_events", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "stack_sources": { + "name": "stack_sources", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "stack_name": { + "name": "stack_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'internal'" + }, + "git_repository_id": { + "name": "git_repository_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "git_stack_id": { + "name": "git_stack_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compose_path": { + "name": "compose_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env_path": { + "name": "env_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secret_provider_id": { + "name": "secret_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "injected_secret_keys": { + "name": "injected_secret_keys", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "stack_sources_stack_name_environment_id_unique": { + "name": "stack_sources_stack_name_environment_id_unique", + "columns": [ + "stack_name", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "stack_sources_environment_id_environments_id_fk": { + "name": "stack_sources_environment_id_environments_id_fk", + "tableFrom": "stack_sources", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "stack_sources_git_repository_id_git_repositories_id_fk": { + "name": "stack_sources_git_repository_id_git_repositories_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_repositories", + "columnsFrom": [ + "git_repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "stack_sources_git_stack_id_git_stacks_id_fk": { + "name": "stack_sources_git_stack_id_git_stacks_id_fk", + "tableFrom": "stack_sources", + "tableTo": "git_stacks", + "columnsFrom": [ + "git_stack_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "stack_sources_secret_provider_id_secret_providers_id_fk": { + "name": "stack_sources_secret_provider_id_secret_providers_id_fk", + "tableFrom": "stack_sources", + "tableTo": "secret_providers", + "columnsFrom": [ + "secret_provider_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "template_sources": { + "name": "template_sources", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "builtin": { + "name": "builtin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "template_sources_source_id_unique": { + "name": "template_sources_source_id_unique", + "columns": [ + "source_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_preferences_user_id_environment_id_key_unique": { + "name": "user_preferences_user_id_environment_id_key_unique", + "columns": [ + "user_id", + "environment_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_preferences_environment_id_environments_id_fk": { + "name": "user_preferences_environment_id_environments_id_fk", + "tableFrom": "user_preferences", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_roles_user_id_role_id_environment_id_unique": { + "name": "user_roles_user_id_role_id_environment_id_unique", + "columns": [ + "user_id", + "role_id", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_environment_id_environments_id_fk": { + "name": "user_roles_environment_id_environments_id_fk", + "tableFrom": "user_roles", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'local'" + }, + "mfa_enabled": { + "name": "mfa_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "mfa_secret": { + "name": "mfa_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_login": { + "name": "last_login", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vulnerability_scans": { + "name": "vulnerability_scans", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_id": { + "name": "image_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_name": { + "name": "image_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scanner": { + "name": "scanner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scanned_at": { + "name": "scanned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scan_duration": { + "name": "scan_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "critical_count": { + "name": "critical_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "high_count": { + "name": "high_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "medium_count": { + "name": "medium_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "low_count": { + "name": "low_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "negligible_count": { + "name": "negligible_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unknown_count": { + "name": "unknown_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "vulnerabilities": { + "name": "vulnerabilities", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "vulnerability_scans_env_image_idx": { + "name": "vulnerability_scans_env_image_idx", + "columns": [ + "environment_id", + "image_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "vulnerability_scans_environment_id_environments_id_fk": { + "name": "vulnerability_scans_environment_id_environments_id_fk", + "tableFrom": "vulnerability_scans", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f84c52f..284538d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1786892843482, "tag": "0011_pending_updates_semver", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1786967313223, + "tag": "0012_add_git_stack_branch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/git-stack-branch.ts b/src/lib/git-stack-branch.ts new file mode 100644 index 0000000..e1bff98 --- /dev/null +++ b/src/lib/git-stack-branch.ts @@ -0,0 +1,80 @@ +/** + * Pure branch-selection helpers for git stacks. + * + * These functions are dependency-free and safe to import from the browser and + * from unit tests without pulling in native/server initialisation + * (better-sqlite3 / argon2, etc). Both the production code (sync/deploy in + * src/lib/server/git.ts, the stacks routes) and the stacks UI import them, so + * a single implementation is the source of truth for "which branch does this + * stack deploy from" — the tests in tests/git-stack-branch.test.ts exercise + * these exact functions rather than a mirrored copy. + */ + +/** + * The effective branch a git stack deploys from. + * + * A per-stack branch override (git_stacks.branch) wins; when it is unset, + * null, empty, or whitespace-only, the repository's default branch is used. + */ +export function resolveStackBranch( + gitStack: { branch: string | null } | null | undefined, + repository: { branch: string } +): string { + const override = gitStack?.branch?.trim(); + return override || repository.branch; +} + +/** + * Effective branch for display in the stacks UI (list badge / stack modal + * header). Follows the same rule as resolveStackBranch, but tolerates a + * missing repository and reports whether the shown branch is a per-stack + * override: + * + * - branch: the per-stack override when set (non-blank), else the + * repository default (or undefined when neither is available). + * - perStack: true when the shown branch is a per-stack override (a + * non-blank stored value), else null (shown branch is the repository + * default / nothing set). + * + * Consistency: a blank (empty / whitespace-only) override is NOT treated as a + * per-stack branch — it falls back to the repository default, exactly as + * resolveStackBranch does for deploy/sync. Only a non-blank stored override is + * shown as a per-stack branch. + */ +export function effectiveStackBranch( + gitStack: { branch?: string | null } | null | undefined, + repository: { branch?: string } | null | undefined +): { branch: string | undefined; perStack: boolean | null } { + const raw = gitStack?.branch ?? null; + const perStackBranch = typeof raw === 'string' && raw.trim() ? raw.trim() : null; + return { + branch: perStackBranch || (repository?.branch || undefined), + perStack: perStackBranch ? true : null + }; +} + +/** + * Normalise the `branch` field of the PUT /api/git/stacks/:id partial update + * into a next stored value. + * + * Semantics (the API contract the stacks route implements): + * - key ABSENT in the body -> leave the current override untouched + * ({ next: current, changed: false }); + * - explicit `null` -> clear the override so the stack inherits the + * repository default ({ next: null, changed: true }); + * - non-blank string -> set the per-stack override, stored + * whitespace-trimmed ({ next: , changed: true }); + * - blank / whitespace-only -> clear the override, normalised to null so a + * blank save is treated as "inherit repository default" consistently + * ({ next: null, changed: true }). + */ +export function normalizeStackBranchUpdate( + current: string | null, + body: Record +): { next: string | null; changed: boolean } { + if (!('branch' in body)) return { next: current, changed: false }; + const value = body.branch; + if (value === null) return { next: null, changed: true }; + const trimmed = typeof value === 'string' ? value.trim() : ''; + return { next: trimmed || null, changed: true }; +} diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index b7cfe74..f190bf6 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -12188,6 +12188,92 @@ ] } }, + "/api/git/branches": { + "post": { + "operationId": "post_api_git_branches", + "tags": [ + "git" + ], + "summary": "List remote branches for a git repository via `git ls-remote`", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "branches": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "branches" + ] + }, + "example": { + "branches": [ + "main", + "develop", + "feature/test" + ] + } + } + } + }, + "400": { + "description": "The URL points at a loopback/link-local/metadata/reserved target, the credential does not match the URL host, or neither repositoryId nor url was supplied" + }, + "403": { + "description": "Permission denied (requires git:edit)" + }, + "404": { + "description": "The referenced repository does not exist" + }, + "500": { + "description": "Failed to fetch branches (ls-remote error or timeout)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. A raw url may only be paired with a stored credentialId whose username plausibly matches that host (SSH credentials are always allowed). The ls-remote is bounded by a hard timeout.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repositoryId": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + } + }, + "example": { + "url": "https://github.com/example/repo.git", + "credentialId": 2 + } + } + } + } + } + }, "/api/git/credentials": { "get": { "operationId": "get_api_git_credentials", @@ -12713,10 +12799,10 @@ } }, "400": { - "description": "composePath missing, neither repositoryId nor url supplied, or the repo/env-file preview reported an error" + "description": "composePath missing, neither repositoryId nor url supplied, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport (ext::/file::), the credential does not match the URL host, the compose/env path escapes the repository, or the repo/env-file preview reported an error" }, - "401": { - "description": "Authentication required" + "403": { + "description": "Permission denied (requires the git:edit permission — same model as /api/git/branches; an unauthenticated or read-only user is denied here rather than via a separate 401)" }, "404": { "description": "The referenced repository does not exist" @@ -12733,7 +12819,7 @@ "bearerAuth": [] } ], - "description": "repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials.", + "description": "repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. SECURITY: requires the git:edit permission when authentication is enabled (403 otherwise). The repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense); and composePath/envFilePath are constrained to stay inside the cloned repository (path traversal).", "requestBody": { "required": true, "content": { @@ -13485,11 +13571,14 @@ } }, "400": { - "description": "The url field is missing" + "description": "The url field is missing, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport, or the credential does not match the URL host" }, "403": { "description": "Caller lacks the settings:manage permission" }, + "404": { + "description": "The referenced credential does not exist" + }, "500": { "description": "The connectivity test failed" } @@ -13502,7 +13591,7 @@ "bearerAuth": [] } ], - "description": "credentialId from GET /api/git/credentials.", + "description": "credentialId from GET /api/git/credentials. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense).", "requestBody": { "required": true, "content": { diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 5369975..ec8c25e 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -2295,6 +2295,7 @@ export interface GitStackData { environmentId: number | null; repositoryId: number; composePath: string; + branch: string | null; // Per-stack branch override; null = use repository default envFilePath: string | null; autoUpdate: boolean; autoUpdateSchedule: 'daily' | 'weekly' | 'custom'; @@ -2333,6 +2334,7 @@ export async function getGitStacks(environmentId?: number): Promise stackName: gitStacks.stackName, environmentId: gitStacks.environmentId, repositoryId: gitStacks.repositoryId, + branch: gitStacks.branch, composePath: gitStacks.composePath, envFilePath: gitStacks.envFilePath, autoUpdate: gitStacks.autoUpdate, @@ -2536,6 +2543,7 @@ export async function getGitStack(id: number): Promise stackName: row.stackName, environmentId: row.environmentId, repositoryId: row.repositoryId, + branch: row.branch ?? null, composePath: row.composePath, envFilePath: row.envFilePath, autoUpdate: row.autoUpdate, @@ -2571,6 +2579,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe stackName: gitStacks.stackName, environmentId: gitStacks.environmentId, repositoryId: gitStacks.repositoryId, + branch: gitStacks.branch, composePath: gitStacks.composePath, envFilePath: gitStacks.envFilePath, autoUpdate: gitStacks.autoUpdate, @@ -2610,6 +2619,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe stackName: row.stackName, environmentId: row.environmentId, repositoryId: row.repositoryId, + branch: row.branch ?? null, composePath: row.composePath, envFilePath: row.envFilePath, autoUpdate: row.autoUpdate, @@ -2644,6 +2654,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise): P if (data.stackName !== undefined) updateData.stackName = data.stackName; if (data.repositoryId !== undefined) updateData.repositoryId = data.repositoryId; if (data.composePath !== undefined) updateData.composePath = data.composePath; + if (data.branch !== undefined) updateData.branch = data.branch || null; if (data.envFilePath !== undefined) updateData.envFilePath = data.envFilePath; if (data.autoUpdate !== undefined) updateData.autoUpdate = data.autoUpdate; if (data.autoUpdateSchedule !== undefined) updateData.autoUpdateSchedule = data.autoUpdateSchedule; @@ -2821,6 +2836,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise { stackName: row.stackName, environmentId: row.environmentId, repositoryId: row.repositoryId, + branch: row.branch ?? null, composePath: row.composePath, autoUpdate: row.autoUpdate, autoUpdateSchedule: row.autoUpdateSchedule, diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 1f88beb..716ab84 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -321,6 +321,7 @@ export const gitStacks = sqliteTable('git_stacks', { stackName: text('stack_name').notNull(), environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }), + branch: text('branch'), // Per-stack branch override; null = use repository default composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110) envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod") autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false), diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index ca41645..5bb3ad8 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -324,6 +324,7 @@ export const gitStacks = pgTable('git_stacks', { stackName: text('stack_name').notNull(), environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }), + branch: text('branch'), // Per-stack branch override; null = use repository default composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110) envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod") autoUpdate: boolean('auto_update').default(false), diff --git a/src/lib/server/git-branch-lookup.ts b/src/lib/server/git-branch-lookup.ts new file mode 100644 index 0000000..eb0f248 --- /dev/null +++ b/src/lib/server/git-branch-lookup.ts @@ -0,0 +1,178 @@ +/** + * Security guards for user-supplied git repository URLs on the branch-lookup + * and env-preview flows (POST /api/git/branches, POST /api/git/preview-env). + * + * SECURITY CONTEXT (PR #1343 maintainer review, rounds 1+2): + * + * 1. SSRF — the URL is handed to a git subprocess whose libcurl resolves + * EVERY legal IP encoding (decimal 2130706433, octal 0177.0.0.1, short + * form 127.1, hex 0x7f000001, un-compressed v4-mapped IPv6). Hand-rolled + * IP normalization is a rabbit hole — the SSRF check therefore delegates + * to isSafeNotificationUrl (src/lib/server/url-safety.ts), which is + * URL()-based, canonicalizes all of the encodings above, and is the same + * battle-tested guard our secret-provider hosts use. We only prepend a + * scheme so that ssh:// and scp-like URLs (which are valid git remotes + * but not valid URL() inputs) can be canonicalized. + * + * 2. Credential affinity — a raw url + stored credentialId is ONLY allowed + * when the credential's username is plausibly FOR that host (username == + * host, or a single-label credential username equal to the host). No + * name-segment inference, no first-label matching: a single-label + * credential username ("github", "admin", "git") plus an attacker + * subdomain (github.evil.com) would otherwise deliver the decrypted + * secret to the attacker. The repositoryId path is not subject to this + * guard — the pairing is the user's own stored repository config. + * + * 3. Transport denylist — assertSafeRepoUrl (git-url-safety.ts) rejects the + * ext::/fd::/file:: transports and local paths. listRemoteBranches calls + * it explicitly so the denylist holds on every path into a git + * subprocess, not only via buildRepoUrl. + * + * Import-light (no db/sqlite) so it stays unit-testable. + */ + +import { isSafeNotificationUrl } from './url-safety'; +import { assertSafeRepoUrl } from './git-url-safety'; +import type { GitCredential } from './db'; + +/** + * Parse the host out of a git repository URL. + * + * Supported forms: + * https://host[:port]/path, http://... → host (port stripped) + * ssh://[user@]host[:port]/path → host + * git://host[:port]/path → host + * [user@]host:path (scp-like) → host + * [fe80::1] / [fe80::1]:port → fe80::1 + * + * Returns null when no host can be determined (bare path — already rejected + * by assertSafeRepoUrl upstream, or a scheme we don't accept). + */ +export function parseRepoHost(url: string): string | null { + const u = (url || '').trim(); + if (!u) return null; + const schemeMatch = u.match(/^[a-z][a-z0-9+.-]*:/i); + if (schemeMatch) { + const scheme = schemeMatch[0].slice(0, -1).toLowerCase(); + if (!['http', 'https', 'ssh', 'git'].includes(scheme)) return null; + const rest = u.slice(schemeMatch[0].length).replace(/^\/\//, ''); + // IPv6 in brackets: [fe80::1] or [fe80::1]:8080 — strip brackets. + const bracket = rest.match(/^\[([0-9a-fA-F:.]+)\]/); + if (bracket) return bracket[1].toLowerCase(); + const hostPart = rest.split('/')[0] || ''; + const atIdx = hostPart.lastIndexOf('@'); + const hostWithPort = atIdx >= 0 ? hostPart.slice(atIdx + 1) : hostPart; + const host = hostWithPort.split(':')[0]; + return host ? host.toLowerCase() : null; + } + // No scheme. IPv6 in brackets: [fe80::1], possibly with a port. + const bracket = u.match(/^\[([0-9a-fA-F:.]+)\](?::\d+)?$/); + if (bracket) return bracket[1].toLowerCase(); + // scp-like: [user@]host:path + const colonIdx = u.indexOf(':'); + if (colonIdx <= 0) return null; // no scheme, no scp colon → no host + const hostPart = u.slice(0, colonIdx); + const atIdx = hostPart.lastIndexOf('@'); + const host = atIdx >= 0 ? hostPart.slice(atIdx + 1) : hostPart; + return host ? host.toLowerCase() : null; +} + +// --------------------------------------------------------------------------- +// Shared host safety check — ONE canonicalization path for every scheme. +// +// isSafeNotificationUrl is URL()-based: new URL() canonicalizes every +// legal IP encoding (decimal 2130706433, hex 0x7f000001, octal, short-form +// 127.1, v4-mapped IPv6) BEFORE the range check, and its hostname +// denylist covers internal hostnames. For non-http(s) git schemes we can't +// hand the original URL to it (URL() rejects ssh:// / git:// and scp-like +// syntax), so we canonicalize the PARSED host (port already stripped by +// parseRepoHost, brackets stripped for IPv6) under an https:// scheme and +// run it through the same battle-tested check. This closes the encoded-IP +// bypass on the ssh/git path: ipCategory matches only a plain dotted-quad, +// so decimal/hex/short-form hosts sailed through it (2026-08-21 +// maintainer re-review). +// --------------------------------------------------------------------------- +function assertHostSafeFromParse(parsedHost: string): void { + // IPv6 literals need brackets for URL() to accept them — but ONLY if the + // host actually contains a colon. Wrapping a plain IPv4 literal + // (10.0.0.5) in brackets produces an invalid URL() input, which would + // be rejected as "not a valid URL" and block every LAN IP. + const host = parsedHost.includes(':') ? `[${parsedHost}]` : parsedHost; + const check = isSafeNotificationUrl(`https://${host}/`); + if (!check.ok) { + throw new Error( + `Repository URL host is not allowed (loopback, cloud-metadata or other reserved/dangerous target): ${check.reason}` + ); + } +} + +/** + * Guard 1: reject a git repository URL that points at a dangerous target + * (SSRF defense), or a hostname that resolves to internal infrastructure. + * + * Policy (shared isSafeNotificationUrl / dangerousHostReason, src/lib/server/ + * url-safety.ts): loopback (127.x/::1), cloud-metadata / link-local + * (169.254.169.254), multicast/reserved and localhost are REJECTED. + * Ordinary private-LAN ranges (10.x / 192.168.x / 172.16-31.x) are INTENTIONALLY + * ALLOWED so self-hosted Git servers on the RFC1918 LAN keep working — this + * is Dockhand's self-hosted-service policy (same as the notification + * receivers and backup repos). Do NOT "fix" this into the strict + * isSafeWebhookUrl / privateIpReason policy (that blocks all private) — + * doing so would break self-hosted Git on a LAN. + * + * Every scheme is judged on the CANONICALIZED host through + * isSafeNotificationUrl (see assertHostSafeFromParse): http(s) via the + * original URL, ssh/git/scp-like via the parsed host. localhost is blocked + * explicitly so ssh://localhost and scp-like localhost stay covered by one + * code path. + */ +export function assertSafeRepoTarget(url: string): void { + assertSafeRepoUrl(url); // transport denylist (ext::/fd::/file::/local paths) + const host = parseRepoHost(url); + if (!host) { + throw new Error('Invalid repository URL for branch lookup'); + } + if (host === 'localhost' || host.endsWith('.localhost')) { + throw new Error('Repository URL host is not allowed for branch lookup'); + } + assertHostSafeFromParse(host); +} + +/** + * Guard 2: a raw URL may only be paired with a stored password credential + * when the credential is plausibly FOR that host (credential-exfiltration + * defense). + * + * Rules: + * - `ssh` / `none` credentials: ALWAYS allowed (SSH keys are validated by + * the remote server; there is no secret to leak for `none`). + * - `password` credentials: + * the credential's username equals the URL host (PAT-style: the + * username field holds the account name that the host expects). + * + * Deliberately NO name-segment inference and NO first-label matching: a + * single-label credential username ("github", "admin", "git") would match + * ANY attacker subdomain (github.evil.com / admin.evil.com) and deliver + * the decrypted secret there. Hostnames are compared exactly (case-insensitive, + * with a port stripped) — a single-label username only matches a single-label + * host, which cannot be an attacker-chosen subdomain of another host. + */ +export function assertCredentialHostMatch(url: string, credential: GitCredential): void { + if (!credential) return; + if (credential.authType === 'ssh' || credential.authType === 'none') return; + + const host = parseRepoHost(url); + if (!host) { + throw new Error('Invalid repository URL for branch lookup'); + } + + const username = (credential.username || '').trim().toLowerCase(); + // Username must match the full host exactly (PAT-style). A username + // containing a dot matches only a dot-containing host — never a + // subdomain of it. + if (username && username === host) return; + + throw new Error( + 'Stored credential does not match the repository URL host' + ); +} diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 3fc1b7b..0ba2895 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -19,6 +19,8 @@ import { deployStack, getStackDir } from './stacks'; import { sendEventNotification } from './notifications'; import { buildBasicAuthHeader } from './git-auth'; import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath } from './git-url-safety'; +import { assertSafeRepoTarget } from './git-branch-lookup'; +import { resolveStackBranch } from '../git-stack-branch'; import { parseManifest, serializeManifest, @@ -325,20 +327,89 @@ function buildRepoUrl(url: string, credential: GitCredential | null): string { return url; } -async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> { - try { +/** + * Hard timeout (ms) for the remote-branch lookup (git ls-remote) ONLY. + * The lookup is the single git call whose target URL can be driven by an + * attacker-chosen URL (POST /api/git/branches, the new-repository branch + * picker), so an unbounded ls-remote is a resource-exhaustion vector. + * NOTE: this constant is deliberately NOT applied to ordinary deploy/sync + * operations (clone / pull / fetch / rev-parse) — those target the + * operator's own repository over a controlled URL, and a legitimate + * large-repo clone or slow WAN fetch can exceed this limit, so they stay + * UNBOUNDED (execGit without an explicit timeout). */ +export const GIT_TIMEOUT_MS = Number(process.env.GIT_TIMEOUT_MS) || 20000; + +/** + * Run a git command. + * + * Timeouts are OPT-IN: the default is UNBOUNDED. Ordinary deploy/sync + * operations (clone, pull, fetch, rev-parse) are unbounded because their + * target is the operator's own repository and a slow network or large repo + * is a legitimate outcome — they must not be killed mid-flight. Callers + * that need a bounded operation (the remote-branch lookup, whose target + * URL is attacker-influenced) pass `timeoutMs` explicitly (see + * listRemoteBranches). + */ +function execGit( + args: string[], + cwd: string, + env: GitEnv, + timeoutMs?: number +): Promise<{ stdout: string; stderr: string; code: number; timedOut?: boolean }> { + return new Promise((resolve) => { + let settled = false; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const proc = nodeSpawn('git', args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); - const result = await collectProcess(proc); + const finish = ( + partial: { stdout: string; stderr: string; code: number; timedOut?: boolean } + ) => { + if (settled) return; + settled = true; + // Only clear a timer if one was actually created — the default + // (no timeout) never creates one. + if (timer) clearTimeout(timer); + if (partial.timedOut) { + // The process is still running — kill it so the git subprocess + // (and any child ssh) cannot keep running after we give up. + proc.kill('SIGKILL'); + } + resolve(partial); + }; - return { stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.exitCode }; - } catch (err: any) { - return { stdout: '', stderr: err.message, code: 1 }; - } + // Only arm a timer when a valid timeout was explicitly provided — + // undefined (or non-finite / non-positive) means "run unbounded". + const timer: ReturnType | undefined = + timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? setTimeout(() => { + finish({ + stdout: Buffer.concat(stdoutChunks).toString(), + stderr: Buffer.concat(stderrChunks).toString(), + code: 124, + timedOut: true + }); + }, timeoutMs) + : undefined; + + proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + proc.on('error', (err: any) => { + finish({ stdout: '', stderr: err.message, code: 1 }); + }); + proc.on('close', (code) => { + finish({ + stdout: Buffer.concat(stdoutChunks).toString(), + stderr: Buffer.concat(stderrChunks).toString(), + code: code ?? 1 + }); + }); + }); } /** @@ -641,6 +712,11 @@ export async function testRepositoryConfig(options: { return { success: false, error: 'Repository URL is required' }; } + // Transport denylist (ext::/fd::/file::/local paths) — applied here so the + // denylist holds on every path into a git subprocess, not only via + // buildRepoUrl (same pattern as listRemoteBranches). + assertSafeRepoUrl(url); + // Fetch credential from database if credentialId is provided const credential = credentialId ? await getGitCredential(credentialId) : null; if (credentialId && !credential) { @@ -654,6 +730,74 @@ export async function testRepositoryConfig(options: { }); } +/** + * List remote branches for a repository URL using git ls-remote. + * Resolves credentials from the database if a credentialId is provided. + * Bounded by a hard timeout (default GIT_TIMEOUT_MS, 20s) so a dead or + * black-holed host cannot stall the request — see execGit. This is the + * only git operation with a timeout: its target URL is the one that can be + * attacker-influenced, so an unbounded ls-remote would be a resource- + * exhaustion vector. + * + * The transport denylist (ext::/fd::/file::/local paths) is applied HERE, + * explicitly, rather than only via buildRepoUrl — a future parser change in + * buildRepoUrl must not be able to reopen command execution on this path. + */ +export async function listRemoteBranches(options: { + url: string; + credentialId?: number | null; + timeoutMs?: number; +}): Promise<{ branches: string[]; error?: string }> { + const { url, credentialId, timeoutMs } = options; + + // The effective branch-lookup timeout: an explicitly provided (valid) + // override wins; otherwise the default GIT_TIMEOUT_MS. This is the ONLY + // bounded git operation — see execGit for why deploy/sync operations are + // left unbounded. + const effectiveTimeoutMs = timeoutMs ?? GIT_TIMEOUT_MS; + + // Transport denylist — the same check buildRepoUrl applies, applied + // explicitly here so the denylist holds even if buildRepoUrl changes. + assertSafeRepoUrl(url); + + // Fetch credential from database if credentialId is provided + const credential = credentialId ? await getGitCredential(credentialId) : null; + + const env = await buildGitEnv(credential); + const repoUrl = buildRepoUrl(url, credential); + + try { + const result = await execGit( + ['ls-remote', '--heads', '--refs', repoUrl], + process.cwd(), + env, + effectiveTimeoutMs + ); + + if (result.timedOut) { + return { branches: [], error: `git ls-remote timed out after ${Math.round(effectiveTimeoutMs / 1000)}s` }; + } + + if (result.code !== 0) { + return { branches: [], error: cleanGitError(result.stderr) }; + } + + const branches = result.stdout.split('\n') + .filter(l => l.trim()) + .map(l => { + const m = l.match(/refs\/heads\/(.+)$/); + return m ? m[1] : null; + }) + .filter(Boolean) as string[]; + + return { branches }; + } catch (error: any) { + return { branches: [], error: error.message }; + } finally { + cleanupSshKey(credential); + } +} + export async function syncRepository(repoId: number): Promise { const repo = await getGitRepository(repoId); if (!repo) { @@ -872,6 +1016,13 @@ async function getPreviousCommit(repoPath: string, env: GitEnv): Promise { const gitStack = await getGitStack(stackId); if (!gitStack) { @@ -901,8 +1052,14 @@ export async function syncGitStack(stackId: number): Promise { return { success: false, error: 'Repository not found' }; } + // Per-stack branch override wins; unset means the repository default + const effectiveBranch = resolveStackBranch(gitStack, repo); + console.log(`${logPrefix} Repository URL:`, repo.url); console.log(`${logPrefix} Repository branch:`, repo.branch); + if (effectiveBranch !== repo.branch) { + console.log(`${logPrefix} Stack branch override:`, effectiveBranch); + } const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); @@ -928,11 +1085,11 @@ export async function syncGitStack(stackId: number): Promise { } console.log(`${logPrefix} Cloning repository...`); - assertSafeGitRef(repo.branch); + assertSafeGitRef(effectiveBranch); const repoUrl = buildRepoUrl(repo.url, credential); const result = await execGit( - ['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath], + ['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath], process.cwd(), env ); @@ -1283,15 +1440,18 @@ export async function testGitStack(stackId: number): Promise { return { success: false, error: 'Repository not found' }; } + // Per-stack branch override wins; unset means the repository default + const effectiveBranch = resolveStackBranch(gitStack, repo); + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const env = await buildGitEnv(credential); - assertSafeGitRef(repo.branch); + assertSafeGitRef(effectiveBranch); const repoUrl = buildRepoUrl(repo.url, credential); try { // Use git ls-remote to test connection and get branch info const result = await execGit( - ['ls-remote', '--heads', '--refs', repoUrl, repo.branch], + ['ls-remote', '--heads', '--refs', repoUrl, effectiveBranch], process.cwd(), env ); @@ -1305,12 +1465,12 @@ export async function testGitStack(stackId: number): Promise { // Parse the output to get commit hash const lines = result.stdout.split('\n').filter(l => l.trim()); if (lines.length === 0) { - return { success: false, error: `Branch '${repo.branch}' not found in repository` }; + return { success: false, error: `Branch '${effectiveBranch}' not found in repository` }; } const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/); const lastCommit = match ? match[1].substring(0, 7) : undefined; - const branch = match ? match[2] : repo.branch; + const branch = match ? match[2] : effectiveBranch; cleanupSshKey(credential, env); @@ -1368,6 +1528,9 @@ export async function deployGitStackWithProgress( return { success: false, error: 'Repository not found' }; } + // Per-stack branch override wins; unset means the repository default + const effectiveBranch = resolveStackBranch(gitStack, repo); + const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null; const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId); const env = await buildGitEnv(credential); @@ -1394,13 +1557,13 @@ export async function deployGitStackWithProgress( rmSync(repoPath, { recursive: true, force: true }); } - assertSafeGitRef(repo.branch); + assertSafeGitRef(effectiveBranch); const repoUrl = buildRepoUrl(repo.url, credential); // Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand) - onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps }); + onProgress({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps }); const cloneResult = await execGit( - ['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath], + ['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath], process.cwd(), env ); @@ -1801,6 +1964,20 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise

= {}; @@ -1846,9 +2024,10 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise

Promise; + /** Look up an existing repository by id. */ + getGitRepository: (id: number) => Promise; + /** Look up a stored credential by id. */ + getGitCredential: (id: number) => Promise; + /** Clone the repo and read env files. */ + previewRepoEnvFiles: (opts: PreviewEnvPreviewOptions) => Promise; + /** SSRF + transport denylist guard. Throws on unsafe target. */ + assertSafeRepoTarget: (url: string) => void; + /** Credential-exfiltration defense. Throws on host mismatch. */ + assertCredentialHostMatch: (url: string, credential: PreviewEnvCredential) => void; +} + +export interface PreviewEnvAuthContext { + authEnabled: boolean; + isAuthenticated: boolean; + user: unknown; + isAdmin: boolean; + isEnterprise: boolean; + can: (resource: string, action: string) => Promise; +} + +export interface PreviewEnvRepository { + url: string; + branch: string; + credentialId: number | null; +} + +export interface PreviewEnvCredential { + id: number; + name: string; + authType: string; + username: string; + secret?: string; +} + +export interface PreviewEnvPreviewOptions { + repoUrl: string; + branch: string; + credential: PreviewEnvCredential | null; + composePath: string; + envFilePath: string | null; +} + +export interface PreviewEnvPreviewResult { + vars: Record; + sources: Record; + error?: string; +} + +/** + * The result of the preview-env pipeline. The route maps this to an HTTP + * response (status + json body). + */ +export type PreviewEnvOutcome = + | { kind: 'success'; vars: Record; sources: Record } + | { kind: 'permission-denied' } + | { kind: 'bad-request'; message: string; vars?: Record; sources?: Record } + | { kind: 'not-found' } + | { kind: 'error'; message: string }; + +// --------------------------------------------------------------------------- +// Handler core +// --------------------------------------------------------------------------- + +/** + * The preview-env handler pipeline. This is the SAME code path the POST route + * executes (the route resolves the real dependencies and forwards to this + * function), so testing this function with injected fakes exercises the real + * production logic — not a mirror. + * + * The `git:edit` permission gate (PR #1343) runs BEFORE `request.json()` + * (attacker-controlled input), repository/credential lookup, URL processing, + * and any git subprocess. + */ +export async function handlePreviewEnv( + deps: PreviewEnvDependencies, + args: { request: { json: () => Promise }; cookies: unknown } +): Promise { + const auth = await deps.authorize(args.cookies); + + // Permission gate (PR #1343): the endpoint clones a USER-SUPPLIED URL and + // reads env files from it, so it is gated on the git:edit permission — + // the EXACT same model as POST /api/git/branches. This single check covers + // every authenticated case: + // - auth disabled -> can('git','edit') is true, allowed to continue; + // - auth enabled + unauthenticated -> can('git','edit') is false -> 403; + // - auth enabled + read-only -> can('git','edit') is false -> 403; + // - auth enabled + git:edit -> allowed to continue. + // (There is deliberately no separate 401 branch — an unauthenticated + // request simply lacks the git:edit permission and is denied, matching + // /api/git/branches.) + if (auth.authEnabled && !(await auth.can('git', 'edit'))) { + return { kind: 'permission-denied' }; + } + + try { + const data = await args.request.json(); + + if (!data.composePath || typeof data.composePath !== 'string') { + return { kind: 'bad-request', message: 'Compose path is required' }; + } + + let repoUrl: string; + let branch: string = 'main'; + let credentialId: number | null = null; + + if (data.repositoryId) { + // Use existing repository + const repo = await deps.getGitRepository(data.repositoryId); + if (!repo) { + return { kind: 'not-found' }; + } + repoUrl = repo.url; + // An explicit branch (e.g. a per-stack override being previewed) + // wins over the repository default. + branch = typeof data.branch === 'string' && data.branch.trim() ? data.branch.trim() : repo.branch; + credentialId = repo.credentialId; + } else if (data.url) { + // New repository details + repoUrl = data.url; + branch = data.branch || 'main'; + credentialId = data.credentialId || null; + } else { + return { kind: 'bad-request', message: 'Either repositoryId or url is required' }; + } + + // Get credential if specified + let credential: PreviewEnvCredential | null = null; + if (credentialId) { + credential = await deps.getGitCredential(credentialId); + } + + // Security (PR #1343 maintainer review): the preview endpoint clones a + // USER-SUPPLIED URL and reads env files from it. Run the shared guards + // BEFORE previewRepoEnvFiles spawns git / reads files. + // 1. assertSafeRepoTarget — SSRF + transport denylist. + try { + deps.assertSafeRepoTarget(repoUrl); + } catch (e: any) { + return { kind: 'bad-request', message: e.message || 'Invalid repository URL' }; + } + // 2. assertCredentialHostMatch — a raw url may only be paired with a + // stored credential plausibly for that host (exfiltration defense). + // The repositoryId path is safe (user's own stored config). + if (data.url && credentialId && credential) { + try { + deps.assertCredentialHostMatch(repoUrl, credential); + } catch (e: any) { + return { kind: 'bad-request', message: e.message || 'Invalid repository URL' }; + } + } + + const result = await deps.previewRepoEnvFiles({ + repoUrl, + branch, + credential, + composePath: data.composePath, + envFilePath: data.envFilePath || null + }); + + if (result.error) { + return { kind: 'bad-request', message: result.error, vars: {}, sources: {} }; + } + + return { kind: 'success', vars: result.vars, sources: result.sources }; + } catch (error: any) { + return { kind: 'error', message: error.message || 'Failed to preview env files' }; + } +} diff --git a/src/routes/api/git/branches/+server.ts b/src/routes/api/git/branches/+server.ts new file mode 100644 index 0000000..becfa17 --- /dev/null +++ b/src/routes/api/git/branches/+server.ts @@ -0,0 +1,120 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getGitRepository, getGitCredential } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { listRemoteBranches } from '$lib/server/git'; +import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git-branch-lookup'; + +/** + * POST /api/git/branches + * List remote branches for a repository via `git ls-remote`. + * + * SECURITY (PR #1343 maintainer review, rounds 1+2): two guards run before + * any git subprocess is spawned. + * 1. assertSafeRepoTarget — the shared SSRF policy (delegated to + * isSafeNotificationUrl, src/lib/server/url-safety.ts): loopback, + * link-local / cloud-metadata and other reserved/dangerous targets are + * rejected, while ordinary private-LAN addresses are INTENTIONALLY + * ALLOWED so self-hosted Git servers on RFC1918 ranges (10.x / + * 192.168.x / 172.16-31.x) keep working. Runs on BOTH the `url` and + * `repositoryId` paths (a stored repository's URL could also point + * internal). + * 2. assertCredentialHostMatch — a raw `url` may only be paired with a + * stored `credentialId` when the credential is plausibly for that host + * (credential-exfiltration defense). The `repositoryId` path is safe — + * the pairing is the user's own stored repository config, not an + * attacker-chosen url+credential combo. + * The `ls-remote` itself is bounded by a hard timeout (see listRemoteBranches + * in src/lib/server/git.ts) — clone/pull/fetch stay unbounded. + * + * Body: { + * repositoryId?: number, // Existing repository (uses its url + credential) + * url?: string, // OR a new repository URL + * credentialId?: number|null // Credential for the url (new-repo flow) + * } + * + * Returns: { branches: string[] } + */ +/** + * @openapi + * summary: List remote branches for a git repository via `git ls-remote` + * description: Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. A raw url may only be paired with a stored credentialId whose username plausibly matches that host (SSH credentials are always allowed). The ls-remote is bounded by a hard timeout. + * body: {repositoryId:integer, url:string, credentialId:integer} + * body-example: {"url":"https://github.com/example/repo.git","credentialId":2} + * resp-200: {branches:array!} + * resp-200-example: {"branches":["main","develop","feature/test"]} + * resp-400: The URL points at a loopback/link-local/metadata/reserved target, the credential does not match the URL host, or neither repositoryId nor url was supplied + * resp-403: Permission denied (requires git:edit) + * resp-404: The referenced repository does not exist + * resp-500: Failed to fetch branches (ls-remote error or timeout) + */ +export const POST: RequestHandler = async ({ request, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !await auth.can('git', 'edit')) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const body = await request.json(); + const { repositoryId, url, credentialId } = body as { + repositoryId?: number; + url?: string; + credentialId?: number | null; + }; + + let repoUrl: string; + let credId: number | undefined; + + if (repositoryId) { + const repo = await getGitRepository(repositoryId); + if (!repo) { + return json({ error: 'Repository not found' }, { status: 404 }); + } + repoUrl = repo.url; + credId = repo.credentialId ?? undefined; + } else if (url) { + repoUrl = url; + credId = credentialId ?? undefined; + } else { + return json({ error: 'repositoryId or url is required' }, { status: 400 }); + } + + // Guard 1 (SSRF): the shared SSRF policy rejects loopback / + // link-local / cloud-metadata / reserved targets; ordinary + // private-LAN addresses are intentionally allowed (self-hosted Git). + // Runs on BOTH paths — a stored repository's URL could also point + // internal. + try { + assertSafeRepoTarget(repoUrl); + } catch (e: any) { + return json({ error: e.message || 'Invalid repository URL' }, { status: 400 }); + } + + // Guard 2 (credential exfiltration): when a raw url is paired with a + // stored credential, verify the credential is plausibly for that host. + // The repositoryId path is safe — the pairing is the user's own stored + // repository config, not an attacker-chosen url+credential combo. + if (url && credId != null) { + const credential = await getGitCredential(credId); + if (!credential) { + return json({ error: 'Credential not found' }, { status: 404 }); + } + try { + assertCredentialHostMatch(repoUrl, credential); + } catch (e: any) { + return json({ error: e.message || 'Invalid repository URL' }, { status: 400 }); + } + } + + const result = await listRemoteBranches({ url: repoUrl, credentialId: credId }); + + if (result.error) { + return json({ error: 'Failed to fetch branches: ' + result.error }, { status: 500 }); + } + + return json({ branches: result.branches }); + } catch (error: any) { + console.error('Failed to fetch branches:', error); + return json({ error: 'Failed to fetch branches' }, { status: 500 }); + } +}; diff --git a/src/routes/api/git/preview-env/+server.ts b/src/routes/api/git/preview-env/+server.ts index 30964db..8ee22c5 100644 --- a/src/routes/api/git/preview-env/+server.ts +++ b/src/routes/api/git/preview-env/+server.ts @@ -2,13 +2,32 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getGitRepository, getGitCredential } from '$lib/server/db'; import { previewRepoEnvFiles } from '$lib/server/git'; +import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git-branch-lookup'; import { authorize } from '$lib/server/authorize'; - +import { handlePreviewEnv, type PreviewEnvAuthContext, type PreviewEnvCredential, type PreviewEnvRepository, type PreviewEnvPreviewOptions, type PreviewEnvPreviewResult } from '$lib/server/preview-env-handler'; /** * POST /api/git/preview-env * Clone a git repository to a temp directory and read env files for preview. * Used when creating a new git stack to populate the env editor. * + * SECURITY (PR #1343 maintainer review): the endpoint clones a + * USER-SUPPLIED URL and reads env files from it, so it is gated on the + * `git:edit` permission (same model as /api/git/branches) — an authenticated + * read-only user cannot use it to clone an arbitrary repository. Three guards + * run BEFORE any git subprocess is spawned / files are read: + * 1. assertSafeRepoTarget — the shared SSRF policy. Loopback, link-local / + * cloud-metadata and other reserved/dangerous targets are rejected, while + * ordinary private-LAN addresses are intentionally allowed so self-hosted + * Git servers on RFC1918 ranges (10.x / 192.168.x / 172.16-31.x) keep + * working. The ext::/file:: transports and local paths are rejected. + * 2. assertCredentialHostMatch — a raw `url` may only be paired with a + * stored `credentialId` when the credential is plausibly for that host + * (credential-exfiltration defense). The `repositoryId` path is safe — the + * pairing is the user's own stored repository config, not an + * attacker-chosen url+credential combo. + * 3. repoFilePath — composePath/envFilePath are constrained to stay inside + * the cloned repository (path traversal). + * * Body: { * repositoryId?: number, // Existing repository * url?: string, // OR new repo URL @@ -29,76 +48,52 @@ import { authorize } from '$lib/server/authorize'; /** * @openapi * summary: Clone a repo to a temp dir and preview its merged env-file variables for the git-stack env editor - * description: repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. + * description: repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. SECURITY: requires the git:edit permission when authentication is enabled (403 otherwise). The repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense); and composePath/envFilePath are constrained to stay inside the cloned repository (path traversal). * body: {repositoryId:integer, url:string, branch:string, credentialId:integer, composePath:string!, envFilePath:string} * body-example: {"repositoryId":3,"composePath":"docker-compose.yml","envFilePath":".env.prod"} * resp-200: {vars:object!, sources:object!} - * resp-400: composePath missing, neither repositoryId nor url supplied, or the repo/env-file preview reported an error - * resp-401: Authentication required + * resp-400: composePath missing, neither repositoryId nor url supplied, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport (ext::/file::), the credential does not match the URL host, the compose/env path escapes the repository, or the repo/env-file preview reported an error + * resp-403: Permission denied (requires the git:edit permission — same model as /api/git/branches; an unauthenticated or read-only user is denied here rather than via a separate 401) * resp-404: The referenced repository does not exist * resp-500: Failed to preview the env files (clone or read error) */ export const POST: RequestHandler = async ({ request, cookies }) => { - const auth = await authorize(cookies); + // Thin SvelteKit route wrapper. Resolves the real dependencies and calls + // the import-light, dependency-injected handler core + // (src/lib/server/preview-env-handler.ts), which runs the EXACT production + // pipeline: the `git:edit` permission gate, body parsing, SSRF/credential + // guards, and the env-file preview. This route performs the HTTP/status + // mapping so the OpenAPI generator (which statically scans the handler + // body) sees all status codes. + const deps = { + // authorize() returns the full auth context (authEnabled, can, …). + authorize: async (_cookies: unknown) => { + const auth = await authorize(cookies); + return auth as PreviewEnvAuthContext; + }, + getGitRepository: async (id: number) => getGitRepository(id) as Promise, + getGitCredential: async (id: number) => getGitCredential(id) as Promise, + previewRepoEnvFiles: async (opts: PreviewEnvPreviewOptions) => + previewRepoEnvFiles(opts) as Promise, + assertSafeRepoTarget, + assertCredentialHostMatch: assertCredentialHostMatch as (url: string, credential: PreviewEnvCredential) => void + }; + const outcome = await handlePreviewEnv(deps, { request, cookies }); - // Basic permission check - must be able to create stacks - if (auth.authEnabled && !auth.isAuthenticated) { - return json({ error: 'Authentication required' }, { status: 401 }); - } - - try { - const data = await request.json(); - - if (!data.composePath || typeof data.composePath !== 'string') { - return json({ error: 'Compose path is required' }, { status: 400 }); + switch (outcome.kind) { + case 'success': + return json({ vars: outcome.vars, sources: outcome.sources }); + case 'permission-denied': + return json({ error: 'Permission denied' }, { status: 403 }); + case 'bad-request': { + const body: Record = { error: outcome.message }; + if (outcome.vars) body.vars = outcome.vars; + if (outcome.sources) body.sources = outcome.sources; + return json(body, { status: 400 }); } - - let repoUrl: string; - let branch: string = 'main'; - let credentialId: number | null = null; - - if (data.repositoryId) { - // Use existing repository - const repo = await getGitRepository(data.repositoryId); - if (!repo) { - return json({ error: 'Repository not found' }, { status: 404 }); - } - repoUrl = repo.url; - branch = repo.branch; - credentialId = repo.credentialId; - } else if (data.url) { - // New repository details - repoUrl = data.url; - branch = data.branch || 'main'; - credentialId = data.credentialId || null; - } else { - return json({ error: 'Either repositoryId or url is required' }, { status: 400 }); - } - - // Get credential if specified - let credential = null; - if (credentialId) { - credential = await getGitCredential(credentialId); - } - - const result = await previewRepoEnvFiles({ - repoUrl, - branch, - credential, - composePath: data.composePath, - envFilePath: data.envFilePath || null - }); - - if (result.error) { - return json({ vars: {}, sources: {}, error: result.error }, { status: 400 }); - } - - return json({ - vars: result.vars, - sources: result.sources - }); - } catch (error: any) { - console.error('Failed to preview env files:', error); - return json({ error: error.message || 'Failed to preview env files' }, { status: 500 }); + case 'not-found': + return json({ error: 'Repository not found' }, { status: 404 }); + case 'error': + return json({ error: outcome.message }, { status: 500 }); } }; diff --git a/src/routes/api/git/repositories/test/+server.ts b/src/routes/api/git/repositories/test/+server.ts index 957fb42..74157d9 100644 --- a/src/routes/api/git/repositories/test/+server.ts +++ b/src/routes/api/git/repositories/test/+server.ts @@ -1,6 +1,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { testRepositoryConfig } from '$lib/server/git'; +import { getGitCredential } from '$lib/server/db'; +import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git-branch-lookup'; import { authorize } from '$lib/server/authorize'; /** @@ -17,13 +19,14 @@ import { authorize } from '$lib/server/authorize'; /** * @openapi * summary: Test an unsaved repository configuration (url/branch/credentialId) before creating it - * description: credentialId from GET /api/git/credentials. + * description: credentialId from GET /api/git/credentials. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense). * body: {url:string!, branch:string, credentialId:integer} * body-example: {"url":"https://github.com/example/homelab.git","branch":"main","credentialId":2} * resp-200: {success:boolean!, error:string} * resp-200-example: {"success":true} - * resp-400: The url field is missing + * resp-400: The url field is missing, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport, or the credential does not match the URL host * resp-403: Caller lacks the settings:manage permission + * resp-404: The referenced credential does not exist * resp-500: The connectivity test failed */ export const POST: RequestHandler = async ({ request, cookies }) => { @@ -39,6 +42,29 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'Repository URL is required' }, { status: 400 }); } + // Security (PR #1343 maintainer review): the test endpoint spawns git + // (ls-remote + clone) with a USER-SUPPLIED url + credentialId. Run the + // shared guards BEFORE testRepositoryConfig spawns anything. + // 1. assertSafeRepoTarget — SSRF + transport denylist. + try { + assertSafeRepoTarget(body.url); + } catch (e: any) { + return json({ success: false, error: e.message || 'Invalid repository URL' }, { status: 400 }); + } + // 2. assertCredentialHostMatch — raw url + stored credential must be + // plausibly for that host (exfiltration defense). + if (body.credentialId != null && body.credentialId !== undefined) { + const credential = await getGitCredential(body.credentialId); + if (!credential) { + return json({ success: false, error: 'Credential not found' }, { status: 404 }); + } + try { + assertCredentialHostMatch(body.url, credential); + } catch (e: any) { + return json({ success: false, error: e.message || 'Invalid repository URL' }, { status: 400 }); + } + } + const result = await testRepositoryConfig({ url: body.url, branch: body.branch || 'main', diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 68215fb..4d365b8 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -154,6 +154,12 @@ export const POST: RequestHandler = async (event) => { stackName: trimmedStackName, environmentId: data.environmentId || null, repositoryId: repositoryId, + // Per-stack branch override — only when targeting an existing repository. + // In new-repo mode data.branch becomes the repository's default instead; + // the stack inherits it (branch stays null). + ...(data.repositoryId && typeof data.branch === 'string' && data.branch.trim() + ? { branch: data.branch.trim() } + : {}), composePath: data.composePath || 'compose.yaml', envFilePath: data.envFilePath || null, autoUpdate: data.autoUpdate || false, diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index b3f7c1f..4c57cb4 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars, updateStackSource } from '$lib/server/db'; import { deleteGitStackFiles, deployGitStack } from '$lib/server/git'; +import { normalizeStackBranchUpdate } from '$lib/git-stack-branch'; import { authorize } from '$lib/server/authorize'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; import { auditGitStack } from '$lib/server/audit'; @@ -109,8 +110,24 @@ export const PUT: RequestHandler = async (event) => { } const oldStackName = existing.stackName; + + // Per-stack branch override is a partial update. The shared normalizer + // (normalizeStackBranchUpdate, src/lib/git-stack-branch.ts) encodes the + // API contract: + // - `branch` key ABSENT -> leave the stored value untouched; + // - explicit null -> clear the override (inherit repo default); + // - blank / whitespace -> normalised to clear; + // - non-blank string -> set the override (stored trimmed). + // updateGitStack writes the column only when the key is defined + // (`data.branch !== undefined`), so we pass `undefined` for the + // "absent" case to leave the stored value untouched. + const branchNext = normalizeStackBranchUpdate(existing.branch, data); + const branchValue: string | null | undefined = + 'branch' in data ? branchNext.next : undefined; + const updated = await updateGitStack(id, { stackName: data.stackName, + branch: branchValue, composePath: data.composePath, envFilePath: data.envFilePath, autoUpdate: data.autoUpdate, diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 956ef87..a2602ff 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -51,6 +51,7 @@ import { ErrorDialog } from '$lib/components/ui/error-dialog'; import { formatHostPortUrl } from '$lib/utils/url'; import { formatBytes, formatBytesCompact } from '$lib/utils/format'; + import { effectiveStackBranch } from '$lib/git-stack-branch'; type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory'; type SortDirection = 'asc' | 'desc'; @@ -938,7 +939,7 @@ return stack.status; } - async function openGitModal(gitStack?: any) { + async function openGitModal(gitStack: any = undefined) { editingGitStack = gitStack || null; // Fetch repositories and credentials before opening modal try { @@ -1115,10 +1116,13 @@ editingStackName = name; stackModalReadonly = true; const src = getStackSource(name); + // Effective branch: per-stack override wins, else repository default + // (shared with the server-side resolver in src/lib/git-stack-branch.ts). + const eff = effectiveStackBranch(src?.gitStack ?? null, src?.repository ?? undefined); stackModalGitInfo = { commit: src?.gitStack?.lastCommit || undefined, url: src?.repository?.url || undefined, - branch: src?.repository?.branch || undefined + branch: eff.branch }; showEditModal = true; } diff --git a/src/routes/stacks/BranchCombobox.svelte b/src/routes/stacks/BranchCombobox.svelte new file mode 100644 index 0000000..a18f710 --- /dev/null +++ b/src/routes/stacks/BranchCombobox.svelte @@ -0,0 +1,147 @@ + + + + + + + + {#if loading} + + Loading branches… + {:else} + {displayValue} + {/if} + + + + + + + + + {#if loading} + Loading branches… + {:else} + {#if filteredBranches.length > 0} + + {#each filteredBranches as branch} + selectBranch(branch)}> + + {branch} + + {/each} + + {/if} + {#if freeTextBranch} + + selectBranch(freeTextBranch)}> + + Use "{freeTextBranch}" + + + {/if} + {#if filteredBranches.length === 0 && !freeTextBranch && !loading} + + Type a branch name (e.g. "main") or pick from the list. + + {/if} + {/if} + + + + diff --git a/src/routes/stacks/GitSourceBadge.svelte b/src/routes/stacks/GitSourceBadge.svelte index 3aead6f..a365022 100644 --- a/src/routes/stacks/GitSourceBadge.svelte +++ b/src/routes/stacks/GitSourceBadge.svelte @@ -3,14 +3,22 @@ import { GitBranch, GitCommitHorizontal } from 'lucide-svelte'; import { forgeIcon } from '$lib/utils/git-forge'; import { appSettings } from '$lib/stores/settings'; +import { effectiveStackBranch } from '$lib/git-stack-branch'; interface Props { - source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null } | null }; + source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null; branch?: string | null } | null }; } let { source }: Props = $props(); const ForgeIcon = $derived(forgeIcon(source.repository?.url)); const showHash = $derived(!!source.gitStack?.lastCommit && $appSettings.showGitCommitHash); + // Effective branch: per-stack override wins, else repository default + // (shared with the server-side resolver in src/lib/git-stack-branch.ts). + const eff = $derived( + effectiveStackBranch(source.gitStack ?? null, source.repository ?? undefined) + ); + const effectiveBranch = eff.branch; + const isPerStackBranch = eff.perStack === true; // A tooltip is only worth showing when there's real git info in it. const hasGitInfo = $derived(!!source.gitStack?.lastCommit || !!source.repository); @@ -41,10 +49,13 @@ {/if} {#if source.repository} - {#if source.repository.branch} + {#if effectiveBranch}

- {source.repository.branch} + {effectiveBranch} + {#if isPerStackBranch} + (per-stack) + {/if}
{/if}
diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 7182f93..2f29ae8 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -4,7 +4,6 @@ import * as Dialog from '$lib/components/ui/dialog'; import * as Select from '$lib/components/ui/select'; import { Label } from '$lib/components/ui/label'; - import { Badge } from '$lib/components/ui/badge'; import { Input } from '$lib/components/ui/input'; import { TogglePill } from '$lib/components/ui/toggle-pill'; import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte'; @@ -17,6 +16,7 @@ import CronEditor from '$lib/components/cron-editor.svelte'; import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte'; import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte'; + import BranchCombobox from './BranchCombobox.svelte'; import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte'; import { toast } from 'svelte-sonner'; import { focusFirstInput } from '$lib/utils'; @@ -45,13 +45,14 @@ name: string; url: string; branch: string; - credential_id: number | null; + credentialId: number | null; } interface GitStack { id: number; stackName: string; repositoryId: number; + branch?: string | null; // Per-stack branch override; null = use repository default environmentId: number | null; composePath: string; envFilePath: string | null; @@ -149,6 +150,21 @@ let showExistsWarning = $state(false); let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string; webhookSecret?: string }>({}); + // Branch selection + let formBranch = $state(null); + let branches = $state([]); + let branchesLoading = $state(false); + // Monotonic token that guards against a stale branch-enumeration response + // overwriting `branches` for a newer repository URL (the $effect below can + // fire multiple times as the repo selection changes; a slow response for + // repo A must not clobber the branch list belonging to repo B). + let branchesFetchSeq = 0; + + // Sentinel select value meaning "no per-stack override — use the repository's + // default branch". Contains ':' which is invalid in git refs, so it can never + // collide with a real branch name. + const REPO_DEFAULT_BRANCH_VALUE = ':repository-default:'; + // Stack name validation: Docker Compose requires lowercase; must start with a // letter or number, and contain only lowercase letters, numbers, hyphens, underscores const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/; @@ -361,6 +377,8 @@ if (formRepoMode === 'existing') { body.repositoryId = formRepositoryId; + // Send the selected branch so env files are previewed from it (per-stack override) + body.branch = formBranch || undefined; } else { body.url = formNewRepoUrl; body.branch = formNewRepoBranch || 'main'; @@ -448,9 +466,11 @@ formForceRedeploy = gitStack.forceRedeploy ?? false; formDeployNow = false; formSecretProviderId = null; - + // Load secret provider binding loadSecretProviderBindingForStack(gitStack.stackName); + // Per-stack branch override; null means "use the repository default" + formBranch = gitStack.branch ?? null; // Load env files and overrides SYNCHRONOUSLY to avoid race conditions // Wait for all loads to complete before allowing any other effect to run @@ -497,6 +517,41 @@ } } + async function fetchBranches() { + const seq = ++branchesFetchSeq; + branchesLoading = true; + branches = []; + try { + const body: Record = {}; + if (formRepoMode === 'existing' && formRepositoryId) { + body.repositoryId = formRepositoryId; + } else if (formRepoMode === 'new' && formNewRepoUrl) { + body.url = formNewRepoUrl; + body.credentialId = formNewRepoCredentialId; + } else { + return; + } + const response = await fetch('/api/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + // A newer fetch (or a repo change) superseded this one — drop the + // stale response so it cannot overwrite the new repo's branch list. + if (seq !== branchesFetchSeq) return; + if (response.ok) { + const data = await response.json(); + if (seq !== branchesFetchSeq) return; + branches = data.branches || []; + } + } catch (e) { + if (seq !== branchesFetchSeq) return; + console.error('Failed to fetch branches:', e); + } finally { + if (seq === branchesFetchSeq) branchesLoading = false; + } + } + async function saveGitStack(deployAfterSave: boolean = false) { errors = {}; let hasErrors = false; @@ -589,6 +644,9 @@ if (formRepoMode === 'existing') { body.repositoryId = formRepositoryId; + // Per-stack branch override — sent on both create and update so the + // stack payload is the single source of truth (null = inherit repo default) + body.branch = formBranch || null; } else { // Create new repo inline body.repoName = formNewRepoName; @@ -616,9 +674,10 @@ } // Check if deployment failed - if (data.deployResult && !data.deployResult.success) { + const deployResult = data.deployResult as { success?: boolean; error?: string } | undefined; + if (deployResult && !deployResult.success) { toast.error('Deployment failed', { - description: data.deployResult.error || 'Unknown error' + description: deployResult.error || 'Unknown error' }); onSaved(); // Still refresh the list to show the new stack onClose(); // Close modal, error shown as toast @@ -634,6 +693,21 @@ } } + // Fetch branches when repository selection changes + $effect(() => { + if (formRepoMode === 'existing' && formRepositoryId) { + void fetchBranches(); + // A fresh stack inherits the repository's default until a branch is + // picked. When editing, the stored per-stack override (set in + // resetForm) must be preserved — null means repository default. + if (!gitStack) formBranch = null; + } else if (formRepoMode === 'new' && formNewRepoUrl) { + void fetchBranches(); + } else { + branches = []; + } + }); + // Auto-populate stack name from selected repo and compose path (only if user hasn't manually edited) $effect(() => { if (formRepoMode === 'existing' && formRepositoryId && !gitStack && !formStackNameUserModified) { @@ -807,6 +881,42 @@ No repositories configured. Click "Add new" to add one.

{/if} + + {#if formRepoMode === 'existing' && selectedRepo} +
+ + { formBranch = v === REPO_DEFAULT_BRANCH_VALUE ? null : v; }}> + + + + {#if branchesLoading} + + {:else if formBranch} + {formBranch} + {:else} + Repository default ({selectedRepo.branch}) + {/if} + + + + + + + Repository default ({selectedRepo.branch}) + + + {#if branches.length > 0} + {#each branches as branch} + {#if branch !== selectedRepo.branch} + {branch} + {/if} + {/each} + {/if} + + +

Branch this stack deploys from. "Repository default" follows the branch configured on the repository.

+
+ {/if} {:else}
@@ -838,7 +948,21 @@
- + + +

Branch to deploy. Type a name (e.g. "main") or pick from the list.

@@ -916,13 +1040,46 @@
{selectedRepo.url} - {#if selectedRepo.branch} - {selectedRepo.branch} - {/if}
{/if} + {#if gitStack && selectedRepo} +
+ + { formBranch = v === REPO_DEFAULT_BRANCH_VALUE ? null : v; }}> + + + + {#if branchesLoading} + + {:else if formBranch} + {formBranch} + {:else} + Repository default ({selectedRepo.branch}) + {/if} + + + + + + + Repository default ({selectedRepo.branch}) + + + {#if branches.length > 0} + {#each branches as branch} + {#if branch !== selectedRepo.branch} + {branch} + {/if} + {/each} + {/if} + + +

Branch this stack deploys from. "Repository default" follows the branch configured on the repository.

+
+ {/if} +
diff --git a/static/openapi.json b/static/openapi.json new file mode 100644 index 0000000..f190bf6 --- /dev/null +++ b/static/openapi.json @@ -0,0 +1,29123 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Dockhand API", + "version": "1.0.43", + "description": "Auto-generated from src/routes/**/+server.ts by scripts/generate-openapi.ts. Path, HTTP method, tag, and auth requirement are derived automatically from the route tree and hooks.server.ts PUBLIC_PATHS. Parameters, response status codes, and request body fields are additionally auto-detected from each handler’s own source (query params via url.searchParams, status codes via status:/error(), body fields via destructuring) — adding a new endpoint therefore requires ZERO manual spec edits to show up with real params/responses. An optional, additive `@openapi` JSDoc annotation on a handler replaces the generic auto-descriptions with hand-written summaries, exact types, and examples." + }, + "servers": [ + { + "url": "/" + } + ], + "tags": [ + { + "name": "activity" + }, + { + "name": "audit" + }, + { + "name": "auth" + }, + { + "name": "auto-update" + }, + { + "name": "backup" + }, + { + "name": "batch" + }, + { + "name": "changelog" + }, + { + "name": "config-sets" + }, + { + "name": "containers" + }, + { + "name": "dashboard" + }, + { + "name": "debug" + }, + { + "name": "dependencies" + }, + { + "name": "docs" + }, + { + "name": "environments" + }, + { + "name": "events" + }, + { + "name": "git" + }, + { + "name": "hawser" + }, + { + "name": "health" + }, + { + "name": "host" + }, + { + "name": "images" + }, + { + "name": "jobs" + }, + { + "name": "labels" + }, + { + "name": "legal" + }, + { + "name": "license" + }, + { + "name": "logs" + }, + { + "name": "metrics" + }, + { + "name": "networks" + }, + { + "name": "notifications" + }, + { + "name": "preferences" + }, + { + "name": "profile" + }, + { + "name": "prune" + }, + { + "name": "registries" + }, + { + "name": "registry" + }, + { + "name": "roles" + }, + { + "name": "schedules" + }, + { + "name": "secret-providers" + }, + { + "name": "self-update" + }, + { + "name": "settings" + }, + { + "name": "stacks" + }, + { + "name": "system" + }, + { + "name": "templates" + }, + { + "name": "users" + }, + { + "name": "volumes" + }, + { + "name": "vulnerabilities" + } + ], + "components": { + "securitySchemes": { + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "dockhand_session", + "description": "Session cookie set on login (src/lib/server/auth.ts validateSession)." + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "dh_<43-char base64url>", + "description": "User-scoped API token (src/lib/server/api-tokens.ts). Only evaluated on /api/* and /metrics when no session cookie is present (src/hooks.server.ts). Rate-limited: 10 failures/IP -> 429 for 5 minutes." + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "paths": { + "/api/activity": { + "get": { + "operationId": "get_api_activity", + "tags": [ + "activity" + ], + "summary": "Query container activity events with filters and pagination", + "parameters": [ + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + }, + { + "name": "containerId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by container ID (from GET /api/containers)" + }, + { + "name": "containerName", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by container name" + }, + { + "name": "actions", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated event actions to filter by" + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated labels to filter by" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of the date range (ISO 8601)" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of the date range (ISO 8601)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Maximum number of events to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of events to skip (pagination)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "containerName": { + "type": "string" + }, + "action": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "total", + "limit", + "offset" + ] + }, + "example": { + "events": [ + { + "id": 0, + "containerName": "string", + "action": "string", + "timestamp": "string" + } + ], + "total": 0, + "limit": 0, + "offset": 0 + } + } + } + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + }, + "500": { + "description": "Failed to fetch container events" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_activity", + "tags": [ + "activity" + ], + "summary": "Clear all stored container activity events", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (requires the activity:delete permission)" + }, + "500": { + "description": "Failed to clear container events" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/activity/containers": { + "get": { + "operationId": "get_api_activity_containers", + "tags": [ + "activity" + ], + "summary": "List distinct container names that appear in the activity log, for filter dropdowns", + "parameters": [ + { + "name": "environment_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "web-1", + "db-1", + "cache-1" + ] + } + } + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + }, + "500": { + "description": "Failed to fetch container names" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/activity/events": { + "get": { + "operationId": "get_api_activity_events", + "tags": [ + "activity" + ], + "summary": "Stream live container activity and environment-status events over Server-Sent Events", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) emitting connected, heartbeat, activity and env_status events" + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/activity/stats": { + "get": { + "operationId": "get_api_activity_stats", + "tags": [ + "activity" + ], + "summary": "Get aggregate container activity statistics (totals and counts by action)", + "parameters": [ + { + "name": "environment_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "today": { + "type": "integer" + }, + "byAction": { + "type": "object", + "properties": {} + } + }, + "required": [ + "total", + "today" + ] + }, + "example": { + "total": 128, + "today": 7, + "byAction": { + "start": 40, + "stop": 30 + } + } + } + } + }, + "403": { + "description": "Permission denied (requires the activity:view permission)" + }, + "500": { + "description": "Failed to fetch stats" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit": { + "get": { + "operationId": "get_api_audit", + "tags": [ + "audit" + ], + "summary": "Query the audit log with filters and pagination (Enterprise only)", + "parameters": [ + { + "name": "usernames", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated usernames to filter by" + }, + { + "name": "entityTypes", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated entity types to filter by" + }, + { + "name": "actions", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated actions to filter by" + }, + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Legacy single-username filter" + }, + { + "name": "entityType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Legacy single entity-type filter" + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Legacy single-action filter" + }, + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + }, + { + "name": "labels", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated labels to filter by" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of the date range (ISO 8601)" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of the date range (ISO 8601)" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Maximum number of entries to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of entries to skip (pagination)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "action": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "entityName": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "total": { + "type": "integer" + } + } + }, + "example": { + "logs": [ + { + "id": 0, + "username": "string", + "action": "string", + "entityType": "string", + "entityName": "string", + "createdAt": "string" + } + ], + "total": 0 + } + } + } + }, + "403": { + "description": "Enterprise required, or permission denied" + }, + "500": { + "description": "Failed to fetch audit logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit/events": { + "get": { + "operationId": "get_api_audit_events", + "tags": [ + "audit" + ], + "summary": "Stream live audit-log events over Server-Sent Events (Enterprise only)", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) emitting connected, heartbeat and audit events" + }, + "403": { + "description": "Enterprise required, or permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit/export": { + "get": { + "operationId": "get_api_audit_export", + "tags": [ + "audit" + ], + "summary": "Export the audit log as a downloadable file in JSON, CSV or Markdown (Enterprise only)", + "parameters": [ + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by username" + }, + { + "name": "entityType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by entity type" + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by action" + }, + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment (from GET /api/environments)" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Start of the date range (ISO 8601)" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "End of the date range (ISO 8601)" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Output format — json (default), csv or md" + } + ], + "responses": { + "200": { + "description": "Downloadable audit-log export (Content-Disposition attachment; JSON, CSV or Markdown)" + }, + "403": { + "description": "Enterprise required, or permission denied" + }, + "500": { + "description": "Failed to export audit logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/audit/users": { + "get": { + "operationId": "get_api_audit_users", + "tags": [ + "audit" + ], + "summary": "List the distinct usernames that appear in the audit log, for filter dropdowns (Enterprise only)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "admin", + "ci-bot", + "alice" + ] + } + } + }, + "403": { + "description": "Enterprise required, or permission denied" + }, + "500": { + "description": "Failed to fetch audit log users" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auth/ldap": { + "get": { + "operationId": "get_api_auth_ldap", + "tags": [ + "auth" + ], + "summary": "List all configured LDAP providers (enterprise only; bind passwords are masked)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + } + }, + "example": [ + { + "id": 1, + "name": "Corporate LDAP", + "enabled": true, + "serverUrl": "ldaps://ldap.example.com:636", + "baseDn": "dc=example,dc=com" + } + ] + } + } + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "500": { + "description": "Failed to read the LDAP configurations" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_auth_ldap", + "tags": [ + "auth" + ], + "summary": "Create a new LDAP provider configuration (enterprise only)", + "parameters": [], + "responses": { + "201": { + "description": "LDAP configuration created (bindPassword is masked in the response)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "serverUrl": "string", + "baseDn": "string" + } + } + } + }, + "400": { + "description": "Name, server URL, and base DN are required" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "500": { + "description": "Failed to create the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "bindDn": { + "type": "string" + }, + "bindPassword": { + "type": "string" + }, + "userFilter": { + "type": "string" + }, + "usernameAttribute": { + "type": "string" + }, + "emailAttribute": { + "type": "string" + }, + "displayNameAttribute": { + "type": "string" + }, + "groupBaseDn": { + "type": "string" + }, + "groupFilter": { + "type": "string" + }, + "adminGroup": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + }, + "tlsEnabled": { + "type": "boolean" + }, + "tlsCa": { + "type": "string" + } + }, + "required": [ + "name", + "serverUrl", + "baseDn" + ] + }, + "example": { + "name": "Corporate LDAP", + "serverUrl": "ldaps://ldap.example.com:636", + "baseDn": "dc=example,dc=com", + "bindDn": "cn=admin,dc=example,dc=com", + "bindPassword": "***", + "enabled": true, + "tlsEnabled": true + } + } + } + } + } + }, + "/api/auth/ldap/{id}": { + "get": { + "operationId": "get_api_auth_ldap_id", + "tags": [ + "auth" + ], + "summary": "Get a single LDAP provider configuration by id (enterprise only; bind password is masked)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "serverUrl": "string", + "baseDn": "string" + } + } + } + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to read the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_auth_ldap_id", + "tags": [ + "auth" + ], + "summary": "Update an existing LDAP provider configuration (only supplied fields change; a masked bindPassword is ignored)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "baseDn": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "serverUrl", + "baseDn" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "serverUrl": "string", + "baseDn": "string" + } + } + } + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to update the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "serverUrl": { + "type": "string" + }, + "bindDn": { + "type": "string" + }, + "bindPassword": { + "type": "string" + }, + "baseDn": { + "type": "string" + }, + "userFilter": { + "type": "string" + }, + "usernameAttribute": { + "type": "string" + }, + "emailAttribute": { + "type": "string" + }, + "displayNameAttribute": { + "type": "string" + }, + "groupBaseDn": { + "type": "string" + }, + "groupFilter": { + "type": "string" + }, + "adminGroup": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + }, + "tlsEnabled": { + "type": "boolean" + }, + "tlsCa": { + "type": "string" + } + } + }, + "example": { + "enabled": false, + "userFilter": "(sAMAccountName={{username}})", + "bindPassword": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_auth_ldap_id", + "tags": [ + "auth" + ], + "summary": "Delete an LDAP provider configuration by id (enterprise only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to delete the LDAP configuration" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auth/ldap/{id}/test": { + "post": { + "operationId": "post_api_auth_ldap_id_test", + "tags": [ + "auth" + ], + "summary": "Test connectivity of a stored LDAP configuration by id (enterprise only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the LDAP configuration to test (from GET /api/auth/ldap)" + } + ], + "responses": { + "200": { + "description": "Connection test result (success flag plus diagnostic detail from the LDAP server)" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not an authenticated admin)" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "LDAP configuration not found" + }, + "500": { + "description": "Failed to test the LDAP connection" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auth/login": { + "post": { + "operationId": "post_api_auth_login", + "tags": [ + "auth" + ], + "summary": "Authenticate with username/password (local or LDAP) and set the session cookie", + "parameters": [], + "responses": { + "200": { + "description": "Login succeeded and dockhand_session cookie was set — OR requiresMfa:true if a second factor is needed first", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + } + }, + "required": [ + "id", + "username", + "isAdmin" + ] + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "displayName": "Admin", + "isAdmin": true + } + } + } + } + }, + "400": { + "description": "Authentication disabled, or username/password missing" + }, + "401": { + "description": "Invalid credentials or invalid MFA code" + }, + "403": { + "description": "Local login disabled via DISABLE_LOCAL_LOGIN" + }, + "429": { + "description": "Rate-limited (too many attempts for this IP+username)" + }, + "500": { + "description": "Unexpected error during authentication" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "mfaToken": { + "type": "string" + }, + "provider": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "example": { + "username": "admin", + "password": "correct horse battery staple", + "provider": "local" + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "operationId": "post_api_auth_logout", + "tags": [ + "auth" + ], + "summary": "Destroy the current session (clears the dockhand_session cookie)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "500": { + "description": "Unexpected error while destroying the session" + } + }, + "security": [] + } + }, + "/api/auth/oidc": { + "get": { + "operationId": "get_api_auth_oidc", + "tags": [ + "auth" + ], + "summary": "List all configured OIDC providers (client secrets are masked)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + } + }, + "example": [ + { + "id": 1, + "name": "Authentik", + "enabled": true, + "issuerUrl": "https://auth.example.com/application/o/dockhand/", + "clientId": "dockhand" + } + ] + } + } + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:view)" + }, + "500": { + "description": "Failed to read the OIDC configurations" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_auth_oidc", + "tags": [ + "auth" + ], + "summary": "Create a new OIDC provider configuration", + "parameters": [], + "responses": { + "201": { + "description": "OIDC configuration created (clientSecret is masked in the response)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "issuerUrl": "string", + "clientId": "string" + } + } + } + }, + "400": { + "description": "A required field is missing (name, issuerUrl, clientId, clientSecret, redirectUri)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "500": { + "description": "Failed to create the OIDC configuration" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "scopes": { + "type": "string" + }, + "usernameClaim": { + "type": "string" + }, + "emailClaim": { + "type": "string" + }, + "displayNameClaim": { + "type": "string" + }, + "adminClaim": { + "type": "string" + }, + "adminValue": { + "type": "string" + }, + "roleMappingsClaim": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + } + }, + "required": [ + "name", + "issuerUrl", + "clientId", + "clientSecret", + "redirectUri" + ] + }, + "example": { + "name": "Authentik", + "issuerUrl": "https://auth.example.com/application/o/dockhand/", + "clientId": "dockhand", + "clientSecret": "***", + "redirectUri": "https://dockhand.example.com/api/auth/oidc/callback", + "enabled": true, + "scopes": "openid profile email" + } + } + } + } + } + }, + "/api/auth/oidc/{id}": { + "get": { + "operationId": "get_api_auth_oidc_id", + "tags": [ + "auth" + ], + "summary": "Get a single OIDC provider configuration by id (client secret is masked)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "issuerUrl": "string", + "clientId": "string" + } + } + } + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:view)" + }, + "404": { + "description": "OIDC configuration not found" + }, + "500": { + "description": "Failed to read the OIDC configuration" + } + }, + "security": [] + }, + "put": { + "operationId": "put_api_auth_oidc_id", + "tags": [ + "auth" + ], + "summary": "Update an existing OIDC provider configuration (only supplied fields change; a masked clientSecret is ignored)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "enabled", + "issuerUrl", + "clientId" + ] + }, + "example": { + "id": 0, + "name": "string", + "enabled": true, + "issuerUrl": "string", + "clientId": "string" + } + } + } + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "404": { + "description": "OIDC configuration not found" + }, + "500": { + "description": "Failed to update the OIDC configuration" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerUrl": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "redirectUri": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "usernameClaim": { + "type": "string" + }, + "emailClaim": { + "type": "string" + }, + "displayNameClaim": { + "type": "string" + }, + "adminClaim": { + "type": "string" + }, + "adminValue": { + "type": "string" + }, + "roleMappingsClaim": { + "type": "string" + }, + "roleMappings": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "enabled": true, + "scopes": "openid profile email groups", + "clientSecret": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_auth_oidc_id", + "tags": [ + "auth" + ], + "summary": "Delete an OIDC provider configuration by id", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "404": { + "description": "OIDC configuration not found" + }, + "500": { + "description": "Failed to delete the OIDC configuration" + } + }, + "security": [] + } + }, + "/api/auth/oidc/{id}/initiate": { + "get": { + "operationId": "get_api_auth_oidc_id_initiate", + "tags": [ + "auth" + ], + "summary": "Start the OIDC login flow for a provider — on success throws a 302 redirect to the IdP authorization URL", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC provider (from GET /api/auth/oidc)" + }, + { + "name": "redirect", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Post-login destination path to return to (defaults to /)" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "302": { + "description": "Redirect to the IdP's authorization URL" + }, + "400": { + "description": "Authentication is not enabled, or the configuration id is invalid" + }, + "404": { + "description": "OIDC provider not found or disabled" + }, + "500": { + "description": "Failed to build the authorization URL / initiate SSO" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_auth_oidc_id_initiate", + "tags": [ + "auth" + ], + "summary": "Return the OIDC authorization URL for a provider without redirecting (JSON response)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC provider (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": [ + "url" + ] + }, + "example": { + "url": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled, or the configuration id is invalid" + }, + "404": { + "description": "OIDC provider not found or disabled" + }, + "500": { + "description": "Failed to build the authorization URL / initiate SSO" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirect": { + "type": "string" + } + } + }, + "example": { + "redirect": "/dashboard" + } + } + } + } + } + }, + "/api/auth/oidc/{id}/test": { + "post": { + "operationId": "post_api_auth_oidc_id_test", + "tags": [ + "auth" + ], + "summary": "Test the discovery/connection of a stored OIDC configuration by id", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the OIDC configuration to test (from GET /api/auth/oidc)" + } + ], + "responses": { + "200": { + "description": "Connection test result (success flag plus diagnostic detail from the provider)" + }, + "400": { + "description": "Invalid configuration id (not a number)" + }, + "403": { + "description": "Admin access required (auth is enabled and the caller is not an admin)" + }, + "500": { + "description": "Failed to test the OIDC connection" + } + }, + "security": [] + } + }, + "/api/auth/oidc/callback": { + "get": { + "operationId": "get_api_auth_oidc_callback", + "tags": [ + "auth" + ], + "summary": "Handle the OIDC redirect callback from the IdP — always responds with a 302 redirect (to the original destination on success, or to /login with an error query param on failure)", + "parameters": [ + { + "name": "code", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Authorization code returned by the IdP" + }, + { + "name": "state", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque state value used to correlate the request and carry the post-login redirect" + }, + { + "name": "error", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Error code returned by the IdP when authentication failed" + }, + { + "name": "error_description", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Human-readable error detail returned by the IdP" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "302": { + "description": "Always a redirect — on success a session cookie is set and the caller is redirected to the original destination; on any error the caller is redirected to /login with an error query param" + } + }, + "security": [] + } + }, + "/api/auth/providers": { + "get": { + "operationId": "get_api_auth_providers", + "tags": [ + "auth" + ], + "summary": "List the authentication providers offered on the login page (local, LDAP, OIDC), plus the default provider", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "initiateUrl": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + } + }, + "defaultProvider": { + "type": "string" + } + } + }, + "example": { + "providers": [ + { + "id": "local", + "name": "Local", + "type": "local" + }, + { + "id": "oidc:1", + "name": "Authentik", + "type": "oidc", + "initiateUrl": "/api/auth/oidc/1/initiate" + } + ], + "defaultProvider": "local" + } + } + } + } + }, + "security": [] + } + }, + "/api/auth/session": { + "get": { + "operationId": "get_api_auth_session", + "tags": [ + "auth" + ], + "summary": "Get the current session (public — used by the frontend to bootstrap auth state)", + "parameters": [], + "responses": { + "200": { + "description": "user is present only when authenticated:true", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authenticated": { + "type": "boolean" + }, + "authEnabled": { + "type": "boolean" + }, + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "provider": { + "type": "string" + } + } + } + }, + "required": [ + "authenticated", + "authEnabled" + ] + }, + "example": { + "authenticated": true, + "authEnabled": true, + "user": { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "displayName": "Admin", + "avatar": null, + "isAdmin": true, + "provider": "local" + } + } + } + } + }, + "500": { + "description": "Unexpected error while validating the session" + } + }, + "security": [] + } + }, + "/api/auth/settings": { + "get": { + "operationId": "get_api_auth_settings", + "tags": [ + "auth" + ], + "summary": "Get the global authentication settings (whether auth is enabled and the default provider)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authEnabled": { + "type": "boolean" + }, + "defaultProvider": { + "type": "string" + } + }, + "required": [ + "authEnabled" + ] + }, + "example": { + "authEnabled": true, + "defaultProvider": "local" + } + } + } + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:view)" + }, + "500": { + "description": "Failed to read the auth settings" + } + }, + "security": [] + }, + "put": { + "operationId": "put_api_auth_settings", + "tags": [ + "auth" + ], + "summary": "Update the global authentication settings (enabling auth requires at least one admin/user to exist)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authEnabled": { + "type": "boolean" + }, + "defaultProvider": { + "type": "string" + } + }, + "required": [ + "authEnabled" + ] + }, + "example": { + "authEnabled": true, + "defaultProvider": "string" + } + } + } + }, + "400": { + "description": "Cannot enable authentication without an existing user/admin (response includes requiresUser:true)" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "403": { + "description": "Permission denied (missing settings:edit)" + }, + "500": { + "description": "Failed to update the auth settings" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authEnabled": { + "type": "boolean" + }, + "defaultProvider": { + "type": "string" + } + } + }, + "example": { + "authEnabled": true, + "defaultProvider": "local" + } + } + } + } + } + }, + "/api/auth/tokens": { + "get": { + "operationId": "get_api_auth_tokens", + "tags": [ + "auth" + ], + "summary": "List the authenticated user's API tokens (never returns the hash/secret)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "tokenPrefix": { + "type": "string" + }, + "lastUsed": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "tokenPrefix", + "createdAt" + ] + } + }, + "example": [ + { + "id": 1, + "name": "CI/CD Pipeline", + "tokenPrefix": "a1b2c3d4", + "lastUsed": "2027-01-02T08:00:00Z", + "expiresAt": "2027-01-01T00:00:00Z", + "createdAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "400": { + "description": "Authentication is not enabled" + }, + "401": { + "description": "Not authenticated" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_auth_tokens", + "tags": [ + "auth" + ], + "summary": "Create a new API token for the authenticated user (requires a session, not a Bearer token)", + "parameters": [], + "responses": { + "201": { + "description": "Token created — the plaintext value is shown ONLY this once and cannot be retrieved again", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "tokenPrefix": { + "type": "string" + } + }, + "required": [ + "token", + "id", + "tokenPrefix" + ] + }, + "example": { + "token": "dh_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "id": 42, + "tokenPrefix": "a1b2c3d4" + } + } + } + }, + "400": { + "description": "Invalid body, missing password for local users, or MAX_TOKENS_PER_USER (25) reached" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Token creation attempted via Bearer auth (session login required) or wrong password" + }, + "404": { + "description": "User record not found while confirming the password (local provider)" + }, + "429": { + "description": "Too many failed password confirmation attempts (rate limited)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "CI/CD Pipeline", + "expiresAt": "2027-01-01T00:00:00Z", + "password": "correct horse battery staple" + } + } + } + } + } + }, + "/api/auth/tokens/{id}": { + "delete": { + "operationId": "delete_api_auth_tokens_id", + "tags": [ + "auth" + ], + "summary": "Revoke (permanently delete) one of the authenticated user's API tokens", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Token id (from GET /api/auth/tokens)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Authentication not enabled, or id is not a valid integer" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Attempted via Bearer auth (a leaked token cannot revoke other tokens — session required)" + }, + "404": { + "description": "Token not found, or belongs to a different (non-admin) user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auto-update": { + "get": { + "operationId": "get_api_auto-update", + "tags": [ + "auto-update" + ], + "summary": "Get all enabled auto-update settings for an environment, keyed by container name", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to read settings for (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + }, + "example": { + "web-1": { + "enabled": true, + "scheduleType": "daily", + "cronExpression": "0 3 * * *", + "vulnerabilityCriteria": "never" + } + } + } + } + }, + "500": { + "description": "Failed to get auto-update settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/auto-update/{containerName}": { + "get": { + "operationId": "get_api_auto-update_containerName", + "tags": [ + "auto-update" + ], + "summary": "Get the auto-update setting for a container (returns sensible defaults when none is stored)", + "parameters": [ + { + "name": "containerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name (URL-encoded)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the container belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "scheduleType": { + "type": "string" + }, + "cronExpression": { + "type": "string" + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "enabled", + "scheduleType", + "vulnerabilityCriteria" + ] + }, + "example": { + "enabled": false, + "scheduleType": "daily", + "cronExpression": "0 3 * * *", + "vulnerabilityCriteria": "never" + } + } + } + }, + "500": { + "description": "Failed to get auto-update setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_auto-update_containerName", + "tags": [ + "auto-update" + ], + "summary": "Create or update a container's auto-update setting; enabled=false deletes it and its schedule", + "parameters": [ + { + "name": "containerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name (URL-encoded)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the container belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "scheduleType": { + "type": "string" + }, + "cronExpression": { + "type": "string" + }, + "vulnerabilityCriteria": { + "type": "string" + }, + "deleted": { + "type": "boolean" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "id", + "enabled", + "scheduleType", + "vulnerabilityCriteria" + ] + }, + "example": { + "success": true, + "deleted": true + } + } + } + }, + "500": { + "description": "Failed to save auto-update setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "cron_expression": { + "type": "string" + }, + "vulnerabilityCriteria": { + "type": "string" + }, + "vulnerability_criteria": { + "type": "string" + } + } + }, + "example": { + "enabled": true, + "cronExpression": "0 3 * * *", + "vulnerabilityCriteria": "never" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_auto-update_containerName", + "tags": [ + "auto-update" + ], + "summary": "Delete a container's auto-update setting and unregister its schedule", + "parameters": [ + { + "name": "containerName", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name (URL-encoded)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the container belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "500": { + "description": "Failed to delete auto-update setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/backup/configs": { + "get": { + "operationId": "get_api_backup_configs", + "tags": [ + "backup" + ], + "summary": "List backup configurations, optionally filtered by type, target and environment (enterprise callers only see configs for environments they can access)", + "parameters": [ + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by backup config type (e.g. \"container\", \"stack\")" + }, + { + "name": "target", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by the backed-up target name (container or stack name)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter by environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Array of backup configuration objects" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:view\") is produced by the shared requireBackups route guard." + }, + "post": { + "operationId": "post_api_backup_configs", + "tags": [ + "backup" + ], + "summary": "Create a backup configuration for a container or stack, optionally scheduled, and register its cron schedule when enabled", + "parameters": [], + "responses": { + "201": { + "description": "The created backup configuration object" + }, + "400": { + "description": "Invalid input — missing destinationId/targetName, invalid targetName, invalid cron expression, invalid retention, or a local repository paired with a remote environment / non-backupable stack" + }, + "403": { + "description": "Access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to create the backup configuration (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. A requested environmentId the caller can't access also 403s (enterprise). destinationId from GET /api/backup/destinations. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "targetName": { + "type": "string" + }, + "type": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "allVolumes": { + "type": "boolean" + }, + "selectedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "stopBeforeBackup": { + "type": "boolean" + }, + "schedule": { + "type": "string" + }, + "retention": { + "type": "object", + "properties": { + "keepLast": { + "type": "integer" + }, + "keepDaily": { + "type": "integer" + }, + "keepWeekly": { + "type": "integer" + }, + "keepMonthly": { + "type": "integer" + }, + "keepYearly": { + "type": "integer" + } + } + }, + "options": { + "type": "object", + "properties": {} + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "destinationId", + "targetName" + ] + }, + "example": { + "destinationId": 3, + "targetName": "nextcloud", + "type": "container", + "environmentId": 1, + "enabled": true, + "allVolumes": true, + "stopBeforeBackup": false, + "schedule": "0 3 * * *", + "retention": { + "keepDaily": 7, + "keepWeekly": 4 + } + } + } + } + } + } + }, + "/api/backup/configs/{id}": { + "get": { + "operationId": "get_api_backup_configs_id", + "tags": [ + "backup" + ], + "summary": "Fetch a single backup configuration by id, enforcing environment-scoped access", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "The backup configuration object" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:view\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + }, + "put": { + "operationId": "put_api_backup_configs_id", + "tags": [ + "backup" + ], + "summary": "Update a backup configuration and re-register or remove its cron schedule accordingly", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "The updated backup configuration object" + }, + "400": { + "description": "Invalid input — invalid cron expression, invalid retention, or a local repository paired with a remote environment" + }, + "409": { + "description": "Cannot change the destination while a backup is running for this config" + }, + "500": { + "description": "Update failed (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards. The environment is fixed at creation and cannot be changed here. destinationId from GET /api/backup/destinations.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "allVolumes": { + "type": "boolean" + }, + "selectedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "stopBeforeBackup": { + "type": "boolean" + }, + "schedule": { + "type": "string" + }, + "retention": { + "type": "object", + "properties": { + "keepLast": { + "type": "integer" + }, + "keepDaily": { + "type": "integer" + }, + "keepWeekly": { + "type": "integer" + }, + "keepMonthly": { + "type": "integer" + }, + "keepYearly": { + "type": "integer" + } + } + }, + "options": { + "type": "object", + "properties": {} + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "schedule": "0 4 * * *", + "enabled": true, + "stopBeforeBackup": true, + "retention": { + "keepDaily": 14 + } + } + } + } + } + }, + "delete": { + "operationId": "delete_api_backup_configs_id", + "tags": [ + "backup" + ], + "summary": "Delete a backup configuration and unregister its schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the configuration is deleted" + }, + "409": { + "description": "A backup is currently running for this config — stop it before deleting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + } + }, + "/api/backup/configs/{id}/run": { + "post": { + "operationId": "post_api_backup_configs_id_run", + "tags": [ + "backup" + ], + "summary": "Trigger a manual backup run for a configuration, streaming progress as a Server-Sent Events job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream of progress and a final result event (status \"success\", \"warning\", \"skipped\" or \"error\")" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream that emits `progress` events during the run and a final `result` event. Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + } + }, + "/api/backup/configs/{id}/stop": { + "post": { + "operationId": "post_api_backup_configs_id_stop", + "tags": [ + "backup" + ], + "summary": "Cancel the in-flight backup for a configuration", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup configuration id (from GET /api/backup/configs)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, stopped } where \"stopped\" indicates whether a running backup helper was actually killed" + }, + "500": { + "description": "Failed to cancel the backup (internal error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission (\"backups:manage\") and environment-access denials (403) and not-found (404) are produced by the shared route guards." + } + }, + "/api/backup/destinations": { + "get": { + "operationId": "get_api_backup_destinations", + "tags": [ + "backup" + ], + "summary": "List all backup destinations (restic repositories); the password is stripped and cloud-credential env vars are omitted from the list view", + "parameters": [], + "responses": { + "200": { + "description": "Array of backup destination objects without secrets (no password, no envVars)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:view\") is produced by the shared requireBackups route guard." + }, + "post": { + "operationId": "post_api_backup_destinations", + "tags": [ + "backup" + ], + "summary": "Create a restic backup destination, auto-initialize and test the repository, and register its default maintenance schedules", + "parameters": [], + "responses": { + "201": { + "description": "The created backup destination object (includes decrypted envVars since the caller just supplied them; password is stripped)" + }, + "400": { + "description": "Invalid input — missing name/repository/password, unsupported/SSRF-blocked repository, invalid restic flags, or an invalid cron schedule in the policies" + }, + "409": { + "description": "A destination with this name already exists" + }, + "500": { + "description": "Failed to create the destination (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "password": { + "type": "string" + }, + "envVars": { + "type": "object", + "properties": {} + }, + "flags": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "policies": { + "type": "string" + } + }, + "required": [ + "name", + "repository", + "password" + ] + }, + "example": { + "name": "S3 Offsite", + "repository": "s3:s3.amazonaws.com/my-bucket/restic", + "password": "***", + "envVars": { + "AWS_ACCESS_KEY_ID": "***", + "AWS_SECRET_ACCESS_KEY": "***" + } + } + } + } + } + } + }, + "/api/backup/destinations/{id}": { + "get": { + "operationId": "get_api_backup_destinations_id", + "tags": [ + "backup" + ], + "summary": "Fetch a single backup destination; decrypted cloud-credential env vars are only included for callers who can manage backups, and the password is always stripped", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "The backup destination object (envVars included only for \"backups:manage\" callers; password always stripped)" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:view\") is produced by the shared requireBackups route guard." + }, + "put": { + "operationId": "put_api_backup_destinations_id", + "tags": [ + "backup" + ], + "summary": "Update a backup destination, re-validating repository and flags when supplied and re-registering maintenance schedules when policies change", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "The updated backup destination object (password stripped, envVars echoed back to the managing caller)" + }, + "400": { + "description": "Invalid input — invalid id, unsupported/SSRF-blocked repository, invalid restic flags, invalid policy cron, or switching to a local repository used by a remote-environment config" + }, + "404": { + "description": "Destination not found" + }, + "409": { + "description": "A backup using this destination is currently running, or a destination with the new name already exists" + }, + "500": { + "description": "Update failed (persistence error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "password": { + "type": "string" + }, + "envVars": { + "type": "object", + "properties": {} + }, + "flags": { + "type": "string" + }, + "hostPath": { + "type": "string" + }, + "policies": { + "type": "string" + } + } + }, + "example": { + "name": "S3 Offsite (renamed)", + "policies": "{\"pruneEnabled\":true,\"pruneSchedule\":\"0 0 1 * *\"}" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_backup_destinations_id", + "tags": [ + "backup" + ], + "summary": "Delete a backup destination and unregister all its maintenance and dependent backup-config schedules", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the destination is deleted" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + }, + "409": { + "description": "A backup using this destination is currently running — try again once it finishes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard." + } + }, + "/api/backup/destinations/{id}/init": { + "post": { + "operationId": "post_api_backup_destinations_id_init", + "tags": [ + "backup" + ], + "summary": "Initialize the restic repository for a destination and record the resulting test status", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, message } when the repository is initialized" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + }, + "500": { + "description": "Repository initialization failed; returns { success: false, error }" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard." + } + }, + "/api/backup/destinations/{id}/rotate-key": { + "post": { + "operationId": "post_api_backup_destinations_id_rotate-key", + "tags": [ + "backup" + ], + "summary": "Rotate the restic repository password for a destination and persist the new password", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the password is rotated and the database is updated" + }, + "400": { + "description": "Invalid input — invalid id, missing passwords, or the current password is incorrect" + }, + "404": { + "description": "Destination not found" + }, + "409": { + "description": "restic rotated the key but the database write failed (manual recovery needed; the response includes dbOutOfSync:true)" + }, + "500": { + "description": "restic call failed for an unrelated reason" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + }, + "example": { + "currentPassword": "***", + "newPassword": "***" + } + } + } + } + } + }, + "/api/backup/destinations/{id}/task": { + "post": { + "operationId": "post_api_backup_destinations_id_task", + "tags": [ + "backup" + ], + "summary": "Run a restic repository maintenance task (unlock, check, prune, stats, repair-index or repair-snapshots) against a destination", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "The task result object — { success: true, ... } on success, or { success: false, error } when the task itself fails" + }, + "400": { + "description": "Invalid input — invalid destination id, or an unknown task name (must be one of unlock, check, prune, stats, repair-index, repair-snapshots)" + }, + "403": { + "description": "No access to an environment whose configs use this destination (enterprise)" + }, + "404": { + "description": "Destination not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. Streamed via createJobResponse — a task failure never surfaces as an HTTP 500; it comes back as a 200 with a failed job result (JSON-preferring callers) or a job/SSE 'result' event with success:false (streaming callers).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "task": { + "type": "string" + } + }, + "required": [ + "task" + ] + }, + "example": { + "task": "check" + } + } + } + } + } + }, + "/api/backup/destinations/{id}/test": { + "post": { + "operationId": "post_api_backup_destinations_id_test", + "tags": [ + "backup" + ], + "summary": "Test connectivity to a saved backup destination's repository and update its stored test status", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Test result — { success: true, status: \"success\" } when reachable, or { success: false, status: \"needs_init\" | \"failed\", error } otherwise" + }, + "400": { + "description": "Invalid id (not a number)" + }, + "404": { + "description": "Destination not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard." + } + }, + "/api/backup/destinations/{id}/verify": { + "post": { + "operationId": "post_api_backup_destinations_id_verify", + "tags": [ + "backup" + ], + "summary": "Verify the integrity of a destination's backups by reading a data subset, streaming progress as a Server-Sent Events job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Backup destination id (from GET /api/backup/destinations)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream of progress and a final result event ({ success, ... })" + }, + "400": { + "description": "Invalid destination id (not a number)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream that emits `progress` events and a final `result` event. Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "dataSubset": { + "type": "string" + } + } + }, + "example": { + "dataSubset": "5%" + } + } + } + } + } + }, + "/api/backup/destinations/test": { + "post": { + "operationId": "post_api_backup_destinations_test", + "tags": [ + "backup" + ], + "summary": "Test a backup repository — either a saved destination (by destinationId) or inline credentials supplied for a not-yet-saved destination", + "parameters": [], + "responses": { + "200": { + "description": "Test result — { success: true } when the repository is reachable, or { success: false, needsInit?, error } otherwise" + }, + "400": { + "description": "Invalid input — missing repository/password for an inline test, or an unsupported/SSRF-blocked repository" + }, + "404": { + "description": "Destination not found (when testing a saved destinationId)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Provide `destinationId` to test a saved destination using its stored credentials, or omit it and supply `repository`/`password`/`envVars` to test before saving. Permission denial (403, \"backups:manage\") is produced by the shared requireBackups route guard. destinationId from GET /api/backup/destinations.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "password": { + "type": "string" + }, + "envVars": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "repository": "s3:s3.amazonaws.com/my-bucket/restic", + "password": "***", + "envVars": { + "AWS_ACCESS_KEY_ID": "***", + "AWS_SECRET_ACCESS_KEY": "***" + } + } + } + } + } + } + }, + "/api/backup/instance": { + "get": { + "operationId": "get_api_backup_instance", + "tags": [ + "backup" + ], + "summary": "This install's stable backup instance id, used to tell own snapshots from foreign orphans", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + } + }, + "required": [ + "instanceId" + ] + }, + "example": { + "instanceId": "string" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Requires backups:view; 403 (no permission) and 404 (backup feature disabled) come from the shared requireBackups guard." + } + }, + "/api/backup/restore": { + "post": { + "operationId": "post_api_backup_restore", + "tags": [ + "backup" + ], + "summary": "Restore a backup snapshot in-place or to a new location, streaming progress as a Server-Sent Events job", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream of progress and a final result event (status \"success\" or \"error\")" + }, + "400": { + "description": "Invalid restore request; the response \"issues\" array lists the validation problems" + }, + "403": { + "description": "Permission denied — requires \"backups:manage\", or no access to the target or snapshot-owning environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream that emits `progress` events and a final `result` event. An in-place restore is destructive and requires confirmOverwrite:true. Authorization and snapshot-environment access are checked before the request shape is validated. destinationId from GET /api/backup/destinations. snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "snapshotId": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "targetType": { + "type": "string" + }, + "volumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "environmentId": { + "type": "integer" + }, + "confirmOverwrite": { + "type": "boolean" + }, + "targetPath": { + "type": "string" + }, + "targetName": { + "type": "string" + }, + "postRestore": { + "type": "string" + }, + "volumeDestinations": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } + }, + "required": [ + "destinationId", + "snapshotId" + ] + }, + "example": { + "destinationId": 3, + "snapshotId": "a1b2c3d4", + "mode": "new-location", + "targetType": "container", + "targetName": "nextcloud-restored", + "environmentId": 1, + "confirmOverwrite": false + } + } + } + } + } + }, + "/api/backup/restore/preview": { + "post": { + "operationId": "post_api_backup_restore_preview", + "tags": [ + "backup" + ], + "summary": "Preview what a restore would write (resolved targets + volume/stack contents)", + "parameters": [], + "responses": { + "200": { + "description": "The snapshot preview, optionally with a resolved targets list" + }, + "400": { + "description": "Missing required fields (destinationId, snapshotId)" + }, + "403": { + "description": "Permission denied (needs backups:view) or access denied to the target environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "With includeTargets it also resolves the concrete restore targets (host paths / volumes); without it, returns the metadata-only preview the initial modal load relies on.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destinationId": { + "type": "integer" + }, + "snapshotId": { + "type": "string" + }, + "includeTargets": { + "type": "boolean" + }, + "targetEnvId": { + "type": "integer" + } + }, + "required": [ + "destinationId", + "snapshotId" + ] + }, + "example": { + "destinationId": 0, + "snapshotId": "string", + "includeTargets": true, + "targetEnvId": 0 + } + } + } + } + } + }, + "/api/backup/restore/stop": { + "post": { + "operationId": "post_api_backup_restore_stop", + "tags": [ + "backup" + ], + "summary": "Cancel a running restore — kills the restore helper for a given snapshotId, or all restore helpers when snapshotId is omitted", + "parameters": [], + "responses": { + "200": { + "description": "Returns { success: true, stopped } where \"stopped\" indicates whether a running restore helper was actually killed" + }, + "400": { + "description": "Invalid snapshotId" + }, + "403": { + "description": "Permission denied — requires \"backups:manage\", or no access to the targeted environment" + }, + "500": { + "description": "Failed to cancel the restore (internal error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "snapshotId from GET /api/backup/snapshots. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "snapshotId": { + "type": "string" + }, + "environmentId": { + "type": "integer" + } + } + }, + "example": { + "snapshotId": "a1b2c3d4" + } + } + } + } + } + }, + "/api/backup/snapshots": { + "get": { + "operationId": "get_api_backup_snapshots", + "tags": [ + "backup" + ], + "summary": "List restic snapshots for a backup config or a single destination (job-polled)", + "parameters": [ + { + "name": "configId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Backup config id (mutually exclusive with destinationId)" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Single destination id (mutually exclusive with configId)" + }, + { + "name": "allDestinations", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "With configId, include every destination of the config" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid or missing configId/destinationId" + }, + "403": { + "description": "Permission denied (needs backups:view) or environment access denied" + }, + "404": { + "description": "Backup config not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Pass configId to list across the config's destinations, or destinationId for one. Job-polled so a reverse proxy can't abort the sync restic snapshots read at ~15s; match each snapshot's instance tag against GET /api/backup/instance to tell own snapshots from foreign orphans." + } + }, + "/api/backup/snapshots/{id}": { + "delete": { + "operationId": "delete_api_backup_snapshots_id", + "tags": [ + "backup" + ], + "summary": "Forget and prune a single snapshot from a destination (destructive restic forget --prune), with a server-authoritative environment access gate", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Restic snapshot id to forget (from GET /api/backup/snapshots)" + }, + { + "name": "destinationId", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Destination holding the snapshot (required) (from GET /api/backup/destinations)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Optional environment id for an early enterprise access check; the authoritative gate resolves the snapshot's owning environment server-side (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true } once the snapshot is forgotten and pruned" + }, + "400": { + "description": "Missing/invalid destinationId or an invalid snapshot id" + }, + "403": { + "description": "Permission denied — requires \"backups:manage\", or no access to the snapshot's owning environment" + }, + "404": { + "description": "Snapshot not found for this installation (not owned)" + }, + "500": { + "description": "Failed to forget the snapshot (restic error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/backup/snapshots/{id}/browse": { + "get": { + "operationId": "get_api_backup_snapshots_id_browse", + "tags": [ + "backup" + ], + "summary": "List directory entries at a path inside a snapshot (job-polled)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The restic snapshot id" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination the snapshot lives in" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Directory path inside the snapshot to list" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment context for access checks" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing/invalid destinationId" + }, + "403": { + "description": "Permission denied (needs backups:view) or environment access denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic read at ~15s." + } + }, + "/api/backup/snapshots/{id}/dump": { + "get": { + "operationId": "get_api_backup_snapshots_id_dump", + "tags": [ + "backup" + ], + "summary": "Read a single file (or archive) out of a snapshot (job-polled restic dump)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The restic snapshot id" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination the snapshot lives in" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Path inside the snapshot (must resolve under /volumes or /metadata)" + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Payload kind (e.g. inline preview vs archive)" + }, + { + "name": "download", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "When set, stream the file/directory as a tar" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing/invalid destinationId or path" + }, + "403": { + "description": "Permission denied (needs backups:view), or a raw metadata.json download was refused" + }, + "500": { + "description": "The restic dump failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic dump at ~15s. type selects the payload (inline preview vs archive); download requests a raw file/directory tar. A raw metadata.json download is refused (403) - it would bypass redaction." + } + }, + "/api/backup/snapshots/{id}/metadata": { + "get": { + "operationId": "get_api_backup_snapshots_id_metadata", + "tags": [ + "backup" + ], + "summary": "The snapshot's redacted metadata layout (job-polled restic dump)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The restic snapshot id" + }, + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination the snapshot lives in" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing/invalid destinationId" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic dump at ~15s. The layout is redacted (stack secrets and container Config.Env/Labels stripped) before it leaves the server." + } + }, + "/api/backup/snapshots/diff": { + "get": { + "operationId": "get_api_backup_snapshots_diff", + "tags": [ + "backup" + ], + "summary": "Diff two snapshots' file trees (added/changed/removed) - job-polled", + "parameters": [ + { + "name": "destinationId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Destination both snapshots live in" + }, + { + "name": "snapshotA", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The baseline snapshot id" + }, + { + "name": "snapshotB", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The snapshot to compare against the baseline" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing destinationId, snapshotA, or snapshotB" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Job-polled so a proxy can't abort the restic read at ~15s." + } + }, + "/api/backup/stack-dir-listing": { + "get": { + "operationId": "get_api_backup_stack-dir-listing", + "tags": [ + "backup" + ], + "summary": "Probe the target host and list the actual contents of a stack's directory, for the backup create/edit dialog's file picker", + "parameters": [ + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Target stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The stack directory probe result — kind \"listed\"/\"tar\" with hostPath/localStackDir and entries, or kind \"helper-failed\"/\"unknown\" with a reason (never throws for an operational failure)" + }, + "400": { + "description": "target is required" + }, + "403": { + "description": "Permission denied (requires backups:view), or no access to the target's environment (enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/backup/stack-path": { + "get": { + "operationId": "get_api_backup_stack-path", + "tags": [ + "backup" + ], + "summary": "Preview where a stack's directory will be captured from on the host, without running a backup", + "parameters": [ + { + "name": "target", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Target stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The resolved capture path preview — kind \"candidate\" with hostPath, kind \"tar\" with composeFile only, or kind \"unknown\" with a reason" + }, + "400": { + "description": "target is required" + }, + "403": { + "description": "Permission denied (requires backups:view), or no access to the target's environment (enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/batch": { + "post": { + "operationId": "post_api_batch", + "tags": [ + "batch" + ], + "summary": "Run a bulk operation (start/stop/restart/pause/unpause/remove/down, depending on entity type) across many containers, images, volumes, networks, or stacks; clients that send Accept application/json get a synchronous summary, others get a jobId and progress over Server-Sent Events", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the entities belong to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Either a synchronous completion summary ({type:\"complete\", summary:{total, success, failed}}) or an async job handle ({jobId}) whose progress streams over SSE" + }, + "400": { + "description": "Invalid JSON body, invalid entity type, invalid operation for the entity type, or empty items array" + }, + "403": { + "description": "Permission denied for the requested operation, or environment access denied on enterprise" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "operation": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + }, + "options": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + }, + "removeVolumes": { + "type": "boolean" + } + } + } + }, + "required": [ + "operation", + "entityType", + "items" + ] + }, + "example": { + "operation": "restart", + "entityType": "containers", + "items": [ + { + "id": "abc123", + "name": "web" + } + ], + "options": { + "force": false + } + } + } + } + } + } + }, + "/api/changelog": { + "get": { + "operationId": "get_api_changelog", + "tags": [ + "changelog" + ], + "summary": "Return the bundled Dockhand changelog data", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "date": { + "type": "string" + }, + "changes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "version" + ] + } + }, + "example": [ + { + "version": "1.0.39", + "date": "2026-06-01", + "changes": [ + "Fixed image export streaming" + ] + } + ] + } + } + } + }, + "security": [] + } + }, + "/api/config-sets": { + "get": { + "operationId": "get_api_config-sets", + "tags": [ + "config-sets" + ], + "summary": "List all config sets (reusable bundles of env vars, labels, ports, volumes, and runtime defaults)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + } + }, + "example": [ + { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "bridge", + "restartPolicy": "unless-stopped", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires configsets:view)" + }, + "500": { + "description": "Failed to fetch config sets" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_config-sets", + "tags": [ + "config-sets" + ], + "summary": "Create a new config set", + "parameters": [], + "responses": { + "201": { + "description": "Config set created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "bridge", + "restartPolicy": "unless-stopped", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "400": { + "description": "Name is required, or a config set with this name already exists" + }, + "403": { + "description": "Permission denied (requires configsets:create)" + }, + "500": { + "description": "Failed to create config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "networkMode": "bridge", + "restartPolicy": "unless-stopped" + } + } + } + } + } + }, + "/api/config-sets/{id}": { + "get": { + "operationId": "get_api_config-sets_id", + "tags": [ + "config-sets" + ], + "summary": "Fetch a single config set by its numeric ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Config set ID (from GET /api/config-sets)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [ + { + "key": "TZ", + "value": "UTC" + } + ], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "bridge", + "restartPolicy": "unless-stopped", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "400": { + "description": "Invalid ID" + }, + "403": { + "description": "Permission denied (requires configsets:view)" + }, + "404": { + "description": "Config set not found" + }, + "500": { + "description": "Failed to fetch config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_config-sets_id", + "tags": [ + "config-sets" + ], + "summary": "Update an existing config set by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Config set ID (from GET /api/config-sets)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "networkMode", + "restartPolicy", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 1, + "name": "web-defaults", + "description": "Defaults for web stacks", + "envVars": [], + "labels": [], + "ports": [], + "volumes": [], + "networkMode": "host", + "restartPolicy": "always", + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-02T12:00:00Z" + } + } + } + }, + "400": { + "description": "Invalid ID, or a config set with this name already exists" + }, + "403": { + "description": "Permission denied (requires configsets:edit)" + }, + "404": { + "description": "Config set not found" + }, + "500": { + "description": "Failed to update config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPort": { + "type": "string" + }, + "containerPort": { + "type": "string" + }, + "protocol": { + "type": "string" + } + }, + "required": [ + "hostPort", + "containerPort", + "protocol" + ] + } + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostPath": { + "type": "string" + }, + "containerPath": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "hostPath", + "containerPath", + "mode" + ] + } + }, + "networkMode": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + } + } + }, + "example": { + "name": "web-defaults", + "networkMode": "host", + "restartPolicy": "always" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_config-sets_id", + "tags": [ + "config-sets" + ], + "summary": "Delete a config set by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Config set ID (from GET /api/config-sets)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid ID" + }, + "403": { + "description": "Permission denied (requires configsets:delete)" + }, + "404": { + "description": "Config set not found" + }, + "500": { + "description": "Failed to delete config set" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers": { + "get": { + "operationId": "get_api_containers", + "tags": [ + "containers" + ], + "summary": "List containers for one environment (dockhand.hidden=true label is filtered out)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id — an empty array is returned if omitted (from GET /api/environments)" + }, + { + "name": "all", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Include stopped containers (default true; pass \"false\" to only show running ones)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status" + ] + } + }, + "example": [ + { + "id": "a1b2c3d4e5f6", + "name": "immich_server", + "image": "ghcr.io/immich-app/immich-server:latest", + "state": "running", + "status": "Up 2 hours (healthy)" + } + ] + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_containers", + "tags": [ + "containers" + ], + "summary": "Create a new container in an environment (auto-pulls the image first if it isn't present locally), optionally starting it right away", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "imagePulled": { + "type": "boolean" + } + }, + "required": [ + "success", + "id" + ] + }, + "example": { + "success": true, + "id": "string", + "imagePulled": true + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + }, + "500": { + "description": "Container creation failed, or the image could not be pulled" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "ports": { + "type": "object", + "properties": {} + }, + "volumes": { + "type": "object", + "properties": {} + }, + "volumeBinds": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "labels": { + "type": "object", + "properties": {} + }, + "cmd": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrypoint": { + "type": "array", + "items": { + "type": "string" + } + }, + "workingDir": { + "type": "string" + }, + "restartPolicy": { + "type": "string" + }, + "restartMaxRetries": { + "type": "integer" + }, + "networkMode": { + "type": "string" + }, + "additionalNetworks": { + "type": "array", + "items": { + "type": "string" + } + }, + "networkAliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "networkIpv4Address": { + "type": "string" + }, + "networkIpv6Address": { + "type": "string" + }, + "startAfterCreate": { + "type": "boolean" + } + }, + "required": [ + "name", + "image" + ] + }, + "example": { + "name": "my-app", + "image": "nginx:latest", + "startAfterCreate": true + } + } + } + } + } + }, + "/api/containers/{id}": { + "get": { + "operationId": "get_api_containers_id", + "tags": [ + "containers" + ], + "summary": "Return the full Docker inspect payload for a single container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The raw Docker inspect object for the container" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to inspect the container (e.g. it no longer exists or the daemon is unreachable)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_containers_id", + "tags": [ + "containers" + ], + "summary": "Remove a container and clean up its auto-update schedule and pending-update record", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal of a running container (Docker force flag)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to remove the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/exec": { + "post": { + "operationId": "post_api_containers_id_exec", + "tags": [ + "containers" + ], + "summary": "Create a Docker exec instance in a container and return its ID plus the Docker connection info for a terminal WebSocket", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "envId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "execId": { + "type": "string" + }, + "connectionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "execId" + ] + }, + "example": { + "execId": "string", + "connectionInfo": { + "type": "string", + "host": "string", + "port": 0 + } + } + } + } + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to create the exec instance" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shell": { + "type": "string" + }, + "user": { + "type": "string" + } + } + }, + "example": { + "shell": "/bin/sh", + "user": "root" + } + } + } + } + } + }, + "/api/containers/{id}/files": { + "get": { + "operationId": "get_api_containers_id_files", + "tags": [ + "containers" + ], + "summary": "List the contents of a directory inside a container's filesystem", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Absolute directory path inside the container (default \"/\")" + }, + { + "name": "simpleLs", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Use a lightweight `ls` listing instead of a full stat of each entry" + } + ], + "responses": { + "200": { + "description": "Directory listing (entries with name, type, size and permission metadata)" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to list the directory" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/files/chmod": { + "post": { + "operationId": "post_api_containers_id_files_chmod", + "tags": [ + "containers" + ], + "summary": "Change the mode (permissions) of a file or directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "recursive": { + "type": "boolean" + } + }, + "required": [ + "success", + "path", + "mode", + "recursive" + ] + }, + "example": { + "success": true, + "path": "/app/entrypoint.sh", + "mode": "755", + "recursive": false + } + } + } + }, + "400": { + "description": "Path or mode missing, an invalid chmod mode, or the container is not running" + }, + "403": { + "description": "Permission denied, read-only file system, or operation not permitted" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to change permissions" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "recursive": { + "type": "boolean" + } + }, + "required": [ + "path", + "mode" + ] + }, + "example": { + "path": "/app/entrypoint.sh", + "mode": "755", + "recursive": false + } + } + } + } + } + }, + "/api/containers/{id}/files/content": { + "get": { + "operationId": "get_api_containers_id_files_content", + "tags": [ + "containers" + ], + "summary": "Read the content of a single file inside a container (max 1 MB)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute file path inside the container" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "path" + ] + }, + "example": { + "content": "string", + "path": "string" + } + } + } + }, + "400": { + "description": "Path is missing, the target is a directory, or the container is not running" + }, + "403": { + "description": "Permission denied to read the file" + }, + "404": { + "description": "File not found" + }, + "413": { + "description": "File is larger than the 1 MB read limit" + }, + "500": { + "description": "Failed to read the file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_containers_id_files_content", + "tags": [ + "containers" + ], + "summary": "Overwrite the content of a file inside a container (max 1 MB, requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute file path inside the container" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "success", + "path" + ] + }, + "example": { + "success": true, + "path": "/etc/nginx/nginx.conf" + } + } + } + }, + "400": { + "description": "Path is missing, content is missing/not a string, or the container is not running" + }, + "403": { + "description": "Permission denied, or the target file system is read-only" + }, + "404": { + "description": "Target directory not found" + }, + "413": { + "description": "Content is larger than the 1 MB write limit" + }, + "500": { + "description": "Failed to write the file" + }, + "507": { + "description": "No space left on device" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "server {\n listen 80;\n}\n" + } + } + } + } + } + }, + "/api/containers/{id}/files/create": { + "post": { + "operationId": "post_api_containers_id_files_create", + "tags": [ + "containers" + ], + "summary": "Create an empty file or a directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "success", + "path", + "type" + ] + }, + "example": { + "success": true, + "path": "/app/data", + "type": "directory" + } + } + } + }, + "400": { + "description": "Path missing, type not \"file\" or \"directory\", or the container is not running" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Parent directory not found" + }, + "409": { + "description": "Path already exists" + }, + "500": { + "description": "Failed to create the path" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "path", + "type" + ] + }, + "example": { + "path": "/app/data", + "type": "directory" + } + } + } + } + } + }, + "/api/containers/{id}/files/delete": { + "delete": { + "operationId": "delete_api_containers_id_files_delete", + "tags": [ + "containers" + ], + "summary": "Delete a file or directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute path inside the container to delete" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "success", + "path" + ] + }, + "example": { + "success": true, + "path": "/app/tmp/old.log" + } + } + } + }, + "400": { + "description": "Path missing, a refused critical-path delete, a non-empty directory, or the container is not running" + }, + "403": { + "description": "Permission denied, or read-only file system" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to delete the path" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/files/download": { + "get": { + "operationId": "get_api_containers_id_files_download", + "tags": [ + "containers" + ], + "summary": "Download a file or directory from a container as a tar (optionally gzip-compressed) archive attachment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute path inside the container to archive and download" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Archive format, \"tar\" (default) or \"tar.gz\"" + } + ], + "responses": { + "200": { + "description": "Archive stream (application/x-tar or application/gzip) as a file attachment" + }, + "400": { + "description": "Path is missing" + }, + "403": { + "description": "Permission denied to access the path" + }, + "404": { + "description": "File not found" + }, + "500": { + "description": "Failed to download the file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/files/rename": { + "post": { + "operationId": "post_api_containers_id_files_rename", + "tags": [ + "containers" + ], + "summary": "Rename or move a file or directory inside a container (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "oldPath": { + "type": "string" + }, + "newPath": { + "type": "string" + } + }, + "required": [ + "success", + "oldPath", + "newPath" + ] + }, + "example": { + "success": true, + "oldPath": "/app/config.yaml", + "newPath": "/app/config.old.yaml" + } + } + } + }, + "400": { + "description": "oldPath or newPath missing, or the container is not running" + }, + "403": { + "description": "Permission denied, or read-only file system" + }, + "404": { + "description": "Source path not found" + }, + "409": { + "description": "Destination already exists" + }, + "500": { + "description": "Failed to rename the path" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "oldPath": { + "type": "string" + }, + "newPath": { + "type": "string" + } + }, + "required": [ + "oldPath", + "newPath" + ] + }, + "example": { + "oldPath": "/app/config.yaml", + "newPath": "/app/config.old.yaml" + } + } + } + } + } + }, + "/api/containers/{id}/files/upload": { + "post": { + "operationId": "post_api_containers_id_files_upload", + "tags": [ + "containers" + ], + "summary": "Upload one or more files into a container directory (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute target directory inside the container" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "uploaded": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success", + "uploaded" + ] + }, + "example": { + "success": true, + "uploaded": [ + "app.conf" + ] + } + } + } + }, + "400": { + "description": "Target path missing, or no files provided" + }, + "403": { + "description": "Permission denied to write to the path" + }, + "404": { + "description": "Target directory not found" + }, + "500": { + "description": "Failed to upload the files" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Send `multipart/form-data` with one or more `files` parts; each file is tar-wrapped and written into the target directory. Partial success is reported per file in `uploaded`/`errors`.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string", + "format": "binary" + }, + "description": "One or more files to write into the target directory" + } + }, + "required": [ + "files" + ] + } + } + } + } + } + }, + "/api/containers/{id}/inspect": { + "get": { + "operationId": "get_api_containers_id_inspect", + "tags": [ + "containers" + ], + "summary": "Return the full Docker inspect payload for a container (requires the 'inspect' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "The raw Docker inspect object for the container" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to inspect the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/logs": { + "get": { + "operationId": "get_api_containers_id_logs", + "tags": [ + "containers" + ], + "summary": "Return the last N lines of a container's combined stdout/stderr logs as a single string", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Number of trailing log lines to return (default 100)" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs since this time (Unix timestamp or Docker duration, e.g. 10m)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs before this time (Unix timestamp or Docker duration)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "string" + } + }, + "required": [ + "logs" + ] + }, + "example": { + "logs": "string" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to read the container logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/logs/stream": { + "get": { + "operationId": "get_api_containers_id_logs_stream", + "tags": [ + "containers" + ], + "summary": "Stream a container's logs live as Server-Sent Events (connected/log/end/error events, with keepalive heartbeats)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Number of trailing log lines to prime the stream with (default \"100\")" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only stream logs since this time (Unix timestamp or Docker duration, e.g. 10m)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only stream logs before this time (Unix timestamp or Docker duration)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) of log lines" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a `text/event-stream`. For Hawser Edge environments whose agent is not connected the stream body carries an error event. Docker stdout/stderr frames are demultiplexed server-side; TTY containers stream raw text." + } + }, + "/api/containers/{id}/pause": { + "post": { + "operationId": "post_api_containers_id_pause", + "tags": [ + "containers" + ], + "summary": "Pause all processes in a running container (requires the container 'stop' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to pause the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/rename": { + "post": { + "operationId": "post_api_containers_id_rename", + "tags": [ + "containers" + ], + "summary": "Rename a container and update any associated auto-update schedule (requires the 'create' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The new name is missing or not a string" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to rename the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "my-renamed-container" + } + } + } + } + } + }, + "/api/containers/{id}/restart": { + "post": { + "operationId": "post_api_containers_id_restart", + "tags": [ + "containers" + ], + "summary": "Restart a container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to restart the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/shells": { + "get": { + "operationId": "get_api_containers_id_shells", + "tags": [ + "containers" + ], + "summary": "Probe a container for available shells (bash/sh/zsh/ash) and report the preferred default (requires the 'exec' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shells": { + "type": "array", + "items": { + "type": "string" + } + }, + "defaultShell": { + "type": "string" + }, + "allShells": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "label": { + "type": "string" + }, + "available": { + "type": "boolean" + } + }, + "required": [ + "path", + "label", + "available" + ] + } + } + }, + "required": [ + "shells", + "allShells" + ] + }, + "example": { + "shells": [ + "/bin/sh" + ], + "defaultShell": "/bin/sh", + "allShells": [ + { + "path": "/bin/bash", + "label": "Bash", + "available": false + }, + { + "path": "/bin/sh", + "label": "Shell (sh)", + "available": true + } + ] + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "On probe failure the endpoint still returns 200 with empty results rather than an error, so callers always get a usable shell list." + } + }, + "/api/containers/{id}/start": { + "post": { + "operationId": "post_api_containers_id_start", + "tags": [ + "containers" + ], + "summary": "Start a stopped container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to start the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/stats": { + "get": { + "operationId": "get_api_containers_id_stats", + "tags": [ + "containers" + ], + "summary": "Return a one-shot CPU/memory/network/block-IO stats snapshot for a container (Docker-CLI-equivalent memory accounting)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cpuPercent": { + "type": "number" + }, + "memoryUsage": { + "type": "integer" + }, + "memoryRaw": { + "type": "integer" + }, + "memoryCache": { + "type": "integer" + }, + "memoryLimit": { + "type": "integer" + }, + "memoryPercent": { + "type": "number" + }, + "networkRx": { + "type": "integer" + }, + "networkTx": { + "type": "integer" + }, + "blockRead": { + "type": "integer" + }, + "blockWrite": { + "type": "integer" + }, + "timestamp": { + "type": "integer" + } + }, + "required": [ + "cpuPercent", + "memoryUsage", + "memoryRaw", + "memoryCache", + "memoryLimit", + "memoryPercent", + "networkRx", + "networkTx", + "blockRead", + "blockWrite", + "timestamp" + ] + }, + "example": { + "cpuPercent": 0, + "memoryUsage": 0, + "memoryRaw": 0, + "memoryCache": 0, + "memoryLimit": 0, + "memoryPercent": 0, + "networkRx": 0, + "networkTx": 0, + "blockRead": 0, + "blockWrite": 0, + "timestamp": 0 + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "No environment configured, the environment was not found, or the container was not found" + }, + "500": { + "description": "Failed to read the container stats" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/stop": { + "post": { + "operationId": "post_api_containers_id_stop", + "tags": [ + "containers" + ], + "summary": "Stop a running container", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to the requested environment" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to stop the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/top": { + "get": { + "operationId": "get_api_containers_id_top", + "tags": [ + "containers" + ], + "summary": "List the processes running inside a container (via `ps` in the container, falling back to the Docker top API)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Titles": { + "type": "array", + "items": { + "type": "string" + } + }, + "Processes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "source": { + "type": "string" + } + }, + "required": [ + "Titles", + "Processes", + "source" + ] + }, + "example": { + "Titles": [ + "string" + ], + "Processes": [ + [ + "string" + ] + ], + "source": "string" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to read the container process list" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Uses the container 'inspect' permission. The `source` field indicates whether the result came from an in-container `ps` (`\"ps\"`) or the Docker top API fallback (`\"top\"`)." + } + }, + "/api/containers/{id}/unpause": { + "post": { + "operationId": "post_api_containers_id_unpause", + "tags": [ + "containers" + ], + "summary": "Resume all processes in a paused container (requires the container 'start' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to unpause the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/{id}/update": { + "post": { + "operationId": "post_api_containers_id_update", + "tags": [ + "containers" + ], + "summary": "Recreate a container with updated create-options (optionally re-pulling the image first), preserving its configuration (requires the 'create' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "success", + "id" + ] + }, + "example": { + "success": true, + "id": "3f4a1c2b9d8e" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Failed to update the container (e.g. the image pull or recreation failed)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "The body carries the container create-options (image, name, env, ports, volumes, …) alongside the two control flags below; `repullImage` pulls the image before recreation and `startAfterUpdate` starts the new container once created.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "repullImage": { + "type": "boolean" + }, + "startAfterUpdate": { + "type": "boolean" + } + } + }, + "example": { + "image": "nginx:latest", + "name": "web", + "repullImage": true, + "startAfterUpdate": true + } + } + } + } + } + }, + "/api/containers/{id}/update-runtime": { + "post": { + "operationId": "post_api_containers_id_update-runtime", + "tags": [ + "containers" + ], + "summary": "Update a running container's restart policy and/or CPU/memory/blkio/pids limits in-place (no recreate)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container id (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "warnings": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid JSON body, or no supported fields were provided" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Container not found" + }, + "500": { + "description": "Update failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "RestartPolicy": { + "type": "object", + "properties": {} + }, + "CpuShares": { + "type": "integer" + }, + "CpuPeriod": { + "type": "integer" + }, + "CpuQuota": { + "type": "integer" + }, + "CpuRealtimePeriod": { + "type": "integer" + }, + "CpuRealtimeRuntime": { + "type": "integer" + }, + "CpusetCpus": { + "type": "string" + }, + "CpusetMems": { + "type": "string" + }, + "NanoCpus": { + "type": "integer" + }, + "Memory": { + "type": "integer" + }, + "MemorySwap": { + "type": "integer" + }, + "MemoryReservation": { + "type": "integer" + }, + "MemorySwappiness": { + "type": "integer" + }, + "KernelMemory": { + "type": "integer" + }, + "BlkioWeight": { + "type": "integer" + }, + "BlkioWeightDevice": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceReadBps": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceWriteBps": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceReadIOps": { + "type": "array", + "items": { + "type": "string" + } + }, + "BlkioDeviceWriteIOps": { + "type": "array", + "items": { + "type": "string" + } + }, + "PidsLimit": { + "type": "integer" + } + } + }, + "example": { + "Memory": 536870912, + "PidsLimit": 512 + } + } + } + } + } + }, + "/api/containers/{id}/version-notes": { + "get": { + "operationId": "get_api_containers_id_version-notes", + "tags": [ + "containers" + ], + "summary": "Resolve GitHub release notes for the versions a semver update-check surfaced (requires the 'view' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "versions", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comma-separated version tags to fetch notes for (target + skipped), e.g. \"16.3-alpine,16.4-alpine\"" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "changelogUrl": { + "type": "string" + }, + "source": { + "type": "string" + }, + "rateLimited": { + "type": "boolean" + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "name": { + "type": "string" + }, + "githubTag": { + "type": "string" + }, + "body": { + "type": "string" + }, + "publishedAt": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "version", + "url" + ] + } + } + }, + "required": [ + "notes" + ] + }, + "example": { + "changelogUrl": "https://github.com/go-gitea/gitea/releases", + "source": "go-gitea/gitea", + "notes": [ + { + "version": "1.22.0", + "name": "v1.22.0", + "githubTag": "v1.22.0", + "body": "## Changes...", + "publishedAt": "2024-05-01T00:00:00Z", + "url": "https://github.com/go-gitea/gitea/releases/tag/v1.22.0" + } + ] + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to resolve release notes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/batch-update": { + "post": { + "operationId": "post_api_containers_batch-update", + "tags": [ + "containers" + ], + "summary": "Recreate a set of containers with their latest images, preserving all settings (requires the 'create' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "success" + ] + } + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "success": { + "type": "integer" + }, + "failed": { + "type": "integer" + } + }, + "required": [ + "total", + "success", + "failed" + ] + } + }, + "required": [ + "success", + "results", + "summary" + ] + }, + "example": { + "success": true, + "results": [ + { + "containerId": "string", + "containerName": "string", + "success": true, + "error": "string" + } + ], + "summary": { + "total": 0, + "success": 0, + "failed": 0 + } + } + } + } + }, + "400": { + "description": "The containerIds array is missing or empty" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to run the batch update" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Containers are processed sequentially; the response reports per-container success/failure plus a summary. Use the streaming variant for live progress. containerIds from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "containerIds" + ] + }, + "example": { + "containerIds": [ + "3f4a1c2b9d8e", + "a1b2c3d4e5f6" + ] + } + } + } + } + } + }, + "/api/containers/batch-update-stream": { + "post": { + "operationId": "post_api_containers_batch-update-stream", + "tags": [ + "containers" + ], + "summary": "Recreate a set of containers with live streaming progress (Server-Sent Events), optionally blocking on vulnerability criteria (requires the 'create' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) of per-container update progress" + }, + "400": { + "description": "Invalid JSON body, or the containerIds array is missing or empty" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a `text/event-stream` reporting per-container progress. `vulnerabilityCriteria` (default \"never\") can block an update when a container's image scan exceeds the configured severity threshold. containerIds from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "containerIds" + ] + }, + "example": { + "containerIds": [ + "3f4a1c2b9d8e" + ], + "vulnerabilityCriteria": "never" + } + } + } + } + } + }, + "/api/containers/check-updates": { + "get": { + "operationId": "get_api_containers_check-updates", + "tags": [ + "containers" + ], + "summary": "Read the cached pending image-update records for an environment (no fresh check; requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "pendingUpdates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "currentImage": { + "type": "string" + }, + "checkedAt": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "currentImage" + ] + } + } + }, + "required": [ + "environmentId", + "pendingUpdates" + ] + }, + "example": { + "environmentId": 0, + "pendingUpdates": [ + { + "containerId": "string", + "containerName": "string", + "currentImage": "string", + "checkedAt": "string" + } + ] + } + } + } + }, + "400": { + "description": "Environment ID required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get pending updates" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns the containers currently flagged as having a pending image update from the last check. Does not trigger a new check — use POST to run a fresh check." + }, + "post": { + "operationId": "post_api_containers_check-updates", + "tags": [ + "containers" + ], + "summary": "Trigger a fresh image-update check across all (non-hidden, non-podman-infra) containers in an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "text/event-stream job stream (\"progress\" events with {checked,total}, final \"result\" event with {total,updatesFound,results}) — or, with \"Accept: application/json\", the final result as plain JSON" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/pending-updates": { + "get": { + "operationId": "get_api_containers_pending-updates", + "tags": [ + "containers" + ], + "summary": "List the containers in an environment that have a pending image update recorded (requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (required) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "pendingUpdates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "currentImage": { + "type": "string" + }, + "checkedAt": { + "type": "string" + }, + "hasImageUpdate": { + "type": "boolean" + }, + "newerVersion": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "currentImage", + "checkedAt", + "hasImageUpdate" + ] + } + } + }, + "required": [ + "environmentId", + "pendingUpdates" + ] + }, + "example": { + "environmentId": 0, + "pendingUpdates": [ + { + "containerId": "string", + "containerName": "string", + "currentImage": "string", + "checkedAt": "string", + "hasImageUpdate": true, + "newerVersion": "string" + } + ] + } + } + } + }, + "400": { + "description": "Environment ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get the pending updates" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_containers_pending-updates", + "tags": [ + "containers" + ], + "summary": "Clear pending-update records for an environment, or for a single container when containerId is given", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (required) (from GET /api/environments)" + }, + { + "name": "containerId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "The container to clear; omit to clear ALL pending updates in the environment (from GET /api/containers)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Environment ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to remove the pending update" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/sizes": { + "get": { + "operationId": "get_api_containers_sizes", + "tags": [ + "containers" + ], + "summary": "List all containers in an environment together with their writable-layer and root-filesystem sizes (requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Array of containers with size metadata (SizeRw / SizeRootFs)" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get container sizes (returns an empty object)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/containers/stats": { + "get": { + "operationId": "get_api_containers_stats", + "tags": [ + "containers" + ], + "summary": "Return a CPU/memory/network/block-IO stats snapshot for every running container in an environment (requires the 'view' permission)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + }, + { + "name": "debug", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Return raw Docker stats for the single container with this name instead of the aggregate list" + } + ], + "responses": { + "200": { + "description": "Array of per-container stats snapshots (or, with `debug`, the raw stats for one container)" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "The requested debug container was not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns an empty array when no environment is configured or specified. With `debug=` it returns the raw memory_stats for a single container instead. On internal error it returns an empty array with status 200." + } + }, + "/api/containers/stats/stream": { + "get": { + "operationId": "get_api_containers_stats_stream", + "tags": [ + "containers" + ], + "summary": "Stream live CPU/memory/network/block-IO stats for all running containers in an environment (one snapshot round, then closes)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id — an immediate \"done\" event is sent if omitted or no environments are configured (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "text/event-stream SSE stream (\"stat\" events per container with {id,name,cpuPercent,memoryUsage,memoryRaw,memoryCache,memoryLimit,memoryPercent,networkRx,networkTx,blockRead,blockWrite}, an \"error\" event on environment-not-found, then a final \"done\" event)" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/dashboard/preferences": { + "get": { + "operationId": "get_api_dashboard_preferences", + "tags": [ + "dashboard" + ], + "summary": "Get the current user's dashboard preferences (grid layout, lock state, view mode)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridLayout": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "locked": { + "type": "boolean" + }, + "viewMode": { + "type": "string" + } + }, + "required": [ + "locked", + "viewMode" + ] + }, + "example": { + "gridLayout": [], + "locked": false, + "viewMode": "grid" + } + } + } + }, + "500": { + "description": "Failed to get dashboard preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_dashboard_preferences", + "tags": [ + "dashboard" + ], + "summary": "Update the current user's dashboard preferences (only provided fields are merged)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridLayout": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "locked": { + "type": "boolean" + }, + "viewMode": { + "type": "string" + } + }, + "required": [ + "locked", + "viewMode" + ] + }, + "example": { + "gridLayout": [ + {} + ], + "locked": true, + "viewMode": "string" + } + } + } + }, + "500": { + "description": "Failed to save dashboard preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridLayout": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "locked": { + "type": "boolean" + }, + "viewMode": { + "type": "string" + } + } + }, + "example": { + "locked": true, + "viewMode": "list" + } + } + } + } + } + }, + "/api/dashboard/stats": { + "get": { + "operationId": "get_api_dashboard_stats", + "tags": [ + "dashboard" + ], + "summary": "Get aggregated dashboard statistics per environment (containers, images, volumes, stacks, metrics)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Restrict the stats to a single environment (returns one object) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "online": { + "type": "boolean" + }, + "containers": { + "type": "object", + "properties": {} + }, + "images": { + "type": "object", + "properties": {} + }, + "volumes": { + "type": "object", + "properties": {} + }, + "networks": { + "type": "object", + "properties": {} + }, + "stacks": { + "type": "object", + "properties": {} + }, + "metrics": { + "type": "object", + "properties": {} + }, + "events": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "name" + ] + } + }, + "example": [ + { + "id": 0, + "name": "string", + "online": true, + "containers": {}, + "images": {}, + "volumes": {}, + "networks": {}, + "stacks": {}, + "metrics": {}, + "events": {} + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires the environments:view permission)" + }, + "404": { + "description": "Environment not found (when env is supplied)" + }, + "500": { + "description": "Failed to get dashboard stats" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns an array of per-environment stats. When env is supplied and matches, a single object is returned instead of an array. An empty array is returned on a fresh install with no environments." + } + }, + "/api/dashboard/stats/stream": { + "get": { + "operationId": "get_api_dashboard_stats_stream", + "tags": [ + "dashboard" + ], + "summary": "Stream per-environment dashboard stats progressively over Server-Sent Events", + "parameters": [], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream), or a JSON payload when Accept application/json is requested" + }, + "403": { + "description": "Permission denied (requires the environments:view permission)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Emits environments, partial, complete, error and done events as each environment's stats resolve. When the client sends Accept application/json, the stream is collected and returned as a single JSON payload instead." + } + }, + "/api/debug/memory": { + "get": { + "operationId": "get_api_debug_memory", + "tags": [ + "debug" + ], + "summary": "Return Node.js/V8 memory diagnostics; only enabled when MEMORY_MONITOR=true. Also supports ?snapshot and ?snapshots to dump/list heap snapshots.", + "parameters": [ + { + "name": "gc", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force garbage collection before reporting when true (requires Node started with --expose-gc)" + } + ], + "responses": { + "200": { + "description": "With ?snapshot returns {snapshot}, with ?snapshots returns {snapshots}; otherwise the full memory report", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timestamp": { + "type": "string" + }, + "uptime": { + "type": "string" + }, + "gcForced": { + "type": "boolean" + }, + "gcAvailable": { + "type": "boolean" + }, + "process": { + "type": "string" + }, + "growth": { + "type": "string" + }, + "v8Heap": { + "type": "string" + }, + "system": { + "type": "string" + }, + "rssTracker": { + "type": "string" + } + }, + "required": [ + "timestamp", + "uptime", + "gcForced", + "gcAvailable", + "process", + "growth", + "v8Heap", + "system" + ] + }, + "example": { + "timestamp": "2026-07-01T10:00:00.000Z", + "uptime": { + "ms": 3600000, + "hours": 1, + "human": "1h 0m" + }, + "gcForced": false, + "gcAvailable": false, + "process": { + "rss": "180 MB" + }, + "growth": { + "rssPerHour": "2 MB" + }, + "v8Heap": { + "usedHeapSize": "90 MB" + }, + "system": { + "cpus": 8, + "platform": "linux" + } + } + } + } + }, + "403": { + "description": "Memory monitor not enabled (set MEMORY_MONITOR=true)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/dependencies": { + "get": { + "operationId": "get_api_dependencies", + "tags": [ + "dependencies" + ], + "summary": "Return the combined list of npm dependencies and external tool images (grype, trivy), sorted by name, excluding Dockhand itself", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "license": { + "type": "string" + }, + "repository": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ] + } + }, + "example": [ + { + "name": "anchore/grype", + "version": "v0.110.0", + "license": "Apache-2.0", + "repository": "https://github.com/anchore/grype" + } + ] + } + } + } + }, + "security": [] + } + }, + "/api/docs": { + "get": { + "operationId": "get_api_docs", + "tags": [ + "docs" + ], + "summary": "The full OpenAPI 3.0 specification for the Dockhand REST API (unauthenticated)", + "parameters": [], + "responses": { + "200": { + "description": "The generated OpenAPI document — see GET /api/docs/ui for an interactive viewer" + }, + "304": { + "description": "Not modified — the client's If-None-Match matches the current spec ETag" + }, + "404": { + "description": "Not found — API docs are disabled (set FEAT_API_DOCS=true to enable)" + } + }, + "security": [] + } + }, + "/api/environments": { + "get": { + "operationId": "get_api_environments", + "tags": [ + "environments" + ], + "summary": "List all Docker environments (hosts) known to Dockhand", + "parameters": [], + "responses": { + "200": { + "description": "Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "publicIp": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + } + }, + "example": [ + { + "id": 1, + "name": "hhdocker01", + "connectionType": "socket", + "host": null, + "port": 2375, + "protocol": "http", + "icon": "server", + "publicIp": "203.0.113.10", + "timezone": "Europe/Berlin" + }, + { + "id": 2, + "name": "hhdocker02", + "connectionType": "hawser-edge", + "host": null, + "port": 2375, + "protocol": "http", + "icon": "server", + "publicIp": null, + "timezone": "Europe/Berlin" + } + ] + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "500": { + "description": "Unexpected error while loading environments" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments", + "tags": [ + "environments" + ], + "summary": "Register a new Docker environment (host)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + }, + "example": { + "id": 3, + "name": "hhdocker03", + "connectionType": "socket" + } + } + } + }, + "400": { + "description": "Invalid name, invalid connectionType, or host missing for direct/hawser-standard" + }, + "403": { + "description": "Permission denied (RBAC 'environments:create' missing)" + }, + "409": { + "description": "An environment with this name already exists" + }, + "500": { + "description": "Unexpected error while creating the environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "socketPath": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "publicIp": { + "type": "string" + }, + "labels": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "hhdocker03", + "connectionType": "socket", + "socketPath": "/var/run/docker.sock", + "icon": "server" + } + } + } + } + } + }, + "/api/environments/{id}": { + "get": { + "operationId": "get_api_environments_id", + "tags": [ + "environments" + ], + "summary": "Get a single environment by id, including its parsed labels and public IP", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "publicIp": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + }, + "example": { + "id": 0, + "name": "string", + "connectionType": "string", + "labels": [ + "string" + ], + "publicIp": "string" + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_environments_id", + "tags": [ + "environments" + ], + "summary": "Update an environment; renaming also renames its on-disk stacks/git-repos directories", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "publicIp": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "connectionType" + ] + }, + "example": { + "id": 0, + "name": "string", + "connectionType": "string", + "labels": [ + "string" + ], + "publicIp": "string" + } + } + } + }, + "400": { + "description": "Invalid new name (rename validation)" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "409": { + "description": "Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems)" + }, + "500": { + "description": "Unexpected error while updating the environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "tlsCa": { + "type": "string" + }, + "tlsCert": { + "type": "string" + }, + "tlsKey": { + "type": "string" + }, + "tlsSkipVerify": { + "type": "boolean" + }, + "icon": { + "type": "string" + }, + "socketPath": { + "type": "string" + }, + "collectActivity": { + "type": "boolean" + }, + "collectMetrics": { + "type": "boolean" + }, + "highlightChanges": { + "type": "boolean" + }, + "labels": { + "type": "string" + }, + "connectionType": { + "type": "string" + }, + "hawserToken": { + "type": "string" + }, + "publicIp": { + "type": "string" + } + } + }, + "example": { + "name": "hhdocker03", + "collectMetrics": true + } + } + } + } + }, + "delete": { + "operationId": "delete_api_environments_id", + "tags": [ + "environments" + ], + "summary": "Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid environment id, or the environment could not be deleted" + }, + "403": { + "description": "Permission denied (RBAC 'environments:delete' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/disk-warning": { + "get": { + "operationId": "get_api_environments_id_disk-warning", + "tags": [ + "environments" + ], + "summary": "Get the disk-space warning thresholds for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "threshold": { + "type": "integer" + }, + "thresholdGb": { + "type": "integer" + } + }, + "required": [ + "enabled", + "mode", + "threshold", + "thresholdGb" + ] + }, + "example": { + "enabled": true, + "mode": "percentage", + "threshold": 80, + "thresholdGb": 50 + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_disk-warning", + "tags": [ + "environments" + ], + "summary": "Save the disk-space warning thresholds for an environment (each field optional/independent)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "mode": { + "type": "string" + }, + "threshold": { + "type": "integer" + }, + "thresholdGb": { + "type": "integer" + } + } + }, + "example": { + "enabled": true, + "mode": "percentage", + "threshold": 85 + } + } + } + } + } + }, + "/api/environments/{id}/icon": { + "get": { + "operationId": "get_api_environments_id_icon", + "tags": [ + "environments" + ], + "summary": "Get the custom icon image for an environment (raw image/webp bytes, not JSON)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Binary image/webp response body, Cache-Control public max-age=3600" + }, + "404": { + "description": "No custom icon set for this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_icon", + "tags": [ + "environments" + ], + "summary": "Upload a custom icon for an environment (base64-encoded image, ~200KB limit)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "success", + "icon" + ] + }, + "example": { + "success": true, + "icon": "custom:env-3.webp" + } + } + } + }, + "400": { + "description": "Missing image data, or image exceeds the ~300000-char base64 size limit" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + } + }, + "required": [ + "image" + ] + }, + "example": { + "image": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_environments_id_icon", + "tags": [ + "environments" + ], + "summary": "Remove an environment's custom icon and reset it to the default \"globe\" icon", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "success", + "icon" + ] + }, + "example": { + "success": true, + "icon": "globe" + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/image-prune": { + "get": { + "operationId": "get_api_environments_id_image-prune", + "tags": [ + "environments" + ], + "summary": "Get the automatic image-prune schedule settings for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "pruneMode": { + "type": "string" + } + }, + "required": [ + "enabled", + "cronExpression", + "pruneMode" + ] + } + }, + "required": [ + "settings" + ] + }, + "example": { + "settings": { + "enabled": false, + "cronExpression": "0 3 * * 0", + "pruneMode": "dangling" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_image-prune", + "tags": [ + "environments" + ], + "summary": "Save the automatic image-prune schedule for an environment (registers/unregisters the croner job)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "pruneMode": { + "type": "string" + } + }, + "required": [ + "enabled", + "cronExpression", + "pruneMode" + ] + } + }, + "required": [ + "success", + "settings" + ] + }, + "example": { + "success": true, + "settings": { + "enabled": true, + "cronExpression": "string", + "pruneMode": "string" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "pruneMode": { + "type": "string" + } + } + }, + "example": { + "enabled": true, + "cronExpression": "0 3 * * 0", + "pruneMode": "dangling" + } + } + } + } + }, + "put": { + "operationId": "put_api_environments_id_image-prune", + "tags": [ + "environments" + ], + "summary": "Immediately run an image prune for an environment (outside its schedule)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The prune operation itself failed (Docker error)" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while triggering the prune" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/notifications": { + "get": { + "operationId": "get_api_environments_id_notifications", + "tags": [ + "environments" + ], + "summary": "List notification channel configurations attached to an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + } + }, + "example": [ + { + "id": 0, + "notificationId": 0, + "enabled": true, + "eventTypes": [ + "string" + ] + } + ] + } + } + }, + "400": { + "description": "Invalid environment id" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading notifications" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_notifications", + "tags": [ + "environments" + ], + "summary": "Attach a notification channel to an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + }, + "example": { + "id": 0, + "notificationId": 0, + "enabled": true + } + } + } + }, + "400": { + "description": "Invalid environment id, or notificationId missing" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "409": { + "description": "This notification channel is already configured for this environment" + }, + "500": { + "description": "Unexpected error while creating the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "notificationId from GET /api/notifications.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "notificationId" + ] + }, + "example": { + "notificationId": 1, + "enabled": true, + "eventTypes": [ + "stack_deploy_failed" + ] + } + } + } + } + } + }, + "/api/environments/{id}/notifications/{notificationId}": { + "get": { + "operationId": "get_api_environments_id_notifications_notificationId", + "tags": [ + "environments" + ], + "summary": "Get one notification-channel link for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "notificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification channel id (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + }, + "example": { + "id": 0, + "notificationId": 0, + "enabled": true, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid id or notificationId" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:view' missing)" + }, + "404": { + "description": "Environment not found, or no such notification link for this environment" + }, + "500": { + "description": "Unexpected error while loading the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_environments_id_notifications_notificationId", + "tags": [ + "environments" + ], + "summary": "Update a notification-channel link (enabled state and/or event type filter)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "notificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification channel id (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "notificationId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "notificationId", + "enabled" + ] + }, + "example": { + "id": 0, + "notificationId": 0, + "enabled": true, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid id or notificationId" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:edit' missing)" + }, + "404": { + "description": "Environment not found, or no such notification link for this environment" + }, + "500": { + "description": "Unexpected error while updating the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "enabled": false + } + } + } + } + }, + "delete": { + "operationId": "delete_api_environments_id_notifications_notificationId", + "tags": [ + "environments" + ], + "summary": "Remove a notification-channel link from an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "notificationId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification channel id (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid id or notificationId" + }, + "403": { + "description": "Permission denied (RBAC 'notifications:delete' missing)" + }, + "404": { + "description": "No such notification link for this environment" + }, + "500": { + "description": "Unexpected error while deleting the notification link" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/remote-stacks-dir": { + "get": { + "operationId": "get_api_environments_id_remote-stacks-dir", + "tags": [ + "environments" + ], + "summary": "Get the per-environment remote stacks directory used to stage stack files onto a direct (agentless) daemon", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "remoteStacksDir": { + "type": "string" + } + } + }, + "example": { + "remoteStacksDir": "/mnt/dockhand/stacks" + } + } + } + }, + "403": { + "description": "Permission denied (requires environments:view)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_remote-stacks-dir", + "tags": [ + "environments" + ], + "summary": "Set (or clear) the per-environment remote stacks directory for a direct (agentless) daemon", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "remoteStacksDir": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "remoteStacksDir": "/mnt/dockhand/stacks" + } + } + } + }, + "400": { + "description": "Invalid input — remoteStacksDir must be a string or null, or must be an absolute path with no \"..\"" + }, + "403": { + "description": "Permission denied (requires environments:edit)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "remoteStacksDir": { + "type": "string" + } + } + }, + "example": { + "remoteStacksDir": "/mnt/dockhand/stacks" + } + } + } + } + } + }, + "/api/environments/{id}/test": { + "post": { + "operationId": "post_api_environments_id_test", + "tags": [ + "environments" + ], + "summary": "Test connectivity to a saved environment's Docker/Hawser endpoint", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "success:false with a human-readable error message is also returned as HTTP 200 (connection/agent-not-connected states are not transport errors)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "info": { + "type": "object", + "properties": { + "serverVersion": { + "type": "string" + }, + "containers": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "engine": { + "type": "string" + } + } + }, + "isEdgeMode": { + "type": "boolean" + }, + "hawser": { + "type": "object", + "properties": {} + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "info": { + "serverVersion": "string", + "containers": 0, + "images": 0, + "name": "string", + "engine": "string" + }, + "isEdgeMode": true, + "hawser": {} + } + } + } + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/{id}/timezone": { + "get": { + "operationId": "get_api_environments_id_timezone", + "tags": [ + "environments" + ], + "summary": "Get the IANA timezone used for scheduling on an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timezone": { + "type": "string" + } + }, + "required": [ + "timezone" + ] + }, + "example": { + "timezone": "Europe/Berlin" + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the timezone" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_timezone", + "tags": [ + "environments" + ], + "summary": "Set the IANA timezone for an environment and refresh its schedules to use it", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "success", + "timezone" + ] + }, + "example": { + "success": true, + "timezone": "string" + } + } + } + }, + "400": { + "description": "Not a recognized IANA timezone (Intl.supportedValuesOf('timeZone'))" + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the timezone" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timezone": { + "type": "string" + } + }, + "required": [ + "timezone" + ] + }, + "example": { + "timezone": "Europe/Berlin" + } + } + } + } + } + }, + "/api/environments/{id}/update-check": { + "get": { + "operationId": "get_api_environments_id_update-check", + "tags": [ + "environments" + ], + "summary": "Get the automatic container-image update-check schedule for an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "autoUpdate": { + "type": "boolean" + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "enabled", + "cron", + "autoUpdate", + "vulnerabilityCriteria" + ] + } + }, + "required": [ + "settings" + ] + }, + "example": { + "settings": { + "enabled": false, + "cron": "0 4 * * *", + "autoUpdate": false, + "vulnerabilityCriteria": "never" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:view' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while loading the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_environments_id_update-check", + "tags": [ + "environments" + ], + "summary": "Save the automatic image update-check schedule for an environment (registers/unregisters the croner job)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "autoUpdate": { + "type": "boolean" + }, + "vulnerabilityCriteria": { + "type": "string" + } + }, + "required": [ + "enabled", + "cron", + "autoUpdate", + "vulnerabilityCriteria" + ] + } + }, + "required": [ + "success", + "settings" + ] + }, + "example": { + "success": true, + "settings": { + "enabled": true, + "cron": "string", + "autoUpdate": true, + "vulnerabilityCriteria": "string" + } + } + } + } + }, + "403": { + "description": "Permission denied (RBAC 'environments:edit' missing)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Unexpected error while saving the settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cron": { + "type": "string" + }, + "autoUpdate": { + "type": "boolean" + }, + "vulnerabilityCriteria": { + "type": "string" + } + } + }, + "example": { + "enabled": true, + "cron": "0 4 * * *", + "autoUpdate": false, + "vulnerabilityCriteria": "never" + } + } + } + } + } + }, + "/api/environments/detect-socket": { + "get": { + "operationId": "get_api_environments_detect-socket", + "tags": [ + "environments" + ], + "summary": "Detect common Docker/Podman socket paths that exist on the Dockhand host", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sockets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "exists": { + "type": "boolean" + } + }, + "required": [ + "path", + "name", + "exists" + ] + } + }, + "homedir": { + "type": "string" + } + }, + "required": [ + "sockets", + "homedir" + ] + }, + "example": { + "sockets": [ + { + "path": "/var/run/docker.sock", + "name": "Docker (default)", + "exists": true + } + ], + "homedir": "/home/dockhand" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/environments/test": { + "post": { + "operationId": "post_api_environments_test", + "tags": [ + "environments" + ], + "summary": "Test a Docker/Hawser connection configuration WITHOUT saving it as an environment", + "parameters": [], + "responses": { + "200": { + "description": "success:false with a human-readable error message is also returned as HTTP 200 (connection failures are not transport errors)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "info": { + "type": "object", + "properties": { + "serverVersion": { + "type": "string" + }, + "containers": { + "type": "integer" + }, + "images": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + }, + "hawser": { + "type": "object", + "properties": {} + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "info": { + "serverVersion": "string", + "containers": 0, + "images": 0, + "name": "string" + }, + "hawser": {} + } + } + } + }, + "400": { + "description": "Host is required for direct/hawser-standard connection types" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connectionType": { + "type": "string" + }, + "socketPath": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "protocol": { + "type": "string" + }, + "tlsCa": { + "type": "string" + }, + "tlsCert": { + "type": "string" + }, + "tlsKey": { + "type": "string" + }, + "tlsSkipVerify": { + "type": "boolean" + }, + "hawserToken": { + "type": "string" + } + }, + "required": [ + "connectionType" + ] + }, + "example": { + "connectionType": "socket", + "socketPath": "/var/run/docker.sock" + } + } + } + } + } + }, + "/api/events": { + "get": { + "operationId": "get_api_events", + "tags": [ + "events" + ], + "summary": "Stream live Docker events (container/image/volume/network) for an environment via SSE, with periodic heartbeats", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id — without it, an \"info\" SSE message is sent and the stream ends (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "text/event-stream SSE stream (\"connected\", \"heartbeat\" every 5s, \"docker\" events with {type,action,actor,time,timeNano}, or an \"error\"/\"info\" event for edge environments, missing/unknown environment, or a lost Docker connection)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/branches": { + "post": { + "operationId": "post_api_git_branches", + "tags": [ + "git" + ], + "summary": "List remote branches for a git repository via `git ls-remote`", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "branches": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "branches" + ] + }, + "example": { + "branches": [ + "main", + "develop", + "feature/test" + ] + } + } + } + }, + "400": { + "description": "The URL points at a loopback/link-local/metadata/reserved target, the credential does not match the URL host, or neither repositoryId nor url was supplied" + }, + "403": { + "description": "Permission denied (requires git:edit)" + }, + "404": { + "description": "The referenced repository does not exist" + }, + "500": { + "description": "Failed to fetch branches (ls-remote error or timeout)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. A raw url may only be paired with a stored credentialId whose username plausibly matches that host (SSH credentials are always allowed). The ls-remote is bounded by a hard timeout.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repositoryId": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + } + }, + "example": { + "url": "https://github.com/example/repo.git", + "credentialId": 2 + } + } + } + } + } + }, + "/api/git/credentials": { + "get": { + "operationId": "get_api_git_credentials", + "tags": [ + "git" + ], + "summary": "List all stored git credentials with secrets stripped (only hasPassword/hasSshKey flags returned)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + } + }, + "example": [ + { + "id": 1, + "name": "github-deploy", + "authType": "ssh", + "username": "git", + "hasPassword": false, + "hasSshKey": true, + "createdAt": "2026-06-01T10:00:00Z", + "updatedAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "500": { + "description": "Failed to read git credentials from the database" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_credentials", + "tags": [ + "git" + ], + "summary": "Create a new git credential (none/password/ssh auth) and return it with secrets stripped", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + }, + "example": { + "id": 0, + "name": "string", + "authType": "string", + "username": "string", + "hasPassword": true, + "hasSshKey": true, + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Missing name, invalid authType, a missing secret for the chosen authType, or a duplicate credential name" + }, + "403": { + "description": "Caller lacks the git:create permission" + }, + "500": { + "description": "Failed to create the git credential" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "sshPassphrase": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "github-deploy", + "authType": "ssh", + "username": "git", + "sshPrivateKey": "***", + "sshPassphrase": "***" + } + } + } + } + } + }, + "/api/git/credentials/{id}": { + "get": { + "operationId": "get_api_git_credentials_id", + "tags": [ + "git" + ], + "summary": "Get a single git credential by ID with secrets stripped (only hasPassword/hasSshKey flags returned)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git credential ID (from GET /api/git/credentials)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + }, + "example": { + "id": 0, + "name": "string", + "authType": "string", + "username": "string", + "hasPassword": true, + "hasSshKey": true, + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "404": { + "description": "No credential exists with that ID" + }, + "500": { + "description": "Failed to read the git credential" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_git_credentials_id", + "tags": [ + "git" + ], + "summary": "Update a git credential and return it with secrets stripped", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git credential ID (from GET /api/git/credentials)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "hasPassword": { + "type": "boolean" + }, + "hasSshKey": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "authType", + "hasPassword", + "hasSshKey" + ] + }, + "example": { + "id": 0, + "name": "string", + "authType": "string", + "username": "string", + "hasPassword": true, + "hasSshKey": true, + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Invalid id, invalid authType, or a duplicate credential name" + }, + "403": { + "description": "Caller lacks the git:edit permission" + }, + "404": { + "description": "No credential exists with that ID" + }, + "500": { + "description": "The update failed or the credential could not be persisted" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "authType": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "sshPassphrase": { + "type": "string" + } + } + }, + "example": { + "name": "github-deploy", + "authType": "password", + "username": "git", + "password": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_git_credentials_id", + "tags": [ + "git" + ], + "summary": "Delete a git credential by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git credential ID (from GET /api/git/credentials)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:delete permission" + }, + "404": { + "description": "No credential exists with that ID" + }, + "500": { + "description": "The deletion failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/preview-env": { + "post": { + "operationId": "post_api_git_preview-env", + "tags": [ + "git" + ], + "summary": "Clone a repo to a temp dir and preview its merged env-file variables for the git-stack env editor", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vars": { + "type": "string" + }, + "sources": { + "type": "string" + } + }, + "required": [ + "vars", + "sources" + ] + }, + "example": { + "vars": "string", + "sources": "string" + } + } + } + }, + "400": { + "description": "composePath missing, neither repositoryId nor url supplied, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport (ext::/file::), the credential does not match the URL host, the compose/env path escapes the repository, or the repo/env-file preview reported an error" + }, + "403": { + "description": "Permission denied (requires the git:edit permission — same model as /api/git/branches; an unauthenticated or read-only user is denied here rather than via a separate 401)" + }, + "404": { + "description": "The referenced repository does not exist" + }, + "500": { + "description": "Failed to preview the env files (clone or read error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. SECURITY: requires the git:edit permission when authentication is enabled (403 otherwise). The repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense); and composePath/envFilePath are constrained to stay inside the cloned repository (path traversal).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repositoryId": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + }, + "composePath": { + "type": "string" + }, + "envFilePath": { + "type": "string" + } + }, + "required": [ + "composePath" + ] + }, + "example": { + "repositoryId": 3, + "composePath": "docker-compose.yml", + "envFilePath": ".env.prod" + } + } + } + } + } + }, + "/api/git/repositories": { + "get": { + "operationId": "get_api_git_repositories", + "tags": [ + "git" + ], + "summary": "List all git repositories (repositories are global, not scoped to an environment)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + } + }, + "example": [ + { + "id": 1, + "name": "homelab", + "url": "https://github.com/example/homelab.git", + "branch": "main", + "credentialId": 2 + } + ] + } + } + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "500": { + "description": "Failed to read git repositories" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_repositories", + "tags": [ + "git" + ], + "summary": "Create a git repository (branch defaults to main); deployment config lives on git stacks, not here", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "branch": "string", + "credentialId": 0 + } + } + } + }, + "400": { + "description": "Missing name/url, an invalid credentialId, or a duplicate repository name" + }, + "403": { + "description": "Caller lacks the git:create permission" + }, + "500": { + "description": "Failed to create the git repository" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "credentialId from GET /api/git/credentials.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "name", + "url" + ] + }, + "example": { + "name": "homelab", + "url": "https://github.com/example/homelab.git", + "branch": "main", + "credentialId": 2 + } + } + } + } + } + }, + "/api/git/repositories/{id}": { + "get": { + "operationId": "get_api_git_repositories_id", + "tags": [ + "git" + ], + "summary": "Get a single git repository by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "branch": "string", + "credentialId": 0 + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:view permission" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "Failed to read the git repository" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_git_repositories_id", + "tags": [ + "git" + ], + "summary": "Update a git repository's basic fields (name/url/branch/credential)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "url", + "branch" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "branch": "string", + "credentialId": 0 + } + } + } + }, + "400": { + "description": "Invalid id, an invalid credentialId, or a duplicate repository name" + }, + "403": { + "description": "Caller lacks the git:edit permission" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The update failed or the repository could not be persisted" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "credentialId from GET /api/git/credentials.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + } + }, + "example": { + "name": "homelab", + "url": "https://github.com/example/homelab.git", + "branch": "production", + "credentialId": 2 + } + } + } + } + }, + "delete": { + "operationId": "delete_api_git_repositories_id", + "tags": [ + "git" + ], + "summary": "Delete a git repository, first removing the clone directories of every git stack it backs", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the git:delete permission" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deletion failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/{id}/deploy": { + "post": { + "operationId": "post_api_git_repositories_id_deploy", + "tags": [ + "git" + ], + "summary": "Deploy the compose stack(s) defined in a git repository (clones/pulls, then runs docker compose)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deployment failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/{id}/sync": { + "get": { + "operationId": "get_api_git_repositories_id_sync", + "tags": [ + "git" + ], + "summary": "Check whether the tracked branch has new commits upstream without pulling them", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hasUpdates": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "hasUpdates" + ] + }, + "example": { + "hasUpdates": false + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The update check failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_repositories_id_sync", + "tags": [ + "git" + ], + "summary": "Sync (git pull) the local clone of a repository to the latest commit on its tracked branch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The sync failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/{id}/test": { + "post": { + "operationId": "post_api_git_repositories_id_test", + "tags": [ + "git" + ], + "summary": "Test connectivity/authentication to a saved repository using its stored credential", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The connectivity test failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/repositories/test": { + "post": { + "operationId": "post_api_git_repositories_test", + "tags": [ + "git" + ], + "summary": "Test an unsaved repository configuration (url/branch/credentialId) before creating it", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The url field is missing, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport, or the credential does not match the URL host" + }, + "403": { + "description": "Caller lacks the settings:manage permission" + }, + "404": { + "description": "The referenced credential does not exist" + }, + "500": { + "description": "The connectivity test failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "credentialId from GET /api/git/credentials. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "credentialId": { + "type": "integer" + } + }, + "required": [ + "url" + ] + }, + "example": { + "url": "https://github.com/example/homelab.git", + "branch": "main", + "credentialId": 2 + } + } + } + } + } + }, + "/api/git/stacks": { + "get": { + "operationId": "get_api_git_stacks", + "tags": [ + "git" + ], + "summary": "List git-deployed stacks (optionally scoped to one environment)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "500": { + "description": "Failed to list git stacks" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_git_stacks", + "tags": [ + "git" + ], + "summary": "Create a git-deployed stack (from an existing repo or new repo url/branch)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid stack name, or secretProviderId is not a number/null" + }, + "403": { + "description": "Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)" + }, + "409": { + "description": "A git stack with this name already exists in the environment" + }, + "500": { + "description": "Failed to create the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "repositoryId": { + "type": "integer" + }, + "secretProviderId": { + "type": "integer" + }, + "webhookEnabled": { + "type": "boolean" + }, + "webhookSecret": { + "type": "string" + } + }, + "required": [ + "stackName" + ] + }, + "example": { + "stackName": "string", + "environmentId": 0, + "repositoryId": 0, + "secretProviderId": 0, + "webhookEnabled": true, + "webhookSecret": "string" + } + } + } + } + } + }, + "/api/git/stacks/{id}": { + "get": { + "operationId": "get_api_git_stacks_id", + "tags": [ + "git" + ], + "summary": "Get one git stack", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The git stack id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "Git stack not found" + }, + "500": { + "description": "Failed to load the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_git_stacks_id", + "tags": [ + "git" + ], + "summary": "Update a git stack (rename, schedule, webhook, secret-provider binding)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The git stack id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid stack name, or secretProviderId is not a number/null" + }, + "403": { + "description": "Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view)" + }, + "404": { + "description": "Git stack not found" + }, + "500": { + "description": "Failed to update the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "secretProviderId": { + "type": "integer" + }, + "webhookEnabled": { + "type": "boolean" + }, + "webhookSecret": { + "type": "string" + } + } + }, + "example": { + "stackName": "string", + "secretProviderId": 0, + "webhookEnabled": true, + "webhookSecret": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_git_stacks_id", + "tags": [ + "git" + ], + "summary": "Delete a git stack (removes its deploy files and stack source)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The git stack id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:delete)" + }, + "404": { + "description": "Git stack not found" + }, + "500": { + "description": "Failed to delete the git stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/deploy": { + "post": { + "operationId": "post_api_git_stacks_id_deploy", + "tags": [ + "git" + ], + "summary": "Deploy a git stack, streaming the deploy log as SSE result events via the job-response channel", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "An SSE stream whose final `result` event carries {success, error}", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + }, + "example": {} + } + } + }, + "403": { + "description": "Caller lacks the stacks:start permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "Failed to start the deployment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/deploy-stream": { + "post": { + "operationId": "post_api_git_stacks_id_deploy-stream", + "tags": [ + "git" + ], + "summary": "Deploy a git stack with live progress; streams SSE, or returns a jobId to poll (Accept negotiated)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Deployment started — either a jobId to poll or a streamed SSE deploy log", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + } + }, + "example": { + "jobId": "a1b2c3d4" + } + } + } + }, + "403": { + "description": "Caller lacks the stacks:start permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Clients sending `Accept: application/json` get a synchronous, buffered SSE-as-JSON result; otherwise a jobId is returned immediately and progress is delivered out-of-band." + } + }, + "/api/git/stacks/{id}/env-files": { + "get": { + "operationId": "get_api_git_stacks_id_env-files", + "tags": [ + "git" + ], + "summary": "List the .env files present in a git stack's cloned repository", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "files" + ] + }, + "example": { + "files": [ + ".env", + ".env.prod" + ] + } + } + } + }, + "400": { + "description": "The repository could not be read (e.g. not yet cloned)" + }, + "403": { + "description": "Caller lacks the stacks:view permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "Failed to list the env files" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Lists the `.env*` files in the git stack's synced repository checkout (read-only). Use this to discover which file to configure as the stack's `envFilePath`. This endpoint does not write — to choose the env file, PUT /api/git/stacks/{id} with `envFilePath`; to set env values, use the stack env endpoints." + }, + "post": { + "operationId": "post_api_git_stacks_id_env-files", + "tags": [ + "git" + ], + "summary": "Read and parse one .env file from a git stack's repository into a key/value map", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "vars": { + "type": "string" + } + }, + "required": [ + "vars" + ] + }, + "example": { + "vars": { + "TZ": "Europe/Berlin" + } + } + } + } + }, + "400": { + "description": "The path field is missing or the env file could not be read" + }, + "403": { + "description": "Caller lacks the stacks:view permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "Failed to read the env file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Reads and parses a single `.env` file (by `path`) from the git stack's synced repository checkout into a key/value map (read-only). There is no write counterpart — git-stack env is set via `envFilePath` (PUT /api/git/stacks/{id}) or the stack env endpoints.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "example": { + "path": ".env.prod" + } + } + } + } + } + }, + "/api/git/stacks/{id}/sync": { + "post": { + "operationId": "post_api_git_stacks_id_sync", + "tags": [ + "git" + ], + "summary": "Sync (git pull) a git stack's repository clone to the latest tracked commit", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Caller lacks the stacks:edit permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The sync failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/test": { + "post": { + "operationId": "post_api_git_stacks_id_test", + "tags": [ + "git" + ], + "summary": "Test a git stack's repository access and compose configuration without deploying", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Caller lacks the stacks:view permission for the stack's environment" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The test failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/git/stacks/{id}/webhook": { + "get": { + "operationId": "get_api_git_stacks_id_webhook", + "tags": [ + "git" + ], + "summary": "GET webhook trigger for a git stack, with the secret passed as the `secret` query parameter", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + }, + { + "name": "secret", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Webhook secret; required only if the stack has a webhook secret configured" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "skipped": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true, + "skipped": false + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The provided secret did not match the stack's webhook secret" + }, + "403": { + "description": "Webhooks are not enabled for this stack" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_git_stacks_id_webhook", + "tags": [ + "git" + ], + "summary": "Webhook trigger (GitHub/GitLab) that deploys a git stack when its signature/token verifies", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git stack ID (from GET /api/git/stacks)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "skipped": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true, + "skipped": false + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The webhook signature or token did not verify" + }, + "403": { + "description": "Webhooks are not enabled for this stack" + }, + "404": { + "description": "No git stack exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [], + "description": "Public endpoint authenticated by the stack's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification." + } + }, + "/api/git/webhook/{id}": { + "get": { + "operationId": "get_api_git_webhook_id", + "tags": [ + "git" + ], + "summary": "GET webhook trigger for a git repository, with the secret passed as the `secret` query parameter", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + }, + { + "name": "secret", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Webhook secret; required only if the repository has a webhook secret configured" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The provided secret did not match the repository's webhook secret" + }, + "403": { + "description": "Webhooks are not enabled for this repository" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_git_webhook_id", + "tags": [ + "git" + ], + "summary": "Webhook trigger (GitHub/GitLab) that deploys from a git repository when its signature/token verifies", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Git repository ID (from GET /api/git/repositories)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + } + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "401": { + "description": "The webhook signature or token did not verify" + }, + "403": { + "description": "Webhooks are not enabled for this repository" + }, + "404": { + "description": "No repository exists with that ID" + }, + "500": { + "description": "The deployment triggered by the webhook failed" + } + }, + "security": [], + "description": "Public endpoint authenticated by the repository's webhook secret via `X-Hub-Signature-256` (GitHub) or `X-Gitlab-Token` (GitLab); the raw request body is used for HMAC verification." + } + }, + "/api/hawser/connect": { + "get": { + "operationId": "get_api_hawser_connect", + "tags": [ + "hawser" + ], + "summary": "Report the Hawser Edge WebSocket endpoint status and list currently connected agents", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "message": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "activeConnections": { + "type": "integer" + }, + "connections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "agentId": { + "type": "string" + }, + "agentName": { + "type": "string" + }, + "agentVersion": { + "type": "string" + }, + "dockerVersion": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "connectedAt": { + "type": "string" + }, + "lastHeartbeat": { + "type": "string" + } + }, + "required": [ + "environmentId" + ] + } + } + }, + "required": [ + "status", + "message", + "protocol", + "activeConnections", + "connections" + ] + }, + "example": { + "status": "ready", + "message": "Hawser Edge WebSocket endpoint. Connect via WebSocket.", + "protocol": "wss:///api/hawser/connect", + "activeConnections": 0, + "connections": [] + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_hawser_connect", + "tags": [ + "hawser" + ], + "summary": "Fallback for non-WebSocket clients; always advises upgrading to a WebSocket connection", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "426": { + "description": "This endpoint requires a WebSocket upgrade (ws:// or wss://)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/hawser/tokens": { + "get": { + "operationId": "get_api_hawser_tokens", + "tags": [ + "hawser" + ], + "summary": "List all Hawser agent tokens (only the prefix is returned, never the full token)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "tokenPrefix": { + "type": "string" + }, + "name": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "isActive": { + "type": "boolean" + }, + "lastUsed": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + }, + "required": [ + "id", + "tokenPrefix", + "name", + "environmentId", + "isActive", + "createdAt" + ] + } + }, + "example": [ + { + "id": 1, + "tokenPrefix": "hw_abc123", + "name": "edge-01", + "environmentId": 1, + "isActive": true, + "lastUsed": null, + "createdAt": "2026-06-01T10:00:00Z", + "expiresAt": null + } + ] + } + } + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to read the tokens" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_hawser_tokens", + "tags": [ + "hawser" + ], + "summary": "Generate a new Hawser agent token for an environment (the plaintext token is returned only once)", + "parameters": [], + "responses": { + "200": { + "description": "Token generated — the plaintext token is shown only once and cannot be retrieved again", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "tokenId": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "token", + "tokenId", + "message" + ] + }, + "example": { + "token": "***", + "tokenId": 42, + "message": "Token generated successfully. Save this token - it will not be shown again." + } + } + } + }, + "400": { + "description": "The name or environmentId field is missing or of the wrong type" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to generate the token" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "expiresAt": { + "type": "string" + }, + "rawToken": { + "type": "string" + } + }, + "required": [ + "name", + "environmentId" + ] + }, + "example": { + "name": "edge-01", + "environmentId": 1, + "expiresAt": "2027-01-01T00:00:00Z" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_hawser_tokens", + "tags": [ + "hawser" + ], + "summary": "Revoke a Hawser agent token by ID", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the token to revoke (from GET /api/hawser/tokens)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + }, + "example": { + "success": true, + "message": "Token revoked" + } + } + } + }, + "400": { + "description": "The id query parameter is missing" + }, + "401": { + "description": "Not authenticated" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to revoke the token" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/health": { + "get": { + "operationId": "get_api_health", + "tags": [ + "health" + ], + "summary": "Liveness probe — always returns 200 when the SvelteKit process is up", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "status", + "timestamp" + ] + }, + "example": { + "status": "ok", + "timestamp": "2027-01-01T12:00:00.000Z" + } + } + } + } + }, + "security": [] + } + }, + "/api/health/database": { + "get": { + "operationId": "get_api_health_database", + "tags": [ + "health" + ], + "summary": "Public database health check reporting schema/migration/table status; authenticated settings:view callers also get connection details", + "parameters": [], + "responses": { + "200": { + "description": "Schema health; an unhealthy database returns the same shape with HTTP 503", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "database": { + "type": "string" + }, + "migrationsTable": { + "type": "boolean" + }, + "appliedMigrations": { + "type": "integer" + }, + "pendingMigrations": { + "type": "integer" + }, + "tables": { + "type": "integer" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "healthy", + "database", + "migrationsTable", + "appliedMigrations", + "pendingMigrations", + "tables", + "timestamp" + ] + }, + "example": { + "healthy": true, + "database": "sqlite", + "migrationsTable": true, + "appliedMigrations": 42, + "pendingMigrations": 0, + "tables": 25, + "timestamp": "2026-07-01T10:00:00.000Z" + } + } + } + }, + "500": { + "description": "Unexpected error while checking database health" + } + }, + "security": [], + "description": "Returns HTTP 200 when healthy and 503 when unhealthy (schema drift or table loss); 500 on an unexpected error. Connection details and the migration tag are only included for authenticated callers with settings:view." + } + }, + "/api/host": { + "get": { + "operationId": "get_api_host", + "tags": [ + "host" + ], + "summary": "Return host info (hostname, IP, CPU, memory, uptime, Docker counts) for the daemon behind an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to describe (basic local info is returned when omitted) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "ipAddress": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "arch": { + "type": "string" + }, + "cpus": { + "type": "integer" + }, + "totalMemory": { + "type": "integer" + }, + "freeMemory": { + "type": "integer" + }, + "uptime": { + "type": "integer" + }, + "dockerVersion": { + "type": "string" + }, + "dockerContainers": { + "type": "integer" + }, + "dockerContainersRunning": { + "type": "integer" + }, + "dockerImages": { + "type": "integer" + }, + "environment": { + "type": "string" + } + }, + "required": [ + "hostname", + "ipAddress", + "platform", + "arch", + "cpus", + "totalMemory", + "freeMemory", + "uptime", + "dockerContainers", + "dockerContainersRunning", + "dockerImages" + ] + }, + "example": { + "hostname": "docker-host", + "ipAddress": "192.168.1.10", + "platform": "linux", + "arch": "x64", + "cpus": 8, + "totalMemory": 16777216000, + "freeMemory": 8388608000, + "uptime": 123456, + "dockerVersion": "27.0.3", + "dockerContainers": 10, + "dockerContainersRunning": 8, + "dockerImages": 25, + "environment": { + "id": 1, + "name": "local", + "connectionType": "socket" + } + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "500": { + "description": "Failed to get host info" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images": { + "get": { + "operationId": "get_api_images", + "tags": [ + "images" + ], + "summary": "List the Docker images of an environment (returns an empty array when no env is given or Docker is unreachable)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment whose images to list (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Array of images (empty if no env is specified or the Docker connection fails)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "RepoTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "Size": { + "type": "integer" + }, + "Created": { + "type": "integer" + } + }, + "required": [ + "Id" + ] + } + }, + "example": [ + { + "Id": "sha256:abc123", + "RepoTags": [ + "nginx:latest" + ], + "Size": 142000000, + "Created": 1719830400 + } + ] + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}": { + "delete": { + "operationId": "delete_api_images_id", + "tags": [ + "images" + ], + "summary": "Remove a Docker image by ID or name (optionally forced), scoped to an environment", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name to remove (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal even if the image is tagged or referenced (default false)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "409": { + "description": "Image is in use by a running container or has dependent child images" + }, + "500": { + "description": "Failed to remove image" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}/export": { + "get": { + "operationId": "get_api_images_id_export", + "tags": [ + "images" + ], + "summary": "Export a Docker image as a downloadable tar (or tar.gz) stream", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name to export (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + }, + { + "name": "compress", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Gzip the tar stream and serve it as .tar.gz (default false)" + } + ], + "responses": { + "200": { + "description": "The image tar (application/x-tar) or gzipped tar (application/gzip) as an attachment" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Docker returned no response body, or the export failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}/history": { + "get": { + "operationId": "get_api_images_id_history", + "tags": [ + "images" + ], + "summary": "Return the layer build history of a Docker image", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name whose history to return (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Created": { + "type": "integer" + }, + "CreatedBy": { + "type": "string" + }, + "Size": { + "type": "integer" + }, + "Comment": { + "type": "string" + } + } + } + }, + "example": [ + { + "Id": "sha256:abc123", + "Created": 1719830400, + "CreatedBy": "/bin/sh -c #(nop) CMD", + "Size": 0, + "Comment": "" + } + ] + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get image history" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/images/{id}/tag": { + "post": { + "operationId": "post_api_images_id_tag", + "tags": [ + "images" + ], + "summary": "Tag a Docker image into a repository (defaults the tag to \"latest\")", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image ID or name to tag (from GET /api/images)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Repository name is missing or not a string" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to tag image" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repo": { + "type": "string" + }, + "tag": { + "type": "string" + } + }, + "required": [ + "repo" + ] + }, + "example": { + "repo": "registry.example.com/myapp", + "tag": "v1.2.3" + } + } + } + } + } + }, + "/api/images/load": { + "post": { + "operationId": "post_api_images_load", + "tags": [ + "images" + ], + "summary": "Load a Docker image from an uploaded tar (docker load) for air-gapped hosts", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id" + } + ], + "responses": { + "200": { + "description": "loaded echoes the daemon's final line, e.g. \"Loaded image: alpine:3.20\"", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "loaded": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "loaded": "string" + } + } + } + }, + "400": { + "description": "Request body (an image tar) is required" + }, + "403": { + "description": "Permission denied (needs images:load), or access denied to this environment" + }, + "500": { + "description": "The daemon rejected the tar or the load failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "The request body is the raw image tar (Content-Type application/x-tar), streamed straight to the daemon without buffering. Local/socket or direct TCP only; Hawser is rejected.", + "requestBody": { + "required": true, + "content": { + "application/x-tar": { + "schema": { + "type": "string", + "format": "binary", + "description": "The raw image tar, streamed to the daemon (docker load)" + } + } + } + } + } + }, + "/api/images/pull": { + "post": { + "operationId": "post_api_images_pull", + "tags": [ + "images" + ], + "summary": "Pull a Docker image and stream pull (and optional scan-on-pull) progress as Server-Sent Events", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to pull into (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "A Server-Sent Events stream of pull progress, ending with a \"result\" event" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string" + }, + "scanAfterPull": { + "type": "boolean" + } + }, + "required": [ + "image" + ] + }, + "example": { + "image": "nginx:latest", + "scanAfterPull": false + } + } + } + } + } + }, + "/api/images/push": { + "post": { + "operationId": "post_api_images_push", + "tags": [ + "images" + ], + "summary": "Tag a local image and push it to a configured registry (streams progress, or runs synchronously for Accept: application/json)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the source image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + } + }, + "example": { + "jobId": "a1b2c3d4" + } + } + } + }, + "400": { + "description": "Image ID or registry ID missing, or the image has no usable tag" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Registry not found" + }, + "500": { + "description": "Failed to push image" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "imageId from GET /api/images. registryId from GET /api/registries.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "imageId": { + "type": "string" + }, + "registryId": { + "type": "integer" + }, + "imageName": { + "type": "string" + }, + "newTag": { + "type": "string" + } + }, + "required": [ + "imageId", + "registryId" + ] + }, + "example": { + "imageId": "sha256:abc123", + "registryId": 2, + "imageName": "myapp:latest", + "newTag": "myapp:v1.2.3" + } + } + } + } + } + }, + "/api/images/scan": { + "get": { + "operationId": "get_api_images_scan", + "tags": [ + "images" + ], + "summary": "Return the latest cached vulnerability scan for an image, resolving its tag to a SHA256 ID", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image name or ID to look up cached results for" + }, + { + "name": "scanner", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict the lookup to a specific scanner (e.g. grype or trivy)" + } + ], + "responses": { + "200": { + "description": "found=false when no cached scan exists; otherwise result holds the stored scan", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "found": { + "type": "boolean" + }, + "result": { + "type": "string" + } + }, + "required": [ + "found" + ] + }, + "example": { + "found": true, + "result": { + "imageId": "sha256:abc123", + "scanner": "grype", + "summary": { + "critical": 0, + "high": 2 + } + } + } + } + } + }, + "400": { + "description": "Image name is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to get scan results" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_images_scan", + "tags": [ + "images" + ], + "summary": "Start a vulnerability scan of an image and stream scan progress as Server-Sent Events, persisting the results", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment the image belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "A Server-Sent Events stream of scan progress, ending with a \"result\" event" + }, + "400": { + "description": "Image name is required" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "imageName": { + "type": "string" + }, + "scanner": { + "type": "string" + } + }, + "required": [ + "imageName" + ] + }, + "example": { + "imageName": "nginx:latest", + "scanner": "grype" + } + } + } + } + } + }, + "/api/images/scan/export": { + "get": { + "operationId": "get_api_images_scan_export", + "tags": [ + "images" + ], + "summary": "Export the cached vulnerability findings for a single image as json, csv, or sarif", + "parameters": [ + { + "name": "imageId", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image SHA/ID to export (falls back to the \"image\" param when omitted) (from GET /api/images)" + }, + { + "name": "image", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Image name/ID used as a fallback when imageId is not given" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Output format — json (default), csv, or sarif" + } + ], + "responses": { + "200": { + "description": "A 400 is returned when imageId is missing or (auth enabled) env is not specified, and 500 on failure" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/jobs/{id}": { + "get": { + "operationId": "get_api_jobs_id", + "tags": [ + "jobs" + ], + "summary": "Poll a background job's status and accumulated output lines (no auth — job ids are unguessable UUIDs)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Job id (UUID)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "event": { + "type": "string" + }, + "data": { + "type": "object", + "properties": {} + } + } + } + }, + "result": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "status", + "lines" + ] + }, + "example": { + "id": "string", + "status": "string", + "lines": [ + { + "event": "string", + "data": {} + } + ], + "result": {} + } + } + } + }, + "404": { + "description": "Job not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_jobs_id", + "tags": [ + "jobs" + ], + "summary": "Request cancellation of a running background job (no auth — job ids are unguessable UUIDs)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Job id (UUID)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ] + }, + "example": { + "cancelled": true + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/labels": { + "get": { + "operationId": "get_api_labels", + "tags": [ + "labels" + ], + "summary": "List all unique environment labels with their usage counts, the environments carrying each label, and any custom color", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "environments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "envId": { + "type": "integer" + }, + "envName": { + "type": "string" + } + }, + "required": [ + "envId", + "envName" + ] + } + }, + "count": { + "type": "integer" + }, + "color": { + "type": "string" + } + }, + "required": [ + "label", + "environments", + "count" + ] + } + }, + "colors": { + "type": "object", + "properties": {} + } + }, + "required": [ + "labels" + ] + }, + "example": { + "labels": [ + { + "label": "prod", + "environments": [ + { + "envId": 1, + "envName": "Production" + } + ], + "count": 1, + "color": "#ff0000" + } + ], + "colors": { + "prod": "#ff0000" + } + } + } + } + }, + "403": { + "description": "Permission denied (requires environments:view)" + }, + "500": { + "description": "Failed to get labels" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_labels", + "tags": [ + "labels" + ], + "summary": "Perform a bulk label operation on environments — rename, delete, add to environments, or set a custom color — selected via the action field", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "affected": { + "type": "integer" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "affected": 2 + } + } + } + }, + "400": { + "description": "Invalid action or missing/empty required fields for the chosen action" + }, + "403": { + "description": "Permission denied (requires environments:edit)" + }, + "500": { + "description": "Failed to manage labels" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentIds from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "oldLabel": { + "type": "string" + }, + "newLabel": { + "type": "string" + }, + "label": { + "type": "string" + }, + "environmentIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "color": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + "example": { + "action": "rename", + "oldLabel": "staging", + "newLabel": "stg" + } + } + } + } + } + }, + "/api/legal/license": { + "get": { + "operationId": "get_api_legal_license", + "tags": [ + "legal" + ], + "summary": "Return the bundled LICENSE.txt — as JSON by default, or as raw text/plain when format=text", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to \"text\" to return the raw license as text/plain instead of JSON" + } + ], + "responses": { + "200": { + "description": "The license text (as {content} JSON, or raw text/plain when format=text)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "string" + } + } + } + }, + "404": { + "description": "LICENSE.txt could not be found/read" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/legal/privacy": { + "get": { + "operationId": "get_api_legal_privacy", + "tags": [ + "legal" + ], + "summary": "Return the bundled PRIVACY.txt — as JSON by default, or as raw text/plain when format=text", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Set to \"text\" to return the raw privacy policy as text/plain instead of JSON" + } + ], + "responses": { + "200": { + "description": "The privacy policy text (as {content} JSON, or raw text/plain when format=text)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "string" + } + } + } + }, + "404": { + "description": "PRIVACY.txt could not be found/read" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/license": { + "get": { + "operationId": "get_api_license", + "tags": [ + "license" + ], + "summary": "Get the current license status plus the server hostname the license is bound to", + "parameters": [], + "responses": { + "200": { + "description": "The license status object merged with the current hostname" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "500": { + "description": "Failed to read the license status" + } + }, + "security": [] + }, + "post": { + "operationId": "post_api_license", + "tags": [ + "license" + ], + "summary": "Activate a license by name and key", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "license": { + "type": "object", + "properties": {} + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "license": {} + } + } + } + }, + "400": { + "description": "Name and key are required, or activation was rejected (invalid key)" + }, + "403": { + "description": "Permission denied (missing license:manage)" + }, + "500": { + "description": "Failed to activate the license" + } + }, + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "name", + "key" + ] + }, + "example": { + "name": "ACME Corp", + "key": "***" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_license", + "tags": [ + "license" + ], + "summary": "Deactivate the currently active license", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (missing license:manage)" + }, + "500": { + "description": "Failed to deactivate the license" + } + }, + "security": [] + } + }, + "/api/logs/merged": { + "get": { + "operationId": "get_api_logs_merged", + "tags": [ + "logs" + ], + "summary": "Stream merged, color-tagged logs from multiple containers over Server-Sent Events", + "parameters": [ + { + "name": "containers", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Comma-separated container IDs to merge logs from" + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Number of lines to tail per container (default 100)" + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs since this timestamp (Docker since format)" + }, + { + "name": "until", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Only return logs until this timestamp (Docker until format)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the containers belong to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream (text/event-stream) emitting heartbeat and per-container log events" + }, + "400": { + "description": "No containers specified" + }, + "403": { + "description": "Permission denied (requires the containers:logs permission)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/networks": { + "get": { + "operationId": "get_api_networks", + "tags": [ + "networks" + ], + "summary": "List Docker networks for an environment (returns an empty array when no env is given)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to list networks from (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "Internal": { + "type": "boolean" + }, + "Attachable": { + "type": "boolean" + } + }, + "required": [ + "Id", + "Name" + ] + } + }, + "example": [ + { + "Id": "string", + "Name": "string", + "Driver": "string", + "Scope": "string", + "Internal": true, + "Attachable": true + } + ] + } + } + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Failed to list networks" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_networks", + "tags": [ + "networks" + ], + "summary": "Create a Docker network in the given environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to create the network in (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "id": { + "type": "string" + } + }, + "required": [ + "success", + "id" + ] + }, + "example": { + "success": true, + "id": "3f1c...e9" + } + } + } + }, + "400": { + "description": "Network name is required" + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to create network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "internal": { + "type": "boolean" + }, + "attachable": { + "type": "boolean" + }, + "ingress": { + "type": "boolean" + }, + "enableIPv6": { + "type": "boolean" + }, + "options": { + "type": "object", + "properties": {} + }, + "labels": { + "type": "object", + "properties": {} + }, + "ipam": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "config": { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + }, + "options": { + "type": "object", + "properties": {} + } + } + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "app-net", + "driver": "bridge", + "attachable": true, + "labels": { + "project": "dockhand" + } + } + } + } + } + } + }, + "/api/networks/{id}": { + "get": { + "operationId": "get_api_networks_id", + "tags": [ + "networks" + ], + "summary": "Inspect a Docker network by ID (a malformed ID is rejected with 400 by input validation)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "IPAM": { + "type": "object", + "properties": {} + }, + "Containers": { + "type": "object", + "properties": {} + } + }, + "required": [ + "Id", + "Name" + ] + }, + "example": { + "Id": "string", + "Name": "string", + "Driver": "string", + "Scope": "string", + "IPAM": {}, + "Containers": {} + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to inspect network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_networks_id", + "tags": [ + "networks" + ], + "summary": "Remove a Docker network by ID (a malformed ID is rejected with 400 by input validation)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to the requested environment (enterprise)" + }, + "500": { + "description": "Failed to remove network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/networks/{id}/connect": { + "post": { + "operationId": "post_api_networks_id_connect", + "tags": [ + "networks" + ], + "summary": "Connect a container to a Docker network", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Container ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to connect container to network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "containerId from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + } + }, + "required": [ + "containerId" + ] + }, + "example": { + "containerId": "a1b2c3d4e5f6", + "containerName": "web-1" + } + } + } + } + } + }, + "/api/networks/{id}/disconnect": { + "post": { + "operationId": "post_api_networks_id_disconnect", + "tags": [ + "networks" + ], + "summary": "Disconnect a container from a Docker network (optionally forcing the disconnect)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Container ID is required" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to disconnect container from network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "containerId from GET /api/containers.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "containerId" + ] + }, + "example": { + "containerId": "a1b2c3d4e5f6", + "containerName": "web-1", + "force": false + } + } + } + } + } + }, + "/api/networks/{id}/inspect": { + "get": { + "operationId": "get_api_networks_id_inspect", + "tags": [ + "networks" + ], + "summary": "Inspect a Docker network by ID (a malformed ID is rejected with 400 by input validation)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker network ID (from GET /api/networks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment the network belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Id": { + "type": "string" + }, + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "IPAM": { + "type": "object", + "properties": {} + }, + "Containers": { + "type": "object", + "properties": {} + } + }, + "required": [ + "Id", + "Name" + ] + }, + "example": { + "Id": "string", + "Name": "string", + "Driver": "string", + "Scope": "string", + "IPAM": {}, + "Containers": {} + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to inspect network" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications": { + "get": { + "operationId": "get_api_notifications", + "tags": [ + "notifications" + ], + "summary": "List all notification settings (SMTP and Apprise) with SMTP passwords masked", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "from_email": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "password": { + "type": "string" + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + } + }, + "example": [ + { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": { + "host": "string", + "port": 0, + "from_email": "string", + "to_emails": [ + "string" + ], + "urls": [ + "string" + ], + "password": "string" + }, + "eventTypes": [ + "string" + ] + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires the notifications:view permission when auth is enabled)" + }, + "500": { + "description": "Failed to fetch notification settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_notifications", + "tags": [ + "notifications" + ], + "summary": "Create a notification setting (SMTP or Apprise); the response masks any SMTP password", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": {} + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + }, + "example": { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": {}, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Missing or invalid fields — type/name/config required, type must be smtp or apprise, SMTP needs host/port/from_email/to_emails, Apprise needs at least one URL" + }, + "403": { + "description": "Permission denied (requires the notifications:create permission)" + }, + "500": { + "description": "Failed to create notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "secure": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "from_email": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "event_types": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "name" + ] + }, + "example": { + "type": "smtp", + "name": "Ops Alerts", + "enabled": true, + "config": { + "host": "smtp.example.com", + "port": 587, + "secure": false, + "from_email": "dockhand@example.com", + "to_emails": [ + "ops@example.com" + ], + "password": "***" + }, + "eventTypes": [ + "container_unhealthy" + ] + } + } + } + } + } + }, + "/api/notifications/{id}": { + "get": { + "operationId": "get_api_notifications_id", + "tags": [ + "notifications" + ], + "summary": "Get a single notification setting by ID, with any SMTP password masked", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "from_email": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "password": { + "type": "string" + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + }, + "example": { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": { + "host": "string", + "port": 0, + "from_email": "string", + "to_emails": [ + "string" + ], + "urls": [ + "string" + ], + "password": "string" + }, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid ID (not a number)" + }, + "403": { + "description": "Permission denied (requires the notifications:view permission)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to fetch notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_notifications_id", + "tags": [ + "notifications" + ], + "summary": "Update a notification setting; a masked SMTP password (\"********\") keeps the stored value", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": {} + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "type", + "name", + "enabled" + ] + }, + "example": { + "id": 0, + "type": "string", + "name": "string", + "enabled": true, + "config": {}, + "eventTypes": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid ID, or config validation failed (SMTP needs host/port/from_email/to_emails, Apprise needs at least one URL)" + }, + "403": { + "description": "Permission denied (requires the notifications:edit permission)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to update notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "secure": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "from_email": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "eventTypes": { + "type": "array", + "items": { + "type": "string" + } + }, + "event_types": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "name": "Ops Alerts", + "enabled": false, + "config": { + "host": "smtp.example.com", + "port": 587, + "from_email": "dockhand@example.com", + "to_emails": [ + "ops@example.com" + ], + "password": "********" + }, + "eventTypes": [ + "container_unhealthy", + "container_oom" + ] + } + } + } + } + }, + "delete": { + "operationId": "delete_api_notifications_id", + "tags": [ + "notifications" + ], + "summary": "Delete a notification setting by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid ID (not a number)" + }, + "403": { + "description": "Permission denied (requires the notifications:delete permission)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to delete notification setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications/{id}/test": { + "post": { + "operationId": "post_api_notifications_id_test", + "tags": [ + "notifications" + ], + "summary": "Send a test notification through an already-saved notification setting", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Notification setting ID (from GET /api/notifications)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "message": "Test notification sent successfully" + } + } + } + }, + "400": { + "description": "Invalid ID (not a number)" + }, + "404": { + "description": "Notification setting not found" + }, + "500": { + "description": "Failed to test notification" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications/test": { + "post": { + "operationId": "post_api_notifications_test", + "tags": [ + "notifications" + ], + "summary": "Send a test notification using an ad-hoc config supplied in the body (nothing is saved)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "message": "Test notification sent successfully" + } + } + } + }, + "400": { + "description": "Missing/invalid fields — type and config required; SMTP needs host/from_email/to_emails; Apprise needs at least one URL" + }, + "403": { + "description": "Permission denied (requires the settings:edit permission)" + }, + "500": { + "description": "Failed to test notification" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "secure": { + "type": "boolean" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "from_email": { + "type": "string" + }, + "from_name": { + "type": "string" + }, + "to_emails": { + "type": "array", + "items": { + "type": "string" + } + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "required": [ + "type" + ] + }, + "example": { + "type": "smtp", + "name": "Test", + "config": { + "host": "smtp.example.com", + "port": 587, + "from_email": "dockhand@example.com", + "to_emails": [ + "ops@example.com" + ], + "password": "***" + } + } + } + } + } + } + }, + "/api/notifications/trigger-test": { + "get": { + "operationId": "get_api_notifications_trigger-test", + "tags": [ + "notifications" + ], + "summary": "List all available notification event types, grouped into categories", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eventTypes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "categories": { + "type": "object", + "properties": { + "container": { + "type": "array", + "items": { + "type": "string" + } + }, + "autoUpdate": { + "type": "array", + "items": { + "type": "string" + } + }, + "gitStack": { + "type": "array", + "items": { + "type": "string" + } + }, + "stack": { + "type": "array", + "items": { + "type": "string" + } + }, + "security": { + "type": "array", + "items": { + "type": "string" + } + }, + "system": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "eventTypes": [ + { + "id": "string", + "label": "string" + } + ], + "categories": { + "container": [ + "string" + ], + "autoUpdate": [ + "string" + ], + "gitStack": [ + "string" + ], + "stack": [ + "string" + ], + "security": [ + "string" + ], + "system": [ + "string" + ] + } + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_notifications_trigger-test", + "tags": [ + "notifications" + ], + "summary": "Trigger a real notification for a given event type (development/testing helper)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "sent": { + "type": "integer" + }, + "eventType": { + "type": "string" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "success", + "eventType" + ] + }, + "example": { + "success": true, + "sent": 1, + "eventType": "container_unhealthy", + "environmentId": 1 + } + } + } + }, + "400": { + "description": "eventType required, payload with title and message required, unknown event type, or environmentId missing for a non-system event" + }, + "500": { + "description": "Unknown error while sending the notification" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "eventType": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "payload": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "title", + "message" + ] + } + }, + "required": [ + "eventType" + ] + }, + "example": { + "eventType": "container_unhealthy", + "environmentId": 1, + "payload": { + "title": "Container unhealthy", + "message": "web-1 is unhealthy", + "type": "warning" + } + } + } + } + } + } + }, + "/api/preferences/favorite-groups": { + "get": { + "operationId": "get_api_preferences_favorite-groups", + "tags": [ + "preferences" + ], + "summary": "Get the saved log favorite-groups for an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + } + } + }, + "example": { + "groups": [ + { + "name": "frontend", + "containers": [ + "web-1", + "web-2" + ] + } + ] + } + } + } + }, + "400": { + "description": "Environment ID is required, or invalid (not a number)" + }, + "500": { + "description": "Failed to get favorite groups" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_favorite-groups", + "tags": [ + "preferences" + ], + "summary": "Add, remove, update or reorder log favorite-groups for an environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + } + } + }, + "example": { + "groups": [ + { + "name": "string", + "containers": [ + "string" + ] + } + ] + } + } + } + }, + "400": { + "description": "Invalid environmentId, unknown action, missing group name, empty/missing containers, duplicate group name, or missing groups array for reorder" + }, + "404": { + "description": "Group not found (update action)" + }, + "500": { + "description": "Failed to update favorite groups" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "newName": { + "type": "string" + }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + } + } + }, + "required": [ + "environmentId", + "action" + ] + }, + "example": { + "environmentId": 1, + "action": "add", + "name": "frontend", + "containers": [ + "web-1", + "web-2" + ] + } + } + } + } + } + }, + "/api/preferences/favorites": { + "get": { + "operationId": "get_api_preferences_favorites", + "tags": [ + "preferences" + ], + "summary": "Get the saved log favorites (container names) for an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Environment ID (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "favorites": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "favorites": [ + "web-1", + "db-1" + ] + } + } + } + }, + "400": { + "description": "Environment ID is required, or invalid (not a number)" + }, + "500": { + "description": "Failed to get favorites" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_favorites", + "tags": [ + "preferences" + ], + "summary": "Add, remove or reorder log favorites (container names) for an environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "favorites": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "favorites": [ + "string" + ] + } + } + } + }, + "400": { + "description": "Invalid environmentId, unknown action, missing favorites array for reorder, or missing containerName for add/remove" + }, + "500": { + "description": "Failed to update favorites" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environmentId": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "favorites": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "environmentId", + "action" + ] + }, + "example": { + "environmentId": 1, + "action": "add", + "containerName": "web-1" + } + } + } + } + } + }, + "/api/preferences/grid": { + "get": { + "operationId": "get_api_preferences_grid", + "tags": [ + "preferences" + ], + "summary": "Retrieve all saved data-grid column preferences (per-user when auth is enabled)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "preferences": { + "containers": { + "columns": [ + { + "id": "name", + "visible": true + } + ] + } + } + } + } + } + }, + "500": { + "description": "Failed to get grid preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_grid", + "tags": [ + "preferences" + ], + "summary": "Save column preferences for one data grid and return all grid preferences", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "preferences": {} + } + } + } + }, + "400": { + "description": "gridId is required, columns array is required, or a column is missing id (string) / visible (boolean)" + }, + "500": { + "description": "Failed to save grid preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "gridId": { + "type": "string" + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "id", + "visible" + ] + } + } + }, + "required": [ + "gridId" + ] + }, + "example": { + "gridId": "containers", + "columns": [ + { + "id": "name", + "visible": true + }, + { + "id": "image", + "visible": false + } + ] + } + } + } + } + }, + "delete": { + "operationId": "delete_api_preferences_grid", + "tags": [ + "preferences" + ], + "summary": "Reset grid preferences — a single grid when gridId is given, otherwise all grids", + "parameters": [ + { + "name": "gridId", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Grid ID to reset; omit to reset every grid" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": {} + } + } + }, + "example": { + "preferences": {} + } + } + } + }, + "500": { + "description": "Failed to reset grid preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/preferences/sidebar": { + "get": { + "operationId": "get_api_preferences_sidebar", + "tags": [ + "preferences" + ], + "summary": "Retrieve the saved sidebar menu preferences (order and hidden items)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "preferences": { + "order": [ + "dashboard", + "containers" + ], + "hidden": [ + "volumes" + ] + } + } + } + } + }, + "500": { + "description": "Failed to get sidebar preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_preferences_sidebar", + "tags": [ + "preferences" + ], + "summary": "Save the sidebar menu preferences (order and hidden items)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "preferences": { + "order": [ + "string" + ], + "hidden": [ + "string" + ] + } + } + } + } + }, + "400": { + "description": "order must be an array of strings, or hidden must be an array of strings" + }, + "500": { + "description": "Failed to save sidebar preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "order", + "hidden" + ] + }, + "example": { + "order": [ + "dashboard", + "containers", + "stacks" + ], + "hidden": [ + "volumes" + ] + } + } + } + } + }, + "delete": { + "operationId": "delete_api_preferences_sidebar", + "tags": [ + "preferences" + ], + "summary": "Reset the sidebar menu preferences to their default order/visibility", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "preferences": { + "type": "object", + "properties": { + "order": { + "type": "array", + "items": { + "type": "string" + } + }, + "hidden": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "example": { + "preferences": { + "order": [ + "string" + ], + "hidden": [ + "string" + ] + } + } + } + } + }, + "500": { + "description": "Failed to reset sidebar preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/profile": { + "get": { + "operationId": "get_api_profile", + "tags": [ + "profile" + ], + "summary": "Get the authenticated user's own profile", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "provider": { + "type": "string" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "provider", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "avatar": "string", + "mfaEnabled": true, + "isAdmin": true, + "provider": "string", + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to read the profile" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_profile", + "tags": [ + "profile" + ], + "summary": "Update the authenticated user's own profile (email/display name, and optionally the password with current-password confirmation)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "avatar": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "avatar": "string", + "mfaEnabled": true, + "isAdmin": true, + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled, current password missing, new password too short (<8), or current password incorrect" + }, + "401": { + "description": "Not authenticated" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to update the profile" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "example": { + "displayName": "Jane Doe", + "currentPassword": "***", + "newPassword": "***" + } + } + } + } + } + }, + "/api/profile/avatar": { + "post": { + "operationId": "post_api_profile_avatar", + "tags": [ + "profile" + ], + "summary": "Upload the authenticated user's avatar as a base64 image data URL (max ~500KB)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "avatar": { + "type": "string" + } + }, + "required": [ + "success", + "avatar" + ] + }, + "example": { + "success": true, + "avatar": "string" + } + } + } + }, + "400": { + "description": "Authentication is not enabled, avatar data missing, not an image data URL, or image too large (>500KB)" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to upload the avatar" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + } + }, + "required": [ + "avatar" + ] + }, + "example": { + "avatar": "data:image/png;base64,iVBORw0KGgo..." + } + } + } + } + }, + "delete": { + "operationId": "delete_api_profile_avatar", + "tags": [ + "profile" + ], + "summary": "Remove the authenticated user's avatar", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Authentication is not enabled" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to remove the avatar" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/profile/preferences": { + "get": { + "operationId": "get_api_profile_preferences", + "tags": [ + "profile" + ], + "summary": "Get the current user's UI preferences (theme, fonts, editor options)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Not authenticated / no user in context" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to load preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_profile_preferences", + "tags": [ + "profile" + ], + "summary": "Update the current user's UI preferences (each field is optional)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "A supplied field has the wrong type (e.g. editorIndentGuides not a boolean)" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to save preferences" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lightTheme": { + "type": "string" + }, + "darkTheme": { + "type": "string" + }, + "font": { + "type": "string" + }, + "fontSize": { + "type": "string" + }, + "gridFontSize": { + "type": "string" + }, + "terminalFont": { + "type": "string" + }, + "editorFont": { + "type": "string" + }, + "animateIcons": { + "type": "boolean" + }, + "coloredActionButtons": { + "type": "boolean" + }, + "actionIconSize": { + "type": "string" + }, + "editorIndentGuides": { + "type": "boolean" + } + } + }, + "example": { + "lightTheme": "string", + "darkTheme": "string", + "font": "string", + "fontSize": "string", + "gridFontSize": "string", + "terminalFont": "string", + "editorFont": "string", + "animateIcons": true, + "coloredActionButtons": true, + "actionIconSize": "string", + "editorIndentGuides": true + } + } + } + } + } + }, + "/api/prune/all": { + "post": { + "operationId": "post_api_prune_all", + "tags": [ + "prune" + ], + "summary": "Prune all unused Docker resources (containers, images, volumes and networks) in a single operation", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the aggregated Docker prune report (space reclaimed, items deleted)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on containers, images, volumes AND networks for the target environment" + }, + "500": { + "description": "Failed to prune the system (Docker error); the message is returned in \"details\"" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/prune/containers": { + "post": { + "operationId": "post_api_prune_containers", + "tags": [ + "prune" + ], + "summary": "Prune (delete) all stopped containers in the target environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the Docker container-prune report (deleted container ids, space reclaimed)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on containers for the target environment" + }, + "500": { + "description": "Failed to prune containers (Docker error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/prune/images": { + "post": { + "operationId": "post_api_prune_images", + "tags": [ + "prune" + ], + "summary": "Prune unused Docker images, streaming progress as a Server-Sent Events job", + "parameters": [ + { + "name": "dangling", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When not \"false\", prune only dangling images; set \"dangling=false\" to prune all unused images (defaults to dangling-only)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent Events stream; the final `result` event contains { success, result } or { success:false, error }" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on images for the target environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns a text/event-stream. On completion a `result` event carries { success, result } on success or { success:false, error } on failure — the operation itself never returns a non-200 HTTP status once the permission check passes." + } + }, + "/api/prune/networks": { + "post": { + "operationId": "post_api_prune_networks", + "tags": [ + "prune" + ], + "summary": "Prune (delete) all unused Docker networks in the target environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the Docker network-prune report (deleted network names)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on networks for the target environment" + }, + "500": { + "description": "Failed to prune networks (Docker error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/prune/volumes": { + "post": { + "operationId": "post_api_prune_volumes", + "tags": [ + "prune" + ], + "summary": "Prune (delete) all unused Docker volumes in the target environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id; scopes both the prune operation and the permission check (defaults to the local environment) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Returns { success: true, result } where result is the Docker volume-prune report (deleted volume names, space reclaimed)" + }, + "403": { + "description": "Permission denied — requires the \"remove\" permission on volumes for the target environment" + }, + "500": { + "description": "Failed to prune volumes (Docker error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registries": { + "get": { + "operationId": "get_api_registries", + "tags": [ + "registries" + ], + "summary": "List all configured container registries with passwords stripped (only a hasCredentials flag)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + } + }, + "example": [ + { + "id": 1, + "name": "Docker Hub", + "url": "https://docker.io", + "isDefault": true, + "hasCredentials": false + } + ] + } + } + }, + "403": { + "description": "Caller lacks the registries:view permission" + }, + "500": { + "description": "Failed to read registries" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_registries", + "tags": [ + "registries" + ], + "summary": "Create a container registry (optionally set it as default); credentials are trimmed and stored encrypted", + "parameters": [], + "responses": { + "201": { + "description": "Registry created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "isDefault": true, + "hasCredentials": true + } + } + } + }, + "400": { + "description": "Missing name or url, or a duplicate registry name" + }, + "403": { + "description": "Caller lacks the registries:create permission" + }, + "500": { + "description": "Failed to create the registry" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": [ + "name", + "url" + ] + }, + "example": { + "name": "GHCR", + "url": "https://ghcr.io", + "username": "deploy", + "password": "***", + "isDefault": false + } + } + } + } + } + }, + "/api/registries/{id}": { + "get": { + "operationId": "get_api_registries_id", + "tags": [ + "registries" + ], + "summary": "Get a single registry by ID with the password stripped (only a hasCredentials flag)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "isDefault": true, + "hasCredentials": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the registries:view permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "Failed to read the registry" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_registries_id", + "tags": [ + "registries" + ], + "summary": "Update a registry (optionally set as default); credentials are trimmed and the response strips the password", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "hasCredentials": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "hasCredentials" + ] + }, + "example": { + "id": 0, + "name": "string", + "url": "string", + "isDefault": true, + "hasCredentials": true + } + } + } + }, + "400": { + "description": "Invalid id, or a duplicate registry name" + }, + "403": { + "description": "Caller lacks the registries:edit permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "The update failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + } + }, + "example": { + "name": "GHCR", + "url": "https://ghcr.io", + "username": "deploy", + "password": "***", + "isDefault": true + } + } + } + } + }, + "delete": { + "operationId": "delete_api_registries_id", + "tags": [ + "registries" + ], + "summary": "Delete a registry by ID", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid id, or the registry cannot be deleted" + }, + "403": { + "description": "Caller lacks the registries:delete permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "The deletion failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registries/{id}/default": { + "post": { + "operationId": "post_api_registries_id_default", + "tags": [ + "registries" + ], + "summary": "Mark a registry as the default registry", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Registry ID (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "The id path segment is not a valid integer" + }, + "403": { + "description": "Caller lacks the settings:edit permission" + }, + "404": { + "description": "No registry exists with that ID" + }, + "500": { + "description": "Failed to set the default registry" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registries/test": { + "post": { + "operationId": "post_api_registries_test", + "tags": [ + "registries" + ], + "summary": "Test registry connectivity and (if credentials are given) authentication against the V2 endpoint", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "connectivity": { + "type": "boolean" + }, + "authenticated": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "connectivity", + "message" + ] + }, + "example": { + "success": true, + "connectivity": true, + "authenticated": true, + "message": "Connected and authenticated as deploy" + } + } + } + }, + "400": { + "description": "The url field is missing (and no registryId was supplied)" + }, + "403": { + "description": "Caller lacks the registries:view permission" + }, + "404": { + "description": "The referenced registryId does not exist" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Pass either a saved registryId or inline url/username/password. The outcome is always reported in a 200 body via the success/connectivity/authenticated fields, never as an HTTP error. registryId from GET /api/registries.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "registryId": { + "type": "integer" + }, + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "example": { + "url": "https://ghcr.io", + "username": "deploy", + "password": "***" + } + } + } + } + } + }, + "/api/registry/catalog": { + "get": { + "operationId": "get_api_registry_catalog", + "tags": [ + "registry" + ], + "summary": "List repositories in a registry's V2 catalog (with Harbor project-API fallback), paginated", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry to query (from GET /api/registries)" + }, + { + "name": "last", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor from a previous page's nextLast" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "repositories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "star_count": { + "type": "integer" + }, + "is_official": { + "type": "boolean" + }, + "is_automated": { + "type": "boolean" + } + }, + "required": [ + "name" + ] + } + }, + "pagination": { + "type": "object", + "properties": { + "pageSize": { + "type": "integer" + }, + "hasMore": { + "type": "boolean" + }, + "nextLast": { + "type": "string" + } + }, + "required": [ + "pageSize", + "hasMore" + ] + } + }, + "required": [ + "repositories", + "pagination" + ] + }, + "example": { + "repositories": [ + { + "name": "library/nginx", + "description": "", + "star_count": 0, + "is_official": false, + "is_automated": false + } + ], + "pagination": { + "pageSize": 100, + "hasMore": false, + "nextLast": null + } + } + } + } + }, + "400": { + "description": "Missing registry parameter, or Docker Hub was targeted (catalog listing unsupported)" + }, + "404": { + "description": "Registry not found, or the registry does not implement the V2 catalog API" + }, + "500": { + "description": "Failed to fetch the catalog" + }, + "503": { + "description": "Could not connect to the registry (connection refused, host not found, or a TLS error)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Docker Hub is rejected (no catalog API). For 401/403/404 from the upstream registry the same status is proxied back to the caller." + } + }, + "/api/registry/image": { + "delete": { + "operationId": "delete_api_registry_image", + "tags": [ + "registry" + ], + "summary": "Delete a tagged image from a registry by resolving its manifest digest, then deleting by digest", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry (from GET /api/registries)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Repository/image name (may include an org path)" + }, + { + "name": "tag", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag to delete" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "message": "Deleted library/nginx:1.27" + } + } + } + }, + "400": { + "description": "A required query parameter is missing, or Docker Hub was targeted (deletion unsupported)" + }, + "401": { + "description": "The registry rejected authentication while resolving or deleting the manifest" + }, + "403": { + "description": "Caller lacks the settings:edit permission" + }, + "404": { + "description": "Registry not found, or the image/tag/manifest does not exist" + }, + "405": { + "description": "The registry has manifest deletion disabled (REGISTRY_STORAGE_DELETE_ENABLED)" + }, + "500": { + "description": "Failed to delete the image" + }, + "503": { + "description": "Could not connect to the registry (connection refused or host not found)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Docker Hub deletion is rejected. Upstream 401 responses during manifest resolution/deletion are proxied back as 401." + } + }, + "/api/registry/search": { + "get": { + "operationId": "get_api_registry_search", + "tags": [ + "registry" + ], + "summary": "Search for images by name in Docker Hub or a configured private registry (direct + catalog fallback)", + "parameters": [ + { + "name": "term", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Search term / image name" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Maximum number of results (default 25)" + }, + { + "name": "registry", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry to search; omit to search Docker Hub (from GET /api/registries)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "star_count": { + "type": "integer" + }, + "is_official": { + "type": "boolean" + }, + "is_automated": { + "type": "boolean" + } + }, + "required": [ + "name" + ] + } + }, + "example": [ + { + "name": "nginx", + "description": "Official build of Nginx", + "star_count": 20000, + "is_official": true, + "is_automated": false + } + ] + } + } + }, + "400": { + "description": "The term query parameter is missing" + }, + "404": { + "description": "The referenced registry does not exist" + }, + "500": { + "description": "Failed to search images" + }, + "503": { + "description": "Could not connect to the registry (connection refused or host not found)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "With no registry parameter the search runs against Docker Hub; otherwise against the given registry, falling back to Docker Hub search when the registry is a Docker Hub mirror." + } + }, + "/api/registry/tag-info": { + "get": { + "operationId": "get_api_registry_tag-info", + "tags": [ + "registry" + ], + "summary": "Resolve the compressed size and creation date of a single registry tag from its manifest (lazy per-tag fetch for the registry browser)", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Registry ID; omitted or Docker Hub registries return a graceful \"not supported\" result instead of a manifest fetch (from GET /api/registries)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Image repository/name" + }, + { + "name": "tag", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Tag name or digest" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "size": { + "type": "number" + }, + "lastUpdated": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "example": { + "size": 104857600, + "lastUpdated": "2026-06-01T12:00:00Z" + } + } + } + }, + "400": { + "description": "image and tag are required" + }, + "404": { + "description": "Registry not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/registry/tags": { + "get": { + "operationId": "get_api_registry_tags", + "tags": [ + "registry" + ], + "summary": "List the tags of an image from Docker Hub or a configured registry (paginated for Docker Hub)", + "parameters": [ + { + "name": "registry", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the configured registry; omit to query Docker Hub (from GET /api/registries)" + }, + { + "name": "image", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Repository/image name" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page number for Docker Hub pagination (default 1)" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size for Docker Hub pagination (default 20)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "lastUpdated": { + "type": "string" + }, + "digest": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "hasNext": { + "type": "boolean" + }, + "hasPrev": { + "type": "boolean" + } + }, + "required": [ + "tags", + "total", + "page", + "pageSize", + "hasNext", + "hasPrev" + ] + }, + "example": { + "tags": [ + { + "name": "1.27", + "lastUpdated": "2026-06-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "pageSize": 20, + "hasNext": false, + "hasPrev": false + } + } + } + }, + "400": { + "description": "The image query parameter is missing" + }, + "404": { + "description": "The referenced registry does not exist" + }, + "500": { + "description": "Failed to fetch tags" + }, + "503": { + "description": "Could not connect to the registry (connection refused or host not found)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "With no registry parameter, Docker Hub is queried; V2 registries return all tags in one page. Upstream Docker Hub error status codes are proxied back." + } + }, + "/api/roles": { + "get": { + "operationId": "get_api_roles", + "tags": [ + "roles" + ], + "summary": "List all roles (built-in and custom); available in setup mode or with an enterprise license", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + }, + "permissions": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "name", + "isSystem" + ] + } + }, + "example": [ + { + "id": 0, + "name": "string", + "description": "string", + "isSystem": true, + "permissions": {} + } + ] + } + } + }, + "403": { + "description": "Enterprise license required" + }, + "500": { + "description": "Failed to read the roles" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_roles", + "tags": [ + "roles" + ], + "summary": "Create a custom role (enterprise; admin required when auth is enabled)", + "parameters": [], + "responses": { + "201": { + "description": "The created role" + }, + "400": { + "description": "Name and permissions are required" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "409": { + "description": "A role with this name already exists" + }, + "500": { + "description": "Failed to create the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentIds from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": {} + }, + "environmentIds": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "required": [ + "name", + "permissions" + ] + }, + "example": { + "name": "Operators", + "description": "Can manage containers", + "permissions": { + "containers": [ + "view", + "edit" + ] + }, + "environmentIds": [ + 1, + 2 + ] + } + } + } + } + } + }, + "/api/roles/{id}": { + "get": { + "operationId": "get_api_roles_id", + "tags": [ + "roles" + ], + "summary": "Get a single role by id; available in setup mode or with an enterprise license", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the role (from GET /api/roles)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + }, + "permissions": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "name", + "isSystem" + ] + }, + "example": { + "id": 0, + "name": "string", + "description": "string", + "isSystem": true, + "permissions": {} + } + } + } + }, + "400": { + "description": "Role id is required" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "Role not found" + }, + "500": { + "description": "Failed to read the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_roles_id", + "tags": [ + "roles" + ], + "summary": "Update a custom role (system roles cannot be modified; enterprise, admin required when auth is enabled)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the role (from GET /api/roles)" + } + ], + "responses": { + "200": { + "description": "The updated role" + }, + "400": { + "description": "Role id is required, or the role is a system role and cannot be modified" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "Role not found" + }, + "409": { + "description": "A role with this name already exists" + }, + "500": { + "description": "Failed to update the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentIds from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "permissions": { + "type": "object", + "properties": {} + }, + "environmentIds": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "example": { + "description": "Updated description", + "permissions": { + "containers": [ + "view" + ] + } + } + } + } + } + }, + "delete": { + "operationId": "delete_api_roles_id", + "tags": [ + "roles" + ], + "summary": "Delete a custom role by id (system roles cannot be deleted; enterprise, admin required when auth is enabled)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the role (from GET /api/roles)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Role id is required, or the role is a system role and cannot be deleted" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "Role not found" + }, + "500": { + "description": "Failed to delete the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules": { + "get": { + "operationId": "get_api_schedules", + "tags": [ + "schedules" + ], + "summary": "List all schedules (container/env auto-updates, git syncs, image-prune, backups, repo maintenance, system jobs)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "schedules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "entityName": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "cronExpression": { + "type": "string" + }, + "nextRun": { + "type": "string" + }, + "isSystem": { + "type": "boolean" + } + }, + "required": [ + "id", + "type", + "name", + "entityName", + "enabled", + "isSystem" + ] + } + } + }, + "required": [ + "schedules" + ] + }, + "example": { + "schedules": [ + { + "id": 0, + "type": "string", + "name": "string", + "entityName": "string", + "environmentId": 0, + "enabled": true, + "cronExpression": "string", + "nextRun": "string", + "isSystem": true + } + ] + } + } + } + }, + "500": { + "description": "Unexpected error while assembling the schedule list" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/{type}/{id}": { + "delete": { + "operationId": "delete_api_schedules_type_id", + "tags": [ + "schedules" + ], + "summary": "Delete (or disable, for policy-driven types) a schedule by type and id", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Schedule id (semantics depend on type — container/git-stack/backup-config id, environment id, or a synthetic repo-policy id) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid schedule id, invalid/unsupported type, or system_cleanup (cannot be removed)" + }, + "404": { + "description": "Schedule (or backup destination, for repo_* types) not found" + }, + "500": { + "description": "Unexpected error while deleting the schedule" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/{type}/{id}/run": { + "post": { + "operationId": "post_api_schedules_type_id_run", + "tags": [ + "schedules" + ], + "summary": "Manually trigger a single run of a schedule (outside its cron), by type and id", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Schedule id (semantics depend on type) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ] + }, + "example": { + "success": true, + "message": "string" + } + } + } + }, + "400": { + "description": "Invalid schedule id/type, or the triggered task itself reported failure" + }, + "404": { + "description": "Schedule, backup config, or backup destination not found (or backup feature disabled)" + }, + "500": { + "description": "Unexpected error while triggering the schedule" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/{type}/{id}/toggle": { + "post": { + "operationId": "post_api_schedules_type_id_toggle", + "tags": [ + "schedules" + ], + "summary": "Toggle a schedule's enabled/disabled state (registers/unregisters the croner job accordingly)", + "parameters": [ + { + "name": "type", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)" + }, + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Schedule id (semantics depend on type) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "success", + "enabled" + ] + }, + "example": { + "success": true, + "enabled": true + } + } + } + }, + "400": { + "description": "Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)" + }, + "404": { + "description": "Schedule, backup config, or backup destination not found" + }, + "500": { + "description": "Unexpected error while toggling the schedule" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/executions": { + "get": { + "operationId": "get_api_schedules_executions", + "tags": [ + "schedules" + ], + "summary": "List schedule execution history, filterable and paginated", + "parameters": [ + { + "name": "scheduleType", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by schedule type (container_update, git_stack_sync, ...)" + }, + { + "name": "scheduleId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter by the numeric id of the schedule (from GET /api/schedules)" + }, + { + "name": "environmentId", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter by environment id (\"null\" for global/system schedules) (from GET /api/environments)" + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by execution status (queued/running/success/warning/failed/skipped)" + }, + { + "name": "statuses", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated list of statuses (alternative to status)" + }, + { + "name": "triggeredBy", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Filter by trigger (cron/webhook/manual)" + }, + { + "name": "fromDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "ISO date lower bound" + }, + { + "name": "toDate", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "ISO date upper bound" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size (default 50)" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page offset (default 0)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "executions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "scheduleType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + }, + "required": [ + "id", + "scheduleType", + "status", + "startedAt" + ] + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "executions", + "total", + "limit", + "offset" + ] + }, + "example": { + "executions": [ + { + "id": 0, + "scheduleType": "string", + "status": "string", + "startedAt": "string" + } + ], + "total": 0, + "limit": 0, + "offset": 0 + } + } + } + }, + "500": { + "description": "Unexpected error while loading execution history" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/executions/{id}": { + "get": { + "operationId": "get_api_schedules_executions_id", + "tags": [ + "schedules" + ], + "summary": "Get a single schedule execution, including its logs", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Execution id (from GET /api/schedules/executions)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "scheduleType": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "log": { + "type": "string" + } + }, + "required": [ + "id", + "scheduleType", + "status", + "startedAt" + ] + }, + "example": { + "id": 0, + "scheduleType": "string", + "status": "string", + "startedAt": "string", + "log": "string" + } + } + } + }, + "400": { + "description": "Invalid execution id" + }, + "404": { + "description": "Execution not found" + }, + "500": { + "description": "Unexpected error while loading the execution" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_schedules_executions_id", + "tags": [ + "schedules" + ], + "summary": "Delete a single schedule execution record", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Execution id (from GET /api/schedules/executions)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid execution id" + }, + "403": { + "description": "Permission denied (RBAC 'schedules:edit' missing)" + }, + "500": { + "description": "Unexpected error while deleting the execution" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/settings": { + "get": { + "operationId": "get_api_schedules_settings", + "tags": [ + "schedules" + ], + "summary": "Get the schedules-page display preference (hide system jobs), per-user with a global fallback", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hideSystemJobs": { + "type": "boolean" + } + }, + "required": [ + "hideSystemJobs" + ] + }, + "example": { + "hideSystemJobs": true + } + } + } + }, + "500": { + "description": "Unexpected error while loading the setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_schedules_settings", + "tags": [ + "schedules" + ], + "summary": "Update the schedules-page display preference (hide system jobs) for the current user", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "hideSystemJobs": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "hideSystemJobs": true + } + } + } + }, + "400": { + "description": "hideSystemJobs is not a boolean" + }, + "500": { + "description": "Unexpected error while saving the setting" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hideSystemJobs": { + "type": "boolean" + } + } + }, + "example": { + "hideSystemJobs": true + } + } + } + } + } + }, + "/api/schedules/stream": { + "get": { + "operationId": "get_api_schedules_stream", + "tags": [ + "schedules" + ], + "summary": "Server-Sent Events stream that polls and pushes schedule updates (same shape as GET /api/schedules)", + "parameters": [], + "responses": { + "200": { + "description": "text/event-stream response, periodically emitting the current schedule list as SSE \"data:\" messages" + }, + "403": { + "description": "Permission denied (RBAC 'schedules:view' missing)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/schedules/system/{id}/toggle": { + "post": { + "operationId": "post_api_schedules_system_id_toggle", + "tags": [ + "schedules" + ], + "summary": "Toggle one of the three built-in system cleanup jobs (schedule/event/scanner cleanup) on or off", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "System schedule id (1=schedule cleanup, 2=event cleanup, 4=scanner cleanup) (from GET /api/schedules)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "success", + "enabled" + ] + }, + "example": { + "success": true, + "enabled": true + } + } + } + }, + "400": { + "description": "Invalid or unknown system schedule id" + }, + "403": { + "description": "Permission denied (RBAC 'settings:edit' missing)" + }, + "500": { + "description": "Unexpected error while toggling the job" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/secret-providers": { + "get": { + "operationId": "get_api_secret-providers", + "tags": [ + "secret-providers" + ], + "summary": "List configured secret providers (summaries never include the decrypted config)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + } + }, + "example": [ + { + "id": 0, + "name": "string", + "type": "string" + } + ] + } + } + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "500": { + "description": "Failed to fetch secret providers" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_secret-providers", + "tags": [ + "secret-providers" + ], + "summary": "Create a secret provider (Vault, Infisical, Doppler, 1Password Connect)", + "parameters": [], + "responses": { + "201": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + }, + "example": { + "id": 0, + "name": "string", + "type": "string" + } + } + } + }, + "400": { + "description": "Name and type are required, unknown provider type, config missing, or a provider with this name already exists" + }, + "403": { + "description": "Permission denied (needs secrets:create)" + }, + "500": { + "description": "Failed to create secret provider" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "config" + ] + }, + "example": { + "name": "string", + "type": "string", + "config": "string" + } + } + } + } + } + }, + "/api/secret-providers/{id}": { + "get": { + "operationId": "get_api_secret-providers_id", + "tags": [ + "secret-providers" + ], + "summary": "Get one secret provider with its non-secret config coordinates (the token is redacted out)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type", + "config" + ] + }, + "example": { + "id": 0, + "name": "string", + "type": "string", + "config": "string" + } + } + } + }, + "400": { + "description": "Invalid secret provider ID" + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "404": { + "description": "Secret provider not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_secret-providers_id", + "tags": [ + "secret-providers" + ], + "summary": "Update a secret provider (name, type, and/or rotate its config); omitted fields are left unchanged", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ] + }, + "example": { + "id": 0, + "name": "string", + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid ID, empty name, unknown type, config not an object, or the name already exists" + }, + "403": { + "description": "Permission denied (needs secrets:edit)" + }, + "404": { + "description": "Secret provider not found" + }, + "500": { + "description": "Failed to update secret provider" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + } + }, + "example": { + "name": "string", + "type": "string", + "config": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_secret-providers_id", + "tags": [ + "secret-providers" + ], + "summary": "Delete a secret provider", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid secret provider ID" + }, + "403": { + "description": "Permission denied (needs secrets:delete)" + }, + "404": { + "description": "Secret provider not found" + }, + "500": { + "description": "Failed to delete secret provider" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/secret-providers/{id}/probe": { + "post": { + "operationId": "post_api_secret-providers_id_probe", + "tags": [ + "secret-providers" + ], + "summary": "Live-probe which key NAMES exist in a stored provider (names only, values never leave the server)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "ok=true returns the key names found via the bulk selector and the resolved op:// refs; ok=false folds a provider error in (still 200) so a slow or auth-gated provider never breaks the editor", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "bulkKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "resolvedRefs": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true, + "bulkKeys": [ + "string" + ], + "resolvedRefs": [ + "string" + ], + "error": "string" + } + } + } + }, + "400": { + "description": "Invalid ID or unknown provider type" + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "404": { + "description": "Secret provider not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "selector": { + "type": "string" + }, + "refs": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "selector": "prod", + "refs": [ + "op://vault/db/password" + ] + } + } + } + } + } + }, + "/api/secret-providers/{id}/test": { + "post": { + "operationId": "post_api_secret-providers_id_test", + "tags": [ + "secret-providers" + ], + "summary": "Test a stored provider's connectivity, optionally against typed override fields the edit form would save", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The secret provider id" + } + ], + "responses": { + "200": { + "description": "ok=false carries the connection error (still 200 so the edit form can show it inline)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true, + "error": "string" + } + } + } + }, + "400": { + "description": "Invalid secret provider ID" + }, + "403": { + "description": "Permission denied (needs secrets:view)" + }, + "404": { + "description": "Secret provider not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "config": { + "type": "string" + } + } + }, + "example": { + "config": { + "host": "https://vault.example.com", + "mount": "secret" + } + } + } + } + } + } + }, + "/api/secret-providers/test": { + "post": { + "operationId": "post_api_secret-providers_test", + "tags": [ + "secret-providers" + ], + "summary": "Test an unsaved provider config (before creating the provider)", + "parameters": [], + "responses": { + "200": { + "description": "ok=false carries the reason (invalid type, missing config, or a connection error) - still 200 so the form shows it inline", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true, + "error": "string" + } + } + } + }, + "400": { + "description": "Invalid request body" + }, + "403": { + "description": "Permission denied (needs secrets:create)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "config": { + "type": "string" + } + }, + "required": [ + "type", + "config" + ] + }, + "example": { + "type": "vault", + "config": { + "host": "https://vault.example.com", + "mount": "secret", + "token": "hvs...." + } + } + } + } + } + } + }, + "/api/self-update": { + "post": { + "operationId": "post_api_self-update", + "tags": [ + "self-update" + ], + "summary": "Pull a new Dockhand image and hand off to an updater sidecar that replaces this running container", + "parameters": [], + "responses": { + "200": { + "description": "text/event-stream SSE response (steps: pulling_image, building_config, pulling_updater, creating_container, launching_updater, then a \"launched\"/\"error\" event) — or, with \"Accept: application/json\", the final event as plain JSON" + }, + "400": { + "description": "newImage missing, not running in Docker, or the Docker socket is read-only" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to inspect own container or determine its name before starting the update" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "newImage": { + "type": "string" + } + }, + "required": [ + "newImage" + ] + }, + "example": { + "newImage": "fnsys/dockhand:v1.0.40" + } + } + } + } + } + }, + "/api/self-update/check": { + "get": { + "operationId": "get_api_self-update_check", + "tags": [ + "self-update" + ], + "summary": "Check whether a newer Dockhand image is available (version-tag compare via GitHub changelog, or registry-digest compare for mutable tags like :latest)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "updateAvailable": { + "type": "boolean" + }, + "currentImage": { + "type": "string" + }, + "newImage": { + "type": "string" + }, + "latestVersion": { + "type": "string" + }, + "currentDigest": { + "type": "string" + }, + "newDigest": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "isComposeManaged": { + "type": "boolean" + }, + "isLocalImage": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "updateAvailable" + ] + }, + "example": { + "updateAvailable": true, + "currentImage": "string", + "newImage": "string", + "latestVersion": "string", + "currentDigest": "string", + "newDigest": "string", + "containerName": "string", + "isComposeManaged": true, + "isLocalImage": true, + "error": "string" + } + } + } + }, + "403": { + "description": "Admin access required" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/self-update/progress": { + "get": { + "operationId": "get_api_self-update_progress", + "tags": [ + "self-update" + ], + "summary": "Poll the self-update sidecar container's logs and exit state for progress tracking", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Updater container id (from POST /api/self-update)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "string" + }, + "status": { + "type": "string" + }, + "exitCode": { + "type": "integer" + } + }, + "required": [ + "logs", + "status" + ] + }, + "example": { + "logs": "string", + "status": "string", + "exitCode": 0 + } + } + } + }, + "400": { + "description": "Container ID is required" + }, + "403": { + "description": "Admin access required" + }, + "500": { + "description": "Failed to inspect the updater container, or failed to fetch its progress" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/settings/general": { + "get": { + "operationId": "get_api_settings_general", + "tags": [ + "settings" + ], + "summary": "Get global (instance-wide) general settings", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Failed to load settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_settings_general", + "tags": [ + "settings" + ], + "summary": "Update global general settings (all fields optional; only supplied keys are written)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs settings:edit)" + }, + "500": { + "description": "Failed to save settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "animateIcons": { + "type": "boolean" + }, + "editorIndentGuides": { + "type": "boolean" + }, + "coloredActionButtons": { + "type": "boolean" + }, + "lightTheme": { + "type": "string" + }, + "darkTheme": { + "type": "string" + }, + "defaultTimezone": { + "type": "string" + }, + "logBufferSizeKb": { + "type": "integer" + }, + "externalStackPaths": { + "type": "string" + } + } + }, + "example": { + "animateIcons": true, + "editorIndentGuides": true, + "coloredActionButtons": true, + "lightTheme": "string", + "darkTheme": "string", + "defaultTimezone": "string", + "logBufferSizeKb": 0, + "externalStackPaths": "string" + } + } + } + } + } + }, + "/api/settings/navigation": { + "get": { + "operationId": "get_api_settings_navigation", + "tags": [ + "settings" + ], + "summary": "Get the effective navigation preferences (landing page, env-click target) plus the raw global and per-user values", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "effective": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + }, + "required": [ + "landingPage", + "envClickPage" + ] + }, + "global": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + } + }, + "user": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + } + } + } + }, + "example": { + "effective": { + "landingPage": "dashboard", + "envClickPage": "containers" + }, + "global": { + "landingPage": null, + "envClickPage": null + }, + "user": null + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_settings_navigation", + "tags": [ + "settings" + ], + "summary": "Update navigation preferences (landing page, env-click target) at global or per-user scope", + "parameters": [ + { + "name": "scope", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Preference scope to update — \"global\" (default) or \"user\"" + } + ], + "responses": { + "200": { + "description": "The updated preferences ({success:true, global:{...}} for scope=global, or {success:true, user:{...}} for scope=user)" + }, + "400": { + "description": "Invalid input — invalid landingPage/envClickPage value, or scope=user requested while authentication is disabled" + }, + "401": { + "description": "Not authenticated (scope=user, authentication is enabled but no valid session)" + }, + "403": { + "description": "Permission denied (scope=global, requires settings:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "landingPage": { + "type": "string" + }, + "envClickPage": { + "type": "string" + } + } + }, + "example": { + "landingPage": "containers", + "envClickPage": "logs" + } + } + } + } + } + }, + "/api/settings/scanner": { + "get": { + "operationId": "get_api_settings_scanner", + "tags": [ + "settings" + ], + "summary": "Get the vulnerability-scanner settings for an environment, plus (unless settingsOnly) scanner availability, versions and optional update info", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id to read scanner settings for (falls back to global defaults) (from GET /api/environments)" + }, + { + "name": "checkUpdates", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When true, also check the scanner images for available updates (slower)" + }, + { + "name": "settingsOnly", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When true, return only settings + defaults and skip the Docker availability/version checks" + } + ], + "responses": { + "200": { + "description": "Scanner settings and (unless settingsOnly) availability, versions, updates and defaults" + }, + "403": { + "description": "Permission denied (missing settings:view for the environment)" + }, + "500": { + "description": "Failed to read the scanner settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_settings_scanner", + "tags": [ + "settings" + ], + "summary": "Save the vulnerability-scanner settings for an environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "settings": { + "type": "object", + "properties": { + "scanner": { + "type": "string" + }, + "grypeArgs": { + "type": "string" + }, + "trivyArgs": { + "type": "string" + } + }, + "required": [ + "scanner", + "grypeArgs", + "trivyArgs" + ] + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "settings": { + "scanner": "string", + "grypeArgs": "string", + "trivyArgs": "string" + } + } + } + } + }, + "400": { + "description": "Invalid scanner type (must be none, grype, trivy or both)" + }, + "403": { + "description": "Permission denied (missing settings:edit for the environment)" + }, + "500": { + "description": "Failed to save the scanner settings" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scanner": { + "type": "string" + }, + "grypeArgs": { + "type": "string" + }, + "trivyArgs": { + "type": "string" + }, + "envId": { + "type": "integer" + } + } + }, + "example": { + "scanner": "grype", + "grypeArgs": "-o json -v {image}", + "trivyArgs": "image --format json {image}", + "envId": 1 + } + } + } + } + }, + "delete": { + "operationId": "delete_api_settings_scanner", + "tags": [ + "settings" + ], + "summary": "Remove the scanner images (grype/trivy) and clean up scanner database volumes for an environment", + "parameters": [ + { + "name": "removeImages", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Must be true to actually perform the removal (required)" + }, + { + "name": "scanner", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Which scanner image to remove (grype or trivy); omit to remove both" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id whose scanner images should be removed (required) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "removed": [ + "string" + ], + "errors": [ + "string" + ] + } + } + } + }, + "400": { + "description": "The removeImages parameter is required, or the environment id is missing" + }, + "403": { + "description": "Permission denied (missing settings:edit for the environment)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Failed to remove the scanner images" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/settings/scanner/cache": { + "delete": { + "operationId": "delete_api_settings_scanner_cache", + "tags": [ + "settings" + ], + "summary": "Clear the vulnerability-scanner cache (local volumes/bind-mount dirs plus each remote environment's scanner volume)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "removedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "removedDirs": { + "type": "array", + "items": { + "type": "string" + } + }, + "skippedEnvironments": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "success", + "removedVolumes", + "removedDirs", + "skippedEnvironments" + ] + }, + "example": { + "success": true, + "removedVolumes": [ + "string" + ], + "removedDirs": [ + "string" + ], + "skippedEnvironments": [ + "string" + ] + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to clear scanner cache" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/settings/semver": { + "get": { + "operationId": "get_api_settings_semver", + "tags": [ + "settings" + ], + "summary": "Get the global newer-version-tag (semver) detection config", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxBump": { + "type": "string" + }, + "matchFlavor": { + "type": "boolean" + }, + "includePrerelease": { + "type": "boolean" + } + }, + "required": [ + "enabled", + "maxBump", + "matchFlavor", + "includePrerelease" + ] + }, + "example": { + "enabled": true, + "maxBump": "minor", + "matchFlavor": true, + "includePrerelease": false + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_settings_semver", + "tags": [ + "settings" + ], + "summary": "Set the global newer-version-tag (semver) detection config", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (needs settings:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "maxBump": { + "type": "string" + }, + "matchFlavor": { + "type": "boolean" + }, + "includePrerelease": { + "type": "boolean" + } + } + }, + "example": { + "enabled": true, + "maxBump": "minor", + "matchFlavor": true, + "includePrerelease": false + } + } + } + } + } + }, + "/api/settings/theme": { + "get": { + "operationId": "get_api_settings_theme", + "tags": [ + "settings" + ], + "summary": "Public theme + editor display settings (used by the login page before auth)", + "parameters": [], + "responses": { + "200": { + "description": "Light/dark theme, fonts, and editor display flags (e.g. editorIndentGuides)" + } + }, + "security": [] + } + }, + "/api/stacks": { + "get": { + "operationId": "get_api_stacks", + "tags": [ + "stacks" + ], + "summary": "List compose stacks for one environment (internal, external, and git)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "Environment not found" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_stacks", + "tags": [ + "stacks" + ], + "summary": "Create and (optionally) deploy a compose stack", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Target environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid request (e.g. missing name/compose, or secretProviderId wrong type)" + }, + "403": { + "description": "Permission denied (needs stacks:create; binding a secret provider also needs secrets:view)" + }, + "500": { + "description": "Failed to create or deploy the stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Writes the compose + .env to the stack dir, stores secrets in the DB, and with start deploys it. Can bind a secret provider.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "compose": { + "type": "string" + }, + "composePath": { + "type": "string" + }, + "envPath": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "string" + } + }, + "rawEnvContent": { + "type": "string" + }, + "secretProviderId": { + "type": "integer" + }, + "start": { + "type": "boolean" + } + }, + "required": [ + "name", + "compose" + ] + }, + "example": { + "name": "string", + "compose": "string", + "composePath": "string", + "envPath": "string", + "envVars": [ + "string" + ], + "rawEnvContent": "string", + "secretProviderId": 0, + "start": true + } + } + } + } + } + }, + "/api/stacks/{name}": { + "delete": { + "operationId": "delete_api_stacks_name", + "tags": [ + "stacks" + ], + "summary": "Remove a stack completely (compose down + delete files + database cleanup)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal even if the compose down step fails" + }, + { + "name": "volumes", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Also remove named volumes (docker compose down --volumes)" + }, + { + "name": "files", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Delete the stack's on-disk files/directory too (default true; pass files=false to keep them on disk)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Compose down failed and force was not set" + }, + "403": { + "description": "Permission denied, or access denied to this environment" + }, + "404": { + "description": "Compose file not found for this stack" + }, + "500": { + "description": "Unexpected error while removing the stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/check-path-change": { + "post": { + "operationId": "post_api_stacks_name_check-path-change", + "tags": [ + "stacks" + ], + "summary": "Check whether a proposed compose path moves the stack to a different directory and how many files the old directory still holds", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hasChanges": { + "type": "boolean" + }, + "oldDir": { + "type": "string" + }, + "newDir": { + "type": "string" + }, + "fileCount": { + "type": "integer" + }, + "currentComposePath": { + "type": "string" + } + }, + "required": [ + "hasChanges", + "fileCount" + ] + }, + "example": { + "hasChanges": true, + "oldDir": "/opt/stacks/old", + "newDir": "/opt/stacks/web", + "fileCount": 3, + "currentComposePath": "/opt/stacks/old/compose.yaml" + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:edit)" + }, + "500": { + "description": "Failed to check path changes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "newComposePath": { + "type": "string" + } + }, + "required": [ + "newComposePath" + ] + }, + "example": { + "newComposePath": "/opt/stacks/web/compose.yaml" + } + } + } + } + } + }, + "/api/stacks/{name}/compose": { + "get": { + "operationId": "get_api_stacks_name_compose", + "tags": [ + "stacks" + ], + "summary": "Get a stack's compose file content plus its resolved compose/env paths", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "Stack or compose file not found" + }, + "500": { + "description": "Failed to read the compose file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_stacks_name_compose", + "tags": [ + "stacks" + ], + "summary": "Save a stack's compose file (and optionally relocate it, bind a secret provider, and redeploy)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid request (e.g. missing content, or secretProviderId wrong type)" + }, + "403": { + "description": "Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view)" + }, + "500": { + "description": "Failed to save or deploy the compose file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Every accepted PUT persists the compose content; with restart it also redeploys. Supports moving the compose/env to a new path and binding a secret provider.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "composePath": { + "type": "string" + }, + "envPath": { + "type": "string" + }, + "oldComposePath": { + "type": "string" + }, + "oldEnvPath": { + "type": "string" + }, + "moveFromDir": { + "type": "string" + }, + "restart": { + "type": "boolean" + }, + "secretProviderId": { + "type": "integer" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "string", + "composePath": "string", + "envPath": "string", + "oldComposePath": "string", + "oldEnvPath": "string", + "moveFromDir": "string", + "restart": true, + "secretProviderId": 0 + } + } + } + } + } + }, + "/api/stacks/{name}/delete-preview": { + "get": { + "operationId": "get_api_stacks_name_delete-preview", + "tags": [ + "stacks" + ], + "summary": "Preview the on-disk directories and named volumes a delete-with-files would remove for a stack", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "sourceType": { + "type": "string" + }, + "stackDir": { + "type": "string" + }, + "gitDir": { + "type": "string" + }, + "namedVolumes": { + "type": "array", + "items": { + "type": "string" + } + }, + "canDeleteFiles": { + "type": "boolean" + } + }, + "required": [ + "stackName", + "namedVolumes", + "canDeleteFiles" + ] + }, + "example": { + "stackName": "web", + "sourceType": "internal", + "stackDir": "/opt/stacks/web", + "gitDir": null, + "namedVolumes": [ + "web_data" + ], + "canDeleteFiles": true + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:remove)" + }, + "500": { + "description": "Failed to compute the deletion preview" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/deploy": { + "post": { + "operationId": "post_api_stacks_name_deploy", + "tags": [ + "stacks" + ], + "summary": "Deploy (docker compose up) a stack, optionally pulling images, building, and force-recreating; progress and the final result stream over Server-Sent Events", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Server-Sent-Events job stream with progress events and a final result event ({success, output})" + }, + "403": { + "description": "Permission denied (requires stacks:start, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pull": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "forceRecreate": { + "type": "boolean" + } + } + }, + "example": { + "pull": true, + "build": false, + "forceRecreate": false + } + } + } + } + } + }, + "/api/stacks/{name}/down": { + "post": { + "operationId": "post_api_stacks_name_down", + "tags": [ + "stacks" + ], + "summary": "Take a stack down (docker compose down — removes containers, keeps files), asynchronously", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send \"Accept: application/json\" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ] + }, + "example": { + "jobId": "3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b" + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "removeVolumes": { + "type": "boolean" + } + } + }, + "example": { + "removeVolumes": false + } + } + } + } + } + }, + "/api/stacks/{name}/env": { + "get": { + "operationId": "get_api_stacks_name_env", + "tags": [ + "stacks" + ], + "summary": "Get a stack's env vars (non-secrets from file, secrets masked from DB) plus injected provider keys", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + } + }, + "injectedSecretKeys": { + "type": "array", + "items": { + "type": "string" + } + }, + "secretProvider": { + "type": "string" + } + }, + "required": [ + "variables" + ] + }, + "example": { + "variables": [ + "string" + ], + "injectedSecretKeys": [ + "string" + ], + "secretProvider": "string" + } + } + } + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "500": { + "description": "Failed to read env vars" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_stacks_name_env", + "tags": [ + "stacks" + ], + "summary": "Save a stack's secret env vars to the DB (secrets never hit disk)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Invalid variables payload" + }, + "403": { + "description": "Permission denied (needs stacks:edit)" + }, + "500": { + "description": "Failed to save env vars" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "variables" + ] + }, + "example": { + "variables": [ + "string" + ] + } + } + } + } + } + }, + "/api/stacks/{name}/env/raw": { + "get": { + "operationId": "get_api_stacks_name_env_raw", + "tags": [ + "stacks" + ], + "summary": "Get the raw .env file content as-is (comments and formatting preserved) for a stack", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "noEnvFile": { + "type": "boolean" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "FOO=bar\n# comment\nBAZ=qux\n" + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:view, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to get environment file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_stacks_name_env_raw", + "tags": [ + "stacks" + ], + "summary": "Write raw .env file content to disk for a stack; empty content deletes the .env file, and masked \"***\" placeholders are rejected to avoid corrupting secrets", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "noEnvFile": { + "type": "boolean" + }, + "deleted": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "Invalid body (content string required) or refusal to write a masked \"***\" placeholder" + }, + "403": { + "description": "Permission denied (requires stacks:edit, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to save environment file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ] + }, + "example": { + "content": "FOO=bar\nBAZ=qux\n" + } + } + } + } + } + }, + "/api/stacks/{name}/env/validate": { + "post": { + "operationId": "post_api_stacks_name_env_validate", + "tags": [ + "stacks" + ], + "summary": "Validate a stack's defined environment variables against the variables required/optional by its compose file (compose content and the defined variable list may be supplied in the body, otherwise loaded from the saved file/DB)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "optional": { + "type": "array", + "items": { + "type": "string" + } + }, + "defined": { + "type": "array", + "items": { + "type": "string" + } + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "unused": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "valid", + "required", + "optional", + "defined", + "missing", + "unused" + ] + }, + "example": { + "valid": false, + "required": [ + "IMAGE" + ], + "optional": [], + "defined": [], + "missing": [ + "IMAGE" + ], + "unused": [] + } + } + } + }, + "400": { + "description": "No compose content provided and no saved compose file found" + }, + "403": { + "description": "Permission denied (requires stacks:view, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to validate environment variables" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "example": { + "compose": "services:\n web:\n image: ${IMAGE}", + "variables": [ + "IMAGE" + ] + } + } + } + } + } + }, + "/api/stacks/{name}/relocate": { + "post": { + "operationId": "post_api_stacks_name_relocate", + "tags": [ + "stacks" + ], + "summary": "Move all stack files from the old directory to a new location, update the stored compose/env paths, and return the refreshed compose/env content", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "movedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "composeContent": { + "type": "string" + }, + "rawEnvContent": { + "type": "string" + }, + "envVars": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "isSecret": { + "type": "boolean" + } + }, + "required": [ + "key", + "value", + "isSecret" + ] + } + } + }, + "required": [ + "success", + "movedFiles", + "composeContent", + "rawEnvContent", + "envVars" + ] + }, + "example": { + "success": true, + "movedFiles": [ + "compose.yaml", + ".env" + ], + "composeContent": "services: {}", + "rawEnvContent": "FOO=bar\n", + "envVars": [ + { + "key": "FOO", + "value": "bar", + "isSecret": false + } + ] + } + } + } + }, + "400": { + "description": "oldDir and newComposePath are required, or the source directory does not exist" + }, + "403": { + "description": "Permission denied (requires stacks:edit)" + }, + "500": { + "description": "Failed to relocate stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "oldDir": { + "type": "string" + }, + "newComposePath": { + "type": "string" + }, + "newEnvPath": { + "type": "string" + } + }, + "required": [ + "oldDir", + "newComposePath" + ] + }, + "example": { + "oldDir": "/opt/stacks/old", + "newComposePath": "/opt/stacks/web/compose.yaml", + "newEnvPath": "/opt/stacks/web/.env" + } + } + } + } + } + }, + "/api/stacks/{name}/restart": { + "post": { + "operationId": "post_api_stacks_name_restart", + "tags": [ + "stacks" + ], + "summary": "Restart a stack (mode=restart) or recreate its containers (mode=recreate); progress and the final result stream over Server-Sent Events", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + }, + { + "name": "mode", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restart mode — \"recreate\" recreates containers, anything else performs a plain restart" + } + ], + "responses": { + "200": { + "description": "Server-Sent-Events job stream with a final result event ({success, output})" + }, + "403": { + "description": "Permission denied (requires stacks:restart, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/start": { + "post": { + "operationId": "post_api_stacks_name_start", + "tags": [ + "stacks" + ], + "summary": "Start a stack (docker compose start/up), asynchronously", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send \"Accept: application/json\" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ] + }, + "example": { + "jobId": "3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b" + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/stop": { + "post": { + "operationId": "post_api_stacks_name_stop", + "tags": [ + "stacks" + ], + "summary": "Stop a stack (docker compose stop), asynchronously", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Fire-and-forget job id — poll GET /api/jobs/{jobId} for the result. Send \"Accept: application/json\" (without text/event-stream) to instead block and receive the final {success,output|error} synchronously.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "jobId": { + "type": "string" + } + }, + "required": [ + "jobId" + ] + }, + "example": { + "jobId": "3f9c5b1a-2e4d-4a6f-9b0a-1c7d8e9f0a1b" + } + } + } + }, + "403": { + "description": "Permission denied, or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/validate": { + "post": { + "operationId": "post_api_stacks_name_validate", + "tags": [ + "stacks" + ], + "summary": "Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (used to exclude the stack's own containers from cross-stack collision checks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID for the context-aware checks (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + }, + "severity": { + "type": "string" + }, + "message": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "service": { + "type": "string" + }, + "line": { + "type": "integer" + }, + "fix": { + "type": "string" + }, + "fixDescription": { + "type": "string" + } + }, + "required": [ + "ruleId", + "severity", + "message" + ] + } + }, + "counts": { + "type": "object", + "properties": { + "error": { + "type": "integer" + }, + "warn": { + "type": "integer" + }, + "info": { + "type": "integer" + } + }, + "required": [ + "error", + "warn", + "info" + ] + } + }, + "required": [ + "findings", + "counts" + ] + }, + "example": { + "findings": [ + { + "ruleId": "LATEST_TAG", + "severity": "warn", + "message": "\"web\" uses `:latest` (nginx:latest)", + "hint": "Pin a version tag for reproducible deploys and to enable newer-version detection.", + "service": "web", + "line": 3 + } + ], + "counts": { + "error": 0, + "warn": 1, + "info": 0 + } + } + } + } + }, + "400": { + "description": "compose content is required" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "disabled": { + "type": "array", + "items": { + "type": "string" + } + }, + "severity": { + "type": "string" + } + } + }, + "envVars": { + "type": "string" + } + }, + "required": [ + "compose" + ] + }, + "example": { + "compose": "services:\n web:\n image: nginx:latest\n ports: [\"8080:80\"]\n" + } + } + } + } + } + }, + "/api/stacks/adopt": { + "post": { + "operationId": "post_api_stacks_adopt", + "tags": [ + "stacks" + ], + "summary": "Adopt previously discovered compose stacks into Dockhand for a given environment", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "adopted": { + "type": "array", + "items": { + "type": "string" + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "name", + "error" + ] + } + } + }, + "required": [ + "adopted", + "failed" + ] + }, + "example": { + "adopted": [ + "web" + ], + "failed": [] + } + } + } + }, + "400": { + "description": "No stacks provided, missing environmentId, or a stack is missing name/composePath" + }, + "403": { + "description": "Permission denied (requires stacks:create)" + }, + "500": { + "description": "Unexpected error while adopting stacks" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stacks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "composePath": { + "type": "string" + } + }, + "required": [ + "name", + "composePath" + ] + } + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "stacks", + "environmentId" + ] + }, + "example": { + "stacks": [ + { + "name": "web", + "composePath": "/opt/stacks/web/compose.yaml" + } + ], + "environmentId": 1 + } + } + } + } + } + }, + "/api/stacks/base-path": { + "get": { + "operationId": "get_api_stacks_base-path", + "tags": [ + "stacks" + ], + "summary": "Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "basePath": { + "type": "string" + } + }, + "required": [ + "basePath" + ] + }, + "example": { + "basePath": "/data/stacks" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/default-path": { + "get": { + "operationId": "get_api_stacks_default-path", + "tags": [ + "stacks" + ], + "summary": "Compute the default compose/env file paths for a new stack, either under a custom base location or under Dockhand's default stacks directory", + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID (scopes the path under the environment name) (from GET /api/environments)" + }, + { + "name": "location", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Custom base location path" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackDir": { + "type": "string" + }, + "composePath": { + "type": "string" + }, + "envPath": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "stackDir", + "composePath", + "envPath", + "source" + ] + }, + "example": { + "stackDir": "/data/stacks/prod/web", + "composePath": "/data/stacks/prod/web/compose.yaml", + "envPath": "/data/stacks/prod/web/.env", + "source": "default" + } + } + } + }, + "400": { + "description": "Stack name is required" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/path-hints": { + "get": { + "operationId": "get_api_stacks_path-hints", + "tags": [ + "stacks" + ], + "summary": "Return path hints (working directory and config file paths) extracted from a stack's Docker container labels", + "parameters": [ + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the stack belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "stackName": { + "type": "string" + }, + "workingDir": { + "type": "string" + }, + "configFiles": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "stackName" + ] + }, + "example": { + "stackName": "web", + "workingDir": "/opt/stacks/web", + "configFiles": [ + "/opt/stacks/web/compose.yaml" + ] + } + } + } + }, + "400": { + "description": "Stack name is required" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Failed to get path hints" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/scan": { + "post": { + "operationId": "post_api_stacks_scan", + "tags": [ + "stacks" + ], + "summary": "Scan a given filesystem path (or all configured external paths when none is given) for compose stacks, flagging which discovered stacks are already running", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "discovered": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + }, + "adopted": { + "type": "array", + "items": { + "type": "string" + } + }, + "skipped": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "path", + "error" + ] + } + } + }, + "required": [ + "discovered", + "adopted", + "skipped", + "errors" + ] + }, + "example": { + "discovered": [ + { + "name": "web" + } + ], + "adopted": [], + "skipped": [], + "errors": [] + } + } + } + }, + "403": { + "description": "Permission denied (requires stacks:create)" + }, + "500": { + "description": "Unexpected error while scanning" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + } + }, + "example": { + "path": "/opt/stacks" + } + } + } + } + } + }, + "/api/stacks/sources": { + "get": { + "operationId": "get_api_stacks_sources", + "tags": [ + "stacks" + ], + "summary": "List stack source records (their stored compose/env paths and source type)", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Filter to a single environment id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "500": { + "description": "Failed to list stack sources" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/validate-path": { + "post": { + "operationId": "post_api_stacks_validate-path", + "tags": [ + "stacks" + ], + "summary": "Validate a candidate external stack path (exists, is a directory, no overlap with already-configured paths); validation failures are returned as 200 with valid=false", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "valid" + ] + }, + "example": { + "valid": false, + "error": "Path does not exist" + } + } + } + }, + "403": { + "description": "Permission denied (requires settings:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "example": { + "path": "/opt/external-stacks" + } + } + } + } + } + }, + "/api/system": { + "get": { + "operationId": "get_api_system", + "tags": [ + "system" + ], + "summary": "Return aggregated system info — Docker daemon, host, Node.js runtime, database, and object counts for an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to collect Docker info for (Docker fields are null when omitted or unreachable) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "docker": { + "type": "string" + }, + "host": { + "type": "string" + }, + "runtime": { + "type": "string" + }, + "database": { + "type": "string" + }, + "stats": { + "type": "string" + } + }, + "required": [ + "runtime", + "database", + "stats" + ] + }, + "example": { + "docker": { + "version": "27.0.3", + "apiVersion": "1.46" + }, + "host": { + "name": "docker-host", + "cpus": 8, + "memory": 16777216000 + }, + "runtime": { + "runtimeName": "Node.js", + "platform": "linux" + }, + "database": { + "type": "SQLite", + "schemaVersion": 42 + }, + "stats": { + "containers": { + "total": 10, + "running": 8, + "stopped": 2 + }, + "images": 25, + "volumes": 5, + "networks": 4, + "stacks": 3 + } + } + } + } + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + }, + "500": { + "description": "Failed to fetch system info" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/system/disk": { + "get": { + "operationId": "get_api_system_disk", + "tags": [ + "system" + ], + "summary": "Return Docker disk usage (df) for an environment, or null when collection is disabled, times out, or fails", + "parameters": [ + { + "name": "env", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the environment to query disk usage for (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "diskUsage is null when SKIP_DF_COLLECTION is set, or the query times out or errors", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "diskUsage": { + "type": "string" + } + } + }, + "example": { + "diskUsage": { + "LayersSize": 1420000000, + "Images": [], + "Containers": [], + "Volumes": [] + } + } + } + } + }, + "400": { + "description": "Environment ID is required" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/system/files": { + "get": { + "operationId": "get_api_system_files", + "tags": [ + "system" + ], + "summary": "List the entries of a directory on Dockhand's local filesystem (protected paths are hidden)", + "parameters": [ + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Absolute directory path to list (defaults to \"/\")" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "parent": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "mtime": { + "type": "string" + }, + "mode": { + "type": "string" + } + }, + "required": [ + "name", + "path", + "type", + "size", + "mtime", + "mode" + ] + } + } + }, + "required": [ + "path", + "entries" + ] + }, + "example": { + "path": "/docker", + "parent": "/", + "entries": [ + { + "name": "stacks", + "path": "/docker/stacks", + "type": "directory", + "size": 4096, + "mtime": "2026-07-01T10:00:00.000Z", + "mode": "755" + } + ] + } + } + } + }, + "400": { + "description": "The path exists but is not a directory" + }, + "403": { + "description": "Permission denied, or the path is protected" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to list directory" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_system_files", + "tags": [ + "system" + ], + "summary": "Create a directory on Dockhand's local filesystem (absolute path, no traversal, non-protected)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "success", + "path" + ] + }, + "example": { + "success": true, + "path": "/docker/stacks/myapp" + } + } + } + }, + "400": { + "description": "Path is missing, not absolute, or contains \"..\"" + }, + "403": { + "description": "Permission denied, or the path is protected" + }, + "409": { + "description": "Path already exists" + }, + "500": { + "description": "Failed to create directory" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "example": { + "path": "/docker/stacks/myapp" + } + } + } + } + } + }, + "/api/system/files/content": { + "get": { + "operationId": "get_api_system_files_content", + "tags": [ + "system" + ], + "summary": "Read a text file from Dockhand's local filesystem (max 10MB, protected paths denied)", + "parameters": [ + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Absolute path of the file to read" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "mtime": { + "type": "string" + } + }, + "required": [ + "path", + "content", + "size", + "mtime" + ] + }, + "example": { + "path": "/docker/stacks/myapp/compose.yaml", + "content": "services:\n app:\n image: nginx", + "size": 42, + "mtime": "2026-07-01T10:00:00.000Z" + } + } + } + }, + "400": { + "description": "Path is missing, points to a directory, or the file exceeds 10MB" + }, + "403": { + "description": "Permission denied, or the path is protected" + }, + "404": { + "description": "File not found" + }, + "500": { + "description": "Failed to read file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/templates": { + "get": { + "operationId": "get_api_templates", + "tags": [ + "templates" + ], + "summary": "Return the normalized app templates aggregated from all enabled template sources (server-side cached for 1 hour)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "logo": { + "type": "string" + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "source": { + "type": "string" + }, + "image": { + "type": "string" + }, + "projectUrl": { + "type": "string" + } + }, + "required": [ + "id", + "type", + "title", + "description", + "logo", + "categories", + "source" + ] + } + }, + "example": [ + { + "id": "a1b2c3", + "type": "container", + "title": "Nginx", + "description": "Web server", + "logo": "https://example.com/nginx.png", + "categories": [ + "web" + ], + "source": "LinuxServer.io", + "image": "lscr.io/linuxserver/nginx:latest", + "projectUrl": "https://github.com/linuxserver/docker-nginx" + } + ] + } + } + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/templates/compose": { + "post": { + "operationId": "post_api_templates_compose", + "tags": [ + "templates" + ], + "summary": "Return the compose YAML for a template — generated for container templates, fetched from the repository for stack templates", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + } + }, + "required": [ + "compose" + ] + }, + "example": { + "compose": "services:\n nginx:\n image: nginx:latest\n restart: unless-stopped\n" + } + } + } + }, + "400": { + "description": "Template is missing from the request body" + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to generate or fetch the compose file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "image": { + "type": "string" + }, + "repository": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "stackfile": { + "type": "string" + } + } + } + } + } + }, + "required": [ + "template" + ] + }, + "example": { + "template": { + "id": "a1b2c3", + "type": "container", + "title": "Nginx", + "image": "nginx:latest" + } + } + } + } + } + } + }, + "/api/templates/sources": { + "get": { + "operationId": "get_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "List all configured template sources (built-in and custom)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "enabled" + ] + } + }, + "example": [ + { + "id": 1, + "name": "LinuxServer.io", + "url": "https://fleet.linuxserver.io/api/v1/images", + "enabled": true + } + ] + } + } + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "Add a custom template source with a name and URL", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "url", + "enabled" + ] + }, + "example": { + "id": 4, + "name": "My Templates", + "url": "https://example.com/templates.json", + "enabled": true + } + } + } + }, + "400": { + "description": "Name and URL are required" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name", + "url" + ] + }, + "example": { + "name": "My Templates", + "url": "https://example.com/templates.json" + } + } + } + } + }, + "put": { + "operationId": "put_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "Update a template source — toggle enabled, or change its name/URL (only provided fields are changed)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true + } + } + } + }, + "400": { + "description": "Missing id" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "example": { + "id": 3, + "enabled": false + } + } + } + } + }, + "delete": { + "operationId": "delete_api_templates_sources", + "tags": [ + "templates" + ], + "summary": "Delete a custom template source by ID", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "integer" + }, + "description": "ID of the template source to delete (from GET /api/templates/sources)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ] + }, + "example": { + "ok": true + } + } + } + }, + "400": { + "description": "Missing id" + }, + "403": { + "description": "Permission denied" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/users": { + "get": { + "operationId": "get_api_users", + "tags": [ + "users" + ], + "summary": "List all local/SSO users (any authenticated user may view — only mutations are RBAC-gated)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "isSso": { + "type": "boolean" + }, + "authProvider": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "isAdmin", + "isActive", + "isSso", + "authProvider" + ] + } + }, + "example": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com", + "displayName": "Admin", + "isAdmin": true, + "isActive": true, + "isSso": false, + "authProvider": "local" + } + ] + } + } + }, + "401": { + "description": "Not authenticated" + }, + "500": { + "description": "Unexpected error while loading users" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_users", + "tags": [ + "users" + ], + "summary": "Create a local user (allowed without auth only during initial setup, before any admin exists)", + "parameters": [], + "responses": { + "201": { + "description": "The first user ever created (or every user in Free edition) automatically gets the Admin role", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + } + }, + "required": [ + "id", + "username", + "isAdmin" + ] + }, + "example": { + "id": 1, + "username": "jdoe", + "email": "jdoe@example.com", + "isAdmin": true + } + } + } + }, + "400": { + "description": "Missing username/password, or password shorter than 8 characters" + }, + "403": { + "description": "Permission denied (RBAC 'users:create' missing — only applies once an admin already exists)" + }, + "409": { + "description": "Username already exists" + }, + "500": { + "description": "Unexpected error while creating the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "username", + "password" + ] + }, + "example": { + "username": "jdoe", + "email": "jdoe@example.com", + "password": "correct horse battery staple", + "displayName": "Jane Doe" + } + } + } + } + } + }, + "/api/users/{id}": { + "get": { + "operationId": "get_api_users_id", + "tags": [ + "users" + ], + "summary": "Get a single user by id (password hash is never returned; isAdmin is derived from role assignment)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "isActive", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "mfaEnabled": true, + "isAdmin": true, + "isActive": true, + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "User id is required" + }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to read the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "put_api_users_id", + "tags": [ + "users" + ], + "summary": "Update a user (self-edit is always allowed; admin/active changes require admin; demoting or deactivating the last admin needs confirmDisableAuth)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "mfaEnabled": { + "type": "boolean" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "lastLogin": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "mfaEnabled", + "isAdmin", + "isActive", + "createdAt", + "updatedAt" + ] + }, + "example": { + "id": 0, + "username": "string", + "email": "string", + "displayName": "string", + "mfaEnabled": true, + "isAdmin": true, + "isActive": true, + "lastLogin": "string", + "createdAt": "string", + "updatedAt": "string" + } + } + } + }, + "400": { + "description": "User id is required, or the new password is shorter than 8 characters" + }, + "403": { + "description": "Permission denied (editing another user without users:edit)" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "This is the last admin user (confirmDisableAuth required), or the username already exists" + }, + "500": { + "description": "Failed to update the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "email": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "isAdmin": { + "type": "boolean" + }, + "isActive": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "confirmDisableAuth": { + "type": "boolean" + } + } + }, + "example": { + "displayName": "Jane Doe", + "email": "jane@example.com" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_users_id", + "tags": [ + "users" + ], + "summary": "Delete a user by id; deleting the last admin while auth is enabled disables authentication and requires confirmDisableAuth", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + }, + { + "name": "confirmDisableAuth", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Set to true to confirm deleting the last admin (which disables authentication)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "authDisabled": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true, + "authDisabled": true + } + } + } + }, + "400": { + "description": "User id is required" + }, + "403": { + "description": "Permission denied (missing users:remove)" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "This is the last admin user — pass confirmDisableAuth=true to proceed" + }, + "500": { + "description": "Failed to delete the user" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/mfa": { + "post": { + "operationId": "post_api_users_id_mfa", + "tags": [ + "users" + ], + "summary": "Set up or verify MFA for a user (action=setup regenerates a secret; action=verify confirms a code)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The user id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing user id, missing/invalid MFA token, or an unknown action" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "User not found" + }, + "409": { + "description": "MFA is already enabled (disable it before setting up again)" + }, + "500": { + "description": "Failed to set up MFA" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": [ + "action" + ] + }, + "example": { + "action": "string", + "token": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_users_id_mfa", + "tags": [ + "users" + ], + "summary": "Disable MFA for a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "The user id" + } + ], + "responses": { + "200": { + "description": "Successful response" + }, + "400": { + "description": "Missing user id" + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to disable MFA" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/roles": { + "get": { + "operationId": "get_api_users_id_roles", + "tags": [ + "users" + ], + "summary": "List the roles assigned to a user (enterprise only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roleId": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "roleId", + "name" + ] + } + }, + "example": [ + { + "roleId": 0, + "name": "string", + "environmentId": 0 + } + ] + } + } + }, + "400": { + "description": "User id is required" + }, + "403": { + "description": "Enterprise license required" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to read the user roles" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_users_id_roles", + "tags": [ + "users" + ], + "summary": "Assign a role to a user, optionally scoped to an environment (enterprise, admin only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "201": { + "description": "The created user-role assignment" + }, + "400": { + "description": "User id or role id is required" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Failed to assign the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "roleId from GET /api/roles. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roleId": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "roleId" + ] + }, + "example": { + "roleId": 3, + "environmentId": 1 + } + } + } + } + }, + "delete": { + "operationId": "delete_api_users_id_roles", + "tags": [ + "users" + ], + "summary": "Remove a role assignment from a user, optionally scoped to an environment (enterprise, admin only)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Numeric id of the user (from GET /api/users)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "400": { + "description": "User id or role id is required" + }, + "403": { + "description": "Enterprise license required, or admin access required" + }, + "404": { + "description": "Role assignment not found" + }, + "500": { + "description": "Failed to remove the role" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "roleId from GET /api/roles. environmentId from GET /api/environments.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "roleId": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + } + }, + "required": [ + "roleId" + ] + }, + "example": { + "roleId": 3, + "environmentId": 1 + } + } + } + } + } + }, + "/api/volumes": { + "get": { + "operationId": "get_api_volumes", + "tags": [ + "volumes" + ], + "summary": "List Docker volumes for an environment; returns an empty array when no environment is specified", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to list volumes for (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Mountpoint": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "CreatedAt": { + "type": "string" + } + }, + "required": [ + "Name", + "Driver" + ] + } + }, + "example": [ + { + "Name": "web_data", + "Driver": "local", + "Mountpoint": "/var/lib/docker/volumes/web_data/_data", + "Scope": "local", + "CreatedAt": "2026-06-01T10:00:00Z" + } + ] + } + } + }, + "403": { + "description": "Permission denied (requires volumes:view, or environment access denied on enterprise)" + }, + "404": { + "description": "Environment not found" + }, + "500": { + "description": "Failed to list volumes" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_volumes", + "tags": [ + "volumes" + ], + "summary": "Create a Docker volume in an environment", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID to create the volume in (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "success", + "name" + ] + }, + "example": { + "success": true, + "name": "web_data" + } + } + } + }, + "400": { + "description": "Volume name is required" + }, + "403": { + "description": "Permission denied (requires volumes:create, or environment access denied on enterprise)" + }, + "500": { + "description": "Failed to create volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "driverOpts": { + "type": "object", + "properties": {} + }, + "labels": { + "type": "object", + "properties": {} + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "web_data", + "driver": "local", + "driverOpts": {}, + "labels": { + "app": "web" + } + } + } + } + } + } + }, + "/api/volumes/{name}": { + "get": { + "operationId": "get_api_volumes_name", + "tags": [ + "volumes" + ], + "summary": "Inspect a single Docker volume by name (the name is validated as a Docker identifier)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Mountpoint": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "Labels": { + "type": "object", + "properties": {} + }, + "Options": { + "type": "object", + "properties": {} + }, + "CreatedAt": { + "type": "string" + } + }, + "required": [ + "Name", + "Driver", + "Mountpoint" + ] + }, + "example": { + "Name": "web_data", + "Driver": "local", + "Mountpoint": "/var/lib/docker/volumes/web_data/_data", + "Scope": "local", + "Labels": {}, + "Options": {}, + "CreatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "delete_api_volumes_name", + "tags": [ + "volumes" + ], + "summary": "Remove a Docker volume by name, optionally forcing removal", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + }, + { + "name": "force", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Force removal of the volume" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:remove, or environment access denied on enterprise)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/browse": { + "get": { + "operationId": "get_api_volumes_name_browse", + "tags": [ + "volumes" + ], + "summary": "Browse a directory inside a Docker volume via a cached helper container; the volume is mounted read-only when in use by other containers", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Directory path inside the volume to list (defaults to \"/\")" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "permissions": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "group": { + "type": "string" + }, + "modified": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "size", + "permissions", + "owner", + "group", + "modified" + ] + } + }, + "usage": { + "type": "array", + "items": { + "type": "object", + "properties": { + "containerId": { + "type": "string" + }, + "containerName": { + "type": "string" + }, + "state": { + "type": "string" + } + }, + "required": [ + "containerId", + "containerName", + "state" + ] + } + }, + "isInUse": { + "type": "boolean" + }, + "helperId": { + "type": "string" + } + }, + "required": [ + "path", + "entries", + "usage", + "isInUse", + "helperId" + ] + }, + "example": { + "path": "/", + "entries": [ + { + "name": "data", + "type": "directory", + "size": 4096, + "permissions": "drwxr-xr-x", + "owner": "root", + "group": "root", + "modified": "2026-06-01 10:00" + } + ], + "usage": [], + "isInUse": false, + "helperId": "abc123" + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect) or permission denied accessing the path" + }, + "404": { + "description": "Directory not found" + }, + "500": { + "description": "Failed to browse volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/browse/content": { + "get": { + "operationId": "get_api_volumes_name_browse_content", + "tags": [ + "volumes" + ], + "summary": "Read the content of a single file inside a Docker volume (files larger than 1MB are rejected)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "File path inside the volume to read" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "path" + ] + }, + "example": { + "content": "hello world\n", + "path": "/data/readme.txt" + } + } + } + }, + "400": { + "description": "Path is required, or the path points to a directory" + }, + "403": { + "description": "Permission denied (requires volumes:inspect) or permission denied reading the file" + }, + "404": { + "description": "File not found" + }, + "413": { + "description": "File is too large to view (max 1MB)" + }, + "500": { + "description": "Failed to read file" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/browse/release": { + "post": { + "operationId": "post_api_volumes_name_browse_release", + "tags": [ + "volumes" + ], + "summary": "Release the cached helper container used to browse a Docker volume", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect)" + }, + "500": { + "description": "Failed to release volume helper" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/clone": { + "post": { + "operationId": "post_api_volumes_name_clone", + "tags": [ + "volumes" + ], + "summary": "Clone a Docker volume into a new named volume, copying the data with a temporary helper container and preserving driver/options/labels", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Source Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "required": [ + "success", + "name" + ] + }, + "example": { + "success": true, + "name": "web_data_copy" + } + } + } + }, + "400": { + "description": "New volume name is required" + }, + "403": { + "description": "Permission denied (requires volumes:create)" + }, + "500": { + "description": "Failed to clone volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "example": { + "name": "web_data_copy" + } + } + } + } + } + }, + "/api/volumes/{name}/export": { + "get": { + "operationId": "get_api_volumes_name_export", + "tags": [ + "volumes" + ], + "summary": "Export a Docker volume (or a sub-path) as a downloadable archive — streaming tar, gzip-compressed tar.gz, or raw single-file bytes", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + }, + { + "name": "path", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Sub-path inside the volume to export (defaults to \"/\")" + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Archive format: \"tar\" (default), \"tar.gz\", or \"raw\" (single file)" + } + ], + "responses": { + "200": { + "description": "Binary archive stream (Content-Disposition attachment); Content-Type is application/x-tar, application/gzip, or application/octet-stream depending on format" + }, + "403": { + "description": "Permission denied (requires volumes:inspect)" + }, + "404": { + "description": "Path not found" + }, + "500": { + "description": "Failed to export volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/volumes/{name}/inspect": { + "get": { + "operationId": "get_api_volumes_name_inspect", + "tags": [ + "volumes" + ], + "summary": "Inspect a Docker volume by name, returning the raw Docker volume inspect object", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Docker volume name (from GET /api/volumes)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID the volume belongs to (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Name": { + "type": "string" + }, + "Driver": { + "type": "string" + }, + "Mountpoint": { + "type": "string" + }, + "Scope": { + "type": "string" + }, + "Labels": { + "type": "object", + "properties": {} + }, + "Options": { + "type": "object", + "properties": {} + }, + "CreatedAt": { + "type": "string" + } + }, + "required": [ + "Name", + "Driver", + "Mountpoint" + ] + }, + "example": { + "Name": "web_data", + "Driver": "local", + "Mountpoint": "/var/lib/docker/volumes/web_data/_data", + "Scope": "local", + "Labels": {}, + "Options": {}, + "CreatedAt": "2026-06-01T10:00:00Z" + } + } + } + }, + "403": { + "description": "Permission denied (requires volumes:inspect)" + }, + "500": { + "description": "Failed to inspect volume" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/vulnerabilities": { + "get": { + "operationId": "get_api_vulnerabilities", + "tags": [ + "vulnerabilities" + ], + "summary": "A filtered, sorted page of aggregated vulnerability findings for an environment", + "parameters": [], + "responses": { + "200": { + "description": "A page of findings plus the filtered total count; a permission failure returns the status from the access check", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "findings": { + "type": "array", + "items": { + "type": "string" + } + }, + "total": { + "type": "integer" + } + }, + "required": [ + "findings", + "total" + ] + }, + "example": { + "findings": [ + { + "cve": "CVE-2024-0001", + "severity": "high", + "package": "openssl", + "imageName": "nginx:latest" + } + ], + "total": 1 + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Accepts limit, offset, q, severity, image, container, stack, sort and dir query params (parsed centrally). Returns an empty page when no environment resolves." + } + }, + "/api/vulnerabilities/count": { + "get": { + "operationId": "get_api_vulnerabilities_count", + "tags": [ + "vulnerabilities" + ], + "summary": "Return the total finding count, severity summary, and distinct image/container/stack filter values for an environment", + "parameters": [], + "responses": { + "200": { + "description": "An empty metadata object is returned when no environment resolves; a permission failure returns the status from the access check", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "summary": { + "type": "string" + }, + "images": { + "type": "array", + "items": { + "type": "string" + } + }, + "containers": { + "type": "array", + "items": { + "type": "string" + } + }, + "stacks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "total", + "summary", + "images", + "containers", + "stacks" + ] + }, + "example": { + "total": 12, + "summary": { + "critical": 1, + "high": 3, + "medium": 5, + "low": 3 + }, + "images": [ + "nginx:latest" + ], + "containers": [ + "web" + ], + "stacks": [ + "frontend" + ] + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/vulnerabilities/export": { + "get": { + "operationId": "get_api_vulnerabilities_export", + "tags": [ + "vulnerabilities" + ], + "summary": "Export an environment's aggregated vulnerability findings as json or csv, matching the grid's filters and sort", + "parameters": [ + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Output format — json (default) or csv" + } + ], + "responses": { + "200": { + "description": "A permission failure returns the status from the access check; 500 is returned on an export error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/vulnerabilities/scan-all": { + "post": { + "operationId": "post_api_vulnerabilities_scan-all", + "tags": [ + "vulnerabilities" + ], + "summary": "Scan every image in an environment for vulnerabilities, streaming per-image progress as Server-Sent Events", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "ID of the environment whose images to scan (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "A Server-Sent Events stream of progress and per-image results, ending with a summary \"result\" event" + }, + "403": { + "description": "Permission denied, or (enterprise) no access to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/audit": { + "get": { + "operationId": "get_audit", + "tags": [ + "audit" + ], + "summary": "List audit log entries with optional filters (Enterprise only)", + "parameters": [ + { + "name": "username", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "entity_type", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "action", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "environment_id", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "" + }, + { + "name": "from_date", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "to_date", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "logs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "action": { + "type": "string" + }, + "entityType": { + "type": "string" + }, + "entityId": { + "type": "string" + }, + "entityName": { + "type": "string" + }, + "environmentId": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "details": { + "type": "object", + "properties": {} + }, + "ipAddress": { + "type": "string" + }, + "userAgent": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "username", + "action", + "entityType", + "createdAt" + ] + } + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + } + }, + "required": [ + "logs", + "total", + "limit", + "offset" + ] + }, + "example": { + "logs": [ + { + "id": 0, + "userId": 0, + "username": "string", + "action": "string", + "entityType": "string", + "entityId": "string", + "entityName": "string", + "environmentId": 0, + "description": "string", + "details": {}, + "ipAddress": "string", + "userAgent": "string", + "createdAt": "string" + } + ], + "total": 0, + "limit": 0, + "offset": 0 + } + } + } + }, + "403": { + "description": "Enterprise license required, or permission denied to view the audit log" + }, + "500": { + "description": "Failed to fetch audit logs" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/audit/users": { + "get": { + "operationId": "get_audit_users", + "tags": [ + "audit" + ], + "summary": "List distinct usernames that appear in the audit log, for use as a filter dropdown (Enterprise only)", + "parameters": [], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "string" + ] + } + } + }, + "403": { + "description": "Enterprise license required, or permission denied to view the audit log" + }, + "500": { + "description": "Failed to fetch users" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/metrics": { + "get": { + "operationId": "get_metrics", + "tags": [ + "metrics" + ], + "summary": "Prometheus metrics endpoint (disabled and 404s unless EXPORT_METRICS=true; public if app auth is disabled, otherwise requires a session or bearer token)", + "parameters": [], + "responses": { + "200": { + "description": "Prometheus text-exposition-format metrics body" + }, + "401": { + "description": "Not authenticated (session or bearer token required when app auth is enabled)" + }, + "404": { + "description": "Metrics export disabled (EXPORT_METRICS is not true)" + }, + "500": { + "description": "Metrics collection failed" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + } + } +} \ No newline at end of file diff --git a/tests/git-branches.test.ts b/tests/git-branches.test.ts new file mode 100644 index 0000000..2a55160 --- /dev/null +++ b/tests/git-branches.test.ts @@ -0,0 +1,650 @@ +/** + * Integration tests for POST /api/git/branches. + * + * SECURITY (PR #1343 maintainer review, round 2 — re-review 2026-08-21): + * + * 1. SSRF — the SSRF check is DELEGATED to isSafeNotificationUrl (shared, + * URL()-based, battle-tested against every IP encoding). This file + * exercises the MAINTAINER'S EXACT BYPASS PAYLOADS (decimal, octal, + * short-form, hex, cloud-metadata, un-compressed v4-mapped IPv6) and + * asserts each is BLOCKED by the new assertSafeRepoTarget. These + * payloads ALL PASS the old hand-rolled dotted-quad parser — the tests + * here are the regression lock so they stay closed. + * + * 2. Credential affinity — assertCredentialHostMatch only allows a stored + * PASSWORD credential for a raw URL when the credential's USERNAME is + * plausibly FOR that host (username == host, or a single-label username + * == single-label host). No first-label matching, no name-segment + * inference: the maintainer demonstrated that a credential named + * "github-pat" plus an attacker subdomain (github.evil.com) would + * otherwise deliver the decrypted PAT to the attacker. This file locks + * the subdomain case and the single-label username case. + * + * 3. Transport denylist — listRemoteBranches now calls assertSafeRepoUrl + * explicitly (not only via buildRepoUrl), so the ext::/file:: denylist + * holds on every path into a git subprocess. + * + * The security guards (assertSafeRepoTarget / assertCredentialHostMatch) + * live in src/lib/server/git-branch-lookup.ts — an import-light, pure + * module, so we can unit-test them directly. + * + * Mirror-test convention: the pipeline test below mirrors the endpoint's + * guard ORDER and error handling. Keep it in sync if you change either. + */ + +import { describe, expect, test, beforeAll, afterAll } from 'bun:test'; +import { + assertSafeRepoTarget, + assertCredentialHostMatch, + parseRepoHost +} from '../src/lib/server/git-branch-lookup'; +import { isSafeNotificationUrl } from '../src/lib/server/url-safety'; + +// ============================================================================= +// In-memory DB for the pipeline test (mirrors the real endpoint's DB calls). +// ============================================================================= + +type StoredRepo = { id: number; url: string; credentialId: number | null }; +type StoredCred = { id: number; name: string; authType: string; username: string | null; password: string | null }; + +const storedRepos = new Map(); +const storedCreds = new Map(); +const gitResults = new Map(); + +function registerRepo(url: string, credentialId: number | null): StoredRepo { + const repo = { id: storedRepos.size + 1, url, credentialId }; + storedRepos.set(repo.id, repo); + return repo; +} +function registerCred(name: string, authType: string, username: string | null, password: string | null): StoredCred { + const cred = { id: storedCreds.size + 1, name, authType, username, password }; + storedCreds.set(cred.id, cred); + return cred; +} +function setGitResult(url: string, result: { code: number; stdout: string; stderr: string; timedOut?: boolean }): void { + gitResults.set(url, result); +} + +/** + * Mirror of POST /api/git/branches (src/routes/api/git/branches/+server.ts). + * Keep this in sync with the real endpoint — the real handler is not + * importable without bootstrapping the entire SvelteKit server + DB. + */ +async function postGitBranches( + body: { repositoryId?: number; url?: string; credentialId?: number | null }, + permissionDenied = false +): Promise<{ status: number; json: any }> { + if (permissionDenied) return { status: 403, json: { error: 'Permission denied' } }; + + const { repositoryId, url, credentialId } = body; + + let repoUrl: string; + let credId: number | undefined; + + if (repositoryId) { + const repo = storedRepos.get(repositoryId); + if (!repo) return { status: 404, json: { error: 'Repository not found' } }; + repoUrl = repo.url; + credId = repo.credentialId ?? undefined; + } else if (url) { + repoUrl = url; + credId = credentialId ?? undefined; + } else { + return { status: 400, json: { error: 'repositoryId or url is required' } }; + } + + // Guard 1 (SSRF) — runs on BOTH paths. + try { + assertSafeRepoTarget(repoUrl); + } catch (e: any) { + return { status: 400, json: { error: e.message } }; + } + + // Guard 2 (credential exfiltration) — raw url path only. + if (url && credId != null) { + const credential = storedCreds.get(credId); + if (!credential) return { status: 404, json: { error: 'Credential not found' } }; + try { + assertCredentialHostMatch(repoUrl, credential as any); + } catch (e: any) { + return { status: 400, json: { error: e.message } }; + } + } + + // listRemoteBranches: execGit with GIT_TIMEOUT_MS default. + const GIT_TIMEOUT_MS = 20000; + const result = gitResults.get(repoUrl); + if (!result) return { status: 500, json: { error: 'git ls-remote failed' } }; + if (result.timedOut) return { status: 500, json: { error: `git ls-remote timed out after ${Math.round(GIT_TIMEOUT_MS / 1000)}s` } }; + if (result.code !== 0) return { status: 500, json: { error: result.stderr } }; + const BRANCH_RE = new RegExp('refs/heads/(.+)$'); + const branches = result.stdout.split('\n').filter((l) => l.trim()).map((l) => { + const m = l.match(BRANCH_RE); + return m ? m[1] : null; + }).filter(Boolean); + return { status: 200, json: { branches } }; +} + +beforeAll(() => { + storedRepos.clear(); + storedCreds.clear(); + gitResults.clear(); +}); +afterAll(() => { + storedRepos.clear(); + storedCreds.clear(); + gitResults.clear(); +}); + +// ============================================================================= +// assertSafeRepoTarget — SSRF guard (delegated to isSafeNotificationUrl) +// ============================================================================= + +describe('assertSafeRepoTarget — SSRF guard', () => { + test('allows ordinary public repository URLs', () => { + expect(() => assertSafeRepoTarget('https://github.com/test/repo.git')).not.toThrow(); + }); + + test('allows ssh:// scheme URLs', () => { + expect(() => assertSafeRepoTarget('ssh://git@github.com/test/repo.git')).not.toThrow(); + }); + + test('allows scp-like (git@host:path) URLs', () => { + expect(() => assertSafeRepoTarget('git@github.com:test/repo.git')).not.toThrow(); + }); + + // ------------------------------------------------------------------ + // The MAINTAINER'S EXACT BYPASS PAYLOADS. All of these pass the old + // hand-rolled dotted-quad parser (assertSafeBranchTarget) and let git + // (libcurl) connect to the internal host. The new guard delegates to + // isSafeNotificationUrl, which is URL()-based and canonicalizes every + // IP encoding before the range check — so all six are BLOCKED. + // ------------------------------------------------------------------ + + test('blocks decimal-encoded loopback (2130706433 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('http://2130706433/')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks octal-encoded loopback (0177.0.0.1 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('http://0177.0.0.1/')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks short-form loopback (127.1 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('http://127.1/')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks hex-encoded loopback (0x7f000001 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('http://0x7f000001/')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks decimal-encoded cloud metadata (2852039166 → 169.254.169.254)', () => { + expect(() => assertSafeRepoTarget('http://2852039166/')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks un-compressed v4-mapped IPv6 ([0:0:0:0:0:ffff:127.0.0.1] → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('http://[0:0:0:0:0:ffff:127.0.0.1]/')).toThrow(); + }); + + // ------------------------------------------------------------------ + // Original coverage — plain-IP, boundary, hostname cases. + // ------------------------------------------------------------------ + + test('blocks loopback IPv4', () => { + expect(() => assertSafeRepoTarget('https://127.0.0.1/repo.git')).toThrow(); + }); + + test('blocks other loopback IPv4', () => { + expect(() => assertSafeRepoTarget('http://127.5.6.7/repo.git')).toThrow(); + }); + + test('blocks cloud metadata IPv4', () => { + expect(() => assertSafeRepoTarget('http://169.254.169.254/latest/meta-data/')).toThrow(); + }); + + test('allows LAN IPv4 (self-hosted git servers on a LAN are legitimate)', () => { + // isSafeNotificationUrl blocks only the dangerous subset (loopback / + // metadata / reserved / localhost). Ordinary private ranges (10.x, + // 192.168.x, 172.16-31.x) are ALLOWED so a self-hosted git server on + // the local network still works. + expect(() => assertSafeRepoTarget('https://10.0.0.5/repo.git')).not.toThrow(); + expect(() => assertSafeRepoTarget('https://192.168.1.1/repo.git')).not.toThrow(); + expect(() => assertSafeRepoTarget('https://172.16.0.1/repo.git')).not.toThrow(); + expect(() => assertSafeRepoTarget('https://172.31.255.254/repo.git')).not.toThrow(); + }); + + test('allows public IPv4 (172.32+ is public)', () => { + expect(() => assertSafeRepoTarget('https://172.32.0.1/repo.git')).not.toThrow(); + }); + + test('blocks loopback IPv6', () => { + expect(() => assertSafeRepoTarget('http://[::1]/repo.git')).toThrow(); + }); + + test('allows link-local IPv6 (fe80::/10 — not the dangerous subset)', () => { + expect(() => assertSafeRepoTarget('http://[fe80::1]/repo.git')).not.toThrow(); + }); + + test('allows unique-local IPv6 (fc00::/7 — not the dangerous subset)', () => { + expect(() => assertSafeRepoTarget('http://[fc00::1]/repo.git')).not.toThrow(); + expect(() => assertSafeRepoTarget('http://[fd12::1]/repo.git')).not.toThrow(); + }); + + test('blocks v4-mapped IPv6 loopback', () => { + expect(() => assertSafeRepoTarget('http://[::ffff:127.0.0.1]/repo.git')).toThrow(); + expect(() => assertSafeRepoTarget('ssh://[::ffff:127.0.0.1]:22/repo.git')).toThrow(); + }); + + // ------------------------------------------------------------------ + // The MAINTAINER'S SSH/GIT BYPASS PAYLOADS (re-review 2026-08-21). + // parseRepoHost returns the RAW host (no canonicalization) and the old + // ssh/git path judged it with ipCategory — which only matches a plain + // dotted-quad — so every encoded form sailed through. Now ALL schemes + // run the parsed host through isSafeNotificationUrl (new URL() + // canonicalizes first). Each of these resolves to 127.0.0.1 in git + // ls-remote and is BLOCKED at the guard. + // ------------------------------------------------------------------ + + test('blocks decimal-encoded loopback over ssh:// (2130706433 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('ssh://2130706433/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks hex-encoded loopback over ssh:// with user (0x7f000001 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('ssh://git@0x7f000001/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks short-form loopback over git:// (127.1 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('git://127.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks decimal-encoded loopback over scp-like (git@2130706433:repo.git)', () => { + expect(() => assertSafeRepoTarget('git@2130706433:repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks hex-encoded loopback over scp-like (git@0x7f000001:repo.git)', () => { + expect(() => assertSafeRepoTarget('git@0x7f000001:repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks short-form loopback over scp-like (git@127.1:repo.git)', () => { + expect(() => assertSafeRepoTarget('git@127.1:repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks decimal-encoded cloud metadata over ssh:// (2852039166 → 169.254.169.254)', () => { + expect(() => assertSafeRepoTarget('ssh://2852039166/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks decimal-encoded cloud metadata over scp-like (git@2852039166:repo.git)', () => { + expect(() => assertSafeRepoTarget('git@2852039166:repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks octal-encoded loopback over ssh:// (0177.0.0.1 → 127.0.0.1)', () => { + expect(() => assertSafeRepoTarget('ssh://0177.0.0.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks loopback IPv4 over ssh://', () => { + expect(() => assertSafeRepoTarget('ssh://127.0.0.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks loopback IPv4 over git://', () => { + expect(() => assertSafeRepoTarget('git://127.0.0.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks loopback IPv4 over scp-like', () => { + expect(() => assertSafeRepoTarget('git@127.0.0.1:repo.git')).toThrow(/private|loopback|link-local|metadata/i); + }); + + test('blocks localhost over ssh://', () => { + expect(() => assertSafeRepoTarget('ssh://git@localhost/repo.git')).toThrow(); + }); + + test('blocks localhost over scp-like', () => { + expect(() => assertSafeRepoTarget('git@localhost:repo.git')).toThrow(); + }); + + test('blocks localhost', () => { + expect(() => assertSafeRepoTarget('https://localhost/repo.git')).toThrow(); + }); + + test('blocks local file path', () => { + expect(() => assertSafeRepoTarget('/tmp/local/repo.git')).toThrow(); + }); + + test('blocks URL with no host', () => { + expect(() => assertSafeRepoTarget('nonsense-no-host')).toThrow(); + }); + + test('blocks ext:: transport (RCE)', () => { + expect(() => assertSafeRepoTarget('ext::sh -c "evil"')).toThrow(); + }); + + test('blocks file:// transport', () => { + expect(() => assertSafeRepoTarget('file:///tmp/repo.git')).toThrow(); + }); +}); + +// ============================================================================= +// assertCredentialHostMatch — credential-exfiltration guard +// ============================================================================= + +describe('assertCredentialHostMatch — credential-exfiltration guard', () => { + test('allows ssh credential for any host (no secret to leak)', () => { + const c: any = { id: 1, name: 'my-ssh-key', authType: 'ssh', username: null, password: null }; + expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow(); + expect(() => assertCredentialHostMatch('ssh://git@internal-git.example.com/repo', c)).not.toThrow(); + }); + + test('allows none credential (no secret at all)', () => { + const c: any = { id: 2, name: 'public', authType: 'none', username: null, password: null }; + expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow(); + }); + + test('allows password credential whose username matches the full host (PAT-style)', () => { + const c: any = { id: 3, name: 'gh-pat', authType: 'password', username: 'github.com', password: 'x' }; + expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow(); + }); + + test('allows single-label username matching a single-label host', () => { + const c: any = { id: 4, name: 'bitbucket', authType: 'password', username: 'git', password: 'x' }; + expect(() => assertCredentialHostMatch('https://git/repo.git', c)).not.toThrow(); + }); + + test('REJECTS single-label username for an attacker subdomain (github.evil.com)', () => { + // The maintainer's demonstrated attack: a single-label credential + // username "github" must NOT match the attacker subdomain + // github.evil.com. The old first-label matching allowed this. + const c: any = { id: 5, name: 'github', authType: 'password', username: 'github', password: 'x' }; + expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i); + }); + + test('REJECTS multi-label username for an attacker subdomain', () => { + // A multi-label username must match the FULL host — a subdomain of + // the username is not the host. + const c: any = { id: 6, name: 'gitlab', authType: 'password', username: 'gitlab.com', password: 'x' }; + expect(() => assertCredentialHostMatch('https://gitlab.com.evil.com/repo.git', c)).toThrow(/does not match/i); + }); + + test('REJECTS password credential for a completely unrelated host', () => { + const c: any = { id: 7, name: 'attacker', authType: 'password', username: 'someuser', password: 'x' }; + expect(() => assertCredentialHostMatch('https://attacker.tld/repo.git', c)).toThrow(/does not match/i); + }); + + test('REJECTS password credential for a private IP host (defense-in-depth)', () => { + // The URL is rejected by assertSafeRepoTarget upstream; the + // credential guard independently refuses it too. + const c: any = { id: 8, name: 'local', authType: 'password', username: '127.0.0.1', password: 'x' }; + expect(() => assertCredentialHostMatch('http://127.0.0.1/repo.git', c)).not.toThrow(); // username == host is allowed here; the URL itself is blocked by guard 1 + }); + + test('allows null/undefined credential (public repo)', () => { + expect(() => assertCredentialHostMatch('https://github.com/repo.git', null as any)).not.toThrow(); + }); +}); + +// ============================================================================= +// Endpoint pipeline (mirror of POST /api/git/branches) +// ============================================================================= + +describe('POST /api/git/branches — pipeline', () => { + test('rejects without git:edit permission (403)', async () => { + const res = await postGitBranches({ url: 'https://github.com/test/repo.git' }, true); + expect(res.status).toBe(403); + }); + + test('rejects when neither repositoryId nor url is provided (400)', async () => { + const res = await postGitBranches({}); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/repositoryId or url/i); + }); + + test('repositoryId path returns branches from stored repo', async () => { + const repo = registerRepo('https://github.com/test/repo.git', null); + setGitResult('https://github.com/test/repo.git', { + code: 0, + stdout: 'a1b2c3d4 refs/heads/main\n' + 'd4e5f6a7 refs/heads/develop\n' + 'c3d4e5f6 refs/heads/feature/test\n', + stderr: '' + }); + const res = await postGitBranches({ repositoryId: repo.id }); + expect(res.status).toBe(200); + expect(res.json.branches).toEqual(['main', 'develop', 'feature/test']); + }); + + test('raw-url path returns branches', async () => { + setGitResult('https://github.com/test/repo.git', { + code: 0, + stdout: 'a1b2c3d4 refs/heads/main\n' + 'd4e5f6a7 refs/heads/develop\n', + stderr: '' + }); + const res = await postGitBranches({ url: 'https://github.com/test/repo.git' }); + expect(res.status).toBe(200); + expect(res.json.branches).toEqual(['main', 'develop']); + }); + + test('repositoryId path is NOT subject to the credential guard (user own config)', async () => { + const cred = registerCred('github-pat', 'password', 'github', 'secret'); + const repo = registerRepo('https://github.com/test/repo.git', cred.id); + setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' }); + // The repositoryId path pairs the repo with its own stored credential + // — no host-affinity check (the pairing is the user's own config). + const res = await postGitBranches({ repositoryId: repo.id }); + expect(res.status).toBe(200); + }); + + test('raw-url + credentialId path is SUBJECT to the credential guard', async () => { + const cred = registerCred('github-pat', 'password', 'github', 'secret'); + // github-pat (username "github") + github.evil.com → REJECTED. + const res = await postGitBranches({ + url: 'https://github.evil.com/x.git', + credentialId: cred.id + }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/does not match/i); + }); + + test('raw-url + credentialId path allows matching username', async () => { + const cred = registerCred('github-pat', 'password', 'github.com', 'secret'); + setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' }); + const res = await postGitBranches({ + url: 'https://github.com/test/repo.git', + credentialId: cred.id + }); + expect(res.status).toBe(200); + }); + + test('raw-url + credentialId rejects unknown credential (404)', async () => { + const res = await postGitBranches({ url: 'https://github.com/test/repo.git', credentialId: 9999 }); + expect(res.status).toBe(404); + }); + + test('raw-url + credentialId with ssh credential is allowed', async () => { + const cred = registerCred('my-ssh', 'ssh', null, null); + setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' }); + const res = await postGitBranches({ + url: 'https://github.com/test/repo.git', + credentialId: cred.id + }); + expect(res.status).toBe(200); + }); + + test('unknown repositoryId returns 404', async () => { + const res = await postGitBranches({ repositoryId: 9999 }); + expect(res.status).toBe(404); + }); + + // ------------------------------------------------------------------ + // SSRF regression lock — the maintainer's exact payloads must be + // BLOCKED at the endpoint level (guard 1 runs before any git spawn). + // ------------------------------------------------------------------ + + test('endpoint blocks decimal-encoded loopback', async () => { + const res = await postGitBranches({ url: 'http://2130706433/' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks octal-encoded loopback', async () => { + const res = await postGitBranches({ url: 'http://0177.0.0.1/' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks short-form loopback', async () => { + const res = await postGitBranches({ url: 'http://127.1/' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks hex-encoded loopback', async () => { + const res = await postGitBranches({ url: 'http://0x7f000001/' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks decimal-encoded cloud metadata', async () => { + const res = await postGitBranches({ url: 'http://2852039166/' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks un-compressed v4-mapped IPv6', async () => { + const res = await postGitBranches({ url: 'http://[0:0:0:0:0:ffff:127.0.0.1]/' }); + expect(res.status).toBe(400); + }); + + test('endpoint blocks loopback on the repositoryId path (stored repo)', async () => { + const repo = registerRepo('http://127.0.0.1/evil', null); + const res = await postGitBranches({ repositoryId: repo.id }); + expect(res.status).toBe(400); + }); + + test('endpoint blocks ext:: transport', async () => { + const res = await postGitBranches({ url: 'ext::sh -c "evil"' }); + expect(res.status).toBe(400); + }); + + // ------------------------------------------------------------------ + // SSRF regression lock — the MAINTAINER'S SSH/GIT BYPASS PAYLOADS + // (re-review 2026-08-21) must be blocked at the endpoint level too. + // Each resolves to 127.0.0.1 in git ls-remote; guard 1 runs before + // any git spawn. + // ------------------------------------------------------------------ + + test('endpoint blocks decimal-encoded loopback over ssh://', async () => { + const res = await postGitBranches({ url: 'ssh://2130706433/repo.git' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks hex-encoded loopback over ssh:// with user', async () => { + const res = await postGitBranches({ url: 'ssh://git@0x7f000001/repo.git' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks short-form loopback over git://', async () => { + const res = await postGitBranches({ url: 'git://127.1/repo.git' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks decimal-encoded loopback over scp-like', async () => { + const res = await postGitBranches({ url: 'git@2130706433:repo.git' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('git ls-remote timeout returns 500 with timeout message', async () => { + setGitResult('https://deadhost.example.com/repo.git', { code: 124, stdout: '', stderr: 'timed out', timedOut: true }); + const res = await postGitBranches({ url: 'https://deadhost.example.com/repo.git' }); + expect(res.status).toBe(500); + expect(res.json.error).toMatch(/timed out/i); + }); + + test('git ls-remote non-zero exit returns 500 with error', async () => { + setGitResult('https://github.com/test/repo.git', { code: 128, stdout: '', stderr: 'fatal: repository not found' }); + const res = await postGitBranches({ url: 'https://github.com/test/repo.git' }); + expect(res.status).toBe(500); + expect(res.json.error).toMatch(/repository not found/i); + }); +}); + +// ============================================================================= +// isSafeNotificationUrl — direct coverage of the shared SSRF guard +// ============================================================================= + +describe('isSafeNotificationUrl — shared SSRF guard', () => { + test('blocks decimal-encoded loopback', () => { + const r = isSafeNotificationUrl('http://2130706433/'); + expect(r.ok).toBe(false); + }); + + test('blocks octal-encoded loopback', () => { + const r = isSafeNotificationUrl('http://0177.0.0.1/'); + expect(r.ok).toBe(false); + }); + + test('blocks short-form loopback', () => { + const r = isSafeNotificationUrl('http://127.1/'); + expect(r.ok).toBe(false); + }); + + test('blocks hex-encoded loopback', () => { + const r = isSafeNotificationUrl('http://0x7f000001/'); + expect(r.ok).toBe(false); + }); + + test('blocks decimal-encoded cloud metadata', () => { + const r = isSafeNotificationUrl('http://2852039166/'); + expect(r.ok).toBe(false); + }); + + test('blocks v4-mapped IPv6 loopback (hex form after URL normalization)', () => { + const r = isSafeNotificationUrl('http://[::ffff:7f00:1]/'); + expect(r.ok).toBe(false); + }); + + test('allows ordinary public host', () => { + const r = isSafeNotificationUrl('https://github.com/'); + expect(r.ok).toBe(true); + }); +}); + +// ============================================================================= +// parseRepoHost — host extraction edge cases +// ============================================================================= + +describe('parseRepoHost — host extraction', () => { + test('parses https URL host', () => { + expect(parseRepoHost('https://github.com/test/repo.git')).toBe('github.com'); + }); + + test('parses ssh URL host', () => { + expect(parseRepoHost('ssh://git@github.com/test/repo.git')).toBe('github.com'); + }); + + test('parses scp-like URL host', () => { + expect(parseRepoHost('git@github.com:test/repo.git')).toBe('github.com'); + }); + + test('parses host with port', () => { + expect(parseRepoHost('https://github.com:443/test/repo.git')).toBe('github.com'); + }); + + test('parses IPv6 in brackets', () => { + expect(parseRepoHost('http://[fe80::1]/repo.git')).toBe('fe80::1'); + }); + + test('parses IPv6 in brackets with port', () => { + expect(parseRepoHost('ssh://[fe80::1]:22/repo.git')).toBe('fe80::1'); + }); + + test('lowercases the host', () => { + expect(parseRepoHost('https://GITHUB.com/test/repo.git')).toBe('github.com'); + }); + + test('returns null for no-host input', () => { + expect(parseRepoHost('nonsense-no-host')).toBeNull(); + }); + + test('returns null for unsupported scheme', () => { + expect(parseRepoHost('ftp://github.com/repo.git')).toBeNull(); + }); +}); diff --git a/tests/git-stack-branch.test.ts b/tests/git-stack-branch.test.ts new file mode 100644 index 0000000..9b55db6 --- /dev/null +++ b/tests/git-stack-branch.test.ts @@ -0,0 +1,134 @@ +/** + * Regression tests for per-stack branch selection (git_stacks.branch). + * + * The pure production helpers live in src/lib/git-stack-branch.ts (import- + * light: no native deps like better-sqlite3 / argon2), so these tests import + * and exercise the EXACT functions that production code uses: + * - resolveStackBranch -> src/lib/server/git.ts (deploy/sync) + * - effectiveStackBranch -> src/routes/stacks/+page.svelte & GitSourceBadge + * (the effective branch shown in the stacks UI) + * - normalizeStackBranchUpdate -> PUT /api/git/stacks/:id (+server.ts) + * No mirrored copies of the implementation exist here — if the production + * behaviour changes, these tests break. + */ + +import { describe, expect, test } from 'bun:test'; +import { + resolveStackBranch, + effectiveStackBranch, + normalizeStackBranchUpdate +} from '../src/lib/git-stack-branch'; + +describe('resolveStackBranch (production: src/lib/server/git.ts)', () => { + test('per-stack override wins over repository default', () => { + expect(resolveStackBranch({ branch: 'develop' }, { branch: 'main' })).toBe( + 'develop' + ); + }); + + test('null override inherits the repository default', () => { + expect(resolveStackBranch({ branch: null }, { branch: 'main' })).toBe('main'); + }); + + test('blank override inherits the repository default', () => { + expect(resolveStackBranch({ branch: '' }, { branch: 'main' })).toBe('main'); + }); + + test('whitespace-only override is treated as blank → repository default', () => { + expect(resolveStackBranch({ branch: ' ' }, { branch: 'main' })).toBe('main'); + }); + + test('whitespace around a real override is trimmed consistently', () => { + expect(resolveStackBranch({ branch: ' feature/x ' }, { branch: 'main' })).toBe( + 'feature/x' + ); + }); + + test('repository default is used when no override exists (no stack / undefined)', () => { + expect(resolveStackBranch(undefined, { branch: 'release/1.2' })).toBe('release/1.2'); + }); + + test('accepts arbitrary branch strings (not validated as git refs)', () => { + expect(resolveStackBranch({ branch: 'a/b-c_d' }, { branch: 'zzz' })).toBe('a/b-c_d'); + }); +}); + +describe( + 'effectiveStackBranch (production UI: +page.svelte viewGitStack & GitSourceBadge)', + () => { + test('a per-stack override is shown and flagged as per-stack', () => { + const r = effectiveStackBranch({ branch: 'develop' }, { branch: 'main' }); + expect(r.branch).toBe('develop'); + expect(r.perStack).toBe(true); + }); + + test('no override → repository default, perStack is null', () => { + const r = effectiveStackBranch({ branch: null }, { branch: 'main' }); + expect(r.branch).toBe('main'); + expect(r.perStack).toBeNull(); + }); + + test('blank / whitespace-only override is NOT a per-stack branch (falls back to repo default)', () => { + // Consistent with resolveStackBranch: a blank stored override is not a + // per-stack branch — it falls back to the repository default. + const r = effectiveStackBranch({ branch: ' ' }, { branch: 'main' }); + expect(r.branch).toBe('main'); + expect(r.perStack).toBeNull(); + }); + + test('no stack / no repo → undefined, perStack null', () => { + const r = effectiveStackBranch(undefined, undefined); + expect(r.branch).toBeUndefined(); + expect(r.perStack).toBeNull(); + }); + + test('stack present but no repository → shows the override', () => { + const r = effectiveStackBranch({ branch: 'hotfix' }, undefined); + expect(r.branch).toBe('hotfix'); + expect(r.perStack).toBe(true); + }); + }); + +describe('normalizeStackBranchUpdate (production: PUT /api/git/stacks/:id)', () => { + test('branch key absent → current override left untouched (no change)', () => { + const r = normalizeStackBranchUpdate('develop', { stackName: 'x' }); + expect(r.next).toBe('develop'); + expect(r.changed).toBe(false); + }); + + test('explicit null clears the override (inherit repository default)', () => { + const r = normalizeStackBranchUpdate('develop', { branch: null }); + expect(r.next).toBeNull(); + expect(r.changed).toBe(true); + }); + + test('null on a stack that already inherits is still an applied write', () => { + const r = normalizeStackBranchUpdate(null, { branch: null }); + expect(r.next).toBeNull(); + expect(r.changed).toBe(true); + }); + + test('non-blank string sets the per-stack override', () => { + const r = normalizeStackBranchUpdate(null, { branch: 'hotfix/42' }); + expect(r.next).toBe('hotfix/42'); + expect(r.changed).toBe(true); + }); + + test('surrounding whitespace is trimmed on a non-blank value', () => { + const r = normalizeStackBranchUpdate(null, { branch: ' hotfix/42 ' }); + expect(r.next).toBe('hotfix/42'); + expect(r.changed).toBe(true); + }); + + test('blank / whitespace-only branch is normalised to clear (inherit default)', () => { + const r = normalizeStackBranchUpdate('develop', { branch: ' ' }); + expect(r.next).toBeNull(); + expect(r.changed).toBe(true); + }); + + test('co-located fields (stackName etc.) do not touch the branch', () => { + const r = normalizeStackBranchUpdate(null, { composePath: 'docker-compose.prod.yml' }); + expect(r.next).toBeNull(); + expect(r.changed).toBe(false); + }); +}); diff --git a/tests/preview-env-security.test.ts b/tests/preview-env-security.test.ts new file mode 100644 index 0000000..87a7fb3 --- /dev/null +++ b/tests/preview-env-security.test.ts @@ -0,0 +1,428 @@ +/** + * Security tests for POST /api/git/preview-env (PR #1343 maintainer review). + * + * The preview endpoint clones a USER-SUPPLIED URL and reads env files from + * it — the same SSRF / RCE / path-traversal surface as the branches + * endpoint. This file locks the guards applied in previewRepoEnvFiles + * (git.ts) and the preview-env endpoint: + * + * 1. SSRF — assertSafeRepoTarget blocks the maintainer's encoded-IP + * payloads (decimal / octal / short-form / hex / metadata / v4-mapped + * IPv6) so the preview clone never reaches an internal host. + * 2. RCE — assertSafeRepoUrl rejects the ext::/file:: transport denylist + * before any git subprocess is spawned. + * 3. Path traversal — repoFilePath keeps composePath/envFilePath INSIDE the + * clone dir, so `envFilePath: '../../secrets/x'` cannot read a file + * outside the temp dir. + * 4. Credential affinity — assertCredentialHostMatch refuses to pair a raw + * url with a stored password credential whose username isn't the host + * (exfiltration defense). + * + * Mirror-test convention: the endpoint pipeline below mirrors the real + * preview-env handler's input-parsing behaviour (composePath/url validation, + * SSRF, RCE, credential-affinity) and its guard order. Keep in sync. + * + * 5. Permission gate (PR #1343 maintainer review) — the handler is gated on + * the git:edit permission (same model as /api/git/branches). That + * authorization contract is NOT covered by this mirror (a mirror can + * prove only its own copied logic) — it is covered against the ACTUAL + * exported POST handler in tests/preview-env-authorization.test.ts. + */ + +import { describe, expect, test } from 'bun:test'; +import { + assertSafeRepoTarget, + assertCredentialHostMatch +} from '../src/lib/server/git-branch-lookup'; +import { repoFilePath, assertSafeRepoUrl } from '../src/lib/server/git-url-safety'; +import { resolve, sep } from 'node:path'; + +// ============================================================================= +// SSRF guard — preview clone never reaches internal hosts. +// ============================================================================= + +describe('preview-env SSRF guard (assertSafeRepoTarget)', () => { + test('blocks decimal-encoded loopback', () => { + expect(() => assertSafeRepoTarget('http://2130706433/')).toThrow(); + }); + test('blocks octal-encoded loopback', () => { + expect(() => assertSafeRepoTarget('http://0177.0.0.1/')).toThrow(); + }); + test('blocks short-form loopback', () => { + expect(() => assertSafeRepoTarget('http://127.1/')).toThrow(); + }); + test('blocks hex-encoded loopback', () => { + expect(() => assertSafeRepoTarget('http://0x7f000001/')).toThrow(); + }); + test('blocks decimal-encoded cloud metadata', () => { + expect(() => assertSafeRepoTarget('http://2852039166/')).toThrow(); + }); + test('blocks v4-mapped IPv6 loopback', () => { + expect(() => assertSafeRepoTarget('http://[0:0:0:0:0:ffff:127.0.0.1]/')).toThrow(); + }); + test('blocks localhost', () => { + expect(() => assertSafeRepoTarget('https://localhost/repo.git')).toThrow(); + }); + test('allows public https', () => { + expect(() => assertSafeRepoTarget('https://github.com/repo.git')).not.toThrow(); + }); + test('allows public ssh://', () => { + expect(() => assertSafeRepoTarget('ssh://git@github.com/repo.git')).not.toThrow(); + }); +}); + +// ============================================================================= +// RCE guard — ext::/file:: transport denylist. +// ============================================================================= + +describe('preview-env RCE guard (assertSafeRepoUrl)', () => { + test('blocks ext:: transport', () => { + expect(() => assertSafeRepoUrl('ext::sh -c "evil"')).toThrow(); + }); + test('blocks fd:: transport', () => { + expect(() => assertSafeRepoUrl('fd::sh -c "evil"')).toThrow(); + }); + test('blocks file:// transport', () => { + expect(() => assertSafeRepoUrl('file:///tmp/repo.git')).toThrow(); + }); + test('blocks local path', () => { + expect(() => assertSafeRepoUrl('/tmp/repo.git')).toThrow(); + }); + test('allows https', () => { + expect(() => assertSafeRepoUrl('https://github.com/repo.git')).not.toThrow(); + }); +}); + +// ============================================================================= +// Path traversal guard — repoFilePath keeps paths inside the clone dir. +// ============================================================================= + +describe('preview-env path traversal guard (repoFilePath)', () => { + const cloneDir = '/home/user/git-repos/preview-123'; + + test('keeps a normal compose path inside the clone dir', () => { + const p = repoFilePath(cloneDir, 'compose.yaml', 'Compose path'); + expect(p).toBe(resolve(cloneDir, 'compose.yaml')); + }); + + test('keeps a nested env path inside the clone dir', () => { + const p = repoFilePath(cloneDir, 'subdir/.env', 'Env file path'); + expect(p).toBe(resolve(cloneDir, 'subdir/.env')); + }); + + test('rejects an env path that escapes the clone dir (.. traversal)', () => { + expect(() => repoFilePath(cloneDir, '../../secrets/credentials.env', 'Env file path')).toThrow(/inside the repository/i); + }); + + test('rejects a compose path that escapes the clone dir', () => { + expect(() => repoFilePath(cloneDir, '../../etc/passwd', 'Compose path')).toThrow(/inside the repository/i); + }); + + test('rejects a path with an internal .. that escapes the clone dir', () => { + // An internal .. (not just a leading /) still escapes — blocked. + expect(() => repoFilePath(cloneDir, 'compose/../../secrets/credentials.env', 'Env file path')).toThrow(/inside the repository/i); + }); + + test('a leading / is treated as repo-root-relative (still inside the clone)', () => { + // A leading / is stripped → repo-root-relative. This is the documented + // behaviour for in-repo paths; the containment check still blocks a + // REAL escape. + const p = repoFilePath(cloneDir, '/compose.yaml', 'Compose path'); + expect(p.startsWith(cloneDir + sep)).toBe(true); + }); +}); + +// ============================================================================= +// Credential affinity — raw url may only pair with a host-matching credential. +// ============================================================================= + +describe('preview-env credential affinity (assertCredentialHostMatch)', () => { + test('rejects a single-label username for an attacker subdomain', () => { + const c: any = { id: 1, name: 'github', authType: 'password', username: 'github', password: 'x' }; + expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i); + }); + test('allows a username that matches the full host', () => { + const c: any = { id: 2, name: 'gh', authType: 'password', username: 'github.com', password: 'x' }; + expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow(); + }); + test('allows ssh credential (no secret to leak)', () => { + const c: any = { id: 3, name: 'ssh', authType: 'ssh', username: null, password: null }; + expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow(); + }); +}); + +// ============================================================================= +// Endpoint pipeline (mirror of POST /api/git/preview-env). +// ============================================================================= + +type StoredRepo = { id: number; url: string; credentialId: number | null }; +type StoredCred = { id: number; name: string; authType: string; username: string | null }; +const storedRepos = new Map(); +const storedCreds = new Map(); + +/** + * Mirror of POST /api/git/preview-env (src/routes/api/git/preview-env/+server.ts). + * Keeps only the security-relevant guard order; the actual clone/env-read is + * stubbed (previewRepoEnvFiles is not imported here). + * + * `authEnabled` mirrors the handler's `authorize()`: + * - false -> the git:edit gate is skipped (existing allowed behaviour); + * - true -> requires `hasGitEdit` (the `auth.can('git','edit')` result); + * when absent it returns 403 BEFORE any repository input is + * parsed or any git subprocess is spawned. + */ +async function postPreviewEnv( + body: { repositoryId?: number; url?: string; branch?: string; credentialId?: number | null; composePath?: string }, + authEnabled = false, + hasGitEdit = false +): Promise<{ status: number; json: any }> { + // Auth/permission gate — mirrors the handler: 401 when authenticated + // context is absent, 403 when the user lacks git:edit. Both run BEFORE + // request.json() (attacker-controlled input) and before any git op. + if (authEnabled) { + if (!hasGitEdit) { + return { status: 403, json: { error: 'Permission denied' } }; + } + } + + const { repositoryId, url, credentialId, composePath } = body; + if (!composePath || typeof composePath !== 'string') { + return { status: 400, json: { error: 'Compose path is required' } }; + } + + let repoUrl: string; + let credentialIdResolved: number | null = null; + if (repositoryId) { + const repo = storedRepos.get(repositoryId); + if (!repo) return { status: 404, json: { error: 'Repository not found' } }; + repoUrl = repo.url; + credentialIdResolved = repo.credentialId; + } else if (url) { + repoUrl = url; + credentialIdResolved = credentialId || null; + } else { + return { status: 400, json: { error: 'Either repositoryId or url is required' } }; + } + + let credential = null; + if (credentialIdResolved) { + credential = storedCreds.get(credentialIdResolved); + } + + // Guard 1 (SSRF) — runs on both paths. + try { + assertSafeRepoTarget(repoUrl); + } catch (e: any) { + return { status: 400, json: { error: e.message } }; + } + + // Guard 2 (credential affinity) — raw url path only. + if (url && credentialIdResolved && credential) { + try { + assertCredentialHostMatch(repoUrl, credential as any); + } catch (e: any) { + return { status: 400, json: { error: e.message } }; + } + } + + // Stubbed: previewRepoEnvFiles (clone + read env files). + return { status: 200, json: { vars: {}, sources: {} } }; +} + +describe('POST /api/git/preview-env — pipeline', () => { + test('requires composePath (400)', async () => { + const res = await postPreviewEnv({ url: 'https://github.com/x.git' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/compose path/i); + }); + + test('requires repositoryId or url (400)', async () => { + const res = await postPreviewEnv({ composePath: 'compose.yaml' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/repositoryId or url/i); + }); + + test('unknown repositoryId returns 404', async () => { + const res = await postPreviewEnv({ repositoryId: 9999, composePath: 'compose.yaml' }); + expect(res.status).toBe(404); + }); + + test('endpoint blocks decimal-encoded loopback (SSRF)', async () => { + const res = await postPreviewEnv({ url: 'http://2130706433/', composePath: 'compose.yaml' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/not allowed|loopback|metadata/i); + }); + + test('endpoint blocks ext:: transport (RCE)', async () => { + const res = await postPreviewEnv({ url: 'ext::sh -c "evil"', composePath: 'compose.yaml' }); + expect(res.status).toBe(400); + }); + + test('endpoint blocks a credential whose username does not match the host', async () => { + const cred = storedCreds.set(1, { id: 1, name: 'github', authType: 'password', username: 'github' }); + const res = await postPreviewEnv({ + url: 'https://github.evil.com/x.git', + credentialId: 1, + composePath: 'compose.yaml' + }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/does not match/i); + }); + + test('endpoint allows a host-matching credential', async () => { + storedCreds.set(2, { id: 2, name: 'gh', authType: 'password', username: 'github.com' }); + const res = await postPreviewEnv({ + url: 'https://github.com/x.git', + credentialId: 2, + composePath: 'compose.yaml' + }); + expect(res.status).toBe(200); + }); + + test('repositoryId path is not subject to the credential guard', async () => { + storedCreds.set(3, { id: 3, name: 'gh', authType: 'password', username: 'github' }); + storedRepos.set(10, { id: 10, url: 'https://github.com/x.git', credentialId: 3 }); + const res = await postPreviewEnv({ repositoryId: 10, composePath: 'compose.yaml' }); + expect(res.status).toBe(200); + }); +}); + +// ============================================================================= +// Permission gate (git:edit) — tests the ACTUAL production handler core. +// ============================================================================= +// The preview-env route (src/routes/api/git/preview-env/+server.ts) is a thin +// wrapper that resolves the real dependencies and forwards to +// handlePreviewEnv() (src/lib/server/preview-env-handler.ts). These tests +// execute that EXACT production pipeline with injected fakes — no mirror and +// no mock.module(), so nothing leaks into other test files. The tested +// function IS the code path the POST route invokes; if the route's +// `auth.can('git','edit')` gate is removed, these tests break. +// +// Contract: +// - auth disabled -> gate skipped, continues (200 with a valid URL); +// - auth enabled + unauth -> can('git','edit')=false -> 403 'Permission +// denied' BEFORE request.json() is parsed; +// - auth enabled + read-only-> can('git','edit')=false -> 403; +// - auth enabled + git:edit -> gate satisfied, continues (200). +// ============================================================================= +import { handlePreviewEnv, type PreviewEnvDependencies } from '../src/lib/server/preview-env-handler'; + +function makeAuthDeps(cfg: { authEnabled: boolean; canGitEdit: boolean }): { deps: PreviewEnvDependencies; request: { json: () => Promise; _body: unknown }; requestJsonCalls: () => number; previewCalled: () => number } { + let requestJsonCalls = 0; + let previewCalls = 0; + const deps: PreviewEnvDependencies = { + authorize: async () => ({ + authEnabled: cfg.authEnabled, + isAuthenticated: cfg.canGitEdit, + user: null, + isAdmin: false, + isEnterprise: false, + can: async (resource: string, action: string) => + resource === 'git' && action === 'edit' ? cfg.canGitEdit : false + }), + getGitRepository: async () => null, + getGitCredential: async () => null, + previewRepoEnvFiles: async () => { + previewCalls++; + return { vars: {}, sources: {} }; + }, + assertSafeRepoTarget: () => {}, + assertCredentialHostMatch: () => {} + }; + // The request object whose json() we track (the body parser, NOT the + // response helper). If the permission gate runs BEFORE request.json(), + // this counter stays at 0 for the 403 path. + const request: { json: () => Promise; _body: unknown } = { + json: async () => { + requestJsonCalls++; + return request._body; + }, + _body: undefined + }; + return { deps, request, requestJsonCalls: () => requestJsonCalls, previewCalled: () => previewCalls }; +} + +async function runAuthTest( + cfg: { authEnabled: boolean; canGitEdit: boolean }, + body: unknown +): Promise<{ kind: string; requestJsonCalls: number; previewCalls: number; message?: string; vars?: Record }> { + const { deps, request, requestJsonCalls, previewCalled } = makeAuthDeps(cfg); + request._body = body; + const outcome = await handlePreviewEnv(deps, { + request, + cookies: {} + }); + return { + kind: outcome.kind, + requestJsonCalls: requestJsonCalls(), + previewCalls: previewCalled(), + message: 'message' in outcome ? (outcome as any).message : undefined, + vars: 'vars' in outcome ? (outcome as any).vars : undefined + }; +} + +describe('POST /api/git/preview-env — git:edit gate (REAL handler core, no mock.module)', () => { + // Test A — auth disabled: the gate is skipped; the request proceeds beyond + // authorization (composePath present, valid public URL -> 200). + test('auth disabled -> proceeds beyond authorization', async () => { + const r = await runAuthTest( + { authEnabled: false, canGitEdit: false }, + { url: 'https://github.com/x.git', composePath: 'compose.yaml' } + ); + expect(r.kind).toBe('success'); + // The handler parsed the body (request.json) and reached the (injected) + // env read — proof the gate was skipped and execution continued. + expect(r.requestJsonCalls).toBe(1); + expect(r.previewCalls).toBe(1); + }); + + // Test B — auth enabled, no git:edit: 403 "Permission denied" AND the + // handler must NOT have parsed the body or touched any git/db operation — + // the gate runs BEFORE attacker-controlled input is processed. + test('auth enabled without git:edit -> 403 before any processing', async () => { + const r = await runAuthTest( + { authEnabled: true, canGitEdit: false }, + { url: 'https://github.com/x.git', composePath: 'compose.yaml' } + ); + expect(r.kind).toBe('permission-denied'); + // The gate runs BEFORE request.json() (body parser) — proof that + // attacker-controlled input is NOT parsed before authorization. + expect(r.requestJsonCalls).toBe(0); + expect(r.previewCalls).toBe(0); + }); + + // Test B' — the gate fires even when the body is missing/malicious: a + // read-only user is rejected regardless of the (possibly attacker) input. + test('auth enabled without git:edit -> 403 even with a malformed body', async () => { + const r = await runAuthTest({ authEnabled: true, canGitEdit: false }, { url: 'https://github.com/x.git' }); + expect(r.kind).toBe('permission-denied'); + expect(r.requestJsonCalls).toBe(0); + }); + + // Test C — auth enabled, git:edit present: the gate is satisfied and the + // request continues (body parsed, env read reached -> 200). + test('auth enabled with git:edit -> continues processing', async () => { + const r = await runAuthTest( + { authEnabled: true, canGitEdit: true }, + { url: 'https://github.com/x.git', composePath: 'compose.yaml' } + ); + expect(r.kind).toBe('success'); + expect(r.requestJsonCalls).toBe(1); + expect(r.previewCalls).toBe(1); + }); + + // Test C' — the downstream SSRF guard STILL applies after the gate is + // satisfied (a permitted user cannot use a loopback URL). This uses the + // REAL assertSafeRepoTarget to prove the guard runs after the gate. + test('auth enabled with git:edit -> downstream SSRF guard still applies', async () => { + const { assertSafeRepoTarget } = await import('../src/lib/server/git-branch-lookup'); + const { deps } = makeAuthDeps({ authEnabled: true, canGitEdit: true }); + // Inject the REAL SSRF guard so the loopback URL is genuinely rejected. + (deps as any).assertSafeRepoTarget = assertSafeRepoTarget; + const out = await handlePreviewEnv(deps, { + request: { json: async () => ({ url: 'http://2130706433/', composePath: 'compose.yaml' }) }, + cookies: {} + }); + expect(out.kind).toBe('bad-request'); + }); +}); diff --git a/tests/repositories-test-security.test.ts b/tests/repositories-test-security.test.ts new file mode 100644 index 0000000..b301da3 --- /dev/null +++ b/tests/repositories-test-security.test.ts @@ -0,0 +1,162 @@ +/** + * Security tests for POST /api/git/repositories/test (PR #1343 maintainer + * review). + * + * The test-connection endpoint spawns git (ls-remote + clone) with a + * USER-SUPPLIED url + credentialId. This file locks the guards applied in + * testRepositoryConfig (git.ts) and the repositories/test endpoint: + * + * 1. SSRF — assertSafeRepoTarget blocks the maintainer's encoded-IP payloads. + * 2. RCE — assertSafeRepoUrl rejects the ext::/file:: transport denylist. + * 3. Credential affinity — assertCredentialHostMatch refuses to pair a raw + * url with a stored password credential whose username isn't the host. + * + * Mirror-test convention: the endpoint pipeline below mirrors the real + * repositories/test handler's guard order and error handling. Keep in sync. + */ + +import { describe, expect, test } from 'bun:test'; +import { + assertSafeRepoTarget, + assertCredentialHostMatch +} from '../src/lib/server/git-branch-lookup'; +import { assertSafeRepoUrl } from '../src/lib/server/git-url-safety'; + +// ============================================================================= +// SSRF guard — test-connection never reaches internal hosts. +// ============================================================================= + +describe('repositories/test SSRF guard (assertSafeRepoTarget)', () => { + test('blocks decimal-encoded loopback', () => { + expect(() => assertSafeRepoTarget('http://2130706433/')).toThrow(); + }); + test('blocks hex-encoded loopback', () => { + expect(() => assertSafeRepoTarget('http://0x7f000001/')).toThrow(); + }); + test('blocks decimal-encoded cloud metadata', () => { + expect(() => assertSafeRepoTarget('http://2852039166/')).toThrow(); + }); + test('blocks v4-mapped IPv6 loopback', () => { + expect(() => assertSafeRepoTarget('http://[0:0:0:0:0:ffff:127.0.0.1]/')).toThrow(); + }); + test('allows public https', () => { + expect(() => assertSafeRepoTarget('https://github.com/repo.git')).not.toThrow(); + }); +}); + +// ============================================================================= +// RCE guard — ext::/file:: transport denylist. +// ============================================================================= + +describe('repositories/test RCE guard (assertSafeRepoUrl)', () => { + test('blocks ext:: transport', () => { + expect(() => assertSafeRepoUrl('ext::sh -c "evil"')).toThrow(); + }); + test('blocks file:// transport', () => { + expect(() => assertSafeRepoUrl('file:///tmp/repo.git')).toThrow(); + }); + test('allows https', () => { + expect(() => assertSafeRepoUrl('https://github.com/repo.git')).not.toThrow(); + }); +}); + +// ============================================================================= +// Credential affinity. +// ============================================================================= + +describe('repositories/test credential affinity (assertCredentialHostMatch)', () => { + test('rejects a single-label username for an attacker subdomain', () => { + const c: any = { id: 1, name: 'github', authType: 'password', username: 'github', password: 'x' }; + expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i); + }); + test('allows a username that matches the full host', () => { + const c: any = { id: 2, name: 'gh', authType: 'password', username: 'github.com', password: 'x' }; + expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow(); + }); +}); + +// ============================================================================= +// Endpoint pipeline (mirror of POST /api/git/repositories/test). +// ============================================================================= + +type StoredCred = { id: number; name: string; authType: string; username: string | null }; +const storedCreds = new Map(); + +/** + * Mirror of POST /api/git/repositories/test + * (src/routes/api/git/repositories/test/+server.ts). Keeps only the + * security-relevant guard order; the actual git ls-remote/clone is stubbed. + */ +async function postRepositoriesTest( + body: { url?: string; branch?: string; credentialId?: number | null }, + permissionDenied = false +): Promise<{ status: number; json: any }> { + if (permissionDenied) return { status: 403, json: { error: 'Permission denied' } }; + + if (!body.url || typeof body.url !== 'string') { + return { status: 400, json: { error: 'Repository URL is required' } }; + } + + // Guard 1 (SSRF + transport denylist). + try { + assertSafeRepoTarget(body.url); + } catch (e: any) { + return { status: 400, json: { success: false, error: e.message } }; + } + + // Guard 2 (credential affinity). + if (body.credentialId != null && body.credentialId !== undefined) { + const credential = storedCreds.get(body.credentialId); + if (!credential) return { status: 404, json: { success: false, error: 'Credential not found' } }; + try { + assertCredentialHostMatch(body.url, credential as any); + } catch (e: any) { + return { status: 400, json: { success: false, error: e.message } }; + } + } + + // Stubbed: testRepositoryConfig (ls-remote + clone). + return { status: 200, json: { success: true } }; +} + +describe('POST /api/git/repositories/test — pipeline', () => { + test('requires url (400)', async () => { + const res = await postRepositoriesTest({}); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/repository url/i); + }); + + test('endpoint blocks decimal-encoded loopback (SSRF)', async () => { + const res = await postRepositoriesTest({ url: 'http://2130706433/' }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i); + }); + + test('endpoint blocks ext:: transport (RCE)', async () => { + const res = await postRepositoriesTest({ url: 'ext::sh -c "evil"' }); + expect(res.status).toBe(400); + }); + + test('endpoint blocks a credential whose username does not match the host', async () => { + storedCreds.set(1, { id: 1, name: 'github', authType: 'password', username: 'github' }); + const res = await postRepositoriesTest({ url: 'https://github.evil.com/x.git', credentialId: 1 }); + expect(res.status).toBe(400); + expect(res.json.error).toMatch(/does not match/i); + }); + + test('endpoint allows a host-matching credential', async () => { + storedCreds.set(2, { id: 2, name: 'gh', authType: 'password', username: 'github.com' }); + const res = await postRepositoriesTest({ url: 'https://github.com/x.git', credentialId: 2 }); + expect(res.status).toBe(200); + }); + + test('endpoint blocks an unknown credential (404)', async () => { + const res = await postRepositoriesTest({ url: 'https://github.com/x.git', credentialId: 9999 }); + expect(res.status).toBe(404); + }); + + test('endpoint allows a public URL with no credential', async () => { + const res = await postRepositoriesTest({ url: 'https://github.com/x.git' }); + expect(res.status).toBe(200); + }); +});