From 77686da02a9e9e4c62d1a494e8e3ab6161539014 Mon Sep 17 00:00:00 2001 From: Badal Singh Date: Mon, 31 Aug 2026 22:24:27 +0530 Subject: [PATCH] feat(stacks): optional STACKS_DIR layout with Hawser display paths (#1482) When STACKS_DIR is set, local managed stacks are stored in a flat $STACKS_DIR// layout instead of $DATA_DIR/stacks///. Existing stacks are not moved automatically and continue to resolve from their existing locations. Invalid or unwritable STACKS_DIR values produce a warning and fall back to $DATA_DIR/stacks instead of aborting startup. With this layout, local stack names must be unique across environments. Hawser agents report their remote stacks directory on hello. That path is persisted on the environment as hawser_stacks_dir so compose APIs and the UI can show host paths rather than Dockhand staging paths. Staging paths are remapped to and from the remote stack dir for compose files, backups, and environment settings. Backup restore may write under either the default staging root or the configured STACKS_DIR. Co-authored-by: xd003 --- drizzle-pg/0015_hawser_stacks_dir.sql | 1 + drizzle-pg/meta/0015_snapshot.json | 3587 ++++++++++++++++ drizzle-pg/meta/_journal.json | 9 +- drizzle/0015_hawser_stacks_dir.sql | 1 + drizzle/meta/0015_snapshot.json | 3754 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- src/hooks.server.ts | 2 + src/lib/openapi.generated.json | 12 +- src/lib/server/backups/index.ts | 14 +- src/lib/server/db/schema/index.ts | 1 + src/lib/server/db/schema/pg-schema.ts | 1 + src/lib/server/docker.ts | 61 +- src/lib/server/hawser.ts | 55 +- src/lib/server/path-utils.ts | 12 + src/lib/server/stack-path-utils.ts | 48 + src/lib/server/stacks-display-paths.ts | 72 + src/lib/server/stacks.ts | 495 ++- src/routes/api/environments/[id]/+server.ts | 32 +- .../api/stacks/[name]/compose/+server.ts | 55 +- src/routes/api/stacks/base-path/+server.ts | 16 +- src/routes/api/stacks/default-path/+server.ts | 26 +- src/routes/api/stacks/sources/+server.ts | 35 +- .../environments/EnvironmentModal.svelte | 23 +- .../environments/EnvironmentsTab.svelte | 12 +- static/openapi.json | 856 +++- tests/hawser-display-paths.test.ts | 95 + tests/stack-path-utils.test.ts | 51 + 27 files changed, 9145 insertions(+), 190 deletions(-) create mode 100644 drizzle-pg/0015_hawser_stacks_dir.sql create mode 100644 drizzle-pg/meta/0015_snapshot.json create mode 100644 drizzle/0015_hawser_stacks_dir.sql create mode 100644 drizzle/meta/0015_snapshot.json create mode 100644 src/lib/server/path-utils.ts create mode 100644 src/lib/server/stack-path-utils.ts create mode 100644 src/lib/server/stacks-display-paths.ts create mode 100644 tests/hawser-display-paths.test.ts create mode 100644 tests/stack-path-utils.test.ts diff --git a/drizzle-pg/0015_hawser_stacks_dir.sql b/drizzle-pg/0015_hawser_stacks_dir.sql new file mode 100644 index 0000000..d0b938d --- /dev/null +++ b/drizzle-pg/0015_hawser_stacks_dir.sql @@ -0,0 +1 @@ +ALTER TABLE "environments" ADD COLUMN "hawser_stacks_dir" text; diff --git a/drizzle-pg/meta/0015_snapshot.json b/drizzle-pg/meta/0015_snapshot.json new file mode 100644 index 0000000..c0ebd33 --- /dev/null +++ b/drizzle-pg/meta/0015_snapshot.json @@ -0,0 +1,3587 @@ +{ + "id": "3e5a7c91-2b4d-4f80-9a1c-6d8e0f2b4c5a", + "prevId": "ed69891b-926f-4272-a30f-fe32b68e5b43", + "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 + }, + "cacert": { + "name": "cacert", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tls_client_cert": { + "name": "tls_client_cert", + "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.container_icon_overrides": { + "name": "container_icon_overrides", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "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": { + "container_icon_overrides_environment_id_environments_id_fk": { + "name": "container_icon_overrides_environment_id_environments_id_fk", + "tableFrom": "container_icon_overrides", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "container_icon_overrides_container_name_environment_id_unique": { + "name": "container_icon_overrides_container_name_environment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "container_name", + "environment_id" + ] + } + }, + "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 + }, + "hawser_stacks_dir": { + "name": "hawser_stacks_dir", + "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 + }, + "repository_id": { + "name": "repository_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "icon": { + "name": "icon", + "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": {} + } +} diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index 47961cb..fe75597 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1787468505425, "tag": "0014_backup_tls_and_container_icons", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1787600000000, + "tag": "0015_hawser_stacks_dir", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/0015_hawser_stacks_dir.sql b/drizzle/0015_hawser_stacks_dir.sql new file mode 100644 index 0000000..65f7896 --- /dev/null +++ b/drizzle/0015_hawser_stacks_dir.sql @@ -0,0 +1 @@ +ALTER TABLE `environments` ADD `hawser_stacks_dir` text; diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..ed9f015 --- /dev/null +++ b/drizzle/meta/0015_snapshot.json @@ -0,0 +1,3754 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7c8e1a20-9b4d-4f3a-8c2e-1f0a6b5d4e3c", + "prevId": "86ccd5df-da4c-42d7-87ad-43cc3989286d", + "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 + }, + "cacert": { + "name": "cacert", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tls_client_cert": { + "name": "tls_client_cert", + "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": {} + }, + "container_icon_overrides": { + "name": "container_icon_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "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": { + "container_icon_overrides_container_name_environment_id_unique": { + "name": "container_icon_overrides_container_name_environment_id_unique", + "columns": [ + "container_name", + "environment_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "container_icon_overrides_environment_id_environments_id_fk": { + "name": "container_icon_overrides_environment_id_environments_id_fk", + "tableFrom": "container_icon_overrides", + "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 + }, + "hawser_stacks_dir": { + "name": "hawser_stacks_dir", + "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 + }, + "repository_id": { + "name": "repository_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "icon": { + "name": "icon", + "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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b9aa6df..19472ac 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1787468495122, "tag": "0014_backup_tls_and_container_icons", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1787600000000, + "tag": "0015_hawser_stacks_dir", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index d0b7fb5..d0ea8aa 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -12,6 +12,7 @@ import { initCryptoFallback } from '$lib/server/crypto-fallback'; import { detectHostDataDir } from '$lib/server/host-path'; import { listContainers, removeContainer } from '$lib/server/docker'; import { migrateCredentials } from '$lib/server/encryption'; +import { validateStacksDirAtStartup } from '$lib/server/stacks'; import { gzipSync } from 'node:zlib'; import { rmSync, readdirSync, existsSync } from 'fs'; import { join } from 'path'; @@ -144,6 +145,7 @@ if (!initialized) { setServerStartTime(); // Track when server started initDatabase(); + validateStacksDirAtStartup(); // Migrate plain text credentials to encrypted storage. // This also handles key rotation if ENCRYPTION_KEY env var differs from the diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index 98fe578..8d67400 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -26306,7 +26306,17 @@ "stacks" ], "summary": "Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default", - "parameters": [], + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID used to select the local or environment-scoped stacks root" + } + ], "responses": { "200": { "description": "Successful response", diff --git a/src/lib/server/backups/index.ts b/src/lib/server/backups/index.ts index 11bfe85..a30a25f 100644 --- a/src/lib/server/backups/index.ts +++ b/src/lib/server/backups/index.ts @@ -698,7 +698,7 @@ async function materialiseStackFiles(destination: any, snapId: string, stackName const { tmpdir } = await import('os'); const { join, dirname, resolve, sep } = await import('path'); const { randomUUID } = await import('crypto'); - const { getStacksDir } = await import('../stacks'); + const { getDefaultStacksDir, getLocalStacksDir, isStacksDirEnvSet } = await import('../stacks'); const { stackDirSource } = await import('./stackdir-plan'); // The stack dir is ALWAYS at /volumes/__dockhand_stackdir__ in the snapshot (local bind // and remote tar both write there), so restore reads ONE deterministic path via @@ -730,9 +730,15 @@ async function materialiseStackFiles(destination: any, snapId: string, stackName // Path-traversal guard: the resolved target MUST stay under the stacks root. const resolvedTarget = resolve(targetPath); - const stacksRoot = resolve(getStacksDir()); - if (resolvedTarget !== stacksRoot && !resolvedTarget.startsWith(stacksRoot + sep)) { - console.log(`[Backup] materialiseStackFiles: refusing target "${resolvedTarget}" outside the stacks root "${stacksRoot}"`); + const allowedRoots = [resolve(getDefaultStacksDir())]; + if (isStacksDirEnvSet()) { + allowedRoots.push(resolve(getLocalStacksDir())); + } + const underAllowedRoot = allowedRoots.some( + (root) => resolvedTarget === root || resolvedTarget.startsWith(root + sep) + ); + if (!underAllowedRoot) { + console.log(`[Backup] materialiseStackFiles: refusing target "${resolvedTarget}" outside allowed stacks roots (${allowedRoots.join(', ')})`); return false; } diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 19a2651..a4e401b 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -44,6 +44,7 @@ export const environments = sqliteTable('environments', { hawserAgentName: text('hawser_agent_name'), hawserVersion: text('hawser_version'), hawserCapabilities: text('hawser_capabilities'), // JSON array: ["compose", "exec", "metrics"] + hawserStacksDir: text('hawser_stacks_dir'), // Remote STACKS_DIR reported by agent createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) }); diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index cf54c5a..d61f85a 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -47,6 +47,7 @@ export const environments = pgTable('environments', { hawserAgentName: text('hawser_agent_name'), hawserVersion: text('hawser_version'), hawserCapabilities: text('hawser_capabilities'), // JSON array: ["compose", "exec", "metrics"] + hawserStacksDir: text('hawser_stacks_dir'), // Remote STACKS_DIR reported by agent createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() }); diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 833a81f..341fdac 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -26,6 +26,7 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r import { parseImageReference } from './registry/image-ref'; export { parseImageReference } from './registry/image-ref'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; +import { db, environments, eq } from './db/drizzle.js'; import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth'; import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact'; import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils'; @@ -4092,23 +4093,47 @@ export async function dockerPing(envId: number): Promise { } } -/** - * Get Hawser agent info (for hawser-standard mode) - * Returns agent info including uptime - */ -export async function getHawserInfo(envId: number): Promise<{ +export interface HawserAgentInfo { agentId: string; agentName: string; dockerVersion: string; hawserVersion: string; mode: string; uptime: number; -} | null> { + /** Hawser agent STACKS_DIR — remote host path where compose stacks are written */ + stacksDir?: string; +} + +/** + * Get Hawser agent info (for hawser-standard mode) + * Returns agent info including uptime + */ +export async function getHawserInfo(envId: number): Promise { + return getHawserInfoCached(envId); +} + +const HAWSER_INFO_TTL_MS = 60_000; +const HAWSER_INFO_NEGATIVE_TTL_MS = 3_000; +const hawserInfoCache = new Map(); + +/** + * Memoized wrapper around the /_hawser/info agent round-trip. Stacks listing + * resolves display paths per stack, so a single list request would otherwise + * hit the agent once per stack (N+1). + */ +async function getHawserInfoCached(envId: number): Promise { + const cached = hawserInfoCache.get(envId); + if (cached && cached.expiresAt > Date.now()) { + return cached.info; + } + + let info: HawserAgentInfo | null = null; for (let attempt = 0; attempt < 2; attempt++) { try { const response = await dockerFetch('/_hawser/info', {}, envId); if (response.ok) { - return await response.json(); + info = await response.json(); + break; } await drainResponse(response); console.warn(`[Hawser] Info endpoint returned ${response.status} for env ${envId}`); @@ -4117,7 +4142,27 @@ export async function getHawserInfo(envId: number): Promise<{ console.warn(`[Hawser] Failed to fetch info for env ${envId} (attempt ${attempt + 1}): ${msg}`); } } - return null; + + // Persist the remote stacks dir so display-path resolution survives agent + // restarts and doesn't depend on container runtime state (labels) or a + // live info round-trip per request. + if (info?.stacksDir) { + try { + await db + .update(environments) + .set({ hawserStacksDir: info.stacksDir }) + .where(eq(environments.id, envId)); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`[Hawser] Failed to persist stacksDir for env ${envId}: ${msg}`); + } + } + + hawserInfoCache.set(envId, { + info, + expiresAt: Date.now() + (info ? HAWSER_INFO_TTL_MS : HAWSER_INFO_NEGATIVE_TTL_MS) + }); + return info; } // Volume operations diff --git a/src/lib/server/hawser.ts b/src/lib/server/hawser.ts index bd49403..99aad79 100644 --- a/src/lib/server/hawser.ts +++ b/src/lib/server/hawser.ts @@ -42,6 +42,8 @@ export interface EdgeConnection { dockerVersion: string; hostname: string; capabilities: string[]; + /** Remote agent STACKS_DIR (from hello message) */ + stacksDir?: string; connectedAt: Date; lastHeartbeat: number; pendingRequests: Map; @@ -496,6 +498,7 @@ export function handleEdgeConnection( dockerVersion: hello.dockerVersion, hostname: hello.hostname, capabilities: hello.capabilities, + stacksDir: hello.stacksDir, connectedAt: new Date(), lastHeartbeat: Date.now(), pendingRequests: new Map(), @@ -522,32 +525,40 @@ export function handleEdgeConnection( } /** - * Update environment status in database + * Update environment status in database. Best-effort: called from connection + * setup, teardown and heartbeat-timeout paths where a DB failure must not + * take down the connection handling. */ async function updateEnvironmentStatus( environmentId: number, connection: EdgeConnection | null ): Promise { - if (connection) { - await db - .update(environments) - .set({ - hawserLastSeen: new Date().toISOString(), - hawserAgentId: connection.agentId, - hawserAgentName: connection.agentName, - hawserVersion: connection.agentVersion, - hawserCapabilities: JSON.stringify(connection.capabilities), - updatedAt: new Date().toISOString() - }) - .where(eq(environments.id, environmentId)); - } else { - await db - .update(environments) - .set({ - hawserLastSeen: new Date().toISOString(), - updatedAt: new Date().toISOString() - }) - .where(eq(environments.id, environmentId)); + try { + if (connection) { + await db + .update(environments) + .set({ + hawserLastSeen: new Date().toISOString(), + hawserAgentId: connection.agentId, + hawserAgentName: connection.agentName, + hawserVersion: connection.agentVersion, + hawserCapabilities: JSON.stringify(connection.capabilities), + hawserStacksDir: connection.stacksDir ?? null, + updatedAt: new Date().toISOString() + }) + .where(eq(environments.id, environmentId)); + } else { + await db + .update(environments) + .set({ + hawserLastSeen: new Date().toISOString(), + updatedAt: new Date().toISOString() + }) + .where(eq(environments.id, environmentId)); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`[Hawser] Failed to update environment status for env ${environmentId}: ${msg}`); } } @@ -930,6 +941,8 @@ export interface HelloMessage { dockerVersion: string; hostname: string; capabilities: string[]; + /** Remote agent STACKS_DIR — host path where compose stacks are written */ + stacksDir?: string; } export interface WelcomeMessage { diff --git a/src/lib/server/path-utils.ts b/src/lib/server/path-utils.ts new file mode 100644 index 0000000..22deb4a --- /dev/null +++ b/src/lib/server/path-utils.ts @@ -0,0 +1,12 @@ +import { resolve, sep as pathSep } from 'node:path'; + +/** + * True when `child` is `root` or a descendant. Uses `root + sep` so a sibling + * path that merely shares a string prefix (e.g. `/repos/myrepo2` vs `/repos/myrepo`) + * is rejected. + */ +export function isPathUnderRoot(childPath: string, rootPath: string): boolean { + const resolvedChild = resolve(childPath); + const resolvedRoot = resolve(rootPath); + return resolvedChild === resolvedRoot || resolvedChild.startsWith(resolvedRoot + pathSep); +} diff --git a/src/lib/server/stack-path-utils.ts b/src/lib/server/stack-path-utils.ts new file mode 100644 index 0000000..e0e4ac3 --- /dev/null +++ b/src/lib/server/stack-path-utils.ts @@ -0,0 +1,48 @@ +import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export function resolveStackDirForLayout( + defaultRoot: string, + localRoot: string, + stackName: string, + environmentName: string | undefined, + flatLocal: boolean +): string { + return join(flatLocal ? localRoot : defaultRoot, ...(!flatLocal && environmentName ? [environmentName] : []), stackName); +} + +export function findStackNameCollision( + sources: T[], + stackName: string, + environmentId?: number | null +): T | undefined { + return sources.find( + (source) => source.stackName === stackName && source.environmentId !== environmentId && source.environmentId != null + ); +} + +/** Move a file atomically when possible, with a copy+delete fallback across filesystems. */ +export function moveStackFilePathCrossDevice( + sourcePath: string, + destPath: string, + label: string, + rename: typeof renameSync = renameSync +): void { + try { + rename(sourcePath, destPath); + console.log(`[Stack] Moved ${label}: ${sourcePath} -> ${destPath}`); + } catch (renameError: any) { + if (renameError.code !== 'EXDEV') { + console.warn(`[Stack] Failed to move ${label}: ${renameError.message}`); + return; + } + + try { + writeFileSync(destPath, readFileSync(sourcePath)); + unlinkSync(sourcePath); + console.log(`[Stack] Copied ${label} (cross-fs): ${sourcePath} -> ${destPath}`); + } catch (error: any) { + console.warn(`[Stack] Failed to copy ${label}: ${error.message}`); + } + } +} diff --git a/src/lib/server/stacks-display-paths.ts b/src/lib/server/stacks-display-paths.ts new file mode 100644 index 0000000..e5cce08 --- /dev/null +++ b/src/lib/server/stacks-display-paths.ts @@ -0,0 +1,72 @@ +import { join, relative, resolve } from 'node:path'; +import { isPathUnderRoot } from './path-utils'; + +function remapPathBetweenStackDirs(fromDir: string, toDir: string, path: string): string { + const fromResolved = resolve(fromDir); + const toResolved = resolve(toDir); + const resolved = resolve(path); + if (isPathUnderRoot(resolved, fromResolved)) { + return join(toResolved, relative(fromResolved, resolved)); + } + return path; +} + +function remapPathsBetweenStackDirs(fromDir: string, toDir: string, paths: string[]): string[] { + return paths.map((p) => remapPathBetweenStackDirs(fromDir, toDir, p)); +} + +function remapComposeContentsBetweenStackDirs( + fromDir: string, + toDir: string, + contents: Record +): Record { + const remapped: Record = {}; + for (const [path, content] of Object.entries(contents)) { + remapped[remapPathBetweenStackDirs(fromDir, toDir, path)] = content; + } + return remapped; +} + +/** + * Remap absolute paths from Dockhand's Hawser staging dir to the remote host stack dir. + */ +export function remapPathsFromStagingToRemote( + stagingStackDir: string, + remoteStackDir: string, + paths: string[] +): string[] { + return remapPathsBetweenStackDirs(stagingStackDir, remoteStackDir, paths); +} + +/** + * Remap absolute paths from the Hawser remote stack dir back to Dockhand staging. + */ +export function remapPathsFromRemoteToStaging( + stagingStackDir: string, + remoteStackDir: string, + paths: string[] +): string[] { + return remapPathsBetweenStackDirs(remoteStackDir, stagingStackDir, paths); +} + +/** + * Remap composeContents keys from staging paths to remote display paths. + */ +export function remapComposeContentsFromStagingToRemote( + stagingStackDir: string, + remoteStackDir: string, + contents: Record +): Record { + return remapComposeContentsBetweenStackDirs(stagingStackDir, remoteStackDir, contents); +} + +/** + * Remap composeContents keys from remote display paths back to staging paths. + */ +export function remapComposeContentsFromRemoteToStaging( + stagingStackDir: string, + remoteStackDir: string, + contents: Record +): Record { + return remapComposeContentsBetweenStackDirs(remoteStackDir, stagingStackDir, contents); +} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index f6f4f23..9b5c3ed 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -5,8 +5,8 @@ * All lifecycle operations use docker compose commands. */ -import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs'; -import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path'; +import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync, accessSync, constants as fsConstants } from 'node:fs'; +import { join, resolve, dirname, basename, isAbsolute, normalize as pathNormalize, sep as pathSep } from 'node:path'; import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { @@ -18,7 +18,17 @@ import { type DeletionApplyResult, type DeletionSkipReason } from './git-deletions'; +import { + remapComposeContentsFromRemoteToStaging, + remapComposeContentsFromStagingToRemote, + remapPathsFromRemoteToStaging, + remapPathsFromStagingToRemote +} from './stacks-display-paths'; +import { isPathUnderRoot } from './path-utils'; +import { findStackNameCollision, moveStackFilePathCrossDevice, resolveStackDirForLayout } from './stack-path-utils'; +import { db, environments, eq } from './db/drizzle.js'; import { isAllowedStackFilename } from './stack-filename'; + import { deriveStackStatus } from './stack-status'; import { getEnvironment, @@ -27,11 +37,11 @@ import { getStackEnvVars, setStackEnvVars, getStackSource, + getStackSources, upsertStackSource, deleteStackSource, getGitStackByName, deleteGitStack, - getStackSources, deleteStackEnvVars, removePendingContainerUpdate, getPendingContainerUpdates, @@ -168,7 +178,8 @@ export class ComposeFileNotFoundError extends Error { // ============================================================================= // Cache stacks directory -let _stacksDir: string | null = null; +let _defaultStacksDir: string | null = null; +let _localStacksDir: string | null = null; // Per-stack locking mechanism to prevent race conditions during concurrent operations const stackLocks = new Map>(); @@ -352,37 +363,101 @@ function redactEnvVarsForLog(vars: Record): Record/ layout applies to socket/direct (and no-env) when STACKS_DIR is set. */ +export async function usesFlatLocalStacksDir(envId?: number | null): Promise { + if (!isStacksDirEnvSet()) return false; + if (envId === undefined || envId === null) return true; + const env = await getEnvironment(envId); + return isLocalConnection(env); +} + +/** Base path shown to the UI for stack file placement for the given environment. */ +export async function getStacksBasePathForEnv(envId?: number | null): Promise { + if (await usesFlatLocalStacksDir(envId)) { + return getLocalStacksDir(); } - return _stacksDir; + return getDefaultStacksDir(); +} + +/** True when dirPath is under the Hawser staging root ($DATA_DIR/stacks). */ +export function isManagedStagingDir(dirPath: string): boolean { + const resolved = resolve(dirPath); + const stagingRoot = resolve(getDefaultStacksDir()); + return resolved === stagingRoot || resolved.startsWith(stagingRoot + pathSep); +} + +/** True when dirPath is under either managed root (staging or flat local STACKS_DIR). */ +export function isManagedStackDir(dirPath: string): boolean { + const resolved = resolve(dirPath); + if (isManagedStagingDir(resolved)) return true; + if (isStacksDirEnvSet()) { + const localRoot = resolve(getLocalStacksDir()); + return resolved === localRoot || resolved.startsWith(localRoot + pathSep); + } + return false; } /** * Get stack directory path for a specific environment. - * New stacks use: $DATA_DIR/stacks/// - * Legacy stacks (no env): $DATA_DIR/stacks// - * - * Automatically looks up environment name from database. + * When STACKS_DIR is set for local envs: STACKS_DIR// (flat). + * Otherwise: $DATA_DIR/stacks/// (or legacy flat). */ export async function getStackDir(stackName: string, envId?: number | null): Promise { - const stacksDir = getStacksDir(); - if (envId) { - const env = await getEnvironment(envId); - if (env) { - return join(stacksDir, env.name, stackName); - } - } - // Legacy path for stacks without environment - return join(stacksDir, stackName); + const flatLocal = await usesFlatLocalStacksDir(envId); + const env = !flatLocal && envId ? await getEnvironment(envId) : undefined; + return resolveStackDirForLayout(getDefaultStacksDir(), getLocalStacksDir(), stackName, env?.name, flatLocal); } /** @@ -460,7 +535,36 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr } } - const stacksDir = getStacksDir(); + const flatLocal = await usesFlatLocalStacksDir(envId); + + if (flatLocal) { + const flatPath = join(getLocalStacksDir(), stackName); + if (existsSync(flatPath)) { + return flatPath; + } + // Safety net: pre-migration paths under $DATA_DIR/stacks + const defaultStacksDir = getDefaultStacksDir(); + if (envId) { + const env = await getEnvironment(envId); + if (env) { + const namePath = join(defaultStacksDir, env.name, stackName); + if (existsSync(namePath)) { + return namePath; + } + } + const idPath = join(defaultStacksDir, String(envId), stackName); + if (existsSync(idPath)) { + return idPath; + } + } + const legacyPath = join(defaultStacksDir, stackName); + if (existsSync(legacyPath)) { + return legacyPath; + } + return null; + } + + const stacksDir = getDefaultStacksDir(); // Look up environment name if we have an ID if (envId) { @@ -539,6 +643,252 @@ export async function countStackEnvVars(stackName: string, envId?: number | null } } +/** Fall back to the default layout when STACKS_DIR cannot safely be used. */ +export function validateStacksDirAtStartup(): void { + const raw = process.env.STACKS_DIR?.trim(); + if (!raw) return; + + const resolved = resolve(raw); + try { + if (!statSync(resolved).isDirectory()) throw new Error('not a directory'); + accessSync(resolved, fsConstants.W_OK); + } catch { + console.warn(`[StacksDir] STACKS_DIR="${raw}" (resolved: ${resolved}) is missing, not a directory, or not writable; falling back to $DATA_DIR/stacks.`); + delete process.env.STACKS_DIR; + return; + } + + console.log(`[StacksDir] Using STACKS_DIR=${resolved}`); +} + +type HawserEnvLike = { connectionType?: string | null; hawserStacksDir?: string | null }; + +async function resolveHawserStackDirPair( + stackName: string, + environmentId: number, + env?: HawserEnvLike | null, + hints?: { workingDir: string | null; configFiles: string[] | null } | null +): Promise<{ stagingStackDir: string; remoteStackDir: string } | null> { + const resolvedEnv = env ?? (await getEnvironment(environmentId)); + if (!isHawserConnection(resolvedEnv)) return null; + + const stagingStackDir = resolve( + (await findStackDir(stackName, environmentId)) ?? (await getStackDir(stackName, environmentId)) + ); + + let remoteStackDir: string | null = null; + const pathHints = hints ?? (await getStackPathHints(stackName, environmentId)); + if (pathHints.workingDir) { + remoteStackDir = pathHints.workingDir; + } else if (pathHints.configFiles?.length) { + remoteStackDir = dirname(pathHints.configFiles[0]); + } + + // DB-persisted remote stacksDir (survives agent restarts) + if (!remoteStackDir && resolvedEnv?.hawserStacksDir) { + remoteStackDir = join(resolvedEnv.hawserStacksDir, stackName); + } + + if (!remoteStackDir) { + const { getHawserInfo } = await import('./docker.js'); + const info = await getHawserInfo(environmentId); + if (info?.stacksDir) { + remoteStackDir = join(info.stacksDir, stackName); + try { + await db + .update(environments) + .set({ hawserStacksDir: info.stacksDir }) + .where(eq(environments.id, environmentId)); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn(`[StacksDir] Failed to persist stacksDir for env ${environmentId}: ${msg}`); + } + } + } + + if (!remoteStackDir) return null; + return { stagingStackDir, remoteStackDir: resolve(remoteStackDir) }; +} + +/** + * For Hawser environments, compose paths in the DB point at Dockhand's staging copy + * ($DATA_DIR/stacks///). Deployed files live on the remote host under the + * Hawser agent's STACKS_DIR//. + */ +export async function remapHawserStagingDisplayPaths( + stackName: string, + environmentId: number | null | undefined, + paths: { composePath: string | null; composePaths: string[] }, + env?: HawserEnvLike | null, + hints?: { workingDir: string | null; configFiles: string[] | null } | null +): Promise<{ composePath: string | null; composePaths: string[] }> { + if (!paths.composePath && paths.composePaths.length === 0) return paths; + if (environmentId == null) return paths; + + const pathList = + paths.composePaths.length > 0 + ? paths.composePaths + : paths.composePath + ? [paths.composePath] + : []; + + const pair = await resolveHawserStackDirPair(stackName, environmentId, env, hints); + if (!pair) return paths; + + const { stagingStackDir, remoteStackDir } = pair; + const usesStaging = pathList.some((p) => { + const resolved = resolve(p); + return isPathUnderRoot(resolved, stagingStackDir) || isManagedStagingDir(dirname(resolved)); + }); + if (!usesStaging) return paths; + + const remapped = remapPathsFromStagingToRemote(stagingStackDir, remoteStackDir, pathList); + return { + composePath: remapped[0] ?? null, + composePaths: remapped + }; +} + +/** + * Remap composeContents keys from staging paths to Hawser remote display paths. + */ +export async function remapHawserStagingDisplayComposeContents( + stackName: string, + environmentId: number | null | undefined, + composeContents: Record | null | undefined, + env?: HawserEnvLike | null, + hints?: { workingDir: string | null; configFiles: string[] | null } | null +): Promise | null | undefined> { + if (!composeContents || environmentId == null) return composeContents; + + const pair = await resolveHawserStackDirPair(stackName, environmentId, env, hints); + if (!pair) return composeContents; + + return remapComposeContentsFromStagingToRemote( + pair.stagingStackDir, + pair.remoteStackDir, + composeContents + ); +} + +/** + * Convert Hawser display paths from the UI back to Dockhand staging paths for disk writes. + */ +export async function unmapHawserDisplayComposeOptionsToStaging( + stackName: string, + environmentId: number | null | undefined, + options: { + composePath?: string; + composePaths?: string[] | null; + composeContents?: Record; + envPath?: string; + moveFromDir?: string; + oldComposePath?: string; + oldEnvPath?: string; + } +): Promise { + if (!options || environmentId == null) return options; + + const pair = await resolveHawserStackDirPair(stackName, environmentId); + if (!pair) return options; + + const { stagingStackDir, remoteStackDir } = pair; + const result = { ...options }; + + if (options.composePath) { + result.composePath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ + options.composePath + ])[0]; + } + if (options.composePaths) { + result.composePaths = remapPathsFromRemoteToStaging( + stagingStackDir, + remoteStackDir, + options.composePaths + ); + } + if (options.composeContents) { + result.composeContents = remapComposeContentsFromRemoteToStaging( + stagingStackDir, + remoteStackDir, + options.composeContents + ); + } + if (options.envPath) { + result.envPath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ + options.envPath + ])[0]; + } + if (options.oldComposePath) { + result.oldComposePath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ + options.oldComposePath + ])[0]; + } + if (options.oldEnvPath) { + result.oldEnvPath = remapPathsFromRemoteToStaging(stagingStackDir, remoteStackDir, [ + options.oldEnvPath + ])[0]; + } + + return result; +} + +/** + * Resolve stack source compose paths for UI display, including Hawser remote remapping. + */ +export async function resolveStackSourceDisplayPathsForEnv( + source: { + stackName: string; + environmentId?: number | null; + sourceType: string; + composePath?: string | null; + gitStack?: { contextDir?: string | null; composePath?: string } | null; + }, + env?: HawserEnvLike | null, + hints?: { workingDir: string | null; configFiles: string[] | null } | null +): Promise<{ composePath: string | null; composePaths: string[] }> { + const base = resolveStackSourceDisplayPaths(source); + return remapHawserStagingDisplayPaths(source.stackName, source.environmentId, base, env, hints); +} + +/** + * Build a map of compose project name -> path hints from a pre-fetched container + * list, so a stacks listing doesn't trigger one container listing per stack. + */ +export function buildStackPathHintsMap( + containers: any[] +): Map { + const map = new Map(); + for (const container of containers) { + const labels = container.labels || {}; + const project = labels['com.docker.compose.project']; + if (!project || map.has(project)) continue; + const workingDir = labels['com.docker.compose.project.working_dir'] || null; + const configFilesRaw = labels['com.docker.compose.project.config_files'] || null; + const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null; + map.set(project, { workingDir, configFiles }); + } + return map; +} + +/** + * Resolve stack source compose paths to absolute on-disk paths for UI display. + * Git stacks store repo-relative paths in the DB; external/adopted stacks use absolute paths. + */ +export function resolveStackSourceDisplayPaths( + source: { + sourceType: string; + composePath?: string | null; + gitStack?: { contextDir?: string | null; composePath?: string } | null; + } +): { composePath: string | null; composePaths: string[] } { + if (!source.composePath) { + return { composePath: null, composePaths: [] }; + } + const path = isAbsolute(source.composePath) ? source.composePath : resolve(source.composePath); + return { composePath: path, composePaths: [path] }; +} + // ============================================================================= // COMPOSE FILE MANAGEMENT // ============================================================================= @@ -728,25 +1078,7 @@ export async function saveStackComposeFile( } } - // Move/rename the compose file to new location - try { - renameSync(options.oldComposePath, options.composePath); - console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`); - } catch (renameErr: any) { - // If rename fails (e.g., cross-filesystem), try copy+delete - if (renameErr.code === 'EXDEV') { - try { - const data = readFileSync(options.oldComposePath); - writeFileSync(options.composePath, data); - unlinkSync(options.oldComposePath); - console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`); - } catch (err: any) { - console.warn(`[Stack] Failed to copy compose file: ${err.message}`); - } - } else { - console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`); - } - } + moveStackFilePathCrossDevice(options.oldComposePath, options.composePath, 'compose file'); } // Handle env file move/rename when path changes @@ -764,25 +1096,7 @@ export async function saveStackComposeFile( } } - // Move/rename the env file to new location - try { - renameSync(options.oldEnvPath, options.envPath); - console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`); - } catch (renameErr: any) { - // If rename fails (e.g., cross-filesystem), try copy+delete - if (renameErr.code === 'EXDEV') { - try { - const data = readFileSync(options.oldEnvPath); - writeFileSync(options.envPath, data); - unlinkSync(options.oldEnvPath); - console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`); - } catch (err: any) { - console.warn(`[Stack] Failed to copy env file: ${err.message}`); - } - } else { - console.warn(`[Stack] Failed to move env file: ${renameErr.message}`); - } - } + moveStackFilePathCrossDevice(options.oldEnvPath, options.envPath, 'env file'); } // Move all files from old directory to new directory when path changes @@ -884,6 +1198,12 @@ export async function saveStackComposeFile( // For creates, use new path; for updates, find existing path first let stackDir: string; if (create) { + if (await usesFlatLocalStacksDir(envId)) { + const collisionError = await checkFlatLocalStackNameCollision(name, envId); + if (collisionError) { + return { success: false, error: collisionError }; + } + } stackDir = await getStackDir(name, envId); } else { const existingDir = await findStackDir(name, envId); @@ -921,6 +1241,23 @@ export async function saveStackComposeFile( } } +async function checkFlatLocalStackNameCollision(stackName: string, envId?: number | null): Promise { + const allSources = await getStackSources(); + const conflict = findStackNameCollision(allSources, stackName, envId); + if (conflict) { + const conflictEnv = conflict.environmentId ? await getEnvironment(conflict.environmentId) : undefined; + return `Stack name "${stackName}" is already used by environment "${conflictEnv?.name ?? conflict.environmentId}". With STACKS_DIR set, local stack names must be unique across environments.`; + } + const flatDir = join(getLocalStacksDir(), stackName); + if (existsSync(flatDir)) { + const existing = await getStackSource(stackName, envId); + if (!existing) { + return `Stack directory "${flatDir}" already exists. With STACKS_DIR set, local stack names must be unique across environments.`; + } + } + return null; +} + // ============================================================================= // REGISTRY AUTHENTICATION // ============================================================================= @@ -2515,7 +2852,6 @@ export async function computeStackDeletionPaths( envId?: number | null ): Promise<{ stackDir: string | null; gitDir: string | null; sourceType: string | null; namedVolumes: string[] }> { const stackSource = await getStackSource(stackName, envId); - const stacksDir = getStacksDir(); // Named volumes compose created for this stack — exactly what `down --volumes` would // remove (compose-managed, labeled with the project). Best-effort: a docker/API hiccup @@ -2534,7 +2870,8 @@ export async function computeStackDeletionPaths( if (stackSource?.composePath) { const customDir = dirname(stackSource.composePath); // SAME strict guard as removeStack (#675): strict subdir + basename match. - if (isDeletableStackDir(customDir, stacksDir, stackName) && existsSync(customDir)) { + const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])]; + if (deletableRoots.some((root) => isDeletableStackDir(customDir, root, stackName)) && existsSync(customDir)) { stackDir = customDir; } } @@ -2591,7 +2928,7 @@ export async function removeStack( let removalFiles: FileToDelete[] | undefined; if (composeResult.stackDir) { const resolvedStaging = resolve(composeResult.stackDir); - if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) { + if (isManagedStackDir(resolvedStaging)) { removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map( ([path, hash]) => ({ path, hash }) ); @@ -2700,16 +3037,14 @@ export async function removeStack( // Only delete files that are within Dockhand's data directory (stacks we created) // Adopted/imported stacks have files outside DATA_DIR and should be preserved const stackSource = await getStackSource(stackName, envId); - const stacksDir = getStacksDir(); // Determine what directory to delete (if any) let stackDir: string | null = null; if (stackSource?.composePath) { - // Only delete if the compose dir is a STRICT SUBDIR of DATA_DIR/stacks/ whose - // basename matches the stack name (#675 guard; see stack-delete-guard.ts). const customDir = dirname(stackSource.composePath); - if (isDeletableStackDir(customDir, stacksDir, stackName) && existsSync(customDir)) { + const deletableRoots = [getDefaultStacksDir(), ...(isStacksDirEnvSet() ? [getLocalStacksDir()] : [])]; + if (deletableRoots.some((root) => isDeletableStackDir(customDir, root, stackName)) && existsSync(customDir)) { stackDir = customDir; } } @@ -2900,7 +3235,14 @@ export async function deployStack(options: DeployStackOptions): Promise { // regardless of where they ultimately deploy — so the rename matters // locally for every env type. if (isRename) { - const stacksDir = getStacksDir(); + const stacksDir = getDefaultStacksDir(); const gitReposDir = getGitReposDir(); const oldStacks = join(stacksDir, oldEnv.name); const newStacks = join(stacksDir, data.name); @@ -317,7 +317,7 @@ export const DELETE: RequestHandler = async (event) => { // Clean up stack directory for this environment // Safety: only delete subdirectory named after the env, never the parent try { - const stacksDir = getStacksDir(); + const stacksDir = getDefaultStacksDir(); const envStackDir = join(stacksDir, env.name); if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) { rmSync(envStackDir, { recursive: true, force: true }); @@ -326,6 +326,28 @@ export const DELETE: RequestHandler = async (event) => { console.error(`Failed to clean up stack directory for environment "${env.name}":`, err); } + // Flat STACKS_DIR layout: remove per-stack managed dirs for this local environment + if (isStacksDirEnvSet() && isLocalConnection(env)) { + try { + const localStacksDir = getLocalStacksDir(); + const resolvedLocalRoot = resolve(localStacksDir); + const stackSources = await getStackSources(id); + for (const source of stackSources) { + const stackDir = join(localStacksDir, source.stackName); + const resolvedStackDir = resolve(stackDir); + if ( + resolvedStackDir.startsWith(resolvedLocalRoot + '/') && + basename(resolvedStackDir) === source.stackName && + existsSync(stackDir) + ) { + rmSync(stackDir, { recursive: true, force: true }); + } + } + } catch (err) { + console.error(`Failed to clean up flat STACKS_DIR stacks for environment "${env.name}":`, err); + } + } + // Clean up git-repos directory for this environment try { const gitReposDir = getGitReposDir(); diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index 8fe2689..8a2effa 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -1,9 +1,23 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks'; +import { dirname } from 'node:path'; +import { getStackComposeFile, deployStack, saveStackComposeFile, remapHawserStagingDisplayPaths, unmapHawserDisplayComposeOptionsToStaging } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { createJobResponse } from '$lib/server/sse'; +async function remapDisplayPath( + name: string, + envId: number | undefined, + path: string | null | undefined +): Promise { + if (!path) return path; + const remapped = await remapHawserStagingDisplayPaths(name, envId, { + composePath: path, + composePaths: [] + }); + return remapped.composePath ?? path; +} + // GET /api/stacks/[name]/compose - Get compose file content /** * @openapi @@ -32,17 +46,26 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return json({ error: result.error, needsFileLocation: result.needsFileLocation || false, - composePath: result.composePath, - envPath: result.envPath + composePath: await remapDisplayPath(name, envIdNum, result.composePath), + envPath: await remapDisplayPath(name, envIdNum, result.envPath) }, { status: 404 }); } + const displayPaths = await remapHawserStagingDisplayPaths(name, envIdNum, { + composePath: result.composePath ?? null, + composePaths: [] + }); + let displayStackDir = result.stackDir; + if (displayPaths.composePath) { + displayStackDir = dirname(displayPaths.composePath); + } + return json({ content: result.content, - stackDir: result.stackDir, - composePath: result.composePath, - envPath: result.envPath, - suggestedEnvPath: result.suggestedEnvPath + stackDir: displayStackDir, + composePath: displayPaths.composePath, + envPath: await remapDisplayPath(name, envIdNum, result.envPath), + suggestedEnvPath: await remapDisplayPath(name, envIdNum, result.suggestedEnvPath) }); } catch (error: any) { console.error(`Error getting compose file for stack ${name}:`, error); @@ -101,17 +124,25 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => return json({ error: 'Permission denied: binding a secret provider requires the secrets permission' }, { status: 403 }); } - // Build options object for custom paths, move operation, file renames, and secret provider binding - const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath || secretProviderId !== undefined) - ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId } - : undefined; + // Convert Hawser display paths from the UI back to Dockhand staging paths + // before writing. secretProviderId is not a path and is merged after unmap. + let pathOptions: Parameters[2] | undefined = + (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath) + ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } + : undefined; + if (pathOptions) { + pathOptions = await unmapHawserDisplayComposeOptionsToStaging(name, envIdNum, pathOptions); + } // Persist the submitted content on EVERY accepted PUT, whether or not path fields came // along. Gating this on pathOptions left Dockhand's stored copy stale while restart:true // deployed the new content - a later GET then served the old copy, silently reverting the // change on the next read/edit/deploy round-trip (#1383). saveStackComposeFile handles a // possibly-undefined pathOptions fine (the non-restart branch already relied on that). - const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); + const saveResult = await saveStackComposeFile(name, content, false, envIdNum, { + ...pathOptions, + ...(secretProviderId !== undefined ? { secretProviderId } : {}) + }); if (!saveResult.success) { return json({ error: saveResult.error }, { status: 500 }); } diff --git a/src/routes/api/stacks/base-path/+server.ts b/src/routes/api/stacks/base-path/+server.ts index cbae22c..2fde237 100644 --- a/src/routes/api/stacks/base-path/+server.ts +++ b/src/routes/api/stacks/base-path/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { getStacksDir } from '$lib/server/stacks'; +import { getStacksBasePathForEnv } from '$lib/server/stacks'; import type { RequestHandler } from './$types'; /** @@ -7,10 +7,20 @@ import type { RequestHandler } from './$types'; * * @openapi * summary: Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default + * query: env:integer Environment ID used to select the local or environment-scoped stacks root * resp-200: {basePath:string!} * resp-200-example: {"basePath":"/data/stacks"} + * + * Returns the Dockhand stacks root for the requested environment context. + * Query params: + * - env: Environment ID (optional) — when set, returns STACKS_DIR for local envs + * with STACKS_DIR configured, otherwise $DATA_DIR/stacks (staging / legacy). */ -export const GET: RequestHandler = async () => { - const basePath = getStacksDir(); +export const GET: RequestHandler = async ({ url }) => { + const envParam = url.searchParams.get('env'); + const envIdNum = envParam ? parseInt(envParam) : undefined; + const basePath = await getStacksBasePathForEnv( + envIdNum !== undefined && !Number.isNaN(envIdNum) ? envIdNum : undefined + ); return json({ basePath }); }; diff --git a/src/routes/api/stacks/default-path/+server.ts b/src/routes/api/stacks/default-path/+server.ts index 4fd6fac..1066820 100644 --- a/src/routes/api/stacks/default-path/+server.ts +++ b/src/routes/api/stacks/default-path/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import { join } from 'path'; -import { getStackDir } from '$lib/server/stacks'; +import { getStackDir, isHawserConnection } from '$lib/server/stacks'; import { getEnvironment } from '$lib/server/db'; import type { RequestHandler } from './$types'; @@ -17,6 +17,15 @@ import type { RequestHandler } from './$types'; * resp-200: {stackDir:string!, composePath:string!, envPath:string!, source:string!} * resp-200-example: {"stackDir":"/data/stacks/prod/web","composePath":"/data/stacks/prod/web/compose.yaml","envPath":"/data/stacks/prod/web/.env","source":"default"} * resp-400: Stack name is required + * + * Query params: + * - name: Stack name (required) + * - env: Environment ID (optional) + * - location: Custom base location path (optional) + * + * If location is provided, path will be: {location}/{envName}/{stackName}/ + * Otherwise uses Dockhand's default via getStackDir (flat STACKS_DIR// + * for local envs when STACKS_DIR is set, else $DATA_DIR/stacks///). */ export const GET: RequestHandler = async ({ url }) => { const stackName = url.searchParams.get('name'); @@ -29,6 +38,7 @@ export const GET: RequestHandler = async ({ url }) => { } let stackDir: string; + let source = 'default'; if (location) { // Custom location: {location}/{envName}/{stackName}/ @@ -44,13 +54,23 @@ export const GET: RequestHandler = async ({ url }) => { } } else { // Dockhand default location - stackDir = await getStackDir(stackName, envIdNum); + if (envIdNum) { + const env = await getEnvironment(envIdNum); + if (env && isHawserConnection(env) && env.hawserStacksDir) { + stackDir = join(env.hawserStacksDir, stackName); + source = 'hawser'; + } else { + stackDir = await getStackDir(stackName, envIdNum); + } + } else { + stackDir = await getStackDir(stackName, envIdNum); + } } return json({ stackDir, composePath: `${stackDir}/compose.yaml`, envPath: `${stackDir}/.env`, - source: 'default' + source }); }; diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts index 7f5bebc..17ae94a 100644 --- a/src/routes/api/stacks/sources/+server.ts +++ b/src/routes/api/stacks/sources/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getStackSources } from '$lib/server/db'; -import { countStackEnvVars } from '$lib/server/stacks'; +import { getStackSources, getEnvironment } from '$lib/server/db'; +import { countStackEnvVars, resolveStackSourceDisplayPathsForEnv, buildStackPathHintsMap } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; +import { listContainers } from '$lib/server/docker'; /** * @openapi @@ -25,7 +26,19 @@ export const GET: RequestHandler = async ({ url, cookies }) => { try { const sources = await getStackSources(envIdNum); - // Convert to a map for easier lookup in the frontend + const envIds = [...new Set(sources.map((s) => s.environmentId).filter((id): id is number => id != null))]; + const envs = await Promise.all(envIds.map((id) => getEnvironment(id))); + const envMap = new Map(envs.filter((e) => e !== undefined).map((e) => [e.id, e])); + + const hintEnvIds = [...new Set(sources.map((s) => s.environmentId ?? null))]; + const hintMaps = await Promise.all( + hintEnvIds.map(async (id) => { + const containers = await listContainers(true, id).catch(() => []); + return { id, map: buildStackPathHintsMap(containers) }; + }) + ); + const hintsByEnv = new Map(hintMaps.map((h) => [String(h.id ?? 'null'), h.map])); + const sourceMap: Record< string, { @@ -37,11 +50,6 @@ export const GET: RequestHandler = async ({ url, cookies }) => { envVarCount?: number; } > = {}; - // Count env vars server-side (one local read per stack) so the list badge does - // not need a /env fetch per stack. GET /env resolves its env param as - // `envId ? parseInt : null`, so pass the SAME null-when-absent value (not - // undefined - getStackEnvVars scopes differently for null vs undefined) so the - // count matches that endpoint's variable list exactly. const countEnvId = envIdNum ?? null; const counts = await Promise.all( sources.map((s) => @@ -50,16 +58,21 @@ export const GET: RequestHandler = async ({ url, cookies }) => { : Promise.resolve(0) ) ); - sources.forEach((source, i) => { + for (const [i, source] of sources.entries()) { + const resolved = await resolveStackSourceDisplayPathsForEnv( + source, + source.environmentId != null ? envMap.get(source.environmentId) ?? null : null, + hintsByEnv.get(String(source.environmentId ?? 'null'))?.get(source.stackName) ?? null + ); sourceMap[source.stackName] = { sourceType: source.sourceType, - composePath: source.composePath, + composePath: resolved.composePath, repository: source.repository, secretProviderId: source.secretProviderId, icon: source.icon ?? null, envVarCount: counts[i], }; - }); + } return json(sourceMap); } catch (error) { diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 244d998..e31e046 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -961,9 +961,9 @@ // - socket/direct: the deploy staging dir AND the in-app editor source // (containers' compose project labels point here, so redeploy is // required after rename or next restart fails) - // - hawser-standard/edge: the in-app editor source for ALL stacks - // PLUS the git-repos clones for git stacks (the agent's deployed - // dir lives on the remote host and is not affected) + // - hawser-standard/edge: the in-app editor staging source for stacks + // (the agent's deployed dir lives on the remote host and is not affected) + // Git repository clones are shared per-repo under git-repos//. // → fire the warning whenever the name changes. const newName = formName.trim(); const willRenameOnDisk = newName !== environment.name; @@ -3143,8 +3143,11 @@ - + @@ -3153,17 +3156,13 @@ Rename environment? -

The following directories will be moved on the Dockhand host:

+

The following staging directories will be moved on the Dockhand host (local deployed stacks under STACKS_DIR, when configured, are not renamed):

$DATA_DIR/stacks/{renameConfirmFrom}/ $DATA_DIR/stacks/{renameConfirmTo}/ -
-
- $DATA_DIR/git-repos/{renameConfirmFrom}/ - - $DATA_DIR/git-repos/{renameConfirmTo}/ + (staging)
{#if renameCountsUnknown} @@ -3213,7 +3212,7 @@

Their containers run on the Hawser agent host, where the deploy directory doesn't include the env name — those keep working without - a redeploy. Only the local editor source and git clone caches move. + a redeploy. Only the local editor staging directory is renamed.

{/if}
diff --git a/src/routes/settings/environments/EnvironmentsTab.svelte b/src/routes/settings/environments/EnvironmentsTab.svelte index 20d72a0..5694351 100644 --- a/src/routes/settings/environments/EnvironmentsTab.svelte +++ b/src/routes/settings/environments/EnvironmentsTab.svelte @@ -706,18 +706,20 @@

The environment {deleteEnvTarget.name} - and the following directories will be permanently removed from the Dockhand host: + and the following directories will be permanently removed from the Dockhand host + (managed local stacks under STACKS_DIR, when configured, are removed per stack name):

$DATA_DIR/stacks/{deleteEnvTarget.name}/ -
-
- - $DATA_DIR/git-repos/{deleteEnvTarget.name}/ + (staging)
+

+ Shared git repository clones under $DATA_DIR/git-repos/<repoName>/ + are not removed when an environment is deleted. +

{#if deleteCountsLoading}

diff --git a/static/openapi.json b/static/openapi.json index f190bf6..8d67400 100644 --- a/static/openapi.json +++ b/static/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Dockhand API", - "version": "1.0.43", + "version": "1.0.45", "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": [ @@ -35,6 +35,9 @@ { "name": "config-sets" }, + { + "name": "container-icons" + }, { "name": "containers" }, @@ -68,6 +71,9 @@ { "name": "host" }, + { + "name": "icons" + }, { "name": "images" }, @@ -2531,6 +2537,7 @@ } }, "security": [], + "description": "sessionTimeout is in seconds (clamped 3600..604800); 0 disables expiry so sessions never time out.", "requestBody": { "required": true, "content": { @@ -2543,12 +2550,16 @@ }, "defaultProvider": { "type": "string" + }, + "sessionTimeout": { + "type": "integer" } } }, "example": { "authEnabled": true, - "defaultProvider": "local" + "defaultProvider": "local", + "sessionTimeout": 86400 } } } @@ -3595,6 +3606,12 @@ "hostPath": { "type": "string" }, + "cacert": { + "type": "string" + }, + "tlsClientCert": { + "type": "string" + }, "policies": { "type": "string" } @@ -3727,6 +3744,12 @@ "hostPath": { "type": "string" }, + "cacert": { + "type": "string" + }, + "tlsClientCert": { + "type": "string" + }, "policies": { "type": "string" } @@ -4103,6 +4126,12 @@ "envVars": { "type": "object", "properties": {} + }, + "cacert": { + "type": "string" + }, + "tlsClientCert": { + "type": "string" } } }, @@ -5977,6 +6006,244 @@ ] } }, + "/api/container-icons": { + "get": { + "operationId": "get_api_container-icons", + "tags": [ + "container-icons" + ], + "summary": "List all container icon overrides for an environment as a name -> icon map", + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id" + } + ], + "responses": { + "200": { + "description": "A JSON object mapping container name to its icon value (lucide name, `selfhst:`, or `custom:container`)" + }, + "403": { + "description": "Permission denied (needs containers:view)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns every container that has a user-set icon override in the given environment, so the containers list can render them in one request instead of one lookup per row." + } + }, + "/api/container-icons/{name}": { + "get": { + "operationId": "get_api_container-icons_name", + "tags": [ + "container-icons" + ], + "summary": "Get a container's uploaded custom icon (raw image/webp bytes, not JSON)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the container belongs to" + } + ], + "responses": { + "200": { + "description": "Binary image/webp response body, Cache-Control public max-age=3600" + }, + "403": { + "description": "Permission denied (needs containers:view)" + }, + "404": { + "description": "No custom icon set for this container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_container-icons_name", + "tags": [ + "container-icons" + ], + "summary": "Set a container's icon override - either a name reference or an uploaded image", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the container belongs to" + } + ], + "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": "selfhst:plex" + } + } + } + }, + "400": { + "description": "Neither icon nor image supplied, or image exceeds the size limit" + }, + "403": { + "description": "Permission denied (needs containers:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Body carries EITHER `icon` (a lucide name or `selfhst:`) to set a referenced icon, OR `image` (a base64-encoded data URL, ~300KB limit) to upload a custom one (stored as `custom:container`). One of the two is required. The override is keyed by container name + environment, so it survives container recreation.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "image": { + "type": "string" + } + } + }, + "example": { + "icon": "string", + "image": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_container-icons_name", + "tags": [ + "container-icons" + ], + "summary": "Remove a container's icon override (fall back to automatic matching)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the container belongs to" + } + ], + "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 containers:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/containers": { "get": { "operationId": "get_api_containers", @@ -6355,6 +6622,81 @@ ] } }, + "/api/containers/{id}/compose": { + "get": { + "operationId": "get_api_containers_id_compose", + "tags": [ + "containers" + ], + "summary": "Generate a docker-compose service definition from a running container's inspect (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)" + } + ], + "responses": { + "200": { + "description": "compose keeps only user-set env; composeFullEnv keeps every env var (incl. image-inherited); stackProject is the compose project the container already belongs to, or null", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "composeFullEnv": { + "type": "string" + }, + "serviceName": { + "type": "string" + }, + "stackProject": { + "type": "string" + } + } + }, + "example": { + "compose": "string", + "composeFullEnv": "string", + "serviceName": "string", + "stackProject": "string" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to inspect the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/containers/{id}/exec": { "post": { "operationId": "post_api_containers_id_exec", @@ -9833,7 +10175,7 @@ "parameters": [], "responses": { "200": { - "description": "Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition)", + "description": "Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition). The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.", "content": { "application/json": { "schema": { @@ -9867,6 +10209,12 @@ }, "timezone": { "type": "string" + }, + "hasTlsKey": { + "type": "boolean" + }, + "hasHawserToken": { + "type": "boolean" } }, "required": [ @@ -9886,7 +10234,9 @@ "protocol": "http", "icon": "server", "publicIp": "203.0.113.10", - "timezone": "Europe/Berlin" + "timezone": "Europe/Berlin", + "hasTlsKey": false, + "hasHawserToken": false }, { "id": 2, @@ -9897,7 +10247,9 @@ "protocol": "http", "icon": "server", "publicIp": null, - "timezone": "Europe/Berlin" + "timezone": "Europe/Berlin", + "hasTlsKey": false, + "hasHawserToken": true } ] } @@ -10049,7 +10401,7 @@ ], "responses": { "200": { - "description": "Successful response", + "description": "The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.", "content": { "application/json": { "schema": { @@ -10072,6 +10424,12 @@ }, "publicIp": { "type": "string" + }, + "hasTlsKey": { + "type": "boolean" + }, + "hasHawserToken": { + "type": "boolean" } }, "required": [ @@ -10087,7 +10445,9 @@ "labels": [ "string" ], - "publicIp": "string" + "publicIp": "string", + "hasTlsKey": true, + "hasHawserToken": true } } } @@ -10130,7 +10490,7 @@ ], "responses": { "200": { - "description": "Successful response", + "description": "The tlsKey/hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored.", "content": { "application/json": { "schema": { @@ -10153,6 +10513,12 @@ }, "publicIp": { "type": "string" + }, + "hasTlsKey": { + "type": "boolean" + }, + "hasHawserToken": { + "type": "boolean" } }, "required": [ @@ -10168,7 +10534,9 @@ "labels": [ "string" ], - "publicIp": "string" + "publicIp": "string", + "hasTlsKey": true, + "hasHawserToken": true } } } @@ -12217,16 +12585,21 @@ }, "example": { "branches": [ - "main", - "develop", - "feature/test" + { + "name": "main", + "sha": "a1b2c3d" + }, + { + "name": "develop", + "sha": "e4f5a6b" + } ] } } } }, "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" + "description": "The URL points at a loopback/link-local/metadata/reserved target, or neither repositoryId nor url was supplied" }, "403": { "description": "Permission denied (requires git:edit)" @@ -12246,7 +12619,7 @@ "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.", + "description": "Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). Each branch carries its short commit SHA. 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 ls-remote is bounded by a hard timeout.", "requestBody": { "required": true, "content": { @@ -12799,7 +13172,7 @@ } }, "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" + "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 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)" @@ -12819,7 +13192,7 @@ "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).", + "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; and composePath/envFilePath are constrained to stay inside the cloned repository (path traversal).", "requestBody": { "required": true, "content": { @@ -13333,6 +13706,9 @@ "400": { "description": "The id path segment is not a valid integer" }, + "403": { + "description": "Caller lacks the git:edit permission" + }, "404": { "description": "No repository exists with that ID" }, @@ -13396,6 +13772,9 @@ "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" }, @@ -13457,6 +13836,9 @@ "400": { "description": "The id path segment is not a valid integer" }, + "403": { + "description": "Caller lacks the git:edit permission" + }, "404": { "description": "No repository exists with that ID" }, @@ -13520,6 +13902,9 @@ "400": { "description": "The id path segment is not a valid integer" }, + "403": { + "description": "Caller lacks the git:edit permission" + }, "404": { "description": "No repository exists with that ID" }, @@ -13571,7 +13956,7 @@ } }, "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" + "description": "The url field is missing, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport." }, "403": { "description": "Caller lacks the settings:manage permission" @@ -13591,7 +13976,7 @@ "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).", + "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.", "requestBody": { "required": true, "content": { @@ -14477,7 +14862,8 @@ "description": "The deployment triggered by the webhook failed" } }, - "security": [] + "security": [], + "description": "A secret is required by default; set ALLOW_WEBHOOKS_WITHOUT_SECRET=true to accept a secret-less trigger on an isolated network." }, "post": { "operationId": "post_api_git_webhook_id", @@ -14535,7 +14921,7 @@ } }, "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." + "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. A secret is required by default; set ALLOW_WEBHOOKS_WITHOUT_SECRET=true to accept a secret-less trigger on an isolated network." } }, "/api/hawser/connect": { @@ -15136,6 +15522,145 @@ ] } }, + "/api/icons/selfhst/{ref}": { + "get": { + "operationId": "get_api_icons_selfhst_ref", + "tags": [ + "icons" + ], + "summary": "Get a selfh.st app icon (raw SVG), fetched once from the CDN and cached on disk", + "parameters": [ + { + "name": "ref", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "selfh.st icon Reference (lowercase letters, digits, hyphens), e.g. \"plex\"" + } + ], + "responses": { + "200": { + "description": "Raw image/svg+xml body (the icon, or a neutral placeholder if it can't be resolved), Cache-Control public max-age=604800" + }, + "400": { + "description": "The ref is not a valid selfh.st reference" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Proxies an icon from the selfh.st collection so the browser never contacts an external CDN directly. The first request for a given ref fetches the SVG from jsdelivr and caches it under DATA_DIR; later requests are served locally. Icons are CC BY 4.0 (selfh.st). When the icon can't be resolved it returns a neutral placeholder SVG (200) rather than a 404, so a grid of icons doesn't read as probing to a WAF." + } + }, + "/api/icons/selfhst/batch": { + "post": { + "operationId": "post_api_icons_selfhst_batch", + "tags": [ + "icons" + ], + "summary": "Resolve multiple selfh.st app icons in one request (avoids per-icon requests a WAF flags as crawling)", + "parameters": [], + "responses": { + "200": { + "description": "icons maps each resolved reference to a data:image/svg+xml base64 URI; unresolvable refs are omitted.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "icons": { + "type": "string" + } + }, + "required": [ + "icons" + ] + }, + "example": { + "icons": { + "plex": "data:image/svg+xml;base64,PHN2Zy4uLg==" + } + } + } + } + }, + "400": { + "description": "The request body is missing a refs array" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Takes a list of selfh.st icon references and returns each resolved icon as a data:image/svg+xml URI, keyed by reference. Unresolvable or invalid refs are omitted rather than returned as errors. Each icon is fetched from the CDN once and cached on disk, same as the single-icon endpoint.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "refs": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "refs" + ] + }, + "example": { + "refs": [ + "plex", + "gitea", + "grafana" + ] + } + } + } + } + } + }, + "/api/icons/selfhst-manifest": { + "get": { + "operationId": "get_api_icons_selfhst-manifest", + "tags": [ + "icons" + ], + "summary": "Get the selfh.st icon manifest (index.json), cached on disk with a 7-day TTL", + "parameters": [], + "responses": { + "200": { + "description": "The raw selfh.st index.json array (application/json)" + }, + "503": { + "description": "The manifest is unavailable (never cached and the upstream fetch failed)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Returns the selfh.st collection manifest (~2880 entries with Name, Reference, format flags, Category, Tags) so the icon picker can search it. Fetched once from the CDN and cached under DATA_DIR. Returns 503 only if it has never been fetched and the fetch fails." + } + }, "/api/images": { "get": { "operationId": "get_api_images", @@ -24441,7 +24966,7 @@ "tags": [ "stacks" ], - "summary": "Get a stack's env vars (non-secrets from file, secrets masked from DB) plus injected provider keys", + "summary": "Get a stack's env vars plus injected provider keys", "parameters": [ { "name": "name", @@ -24473,7 +24998,23 @@ "variables": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "isSecret": { + "type": "boolean" + } + }, + "required": [ + "key", + "value", + "isSecret" + ] } }, "injectedSecretKeys": { @@ -24492,7 +25033,11 @@ }, "example": { "variables": [ - "string" + { + "key": "string", + "value": "string", + "isSecret": true + } ], "injectedSecretKeys": [ "string" @@ -24516,14 +25061,15 @@ { "bearerAuth": [] } - ] + ], + "description": "The source of `variables` depends on the stack type. For a GIT stack, ALL variables (secret and non-secret) come from the database via this endpoint - the DB is the canonical store the UI, `POST /env/validate`, and deploys read. For an INTERNAL/adopted stack, non-secrets come from the on-disk `.env` file (written via `PUT /env/raw`) and only secrets come from the DB. Secret values are masked as `***` and never returned in plaintext. Each variable is `{ key, value, isSecret }`." }, "put": { "operationId": "put_api_stacks_name_env", "tags": [ "stacks" ], - "summary": "Save a stack's secret env vars to the DB (secrets never hit disk)", + "summary": "Save a stack's env vars to the DB", "parameters": [ { "name": "name", @@ -24566,6 +25112,7 @@ "bearerAuth": [] } ], + "description": "Persists EVERY variable in the body to the database (secrets and non-secrets alike). For a GIT stack this is the canonical store - non-secret overrides and secrets both belong here, NOT in the `.env` file. For an INTERNAL/adopted stack the DB is used for secrets while non-secrets are expected in the `.env` file (written via `PUT /env/raw`); posting a non-secret here still saves it but the file remains the source the UI reads for that type. A masked secret value of `***` is left unchanged (the stored value is preserved). Each item is `{ key, value, isSecret? }`; a request whose `variables` are not such objects is rejected with 400.", "requestBody": { "required": true, "content": { @@ -24576,7 +25123,22 @@ "variables": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + }, + "isSecret": { + "type": "boolean" + } + }, + "required": [ + "key", + "value" + ] } } }, @@ -24586,7 +25148,16 @@ }, "example": { "variables": [ - "string" + { + "key": "DB_HOST", + "value": "db", + "isSecret": false + }, + { + "key": "DB_PASSWORD", + "value": "s3cret", + "isSecret": true + } ] } } @@ -24897,6 +25468,207 @@ } } }, + "/api/stacks/{name}/icon": { + "get": { + "operationId": "get_api_stacks_name_icon", + "tags": [ + "stacks" + ], + "summary": "Get a stack's uploaded custom icon (raw image/webp bytes, not JSON)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to" + } + ], + "responses": { + "200": { + "description": "Binary image/webp response body, Cache-Control public max-age=3600" + }, + "403": { + "description": "Permission denied (needs stacks:view)" + }, + "404": { + "description": "No custom icon set for this stack" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "post_api_stacks_name_icon", + "tags": [ + "stacks" + ], + "summary": "Set a stack's icon - either a name reference or an uploaded image", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "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": { + "success": { + "type": "boolean" + }, + "icon": { + "type": "string" + } + }, + "required": [ + "success", + "icon" + ] + }, + "example": { + "success": true, + "icon": "selfhst:plex" + } + } + } + }, + "400": { + "description": "Neither icon nor image supplied, or image exceeds the size limit" + }, + "403": { + "description": "Permission denied (needs stacks:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "Body carries EITHER `icon` (a lucide name or `selfhst:`) to set a referenced icon, OR `image` (a base64-encoded data URL, ~300KB limit) to upload a custom one (stored as `custom:stack`). One of the two is required.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "icon": { + "type": "string" + }, + "image": { + "type": "string" + } + } + }, + "example": { + "icon": "string", + "image": "string" + } + } + } + } + }, + "delete": { + "operationId": "delete_api_stacks_name_icon", + "tags": [ + "stacks" + ], + "summary": "Remove a stack's custom icon", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "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": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "403": { + "description": "Permission denied (needs stacks:edit)" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/stacks/{name}/relocate": { "post": { "operationId": "post_api_stacks_name_relocate", @@ -25059,7 +25831,7 @@ "tags": [ "stacks" ], - "summary": "Restart a stack (mode=restart) or recreate its containers (mode=recreate); progress and the final result stream over Server-Sent Events", + "summary": "Restart a stack (mode=restart|ordered|recreate); progress and the final result stream over Server-Sent Events", "parameters": [ { "name": "name", @@ -25086,7 +25858,7 @@ "schema": { "type": "string" }, - "description": "Restart mode — \"recreate\" recreates containers, anything else performs a plain restart" + "description": "Restart mode — \"recreate\" recreates containers (new IDs, re-pull), \"ordered\" does a stop+start honoring depends_on ordering (same IDs), anything else is a plain in-place restart" } ], "responses": { @@ -25534,7 +26306,17 @@ "stacks" ], "summary": "Return the default Dockhand stacks directory ($DATA_DIR/stacks/) where new stacks are stored by default", - "parameters": [], + "parameters": [ + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment ID used to select the local or environment-scoped stacks root" + } + ], "responses": { "200": { "description": "Successful response", @@ -25859,7 +26641,7 @@ "tags": [ "stacks" ], - "summary": "List stack source records (their stored compose/env paths and source type)", + "summary": "List stack source records (their stored compose/env paths and source type, plus an env-var count)", "parameters": [ { "name": "env", @@ -26432,6 +27214,9 @@ }, "projectUrl": { "type": "string" + }, + "detailsUrl": { + "type": "string" } }, "required": [ @@ -26989,6 +27774,9 @@ "400": { "description": "Missing username/password, or password shorter than 8 characters" }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated; the first admin during initial setup is exempt)" + }, "403": { "description": "Permission denied (RBAC 'users:create' missing — only applies once an admin already exists)" }, @@ -27231,6 +28019,9 @@ "400": { "description": "User id is required, or the new password is shorter than 8 characters" }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, "403": { "description": "Permission denied (editing another user without users:edit)" }, @@ -27345,6 +28136,9 @@ "400": { "description": "User id is required" }, + "401": { + "description": "Authentication required (auth is enabled and the caller is not authenticated)" + }, "403": { "description": "Permission denied (missing users:remove)" }, diff --git a/tests/hawser-display-paths.test.ts b/tests/hawser-display-paths.test.ts new file mode 100644 index 0000000..39ebff0 --- /dev/null +++ b/tests/hawser-display-paths.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { join } from 'node:path'; +import { + remapComposeContentsFromRemoteToStaging, + remapComposeContentsFromStagingToRemote, + remapPathsFromRemoteToStaging, + remapPathsFromStagingToRemote +} from '../src/lib/server/stacks-display-paths'; + +describe('remapPathsFromStagingToRemote', () => { + it('remaps compose paths from Dockhand staging to Hawser STACKS_DIR', () => { + const staging = '/opt/docker/data/dockhand/stacks/nexz447/linkleaner'; + const remote = '/opt/docker/stacks/linkleaner'; + const paths = [ + join(staging, 'compose.yaml'), + join(staging, 'compose.override.yaml') + ]; + + const remapped = remapPathsFromStagingToRemote(staging, remote, paths); + + assert.deepEqual(remapped, [ + join(remote, 'compose.yaml'), + join(remote, 'compose.override.yaml') + ]); + }); + + it('leaves paths outside the staging dir unchanged', () => { + const staging = '/data/stacks/prod/myapp'; + const remote = '/opt/stacks/myapp'; + const external = '/srv/custom/myapp/compose.yaml'; + + const remapped = remapPathsFromStagingToRemote(staging, remote, [external]); + + assert.deepEqual(remapped, [external]); + }); +}); + +describe('remapPathsFromRemoteToStaging', () => { + it('remaps compose paths from Hawser STACKS_DIR back to Dockhand staging', () => { + const staging = '/opt/docker/data/dockhand/stacks/nexz447/linkleaner'; + const remote = '/opt/docker/stacks/linkleaner'; + const paths = [ + join(remote, 'compose.yaml'), + join(remote, 'compose.override.yaml') + ]; + + const remapped = remapPathsFromRemoteToStaging(staging, remote, paths); + + assert.deepEqual(remapped, [ + join(staging, 'compose.yaml'), + join(staging, 'compose.override.yaml') + ]); + }); +}); + +describe('remapComposeContentsFromStagingToRemote', () => { + it('remaps composeContents keys to remote display paths', () => { + const staging = '/opt/docker/data/dockhand/stacks/prod/linkleaner'; + const remote = '/opt/docker/stacks/linkleaner'; + const contents = { + [join(staging, 'compose.yaml')]: 'services:\n app:\n image: app', + [join(staging, 'compose.override.yaml')]: 'services:\n app:\n ports:\n - 8080:80' + }; + + const remapped = remapComposeContentsFromStagingToRemote(staging, remote, contents); + + assert.equal(remapped[join(remote, 'compose.yaml')], contents[join(staging, 'compose.yaml')]); + assert.equal( + remapped[join(remote, 'compose.override.yaml')], + contents[join(staging, 'compose.override.yaml')] + ); + assert.equal(Object.keys(remapped).length, 2); + }); +}); + +describe('remapComposeContentsFromRemoteToStaging', () => { + it('remaps composeContents keys back to staging paths', () => { + const staging = '/opt/docker/data/dockhand/stacks/prod/linkleaner'; + const remote = '/opt/docker/stacks/linkleaner'; + const contents = { + [join(remote, 'compose.yaml')]: 'services:\n app:\n image: app', + [join(remote, 'compose.override.yaml')]: 'services:\n app:\n ports:\n - 8080:80' + }; + + const remapped = remapComposeContentsFromRemoteToStaging(staging, remote, contents); + + assert.equal(remapped[join(staging, 'compose.yaml')], contents[join(remote, 'compose.yaml')]); + assert.equal( + remapped[join(staging, 'compose.override.yaml')], + contents[join(remote, 'compose.override.yaml')] + ); + assert.equal(Object.keys(remapped).length, 2); + }); +}); diff --git a/tests/stack-path-utils.test.ts b/tests/stack-path-utils.test.ts new file mode 100644 index 0000000..1e4441a --- /dev/null +++ b/tests/stack-path-utils.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + findStackNameCollision, + moveStackFilePathCrossDevice, + resolveStackDirForLayout +} from '../src/lib/server/stack-path-utils'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('moveStackFilePathCrossDevice', () => { + it('copies and deletes the source when rename fails with EXDEV', () => { + const dir = mkdtempSync(join(tmpdir(), 'dockhand-stack-move-')); + tempDirs.push(dir); + const source = join(dir, 'old.env'); + const destination = join(dir, 'new.env'); + writeFileSync(source, 'TOKEN=secret\n'); + + moveStackFilePathCrossDevice(source, destination, 'env file', () => { + throw Object.assign(new Error('cross-device link'), { code: 'EXDEV' }); + }); + + expect(readFileSync(destination, 'utf8')).toBe('TOKEN=secret\n'); + expect(existsSync(source)).toBe(false); + }); +}); + +describe('resolveStackDirForLayout', () => { + it('uses flat STACKS_DIR for local stacks and environment scope otherwise', () => { + expect(resolveStackDirForLayout('/data/stacks', '/srv/stacks', 'app', 'local', true)).toBe('/srv/stacks/app'); + expect(resolveStackDirForLayout('/data/stacks', '/srv/stacks', 'app', 'production', false)).toBe('/data/stacks/production/app'); + }); +}); + +describe('findStackNameCollision', () => { + it('finds the same stack name in another local environment', () => { + const sources = [ + { stackName: 'app', environmentId: 1 }, + { stackName: 'worker', environmentId: 2 } + ]; + + expect(findStackNameCollision(sources, 'app', 2)).toEqual(sources[0]); + expect(findStackNameCollision(sources, 'app', 1)).toBeUndefined(); + }); +});