diff --git a/drizzle-pg/0015_deploy_run_index.sql b/drizzle-pg/0015_deploy_run_index.sql new file mode 100644 index 0000000..cbf6dbc --- /dev/null +++ b/drizzle-pg/0015_deploy_run_index.sql @@ -0,0 +1 @@ +CREATE INDEX "schedule_executions_entity_env_idx" ON "schedule_executions" USING btree ("entity_name","environment_id"); \ No newline at end of file diff --git a/drizzle-pg/meta/0015_snapshot.json b/drizzle-pg/meta/0015_snapshot.json new file mode 100644 index 0000000..ef6a743 --- /dev/null +++ b/drizzle-pg/meta/0015_snapshot.json @@ -0,0 +1,3602 @@ +{ + "id": "7b17f0e4-3123-4178-a85a-93c6ea6a46d5", + "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 + }, + "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": {} + }, + "schedule_executions_entity_env_idx": { + "name": "schedule_executions_entity_env_idx", + "columns": [ + { + "expression": "entity_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_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": {} + } +} \ No newline at end of file diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index 47961cb..15869d2 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": 1788099526949, + "tag": "0015_deploy_run_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/0015_deploy_run_index.sql b/drizzle/0015_deploy_run_index.sql new file mode 100644 index 0000000..45b5847 --- /dev/null +++ b/drizzle/0015_deploy_run_index.sql @@ -0,0 +1 @@ +CREATE INDEX `schedule_executions_entity_env_idx` ON `schedule_executions` (`entity_name`,`environment_id`); \ No newline at end of file diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..b78cc54 --- /dev/null +++ b/drizzle/meta/0015_snapshot.json @@ -0,0 +1,3755 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "015e998b-f154-417d-9122-eef38fca253c", + "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 + }, + "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 + }, + "schedule_executions_entity_env_idx": { + "name": "schedule_executions_entity_env_idx", + "columns": [ + "entity_name", + "environment_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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b9aa6df..44367ad 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": 1788099517358, + "tag": "0015_deploy_run_index", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/components/LogViewer.svelte b/src/lib/components/LogViewer.svelte new file mode 100644 index 0000000..d96801f --- /dev/null +++ b/src/lib/components/LogViewer.svelte @@ -0,0 +1,444 @@ + + +
+ +
+
+ {#if loading} + + {/if} +
+
+ + + + + + fontSize = Number(v)}> + + + {fontSize}px + + + {#each fontSizeOptions as size} + + + {size}px + + {/each} + + + + + + + + {#if logSearchActive} +
+ + + + {#if matchCount > 0} + {currentMatchIndex + 1}/{matchCount} + {:else if logSearchQuery} + 0/0 + {/if} + + + +
+ {:else} + + {/if} + + + + + + {#if onClear} + + {/if} + + {#if onRefresh} + + {/if} +
+
+ + +
+ {#if logs} +
{@html wrapHtmlLines(highlightedLogs())}
+ {:else if loading} +
+ + Loading logs... +
+ {:else} +

No logs available

+ {/if} +
+
+ + diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index 0161454..f872a4b 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -22110,7 +22110,7 @@ "schema": { "type": "string" }, - "description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)" + "description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, deploy_log_reconcile)" }, { "name": "id", @@ -22184,7 +22184,7 @@ "schema": { "type": "string" }, - "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)" + "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup, deploy_log_reconcile)" }, { "name": "id", @@ -22224,7 +22224,7 @@ } }, "400": { - "description": "Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)" + "description": "Invalid schedule id, unsupported type, or system_cleanup/deploy_log_reconcile (cannot be paused)" }, "404": { "description": "Schedule, backup config, or backup destination not found" @@ -23777,6 +23777,12 @@ "defaultTrivyImage": { "type": "string" }, + "deployLogReconcileCron": { + "type": "string" + }, + "deployLogReconcileEnabled": { + "type": "boolean" + }, "downloadFormat": { "type": "string" }, @@ -23895,6 +23901,8 @@ "defaultScannerNetworkMode": "string", "defaultTrivyArgs": "string", "defaultTrivyImage": "string", + "deployLogReconcileCron": "string", + "deployLogReconcileEnabled": true, "downloadFormat": "string", "editorFont": "string", "eventCleanupCron": "string", @@ -24675,6 +24683,15 @@ }, "environmentId": { "type": "integer" + }, + "pull": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "forceRecreate": { + "type": "boolean" } }, "required": [ @@ -24694,7 +24711,10 @@ "secretProviderId": 0, "start": true, "envId": 0, - "environmentId": 0 + "environmentId": 0, + "pull": true, + "build": true, + "forceRecreate": true } } } @@ -25036,6 +25056,15 @@ }, "secretProviderId": { "type": "integer" + }, + "pull": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "forceRecreate": { + "type": "boolean" } }, "required": [ @@ -25050,7 +25079,10 @@ "oldEnvPath": "string", "moveFromDir": "string", "restart": true, - "secretProviderId": 0 + "secretProviderId": 0, + "pull": true, + "build": true, + "forceRecreate": true } } } @@ -25221,6 +25253,357 @@ } } }, + "/api/stacks/{name}/deploys": { + "get": { + "operationId": "get_api_stacks_name_deploys", + "tags": [ + "stacks" + ], + "summary": "List deploy runs recorded for a stack (no log text -- see GET .../{runId}/log)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to (from GET /api/environments). Omit, or pass the literal string \"null\", for the local/default environment (deploys triggered without an explicit env, the normal shape on a single-environment install)." + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + }, + "triggeredBy": { + "type": "string" + }, + "triggeredAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "completedAt": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "errorMessage": { + "type": "string" + }, + "details": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "triggeredBy", + "triggeredAt", + "status" + ] + } + } + }, + "required": [ + "runs" + ] + }, + "example": { + "runs": [ + { + "id": 0, + "environmentId": 0, + "triggeredBy": "string", + "triggeredAt": "string", + "startedAt": "string", + "completedAt": "string", + "duration": 0, + "status": "string", + "errorMessage": "string", + "details": {} + } + ] + } + } + } + }, + "400": { + "description": "env query parameter must be an integer, or omitted/\"null\" for the local environment" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:view), or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/deploys/{runId}": { + "get": { + "operationId": "get_api_stacks_name_deploys_runId", + "tags": [ + "stacks" + ], + "summary": "Get a single deploy run's metadata (no log text -- see GET .../{runId}/log)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Run id (from GET /api/stacks/{name}/deploys)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + }, + "triggeredBy": { + "type": "string" + }, + "triggeredAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "completedAt": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "errorMessage": { + "type": "string" + }, + "details": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "triggeredBy", + "triggeredAt", + "status" + ] + }, + "example": { + "id": 0, + "environmentId": 0, + "triggeredBy": "string", + "triggeredAt": "string", + "startedAt": "string", + "completedAt": "string", + "duration": 0, + "status": "string", + "errorMessage": "string", + "details": {} + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of resp-400/resp-404 lines (same pattern as POST /api/backup/destinations/{id}/task's description)." + }, + "delete": { + "operationId": "delete_api_stacks_name_deploys_runId", + "tags": [ + "stacks" + ], + "summary": "Delete a deploy run's database record and its on-disk log file", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Run id (from GET /api/stacks/{name}/deploys)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:edit for the run's own environment), or access denied to this environment" + }, + "409": { + "description": "The run has not finished yet (status is not a terminal one) -- deleting a" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- see GET .../{runId}'s description for why that means prose here instead of resp-400/resp-404 lines." + } + }, + "/api/stacks/{name}/deploys/{runId}/log": { + "get": { + "operationId": "get_api_stacks_name_deploys_runId_log", + "tags": [ + "stacks" + ], + "summary": "Get a deploy run's log text (text/plain, from the on-disk file)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Run id (from GET /api/stacks/{name}/deploys)" + } + ], + "responses": { + "200": { + "description": "Plain-text log content" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment" + }, + "404": { + "description": "Deploy run not found (also returned for a run that belongs to a different stack), or log file missing" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "400 (\"Invalid run id\") is produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of a resp-400 line (same pattern as POST /api/backup/destinations/{id}/task's description). The 404 below covers BOTH loadOwnedDeployRun's \"run not found / belongs to a different stack\" AND this handler's own \"log file missing\" -- the latter IS literal in this file's body, which is why it can stay a resp-404 line." + } + }, "/api/stacks/{name}/down": { "post": { "operationId": "post_api_stacks_name_down", diff --git a/src/lib/server/backups/index.ts b/src/lib/server/backups/index.ts index beb132f..15bb6cb 100644 --- a/src/lib/server/backups/index.ts +++ b/src/lib/server/backups/index.ts @@ -969,6 +969,12 @@ function restorePorts(destination: any, access: { isEnterprise: boolean; canAcce log(`registered: composePath=${composePath} envPath=${envPath ?? '(none)'}`); }, deploy: async () => { + // Deliberately does NOT create a stack_deploy run record: this call goes + // straight to redeployStackFromDir (stacks.ts), not through the + // deployGitStack()/compose-route path that createRunRecorder is wired + // into. A backup restore's redeploy could get its own recorder the same + // way -- there is nothing structural stopping it -- it just hasn't been + // done yet. const r = await redeployStackFromDir(name, stackDir, composeFileName, envId ?? undefined); if (!r.success) throw new Error(r.error || 'docker compose up failed'); } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 8dc2f92..311f673 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -4276,7 +4276,7 @@ export async function saveDashboardPreferences(data: { // SCHEDULE EXECUTION OPERATIONS // ============================================================================= -export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore'; +export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore' | 'stack_deploy' | 'deploy_log_reconcile'; export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup'; export type ScheduleStatus = | 'queued' @@ -4336,6 +4336,10 @@ export interface ScheduleExecutionFilters { scheduleType?: ScheduleType; scheduleId?: number; environmentId?: number | null; + // Powers "runs for this stack" lookups (schedule_executions_entity_env_idx) -- + // paired with environmentId, an exact match on the (entity_name, environment_id) + // index this filter was added for. + entityName?: string; status?: ScheduleStatus; statuses?: ScheduleStatus[]; triggeredBy?: ScheduleTrigger; @@ -4465,6 +4469,9 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters = conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId)); } } + if (filters.entityName !== undefined) { + conditions.push(eq(scheduleExecutions.entityName, filters.entityName)); + } if (filters.status) { conditions.push(eq(scheduleExecutions.status, filters.status)); } @@ -4527,6 +4534,44 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters = }; } +/** + * ALL execution ids (+ parsed details) for a given schedule type, unpaginated. + * + * Deliberately bypasses getScheduleExecutions()'s default 50-row page: a caller that + * needs to reconcile every record against something else (deploy-log-reconcile.ts, + * against files on disk) cannot afford to only see the most recent page -- an older + * record just outside the window would look exactly like an orphan file with no + * record, and the file behind it would be deleted even though a record exists. + * + * `environmentId` is included (F5 fix) so the caller can scope its reconciliation + * PER ENVIRONMENT -- deploy-log-store.ts now keeps one log directory per environment + * (see envDirName()), so a record's file can only ever be found in, or be deleted + * from, ITS OWN environment's directory. Without environmentId here, + * deploy-log-reconcile.ts would have to either reconcile everything as one flat pool + * again (reintroducing the cross-environment mixing the size-budget fix closes) or + * guess which environment a record belongs to. + */ +export async function getScheduleExecutionIdsByType( + scheduleType: ScheduleType +): Promise> { + const rows = await db + .select({ + id: scheduleExecutions.id, + status: scheduleExecutions.status, + details: scheduleExecutions.details, + environmentId: scheduleExecutions.environmentId + }) + .from(scheduleExecutions) + .where(eq(scheduleExecutions.scheduleType, scheduleType)); + + return rows.map((row: { id: number; status: string; details: string | null; environmentId: number | null }) => ({ + id: row.id, + status: row.status as ScheduleStatus, + details: row.details ? JSON.parse(row.details) : null, + environmentId: row.environmentId + })); +} + /** * Scheduled-task health for the metrics endpoint: execution counts by * (scheduleType, status), and per-type age (seconds) of the last run and last @@ -4699,9 +4744,12 @@ const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled'; const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled'; const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron'; const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled'; +const DEPLOY_LOG_RECONCILE_CRON_KEY = 'deploy_log_reconcile_cron'; +const DEPLOY_LOG_RECONCILE_ENABLED_KEY = 'deploy_log_reconcile_enabled'; const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM +const DEFAULT_DEPLOY_LOG_RECONCILE_CRON = '0 4 * * *'; // Daily at 4 AM export async function getScheduleRetentionDays(): Promise { const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY)); @@ -4879,6 +4927,50 @@ export async function setScannerCleanupEnabled(enabled: boolean): Promise } } +export async function getDeployLogReconcileCron(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY)); + if (result[0]) { + return result[0].value || DEFAULT_DEPLOY_LOG_RECONCILE_CRON; + } + return DEFAULT_DEPLOY_LOG_RECONCILE_CRON; +} + +export async function setDeployLogReconcileCron(cron: string): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: cron, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, DEPLOY_LOG_RECONCILE_CRON_KEY)); + } else { + await db.insert(settings).values({ + key: DEPLOY_LOG_RECONCILE_CRON_KEY, + value: cron + }); + } +} + +export async function getDeployLogReconcileEnabled(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY)); + if (result[0]) { + return result[0].value === 'true'; + } + return true; // Enabled by default +} + +export async function setDeployLogReconcileEnabled(enabled: boolean): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() }) + .where(eq(settings.key, DEPLOY_LOG_RECONCILE_ENABLED_KEY)); + } else { + await db.insert(settings).values({ + key: DEPLOY_LOG_RECONCILE_ENABLED_KEY, + value: enabled ? 'true' : 'false' + }); + } +} + // ============================================================================= // EXTERNAL STACK PATHS // ============================================================================= diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 19a2651..7337aba 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -488,7 +488,9 @@ export const scheduleExecutions = sqliteTable('schedule_executions', { logs: text('logs'), // Execution logs/output createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`) }, (table) => ({ - typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId) + typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId), + // Powers "runs for this stack/container" lookups without a full table scan. + entityEnvIdx: index('schedule_executions_entity_env_idx').on(table.entityName, table.environmentId) })); // ============================================================================= diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index cf54c5a..021301b 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -490,7 +490,9 @@ export const scheduleExecutions = pgTable('schedule_executions', { logs: text('logs'), // Execution logs/output createdAt: timestamp('created_at', { mode: 'string' }).defaultNow() }, (table) => ({ - typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId) + typeIdIdx: index('schedule_executions_type_id_idx').on(table.scheduleType, table.scheduleId), + // Powers "runs for this stack/container" lookups without a full table scan. + entityEnvIdx: index('schedule_executions_entity_env_idx').on(table.entityName, table.environmentId) })); // ============================================================================= diff --git a/src/lib/server/deploy-log-reconcile-core.ts b/src/lib/server/deploy-log-reconcile-core.ts new file mode 100644 index 0000000..fabe081 --- /dev/null +++ b/src/lib/server/deploy-log-reconcile-core.ts @@ -0,0 +1,54 @@ +/** + * Pure decision core for the deploy-log reconcile job. + * + * Compares the set of deploy-log files on disk against the set of run records in the + * database and decides what to do about each side of the mismatch -- nothing else. It + * touches neither the filesystem nor the database; the caller (deploy-log-reconcile.ts) + * is responsible for gathering the two id sets and for carrying out the plan. + * + * The two failure modes are NOT symmetric, and that asymmetry is the whole point of + * this module: + * - A file without a record is deleted -- it is an orphan with nothing left to explain it. + * - A record without a file is only marked, never deleted -- the metadata (when, who, + * what was built, which digest) stays valuable even once the log text itself is gone. + * Deleting the record here would be the "too eager" failure this job exists to avoid. + */ + +export interface ReconcileInput { + /** Run ids that have a deploy-log file on disk. */ + fileIds: string[]; + /** Run ids that have a run record in the database. */ + recordIds: string[]; +} + +export interface ReconcilePlan { + /** Files to delete: present on disk, no matching record. */ + deleteFiles: string[]; + /** Records to mark (details.logMissing = true): present in the database, no matching file. */ + markMissing: string[]; +} + +export function planReconcile(input: ReconcileInput): ReconcilePlan { + const recordIdSet = new Set(input.recordIds); + const fileIdSet = new Set(input.fileIds); + + return { + deleteFiles: input.fileIds.filter((id) => !recordIdSet.has(id)), + markMissing: input.recordIds.filter((id) => !fileIdSet.has(id)) + }; +} + +/** + * Whether a record in this status may be judged "missing its log file". + * + * A still in-progress run ('queued'/'running') may simply not have written its first + * log line yet -- the schedule_executions row for a stack_deploy run is created before + * the deploy has produced any output, and the log file itself only comes into existence + * on the first line written (see deploy-log-reconcile.ts's module doc comment for the + * verified timing this guards against). Marking it here would be wrong, and nothing + * downstream ever re-checks or clears the flag once the file does appear -- so the + * record would carry a false "log missing" permanently. + */ +export function isEligibleForMissingMark(status: string): boolean { + return status !== 'queued' && status !== 'running'; +} diff --git a/src/lib/server/deploy-log-store.ts b/src/lib/server/deploy-log-store.ts new file mode 100644 index 0000000..8db500c --- /dev/null +++ b/src/lib/server/deploy-log-store.ts @@ -0,0 +1,342 @@ +import { appendFile, mkdir, readFile, rm, readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +const ID = /^[A-Za-z0-9_-]+$/; + +/** + * A run id comes from a route parameter, so it is validated rather than sanitised: + * a value that is not a plain id is a bug or an attack, and neither should quietly + * resolve to some other file. + */ +export function runLogFileName(runId: string): string { + if (!ID.test(runId)) throw new Error(`invalid run id: ${JSON.stringify(runId)}`); + return `${runId}.log`; +} + +/** + * F5 fix: the directory name (under deploy-logs/) that holds ONE environment's OWN + * run log files and counts against ONE environment's OWN size budget. + * + * Before this existed, every environment shared a SINGLE deploy-logs/ directory and a + * single BUDGET_BYTES total (see SizeBudgetTracker below). A user with deploy rights + * in ONE environment could fill that shared budget with a noisy deploy, which then + * silently truncated the deploy logs and summaries of every OTHER environment -- a + * multi-tenant audit-trail integrity violation, since a deploy log is exactly the + * evidence an operator reaches for after something goes wrong. Scoping the directory + * (and therefore the budget, since usedBytes()/usedBytesExcluding() only ever look + * inside ONE environment's directory) per environment is the entire fix. + * + * `environmentId` always comes from a database column (`number | null`, see + * ScheduleExecutionData in db.ts) -- never attacker-controlled free text reaching + * this function directly. This still asserts the shape rather than trusting it + * blindly, the same posture runLogFileName's ID regex takes for runId: a value that + * is not a plain integer or null is a bug, and should throw rather than quietly + * resolve to some unexpected directory name. + * + * The `null` case (an install with no environments / a run not attributed to one -- + * see createRunRecorder's `envId: number | null` input) gets the literal directory + * name "null", not an omitted segment. `String(null) === 'null'` in JS already reads + * exactly this way, so this is not inventing a new convention -- it is naming the one + * `String(envId)` would already produce, so the special case is documented rather + * than accidental. + */ +export function envDirName(envId: number | null): string { + if (envId === null) return 'null'; + if (!Number.isInteger(envId)) { + throw new Error(`invalid environment id: ${JSON.stringify(envId)}`); + } + return String(envId); +} + +/** + * The inverse of envDirName() -- turns a directory name discovered on disk (via + * listEnvDirNames()) back into the environmentId it represents. Used by the + * reconcile job, which learns which environments have files by LISTING + * deploy-logs/, rather than being handed a set of ids up front. + * + * Throws on anything that is not one of envDirName()'s own outputs. listEnvDirNames() + * already filters to directories only (excluding the pre-F5 flat-layout .log files -- + * see legacyFlatRunLogPath), so in practice this only ever sees "null" or a + * non-negative integer string; the throw is a defensive backstop against a + * hand-created or otherwise unexpected directory being silently misread as some + * arbitrary environment id. + */ +export function parseEnvDirName(name: string): number | null { + if (name === 'null') return null; + if (!/^\d+$/.test(name)) { + throw new Error(`not an environment directory name: ${JSON.stringify(name)}`); + } + return Number(name); +} + +function deployLogsRoot(): string { + return join(process.env.DATA_DIR || './data', 'deploy-logs'); +} + +/** One environment's OWN log directory: deploy-logs//. */ +function logDir(envId: number | null): string { + return join(deployLogsRoot(), envDirName(envId)); +} + +export function runLogPath(envId: number | null, runId: string): string { + return join(logDir(envId), runLogFileName(runId)); +} + +/** + * The path a run's log file would have lived at BEFORE the F5 per-environment + * scoping existed: deploy-logs/.log, no environment subdirectory. This branch + * introduced deploy-log-store.ts in the first place -- there is no released version + * of Dockhand that ever wrote logs in this flat layout, so there is no production + * data to migrate, and appendRunLog()/deleteRunLog() never write here. + * + * readRunLog() still falls back to this path (see its doc comment) purely as a + * robustness measure: a file placed here by hand -- a manual copy, a fixture, a + * future migration script -- is still found instead of silently 404ing, rather than + * this fix assuming its own directory layout is the only one that will ever exist on + * disk. deleteRunLog() also clears it, so such a file cannot outlive the record that + * pointed at it. + */ +function legacyFlatRunLogPath(runId: string): string { + return join(deployLogsRoot(), runLogFileName(runId)); +} + +/** Total bytes ONE environment's log directory may occupy before new writes are refused. */ +const BUDGET_BYTES = 2 * 1024 * 1024 * 1024; + +/** + * How many bytes of a run's OWN output SizeBudgetTracker lets accumulate against its + * cached view of the rest of the directory before re-scanning that view. Bounds how + * stale the cache can get for a single long-running deploy -- see SizeBudgetTracker's + * doc comment for the concurrency trade-off this makes. + */ +const REFRESH_AFTER_OWN_BYTES = 1 * 1024 * 1024; // 1 MiB + +export async function appendRunLog(envId: number | null, runId: string, chunk: string): Promise { + await mkdir(logDir(envId), { recursive: true }); + await appendFile(runLogPath(envId, runId), chunk); +} + +/** + * Reads a run's log text. Tries the run's OWN environment directory first -- the + * only place appendRunLog() ever writes -- and falls back to the pre-F5 flat path + * (legacyFlatRunLogPath) if no file exists there. See that function's doc comment + * for why the fallback exists despite there being no production data in that layout. + * A file that exists at NEITHER path returns null, exactly as before F5. + */ +export async function readRunLog(envId: number | null, runId: string): Promise { + try { + return await readFile(runLogPath(envId, runId), 'utf8'); + } catch { + try { + return await readFile(legacyFlatRunLogPath(runId), 'utf8'); + } catch { + return null; + } + } +} + +/** + * Removes a run's log file from its own environment directory AND from the pre-F5 + * flat legacy path (force: true -- a path that was never there is not an error), so + * a run whose file happens to have been found via readRunLog()'s legacy fallback + * does not survive its own deletion. + */ +export async function deleteRunLog(envId: number | null, runId: string): Promise { + await rm(runLogPath(envId, runId), { force: true }); + await rm(legacyFlatRunLogPath(runId), { force: true }); +} + +export async function listRunLogIds(envId: number | null): Promise { + try { + const names = await readdir(logDir(envId)); + return names.filter((n) => n.endsWith('.log')).map((n) => n.slice(0, -4)); + } catch { + return []; + } +} + +/** + * Last-modified time (ms since epoch) of a run's log file, or null if it does not + * exist. Used by the reconcile job (deploy-log-reconcile.ts) as a TOCTOU guard: the + * set of stack_deploy records it compares files against is a single point-in-time + * snapshot taken at the start of the job, while appendRunLog() keeps writing to (and + * therefore keeps bumping the mtime of) a live deploy's file for as long as it runs. + * A deploy that starts AFTER that snapshot has no record in it yet, but its file can + * already exist by the time this environment's files are listed -- a file this fresh + * is exactly what that race looks like, not an abandoned orphan. + */ +export async function getRunLogMtimeMs(envId: number | null, runId: string): Promise { + try { + return (await stat(runLogPath(envId, runId))).mtimeMs; + } catch { + return null; + } +} + +/** + * Every environment directory name currently present under deploy-logs/. Used by the + * reconcile job (deploy-log-reconcile.ts) to discover which environments have ANY + * files on disk, without first needing to already know the full set of environment + * ids from the database (an environment that was later deleted, but still has + * orphaned log files, would otherwise never be reconciled). + * + * Filters to directories only via readdir's withFileTypes -- a stray FILE directly + * under deploy-logs/ is the pre-F5 flat legacy layout (legacyFlatRunLogPath), not an + * environment directory, and is intentionally excluded: F5 is a fresh, unreleased + * feature with no production data in that layout to reconcile (see that function's + * doc comment). + */ +export async function listEnvDirNames(): Promise { + try { + const entries = await readdir(deployLogsRoot(), { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} + +export async function usedBytes(envId: number | null): Promise { + let total = 0; + for (const id of await listRunLogIds(envId)) { + try { + total += (await stat(runLogPath(envId, id))).size; + } catch { + /* raced with cleanup */ + } + } + return total; +} + +/** + * Same as usedBytes(), but leaves excludeRunId's own file out of the sum. Used by + * SizeBudgetTracker to get the size of "everything ELSE IN THIS ENVIRONMENT", so it + * can add this run's own bytes on top from an in-memory counter instead of + * re-stat'ing its own file. + */ +export async function usedBytesExcluding(envId: number | null, excludeRunId: string): Promise { + let total = 0; + for (const id of await listRunLogIds(envId)) { + if (id === excludeRunId) continue; + try { + total += (await stat(runLogPath(envId, id))).size; + } catch { + /* raced with cleanup */ + } + } + return total; +} + +export async function budgetExceeded(envId: number | null): Promise { + return (await usedBytes(envId)) > BUDGET_BYTES; +} + +/** + * Tracks whether ONE run's output has pushed ITS OWN ENVIRONMENT's deploy-logs// + * over BUDGET_BYTES, without doing a full directory scan (readdir + stat every file) + * on every appended line. + * + * Before the scan-avoidance existed, deploy-run-record.ts's line() called + * budgetExceeded() -- a full usedBytes() scan -- after EVERY line, so a deploy with N + * output lines against a log directory holding M past runs' files cost O(N*M) stat + * calls. Measured on this machine: 200 lines against 500 existing log files took + * ~16.2s (~81ms/line); the same 200 lines with the directory scan removed entirely + * took ~49ms total. That scan is needed to stay honest about the directory's actual + * size, so it can't just be dropped -- it can only be done less often. + * + * How: the directory total EXCLUDING this run's own file (usedBytesExcluding) is + * measured once, lazily, on the first recordAppend() call, and cached. Each + * recordAppend() after that just adds the byte length the caller reports (bytes it + * just wrote to ITS OWN file -- no stat() involved) to that cached total in memory. + * The cache is re-measured every REFRESH_AFTER_OWN_BYTES bytes of this run's own + * output, so a long deploy's view of "everything else" can't drift arbitrarily far + * from reality -- at most REFRESH_AFTER_OWN_BYTES stale before the next refresh + * catches up. + * + * F5 fix, ENVIRONMENT scoping: `envId` is threaded into the constructor and passed to + * every measureOthers() call, so "everything else" means everything else IN THIS + * RUN'S OWN ENVIRONMENT's directory -- never another environment's files. Before this, + * `envId` did not exist here at all: every tracker (regardless of which environment + * its run belonged to) measured the SAME shared deploy-logs/ directory against the + * SAME BUDGET_BYTES total, so a noisy deploy in one environment could push every other + * environment's tracker over budget too, truncating runs that never wrote anywhere + * near BUDGET_BYTES themselves. See deploy-run-record.ts's module doc comment (F5 + * fix) and DeployRunRecorder's envId field for how this is wired end to end. + * + * Concurrency: OTHER runs in the SAME environment writing at the same time are + * invisible between this tracker's refreshes. A second run in the same environment + * that starts writing right after this one's baseline was measured can add up to its + * own full output before the next refresh reflects it, so the total this tracker + * checks against can undercount the real directory size by that much in the + * meantime. This is an intentional trade-off: BUDGET_BYTES (2 GiB, deliberately + * generous) exists to catch a filled disk, not to enforce an exact quota -- the + * design doc calls for aborting "at the end of writing, not truncating mid-line", + * never for a precise cutoff. The OLD per-line full rescan did not close this gap + * either: budgetExceeded() reads the directory at the moment it's called, with no + * locking against another run's concurrent appendRunLog() -- the old code just paid + * for that same imprecision on every single line instead of once per MiB. This + * concurrency trade-off is entirely orthogonal to the F5 environment-scoping fix + * above: it was true before F5 (within the single shared directory) and remains true + * after F5 (within one environment's own directory) -- F5 changes WHICH directory is + * measured, not how fresh that measurement is. + */ +export class SizeBudgetTracker { + /** "Everything else" (every OTHER run's file IN THIS RUN'S OWN ENVIRONMENT), last + * measured by refresh(). NEVER includes this run's own file -- see + * usedBytesExcluding() -- and NEVER includes another environment's files, since + * usedBytesExcluding() only ever reads inside logDir(this.envId). */ + private cachedOtherBytes = 0; + /** Every byte THIS run has appended so far, summed in memory as recordAppend() + * is called. This is the run's true own total and is never reset -- it does not + * need a re-scan to stay accurate, because the caller already knows exactly what + * it just wrote. */ + private ownBytesTotal = 0; + /** Own bytes appended since the LAST refresh() -- reset to 0 on every refresh. + * Only exists to decide WHEN to refresh; the budget check itself always uses + * ownBytesTotal, not this. */ + private ownBytesSinceLastScan = 0; + private loaded = false; + + constructor( + /** F5 fix: which environment's directory/budget this tracker measures against. + * Passed straight through to measureOthers() on every refresh() -- see the + * class doc comment. */ + private readonly envId: number | null, + private readonly runId: string, + private readonly budgetBytes: number = BUDGET_BYTES, + /** Test-only seam: lets tests count/replace the directory scan without + * writing real files. Production code never passes this -- it always gets + * the real usedBytesExcluding. */ + private readonly measureOthers: ( + envId: number | null, + excludeRunId: string + ) => Promise = usedBytesExcluding + ) {} + + private async refresh(): Promise { + this.cachedOtherBytes = await this.measureOthers(this.envId, this.runId); + this.ownBytesSinceLastScan = 0; + this.loaded = true; + } + + /** + * Call once per chunk appended to this run's own log file, with the number of + * UTF-8 bytes just written (Buffer.byteLength(chunk, 'utf8') -- NOT chunk.length, + * which counts UTF-16 code units and undercounts multi-byte characters). Returns + * whether the directory total, including this run's own bytes so far, currently + * exceeds the budget. + * + * ownBytesTotal is updated BEFORE the refresh decision, on purpose: a single + * chunk large enough to cross REFRESH_AFTER_OWN_BYTES on its own must trigger its + * refresh in this same call, not the next one -- otherwise a caller that stops + * appending right after such a chunk would leave the tracker's view of + * "everything else" stale for the rest of the run. + */ + async recordAppend(byteLength: number): Promise { + this.ownBytesTotal += byteLength; + this.ownBytesSinceLastScan += byteLength; + if (!this.loaded || this.ownBytesSinceLastScan >= REFRESH_AFTER_OWN_BYTES) { + await this.refresh(); + } + return this.cachedOtherBytes + this.ownBytesTotal > this.budgetBytes; + } +} diff --git a/src/lib/server/deploy-recorder.ts b/src/lib/server/deploy-recorder.ts new file mode 100644 index 0000000..9cfb449 --- /dev/null +++ b/src/lib/server/deploy-recorder.ts @@ -0,0 +1,18 @@ +// src/lib/server/deploy-recorder.ts +/** + * Decides what may be written to a persisted deploy log. + * + * Only redacted output lines qualify. The 'result' event is excluded on purpose: it + * carries result.output (and, on the compose endpoint, result.error) -- compose's raw + * stdout/stderr, which never passes through redactLine. Persisting it would turn a + * ten-minute in-memory buffer into a permanent plaintext archive on disk. + * + * BOTH conditions below are load-bearing and are covered by separate tests. Do not + * collapse them into the type check alone: that would admit any future event carrying + * type: 'line', and no test would notice. + */ +export function shouldRecord(event: string | undefined, data: unknown): boolean { + if (event !== 'progress') return false; + if (typeof data !== 'object' || data === null) return false; + return (data as { type?: string }).type === 'line'; +} diff --git a/src/lib/server/deploy-run-access.ts b/src/lib/server/deploy-run-access.ts new file mode 100644 index 0000000..2dbebf5 --- /dev/null +++ b/src/lib/server/deploy-run-access.ts @@ -0,0 +1,85 @@ +import { json } from '@sveltejs/kit'; +import { getScheduleExecution, type ScheduleExecutionData } from './db'; +import type { AuthorizationContext } from './authorize'; + +/** + * Loads a schedule_executions row and confirms it actually belongs to the named + * stack before deciding whether the caller may reach it. + * + * Two checks, deliberately in this order: + * + * 1. Ownership -- the row must be a 'stack_deploy' execution whose entityName + * matches the stack in the URL. A run for a DIFFERENT stack (or a + * non-deploy schedule execution that happens to reuse the same numeric id + * space) comes back as the exact same 404 as a run that doesn't exist at + * all. A distinct response for "wrong stack" vs "no such run" would leak + * that the id is valid for something else. + * + * 2. Environment access -- checked against the RUN's OWN environmentId, never + * against a caller-supplied `env` query parameter. A caller could otherwise + * pass an env they legitimately have access to while pointing runId at a + * run that actually belongs to a DIFFERENT environment they have no access + * to at all -- the same cross-env bypass class the backup routes' own + * route-guards.ts closes for snapshot/config lookups (resolveSnapshotEnvId + * + guardSnapshotEnvAccess). Fails closed: enterprise + inaccessible env + * denies, even if that hides an otherwise-existing run. + * + * Shared by GET and DELETE .../deploys/{runId} AND GET .../deploys/{runId}/log + * so the three routes can never drift into checking different things for the + * same row. The stacks:view/stacks:edit permission gate itself is NOT part of + * this helper -- each route checks that on its own, inline, before calling + * this (see the three +server.ts files). + */ +/** + * Statuses a stack_deploy run can be permanently closed under -- i.e. the deploy has + * actually stopped running and nothing will update this row again on its own. Kept as + * an explicit allow-list (not "everything except queued/running") on purpose: the + * status column is free text (db.ts's ScheduleStatus comment), and a status this code + * doesn't recognize must NOT be assumed finished just because it isn't literally + * 'queued' or 'running' -- see isTerminalRunStatus()'s doc comment for what that + * assumption would cost. + * + * This is deliberately its OWN small set, not deploy-log-reconcile-core.ts's + * isEligibleForMissingMark() (which answers "queued"/"running" vs. everything else, + * fail-open for an unrecognized status because marking logMissing is reversible and + * merely metadata). Deleting a run is NOT reversible, so unlike that check, an + * unrecognized status here must fail CLOSED (treated as still in progress), not open. + */ +const TERMINAL_RUN_STATUSES = new Set(['success', 'failed', 'skipped', 'warning', 'error', 'cancelled', 'stale']); + +/** + * Whether a stack_deploy run's status means the deploy is over and its record/log file + * may safely be deleted. + * + * Fails CLOSED for anything not explicitly listed in TERMINAL_RUN_STATUSES -- including + * 'queued', 'running', and any status this code has never been taught about (the status + * column is free text). The alternative (terminal = "not queued/running") would let a + * status this code doesn't recognize be deleted on the assumption that it must be + * finished; there is no way to confirm that, and a wrong guess here is irreversible (see + * loadOwnedDeployRun's DELETE caller in .../deploys/[runId]/+server.ts). + */ +export function isTerminalRunStatus(status: string): boolean { + return TERMINAL_RUN_STATUSES.has(status); +} + +export async function loadOwnedDeployRun( + stackName: string, + runIdParam: string, + auth: AuthorizationContext +): Promise<{ run: ScheduleExecutionData } | { response: Response }> { + const runId = parseInt(runIdParam, 10); + if (isNaN(runId)) { + return { response: json({ error: 'Invalid run id' }, { status: 400 }) }; + } + + const run = await getScheduleExecution(runId); + if (!run || run.scheduleType !== 'stack_deploy' || run.entityName !== stackName) { + return { response: json({ error: 'Deploy run not found' }, { status: 404 }) }; + } + + if (run.environmentId != null && auth.isEnterprise && !(await auth.canAccessEnvironment(run.environmentId))) { + return { response: json({ error: 'Access denied to this environment' }, { status: 403 }) }; + } + + return { run }; +} diff --git a/src/lib/server/deploy-run-record-core.ts b/src/lib/server/deploy-run-record-core.ts new file mode 100644 index 0000000..8602a18 --- /dev/null +++ b/src/lib/server/deploy-run-record-core.ts @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto'; +import type { DeploySummary } from './deploy-summary-core'; + +/** + * Assembles the `details` JSON stored on a stack_deploy schedule_executions row. + * + * Deliberately dependency-free (no db.ts, no filesystem) so it can be unit tested + * under Bun -- see deploy-run-record.ts for the database-touching half that calls + * this. Importing db.ts here would make this module unimportable under Bun + * (better-sqlite3, ERR_DLOPEN_FAILED), breaking this file's own test and dragging + * down every other test file that happens to run in the same process. + */ + +export interface DeployRunOptions { + pull: boolean; + build: boolean; + forceRecreate: boolean; +} + +export interface BuildRunDetailsInput { + options: DeployRunOptions; + summary?: DeploySummary; + exitCode: number; + composeHash: string; + envHash: string; + logFile: string; + /** Set when the run's output hit deploy-log-store's total-size budget and was cut off. */ + truncated?: boolean; +} + +export interface DeployRunDetails { + options: DeployRunOptions; + summary?: DeploySummary; + exitCode: number; + composeHash: string; + envHash: string; + logFile: string; + truncated?: boolean; +} + +/** + * The log file name is what ties this schedule_executions row to its log file on + * disk (deploy-logs/.log). Task 13's reconciler deletes any file whose id has + * no matching record -- if the name were missing here, a live run's own file would + * look orphaned and get eaten out from under it. + */ +export function buildRunDetails(input: BuildRunDetailsInput): DeployRunDetails { + if (!input.logFile) { + throw new Error('buildRunDetails: logFile is required to tie the record to its log file'); + } + return { + options: input.options, + summary: input.summary, + exitCode: input.exitCode, + composeHash: input.composeHash, + envHash: input.envHash, + logFile: input.logFile, + ...(input.truncated ? { truncated: true } : {}) + }; +} + +/** SHA-256 of the compose file content used for this deploy, hex-encoded. */ +export function hashComposeContent(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} + +/** + * SHA-256 fingerprint of the effective env for this deploy (every var -- secret and + * non-secret alike -- that will actually reach the container), hex-encoded. Sorted by + * key so the same env always hashes to the same value regardless of object key order. + * This is a change-fingerprint, not a secret store -- the hash alone can't be turned + * back into the values it was built from, so hashing secret values in is safe even + * though logging them would not be. Callers merge whatever sources make up their + * effective env (DB non-secret + secret vars, or a request body's raw/array vars) + * into one Record before calling this. + */ +export function hashEnvFingerprint(vars: Record): string { + const serialized = Object.keys(vars).sort().map((k) => `${k}=${vars[k]}`).join('\n'); + return createHash('sha256').update(serialized).digest('hex'); +} diff --git a/src/lib/server/deploy-run-record.ts b/src/lib/server/deploy-run-record.ts new file mode 100644 index 0000000..9b6abae --- /dev/null +++ b/src/lib/server/deploy-run-record.ts @@ -0,0 +1,244 @@ +import { + createScheduleExecution, + updateScheduleExecution, + type ScheduleTrigger +} from './db'; +import { appendRunLog, runLogFileName, SizeBudgetTracker } from './deploy-log-store'; +import { summarize } from './deploy-summary-core'; +import { buildRunDetails, type DeployRunOptions } from './deploy-run-record-core'; +import { redactLine } from './secret-redaction'; +import type { RunRecorder } from './sse'; + +/** + * Stored instead of the raw error text when redactLine() had to withhold the whole + * line (a too-short secret value matched, and redacting only that substring would + * risk hitting an unrelated coincidence -- see secret-redaction.ts). A run without an + * error message is unfortunate; a run with a leaked secret in a widely-readable field + * is a security incident, so the row still closes "failed", just without the text. + */ +const ERROR_WITHHELD_MESSAGE = 'Deploy failed (error message withheld: contained a secret value)'; + +/** + * The database-touching half of the stack_deploy run record. Split from + * deploy-run-record-core.ts on purpose: this file imports db.ts (createScheduleExecution + * pulls in better-sqlite3), which is not importable under Bun (ERR_DLOPEN_FAILED). Keeping + * that import out of the pure -core module is what lets deploy-run-record-core.test.ts run + * at all, and keeps it from dragging down unrelated test files sharing the same process. + * + * F5 fix: the caller-supplied `envId` (the environment the run belongs to, or null for a + * run not attributed to one) is threaded through to appendRunLog()/SizeBudgetTracker + * (deploy-log-store.ts) for every filesystem operation this class performs. Before this, + * `envId` was accepted by createRunRecorder()'s input and stored on the schedule_executions + * row (environmentId), but never reached the log file's PATH or its size budget -- every + * environment's deploy output landed in the SAME deploy-logs/ directory and counted against + * the SAME 2 GiB total (deploy-log-store.ts's BUDGET_BYTES), so a noisy deploy in one + * environment could silently truncate another environment's deploy logs. See + * deploy-log-store.ts's SizeBudgetTracker doc comment for the full explanation. + */ + +const TRUNCATION_NOTICE = '\n[deploy log truncated: size budget exceeded]\n'; + +export class DeployRunRecorder implements RunRecorder { + private readonly executionId: number; + private readonly runId: string; + private readonly startedAtMs: number; + private readonly options: DeployRunOptions; + private readonly composeHash: string; + private readonly envHash: string; + private readonly userId?: number; + /** F5 fix: which environment this run belongs to (or null) -- determines the ON-DISK + * directory its log file lives in and which environment's size budget it counts + * against (deploy-log-store.ts's logDir()/SizeBudgetTracker). Never used for anything + * else here -- environmentId on the schedule_executions row itself is set separately, + * by createRunRecorder()'s createScheduleExecution() call. */ + private readonly envId: number | null; + /** Not readonly: addSecrets() extends this after construction -- see its doc comment. */ + private secrets: string[]; + private readonly lines: string[] = []; + /** Chain of queued appends -- see line()'s doc comment for why this exists. */ + private tail: Promise = Promise.resolve(); + private truncated = false; + /** Tracks the deploy-logs/ size budget without a full directory re-scan on every + * appended line -- see SizeBudgetTracker's doc comment (deploy-log-store.ts). */ + private readonly sizeBudget: SizeBudgetTracker; + + constructor( + executionId: number, + startedAtMs: number, + input: { + options: DeployRunOptions; + composeHash: string; + envHash: string; + userId?: number; + secrets: string[]; + /** F5 fix: see the envId field's doc comment above. */ + envId: number | null; + }, + /** Test-only seam: lets tests exercise the truncation path with a tiny budget + * instead of writing gigabytes of real log data. createRunRecorder() never + * passes this, so production always gets SizeBudgetTracker's real default + * (BUDGET_BYTES, deploy-log-store.ts). */ + sizeBudget?: SizeBudgetTracker + ) { + this.executionId = executionId; + this.runId = String(executionId); + this.startedAtMs = startedAtMs; + this.options = input.options; + this.composeHash = input.composeHash; + this.envHash = input.envHash; + this.userId = input.userId; + this.secrets = input.secrets; + this.envId = input.envId; + this.sizeBudget = sizeBudget ?? new SizeBudgetTracker(this.envId, this.runId); + } + + /** + * appendRunLog (deploy-log-store.ts) is async; this method is not. Without a + * chain, two lines arriving close together could race and land in the file out + * of order. Each call hangs its append off the tail of the previous one, so + * writes always happen in the order line() was called -- and end() can await + * `tail` to know every queued write has actually landed before it closes the row. + */ + line(line: string): void { + if (this.truncated) return; + this.tail = this.tail.then(async () => { + if (this.truncated) return; + // Pushed for the summary in the SAME guarded step as the file write, not + // synchronously up in line() -- line() can be called many times before + // the chain gets to run any of them, and truncation only actually takes + // effect partway through that queue. Pushing here keeps `this.lines` + // (what summarize() sees) exactly in sync with what really landed in the + // file: a line that gets skipped because truncation already kicked in + // isn't counted as if it had been written. + this.lines.push(line); + const chunk = line + '\n'; + await appendRunLog(this.envId, this.runId, chunk); + // Checked AFTER writing, not before: a budget check ahead of the write + // would either reject a legitimate line on stale size info, or need a + // second read straight after anyway. Checking once, right after the + // write that just happened, is both simpler and exactly what "abort at + // the end of writing, not truncate mid-line" (design doc §5) asks for. + // recordAppend() itself only re-scans the directory occasionally (see + // SizeBudgetTracker) -- it does NOT re-stat the whole deploy-logs/ + // directory on every call, unlike the budgetExceeded() this replaced. + if (await this.sizeBudget.recordAppend(Buffer.byteLength(chunk, 'utf8'))) { + this.truncated = true; + await appendRunLog(this.envId, this.runId, TRUNCATION_NOTICE); + } + }); + } + + /** + * F4 fix: the caller (a route or deployGitStack) builds this recorder from the + * DB-only vars it has BEFORE calling deployStack(), which then resolves the bound + * secret provider's bulk/inline-ref values internally (resolveProviderEnvVars, + * stacks.ts) -- entirely new values the caller never saw. Without this, a + * provider-resolved secret that ends up in compose's raw stderr passes straight + * through end()'s redaction (it redacts against `this.secrets` only) and into + * errorMessage, which GET /api/schedules/executions returns to every authenticated + * user. Callers pass deployStack()'s result.resolvedSecrets here, after the call + * returns and before end() is invoked -- see stacks.ts's StackOperationResult. + */ + addSecrets(values: string[]): void { + for (const value of values) { + if (!this.secrets.includes(value)) this.secrets.push(value); + } + } + + /** + * Called on every path out of the deploy (success, failure, or throw) -- including + * when no line() ever arrived, e.g. a deploy that fails before compose produces any + * output. That's why this never assumes `this.lines` is non-empty. + */ + async end(ok: boolean, exitCode?: number, error?: string): Promise { + // Wait for every queued append to land before summarizing and closing the + // row -- otherwise a line still in flight could be missing from the summary, + // or the row could close before its own last line is actually on disk. + await this.tail; + + const details = buildRunDetails({ + options: this.options, + summary: this.lines.length > 0 ? summarize(this.lines) : undefined, + // A real exit code (from the local/direct compose path) is preferred; the + // Hawser path doesn't have one to give, so fall back to a code consistent + // with ok/failed rather than leaving it out. + exitCode: exitCode ?? (ok ? 0 : 1), + composeHash: this.composeHash, + envHash: this.envHash, + logFile: runLogFileName(this.runId), + truncated: this.truncated + }); + + // "Who" is best-effort only (design doc §10.5: webhook/cron/startup have no + // user, and auth can be disabled entirely) -- kept out of buildRunDetails' + // pure, tested shape and merged in here instead. + const detailsWithUser = this.userId !== undefined ? { ...details, userId: this.userId } : details; + + // error is compose's raw, unredacted stdout/stderr (deploy-recorder.ts's + // shouldRecord() deliberately excludes the 'result' event from the log file for + // exactly this reason: it never passes through redactLine). This value goes into + // errorMessage on schedule_executions, which GET /api/schedules/executions returns + // to every authenticated user regardless of stack/environment permission -- so it + // gets the same redaction the streamed lines already got before it's stored. + const errorMessage = ok + ? null + : (redactLine(error ?? 'Deploy failed', this.secrets) ?? ERROR_WITHHELD_MESSAGE); + + await updateScheduleExecution(this.executionId, { + status: ok ? 'success' : 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - this.startedAtMs, + errorMessage, + details: detailsWithUser + }); + } +} + +/** + * Creates the schedule_executions row for a stack deploy run and returns a RunRecorder + * that mirrors qualifying progress lines to its log file and closes the row when the + * deploy is over. See sse.ts for why this is built in the route/here, not in sse.ts itself. + */ +export async function createRunRecorder(input: { + stackName: string; + envId: number | null; + userId?: number; + triggeredBy: ScheduleTrigger; + options: DeployRunOptions; + composeHash: string; + envHash: string; + /** Every value (secret AND non-secret) that reaches the container -- same set the + * caller already merged for envHash. Used to redact error/summary text on end(), + * same secrets list stacks.ts uses for makeLineForwarder()/makeRedactedLineSink(). */ + secrets: string[]; +}): Promise { + const execution = await createScheduleExecution({ + scheduleType: 'stack_deploy', + scheduleId: 0, // there is no schedule -- see schema comment "or 0 for system jobs" + environmentId: input.envId, + entityName: input.stackName, + triggeredBy: input.triggeredBy, + status: 'running' + // logs stays empty on purpose: the text lives in the file, not this column. + }); + + // createScheduleExecution doesn't accept startedAt (it fills triggeredAt itself) -- + // same two-step shape as the system-cleanup.ts jobs this one is modeled on. + const startedAtMs = Date.now(); + await updateScheduleExecution(execution.id, { + startedAt: new Date(startedAtMs).toISOString() + }); + + return new DeployRunRecorder(execution.id, startedAtMs, { + options: input.options, + composeHash: input.composeHash, + envHash: input.envHash, + userId: input.userId, + secrets: input.secrets, + // F5 fix: same envId already stored on the schedule_executions row above + // (environmentId: input.envId) -- also threaded to the recorder so its log + // file lands in, and counts against the budget of, THIS run's own + // environment directory. See DeployRunRecorder's envId field doc comment. + envId: input.envId + }); +} diff --git a/src/lib/server/deploy-summary-core.ts b/src/lib/server/deploy-summary-core.ts new file mode 100644 index 0000000..7956799 --- /dev/null +++ b/src/lib/server/deploy-summary-core.ts @@ -0,0 +1,70 @@ +/** + * Condenses compose output into the few facts an operator actually asks after a deploy: + * was anything built or did it all come from cache, was an image pulled or was it already + * there, were containers recreated or merely restarted, and which digest is running now. + * + * Deliberately dependency-free so it can be unit tested under Bun, which cannot import + * the modules that pull in the native database binding. + * + * Compose repeats state transitions when no TTY is attached -- the same "Created" line can + * appear twice -- so every count is over a Set of names, never over line occurrences. + */ +export interface DeploySummary { + containersCreated: number; + containersRecreated: number; + containersStarted: number; + imagesBuilt: string[]; + imagesPulled: string[]; + buildSteps: number; + buildStepsCached: number; + digest?: string; +} + +const CONTAINER = /^\s*Container\s+(\S+)\s+(Created|Recreated|Started)\s*$/; +const IMAGE = /^\s*Image\s+(\S+)\s+(Built|Pulled)\s*$/; +const BUILD_DONE = /^#\d+\s+DONE\s/; +const BUILD_CACHED = /^#\d+\s+CACHED\s*$/; +const DIGEST = /exporting manifest list (sha256:[0-9a-f]{64})/; + +export function summarize(lines: string[]): DeploySummary { + const created = new Set(); + const recreated = new Set(); + const started = new Set(); + const built = new Set(); + const pulled = new Set(); + let buildSteps = 0; + let buildStepsCached = 0; + let digest: string | undefined; + + for (const line of lines) { + const c = CONTAINER.exec(line); + if (c) { + const [, name, verb] = c; + if (verb === 'Created') created.add(name); + else if (verb === 'Recreated') recreated.add(name); + else started.add(name); + continue; + } + const i = IMAGE.exec(line); + if (i) { + const [, name, verb] = i; + (verb === 'Built' ? built : pulled).add(name); + continue; + } + if (BUILD_CACHED.test(line)) { buildSteps++; buildStepsCached++; continue; } + if (BUILD_DONE.test(line)) { buildSteps++; continue; } + const d = DIGEST.exec(line); + if (d) digest = d[1]; + } + + return { + containersCreated: created.size, + containersRecreated: recreated.size, + containersStarted: started.size, + imagesBuilt: [...built], + imagesPulled: [...pulled], + buildSteps, + buildStepsCached, + digest + }; +} diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index a4e9401..fe30910 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -598,6 +598,12 @@ export async function getDockerConfig(envId?: number | null): Promise void; } /** @@ -828,7 +834,7 @@ export async function dockerFetch( const startTime = Date.now(); const config = await getDockerConfig(envId); - const { streaming, ...fetchOptions } = options; + const { streaming, onLine, ...fetchOptions } = options; const method = (options.method || 'GET').toUpperCase(); // Hawser Edge mode - route through WebSocket connection @@ -888,7 +894,8 @@ export async function dockerFetch( streaming || false, (streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal isBinary, - fetchOptions.signal ?? undefined + fetchOptions.signal ?? undefined, + onLine ); const elapsed = Date.now() - startTime; // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) diff --git a/src/lib/server/fs-guard.ts b/src/lib/server/fs-guard.ts index 8d81fd5..b0fa649 100644 --- a/src/lib/server/fs-guard.ts +++ b/src/lib/server/fs-guard.ts @@ -16,6 +16,9 @@ * config embeds remote credentials), at any depth. This blocks the credential * files inside a git-repos clone while still letting the working copies be * browsed (deploy verification, adoption). + * - the deploy-log directory ($DATA_DIR/deploy-logs, see deploy-log-store.ts) — + * deploy logs may carry secrets that survived redaction, and the file + * browser is open by design, so this directory has to be named here too. * * isProtectedPath resolves symlinks (via the nearest existing ancestor) so a * symlink pointing into a protected location can't be used to bypass the check. @@ -38,12 +41,15 @@ function getDataDir(): string { return process.env.DATA_DIR || './data'; } -/** Absolute paths under Dockhand's data dir that must never be exposed. */ -function protectedPaths(): { dbDir: string; keyFile: string } { +/** Absolute paths Dockhand must never expose through the file browser. */ +function protectedPaths(): { dbDir: string; keyFile: string; deployLogsDir: string } { const dataDir = resolve(getDataDir()); return { dbDir: join(dataDir, 'db'), - keyFile: join(dataDir, KEY_FILE_NAME) + keyFile: join(dataDir, KEY_FILE_NAME), + // deploy logs may contain secrets that survived redaction; the file browser is open by + // design, so this directory is named here (see deploy-log-store.ts). + deployLogsDir: join(dataDir, 'deploy-logs') }; } @@ -78,18 +84,23 @@ function isInside(child: string, parent: string): boolean { /** * Returns true if `requestedPath` resolves to a location that must never be read - * through the file browser (Dockhand's DB/key, system secret trees, or any .ssh / - * .git directory). Symlinks are resolved first so they can't bypass it. + * through the file browser (Dockhand's DB/key, the deploy-log directory, system + * secret trees, or any .ssh / .git directory). Symlinks are resolved first so + * they can't bypass it. */ export function isProtectedPath(requestedPath: string): boolean { - const { dbDir, keyFile } = protectedPaths(); + const { dbDir, keyFile, deployLogsDir } = protectedPaths(); const resolved = safeResolve(requestedPath); // Compare against symlink-resolved targets too: the request is resolved, so a // protected target that is itself a symlink (e.g. .encryption_key mounted from // a secret store, or a symlinked db/ volume) must be resolved as well or the // two never match. safeResolve tolerates a not-yet-existing target. - if (resolved === safeResolve(keyFile) || isInside(resolved, safeResolve(dbDir))) { + if ( + resolved === safeResolve(keyFile) || + isInside(resolved, safeResolve(dbDir)) || + isInside(resolved, safeResolve(deployLogsDir)) + ) { return true; } // Match each root both literally and symlink-resolved (e.g. macOS /etc -> /private/etc). diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index e538c27..6d161d6 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -12,11 +12,16 @@ import { updateGitStack, upsertStackSource, getEnvironment, + getSecretEnvVarsAsRecord, + getNonSecretEnvVarsAsRecord, type GitRepository, type GitCredential, - type GitStackWithRepo + type GitStackWithRepo, + type ScheduleTrigger } from './db'; import { deployStack, getStackDir } from './stacks'; +import { createRunRecorder } from './deploy-run-record'; +import { hashComposeContent, hashEnvFingerprint } from './deploy-run-record-core'; import { sendEventNotification } from './notifications'; import { buildBasicAuthHeader } from './git-auth'; import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath, repoBaseEnvPath } from './git-url-safety'; @@ -1315,8 +1320,45 @@ async function notifyGitSync(stackName: string, envId: number | null | undefined } catch { /* never changes the deploy outcome */ } } -export async function deployGitStack(stackId: number, options?: { force?: boolean }): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> { +/** + * The function every git-stack deploy trigger funnels through EXCEPT ONE: a + * config-update-then-redeploy, both webhook methods, create-and-deploy, and (via + * runGitStackSync) the cron scheduler and the schedules page's "run now" all reach + * this function. That is why the stack_deploy run recorder is wired in HERE and not + * at each of those call sites: wiring it at the route level only (the way + * compose/+server.ts does it) would leave every OTHER caller -- crucially the cron- + * and webhook-triggered ones, the truly unattended runs the design doc's Meilenstein 5 + * is about -- unrecorded. Duplicating the composeHash/envHash/secrets setup at + * six-plus call sites would also drift out of sync with each other over time; one + * implementation, reached from everywhere but one, cannot. + * + * The one exception is the UI's own "Deploy" button (deploy-stream/+server.ts), + * which streams live per-step progress via deployGitStackWithProgress() below -- + * a separate pipeline (it re-clones and re-reads the compose file itself rather than + * calling this function) that carries its OWN, separately-wired copy of the same + * recorder setup, right at its own deployStack() call. See that function's doc + * comment for why it is a second call site instead of routing through here. + */ +export async function deployGitStack( + stackId: number, + options?: { + force?: boolean; + /** Who/what triggered this deploy, for the stack_deploy run record. Defaults to + * 'manual' for backward compatibility with existing callers/tests that predate + * this parameter -- every REAL call site below passes its own explicit value. */ + triggeredBy?: ScheduleTrigger; + /** Best-effort only, like every other stack_deploy record (design doc §10.5): + * webhook/cron callers have no user and simply omit this. */ + userId?: number; + /** Forwarded to deployStack() alongside the recorder's own line() call, so an + * HTTP caller (the manual deploy route, the deployNow routes) can still stream + * progress over SSE exactly as before -- recording never depends on whether + * anyone is listening. */ + onLine?: (line: string) => void; + } +): Promise<{ success: boolean; output?: string; error?: string; skipped?: boolean }> { const force = options?.force ?? true; // Default to force for backward compatibility + const triggeredBy: ScheduleTrigger = options?.triggeredBy ?? 'manual'; const gitStack = await getGitStack(stackId); if (!gitStack) { @@ -1386,21 +1428,82 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName); console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)'); - const result = await deployStack({ - name: gitStack.stackName, - compose: syncResult.composeContent!, - envId: gitStack.environmentId, - sourceDir: syncResult.composeDir, // Copy entire directory from git repo - composeFileName: syncResult.composeFileName, // Use original compose filename from repo - envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional) - forceRecreate, - build: gitStack.buildOnDeploy, - noBuildCache: gitStack.noBuildCache, - pullPolicy: gitStack.repullImages ? 'always' : undefined, - filesToDelete: syncResult.deletionPlan?.toDelete, - isGitDeploy: true // suppress stack_* notification; we emit git_sync_* below + // Same stack_deploy run record shape the non-git routes build (compose PUT, + // dedicated deploy endpoint) -- see deploy-run-record.ts. composeHash is taken + // from syncResult.composeContent, the EXACT string handed to deployStack right + // below, not a redundant re-read off disk. + // + // envHash/secrets mirror the same (deliberately approximate) resolution the + // non-git routes already use: getNonSecretEnvVarsAsRecord/getSecretEnvVarsAsRecord + // keyed by stackName+envId -- the same DB-sourced values deployStack itself + // resolves again internally at deploy time (stacks.ts). This does NOT include + // secret-provider-injected vars or the git repo's own .env file content; that's + // fine for the redaction list because deployStack's onLine output is + // independently redacted against ITS OWN, more complete secrets set + // (makeLineForwarder in stacks.ts) -- a leaked value in the log is still caught + // even if it isn't one of the vars hashed here. + const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const effectiveEnvVars = { ...nonSecretVars, ...secretVars }; + + const recorder = await createRunRecorder({ + stackName: gitStack.stackName, + envId: gitStack.environmentId ?? null, + userId: options?.userId, + triggeredBy, + options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: !!forceRecreate }, + composeHash: hashComposeContent(syncResult.composeContent!), + envHash: hashEnvFingerprint(effectiveEnvVars), + secrets: Object.values(effectiveEnvVars) }); + // Mirrors every redacted output line into the run record's log file, in addition + // to (not instead of) forwarding it to the caller's own onLine (SSE progress) -- + // see the recorder doc comment above for why this can't be "record OR stream". + const onLine = (line: string) => { + recorder.line(line); + options?.onLine?.(line); + }; + + let result: Awaited>; + try { + result = await deployStack({ + name: gitStack.stackName, + compose: syncResult.composeContent!, + envId: gitStack.environmentId, + sourceDir: syncResult.composeDir, // Copy entire directory from git repo + composeFileName: syncResult.composeFileName, // Use original compose filename from repo + envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional) + forceRecreate, + build: gitStack.buildOnDeploy, + noBuildCache: gitStack.noBuildCache, + pullPolicy: gitStack.repullImages ? 'always' : undefined, + filesToDelete: syncResult.deletionPlan?.toDelete, + isGitDeploy: true, // suppress stack_* notification; we emit git_sync_* below + onLine + }); + } catch (error) { + // deployStack has never been observed to throw here (every existing caller of + // deployGitStack calls it without a try/catch around this point), but the + // record must not be left "running" forever on the day that changes. + await recorder.end(false, undefined, error instanceof Error ? error.message : String(error)); + throw error; + } + + // F4 fix: deployStack() resolves the bound secret provider's values internally, + // AFTER `recorder` above was already built from the DB-only nonSecretVars/ + // secretVars this function read before calling it. Feed the provider-resolved + // values in now, before recorder.end() below -- otherwise a provider secret + // leaking into result.error (a real possibility: cron/webhook-triggered git + // deploys are exactly the unattended runs most likely to use a bound provider) + // would bypass end()'s redaction entirely. See deploy-run-record.ts. + recorder.addSecrets(result.resolvedSecrets ?? []); + + // Closed right after the result is known, before the post-success bookkeeping + // below (deletion sync, upsertStackSource) -- those never fail the deploy itself, + // so the recorded duration reflects the deploy, not the bookkeeping around it. + await recorder.end(result.success, undefined, result.success ? undefined : result.error); + console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} DEPLOY GIT STACK RESULT`); console.log(`${logPrefix} ----------------------------------------`); @@ -1523,6 +1626,17 @@ type ProgressCallback = (data: { error?: string; }) => void; +/** + * The UI's own "Deploy" button (deploy-stream/+server.ts): runs its own clone/read/ + * deploy pipeline, reporting 5 discrete stages via onProgress, rather than calling + * deployGitStack() -- that function's onLine streams raw compose output lines, a + * different contract than the stage messages this one sends. Because of that split, + * this is a SECOND call site that reaches deployStack() directly, and it carries its + * own copy of the same stack_deploy run-record wiring deployGitStack() has (built + * right before its own deployStack() call below) rather than inheriting it -- see + * deployGitStack's doc comment for the full picture of which callers go through + * which path. + */ export async function deployGitStackWithProgress( stackId: number, onProgress: ProgressCallback @@ -1554,6 +1668,11 @@ export async function deployGitStackWithProgress( const totalSteps = 5; + // Created just before the deployStack() call below, same as deployGitStack -- + // undefined here means the run never got that far (e.g. clone failed), exactly + // mirroring deployGitStack's own "only record a run that is actually happening". + let recorder: Awaited> | undefined; + try { // Step 1: Connecting onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps }); @@ -1720,19 +1839,67 @@ export async function deployGitStackWithProgress( } } - const result = await deployStack({ - name: gitStack.stackName, - compose: composeContent, - envId: gitStack.environmentId, - sourceDir: composeDir, // Copy entire directory from git repo - composeFileName: progressComposeFileName, // Compose filename relative to source dir - envFileName, // Env file relative to compose dir (for --env-file flag, optional) - build: gitStack.buildOnDeploy, - noBuildCache: gitStack.noBuildCache, - pullPolicy: gitStack.repullImages ? 'always' : undefined, - filesToDelete: deletionData.plan.toDelete + // Same stack_deploy run record shape deployGitStack builds (see its doc + // comment above deployGitStack) -- this manual "Deploy" button path runs its + // own clone/read/deploy pipeline instead of calling deployGitStack (it needs + // this function's own discrete onProgress stages, not deployGitStack's onLine + // streaming), so it was the one caller of deployStack that never went through + // createRunRecorder at all -- unlike every other trigger (webhook, + // config-update-then-redeploy, and cron via runGitStackSync, all of which + // funnel through deployGitStack). Wired in here, right before the SAME + // deployStack() call deployGitStack wires it around, so it records without + // touching onProgress's own streaming. + const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const effectiveEnvVars = { ...nonSecretVars, ...secretVars }; + + recorder = await createRunRecorder({ + stackName: gitStack.stackName, + envId: gitStack.environmentId ?? null, + triggeredBy: 'manual', + // forceRecreate is never passed to deployStack below (unlike deployGitStack, + // which computes it from forceRedeploy/gitUpdated) -- this path always omits + // it, so the record should say so too rather than guessing. + options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: false }, + composeHash: hashComposeContent(composeContent), + envHash: hashEnvFingerprint(effectiveEnvVars), + secrets: Object.values(effectiveEnvVars) }); + let result: Awaited>; + try { + result = await deployStack({ + name: gitStack.stackName, + compose: composeContent, + envId: gitStack.environmentId, + sourceDir: composeDir, // Copy entire directory from git repo + composeFileName: progressComposeFileName, // Compose filename relative to source dir + envFileName, // Env file relative to compose dir (for --env-file flag, optional) + build: gitStack.buildOnDeploy, + noBuildCache: gitStack.noBuildCache, + pullPolicy: gitStack.repullImages ? 'always' : undefined, + filesToDelete: deletionData.plan.toDelete, + // Mirrors deploy output into the run record's log file. Not forwarded to + // onProgress -- that callback's contract is the 5 discrete stage messages + // this function already sends above, not raw compose output lines. + onLine: (line) => recorder?.line(line) + }); + } catch (error) { + // deployStack has never been observed to throw here, but the record must + // not be left "running" forever on the day that changes (same posture + // deployGitStack takes around its own deployStack call). + await recorder.end(false, undefined, error instanceof Error ? error.message : String(error)); + throw error; + } + + // F4 fix (see deployGitStack's own addSecrets() call for the full rationale): + // deployStack() resolves the bound secret provider's values internally, after + // `recorder` above was already built from the DB-only vars read before calling + // it. Feed the provider-resolved values in now, before recorder.end(), so a + // provider secret leaking into result.error is still redacted. + recorder.addSecrets(result.resolvedSecrets ?? []); + await recorder.end(result.success, undefined, result.success ? undefined : result.error); + if (result.success) { // Deletion sync: persist manifest + log per-file change summary. // The change table was already shown before the deploy (#1260); diff --git a/src/lib/server/hawser-core.ts b/src/lib/server/hawser-core.ts new file mode 100644 index 0000000..e9203fd --- /dev/null +++ b/src/lib/server/hawser-core.ts @@ -0,0 +1,48 @@ +/** + * hawser-core.ts — the PURE parts of the Hawser Edge protocol message routing: no DB import, + * so it is unit-testable directly (importing hawser.ts pulls in db/drizzle -> better-sqlite3, + * which bun's test runner can't load). hawser.ts re-exports this so callers have one import site. + */ + +/** The minimal shape dispatchStreamMessage needs off an EdgeConnection. */ +export interface StreamDispatchTarget { + pendingStreamRequests?: Map void }>; + // Line callbacks for non-streaming requests (e.g. compose) that still want to observe + // 'stream' messages as they arrive. Separate from pendingStreamRequests, which belongs + // to requests sent with streaming: true (see sendEdgeStreamRequest in hawser.ts). + lineHandlers?: Map void>; +} + +/** + * Route a 'stream' message from the agent to whichever consumer is waiting for it. + * + * Two independent registries can claim a requestId: pendingStreamRequests (requests sent + * with streaming: true) and lineHandlers (non-streaming requests, e.g. compose, that still + * want to observe line-by-line output as it arrives). A requestId is only ever registered in + * one of the two — pendingStreamRequests is checked first, matching the previous behaviour of + * the inline 'stream' case this was extracted from. + * + * Compose requests are sent with streaming: false and land in pendingRequests only. Before + * this function existed, the message dispatcher's 'stream' case looked up pendingStreamRequests + * exclusively, so any line message for a non-streaming request vanished silently — no error, no + * warning. + */ +export function dispatchStreamMessage( + connection: StreamDispatchTarget, + msg: { requestId: string; data: unknown } +): void { + const streamPending = connection.pendingStreamRequests?.get(msg.requestId); + if (streamPending) { + streamPending.onData?.(msg.data as string); + return; + } + + const lineHandler = connection.lineHandlers?.get(msg.requestId); + if (!lineHandler) return; // no handler registered: drop silently, this is the normal case + + // Go marshals StreamMessage.Data ([]byte) as base64 -- sendEdgeStreamRequest's onData + // callback does the same decode on the streaming path. Without this, the UI shows base64 + // gibberish instead of readable output. + const text = typeof msg.data === 'string' ? Buffer.from(msg.data, 'base64').toString('utf8') : ''; + if (text) lineHandler(text); +} diff --git a/src/lib/server/hawser.ts b/src/lib/server/hawser.ts index 70938bf..ec1cf4c 100644 --- a/src/lib/server/hawser.ts +++ b/src/lib/server/hawser.ts @@ -14,6 +14,11 @@ import { isHealthTransition } from './subprocess-manager.js'; import { pushMetric } from './metrics-store.js'; import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js'; import { hashPassword, verifyPassword } from './auth.js'; +// The 'stream' message routing logic lives in a db-free core module so it stays unit-testable +// (importing this file pulls in db/drizzle -> better-sqlite3, which bun's test runner can't +// load). Re-exported so callers get it from one import site. +import { dispatchStreamMessage } from './hawser-core.js'; +export { dispatchStreamMessage }; // Protocol constants export const HAWSER_PROTOCOL_VERSION = '1.0'; @@ -48,6 +53,10 @@ export interface EdgeConnection { lastHeartbeat: number; pendingRequests: Map; pendingStreamRequests: Map; + // Line callbacks for non-streaming requests (e.g. compose) that still want to observe + // 'stream' messages as they arrive. Separate from pendingStreamRequests, which belongs + // to requests sent with streaming: true (see sendEdgeStreamRequest). + lineHandlers?: Map void>; pingInterval?: ReturnType; lastMetrics?: { uptime?: number; @@ -514,7 +523,8 @@ export function handleEdgeConnection( connectedAt: new Date(), lastHeartbeat: Date.now(), pendingRequests: new Map(), - pendingStreamRequests: new Map() + pendingStreamRequests: new Map(), + lineHandlers: new Map() }; edgeConnections.set(environmentId, connection); @@ -578,7 +588,8 @@ export async function sendEdgeRequest( streaming = false, timeout = 30000, isBinary = false, - signal?: AbortSignal + signal?: AbortSignal, + onLine?: (line: string) => void ): Promise { const connection = edgeConnections.get(environmentId); if (!connection) { @@ -587,9 +598,18 @@ export async function sendEdgeRequest( const requestId = secureRandomUUID(); + // A non-streaming request (compose) that wants to observe output as it happens. The agent + // sends it as 'stream' messages, which dispatchStreamMessage routes here by requestId. + // Registered before the message goes out, so no early line can miss the handler. + if (onLine) { + connection.lineHandlers ??= new Map(); + connection.lineHandlers.set(requestId, onLine); + } + return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { connection.pendingRequests.delete(requestId); + connection.lineHandlers?.delete(requestId); if (streaming) { connection.pendingStreamRequests.delete(requestId); } @@ -607,6 +627,7 @@ export async function sendEdgeRequest( 'abort', () => { connection.pendingRequests.delete(requestId); + connection.lineHandlers?.delete(requestId); if (streaming) { connection.pendingStreamRequests.delete(requestId); } @@ -700,6 +721,7 @@ export async function sendEdgeRequest( const sent = globalThis.__hawserSendMessage(environmentId, messageStr); if (!sent) { connection.pendingRequests.delete(requestId); + connection.lineHandlers?.delete(requestId); if (streaming) { connection.pendingStreamRequests.delete(requestId); } @@ -713,6 +735,7 @@ export async function sendEdgeRequest( const errorMsg = sendError instanceof Error ? sendError.message : String(sendError); console.error(`[Hawser Edge] Error sending message:`, errorMsg); connection.pendingRequests.delete(requestId); + connection.lineHandlers?.delete(requestId); if (streaming) { connection.pendingStreamRequests.delete(requestId); } @@ -1261,6 +1284,10 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp if (pending) { clearTimeout(pending.timeout); connection.pendingRequests.delete(msg.requestId); + // A non-streaming request that registered a line handler (compose, Task 9) is + // done once its response arrives -- without this the map grows unbounded for + // the lifetime of the process. + connection.lineHandlers?.delete(msg.requestId); pending.resolve({ statusCode: msg.statusCode, headers: msg.headers || {}, @@ -1272,10 +1299,7 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp } case 'stream': { - const streamPending = connection.pendingStreamRequests.get(msg.requestId); - if (streamPending) { - streamPending.onData?.(msg.data); - } + dispatchStreamMessage(connection, msg); break; } diff --git a/src/lib/server/process-output-core.ts b/src/lib/server/process-output-core.ts new file mode 100644 index 0000000..680bd9b --- /dev/null +++ b/src/lib/server/process-output-core.ts @@ -0,0 +1,56 @@ +/** + * Pure child-process output collector. No imports, so it is unit-testable in + * the bun runner without dragging in better-sqlite3 via stacks.ts -> ./db -> + * ./db/drizzle (top-level `await seedDatabase()`, unsupported under Bun -- + * see tests/stacks-collect-process.test.ts for the reproduction). + */ +import type { ChildProcess } from 'node:child_process'; + +export type LineCallback = (line: string) => void; + +/** + * Collect stdout/stderr from a child process and wait for it to exit. + * When `onLine` is provided, it is called for each complete line (split on '\n') + * seen on either stdout or stderr, in arrival order, as the process runs — plus + * once more for a trailing partial line (no terminating '\n') once the process closes. + * Without `onLine`, behavior is unchanged. + */ +export function collectProcess( + proc: ChildProcess, + onLine?: LineCallback +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let lineBuffer = ''; + + const emitLines = (chunk: string) => { + if (!onLine) return; + lineBuffer += chunk; + const parts = lineBuffer.split('\n'); + lineBuffer = parts.pop() ?? ''; // last part may be incomplete + for (const line of parts) onLine(line); + }; + + proc.stdout?.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + emitLines(chunk.toString()); + }); + proc.stderr?.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + emitLines(chunk.toString()); + }); + proc.on('error', reject); + proc.on('close', (code) => { + if (onLine && lineBuffer.length > 0) { + onLine(lineBuffer); + lineBuffer = ''; + } + resolve({ + exitCode: code ?? 1, + stdout: Buffer.concat(stdoutChunks).toString(), + stderr: Buffer.concat(stderrChunks).toString() + }); + }); + }); +} diff --git a/src/lib/server/scheduler/index.ts b/src/lib/server/scheduler/index.ts index 7564e40..78e8b58 100644 --- a/src/lib/server/scheduler/index.ts +++ b/src/lib/server/scheduler/index.ts @@ -18,11 +18,13 @@ import { getScheduleCleanupCron, getEventCleanupCron, getScannerCleanupCron, + getDeployLogReconcileCron, getScheduleRetentionDays, getEventRetentionDays, getScheduleCleanupEnabled, getEventCleanupEnabled, getScannerCleanupEnabled, + getDeployLogReconcileEnabled, getEnvironments, getEnvUpdateCheckSettings, getAllEnvUpdateCheckSettings, @@ -61,6 +63,7 @@ import { SYSTEM_VOLUME_HELPER_CLEANUP_ID, SYSTEM_SCANNER_CLEANUP_ID } from './tasks/system-cleanup'; +import { runDeployLogReconcileJob, DEPLOY_LOG_RECONCILE_ID } from './tasks/deploy-log-reconcile'; // Store all active cron jobs const activeJobs: Map = new Map(); @@ -70,6 +73,7 @@ let cleanupJob: Cron | null = null; let eventCleanupJob: Cron | null = null; let volumeHelperCleanupJob: Cron | null = null; let scannerCacheCleanupJob: Cron | null = null; +let deployLogReconcileJob: Cron | null = null; // Scheduler state let isRunning = false; @@ -170,6 +174,7 @@ export async function startScheduler(): Promise { const scheduleCleanupCron = await getScheduleCleanupCron(); const eventCleanupCron = await getEventCleanupCron(); const scannerCleanupCron = await getScannerCleanupCron(); + const deployLogReconcileCron = await getDeployLogReconcileCron(); const defaultTimezone = await getDefaultTimezone(); // Start system cleanup jobs (static schedules with default timezone) @@ -205,10 +210,19 @@ export async function startScheduler(): Promise { }); } + // Deploy log reconcile: own enabled flag + cron, same pattern as scanner cleanup + const deployLogReconcileEnabled = await getDeployLogReconcileEnabled(); + if (deployLogReconcileEnabled) { + deployLogReconcileJob = new Cron(deployLogReconcileCron, { timezone: defaultTimezone, legacyMode: false }, async () => { + await runDeployLogReconcileJob('cron'); + }); + } + console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`); console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`); console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`); console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`); + console.log(`[Scheduler] Deploy log reconcile: ${deployLogReconcileEnabled ? deployLogReconcileCron : 'disabled'} [${defaultTimezone}]`); // Register all dynamic schedules from database await refreshAllSchedules(); @@ -247,6 +261,10 @@ export function stopScheduler(): void { scannerCacheCleanupJob.stop(); scannerCacheCleanupJob = null; } + if (deployLogReconcileJob) { + deployLogReconcileJob.stop(); + deployLogReconcileJob = null; + } // Stop all dynamic jobs for (const [key, job] of activeJobs.entries()) { @@ -668,6 +686,8 @@ export async function refreshSystemJobs(): Promise { const eventCleanupCron = await getEventCleanupCron(); const scannerCleanupCron = await getScannerCleanupCron(); const scannerCleanupEnabled = await getScannerCleanupEnabled(); + const deployLogReconcileCron = await getDeployLogReconcileCron(); + const deployLogReconcileEnabled = await getDeployLogReconcileEnabled(); const defaultTimezone = await getDefaultTimezone(); // Cleanup functions to pass to the job @@ -693,6 +713,9 @@ export async function refreshSystemJobs(): Promise { if (scannerCacheCleanupJob) { scannerCacheCleanupJob.stop(); } + if (deployLogReconcileJob) { + deployLogReconcileJob.stop(); + } // Re-create with new timezone cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => { @@ -713,10 +736,17 @@ export async function refreshSystemJobs(): Promise { }); } + if (deployLogReconcileEnabled) { + deployLogReconcileJob = new Cron(deployLogReconcileCron, { timezone: defaultTimezone, legacyMode: false }, async () => { + await runDeployLogReconcileJob('cron'); + }); + } + console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`); console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`); console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`); console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`); + console.log(`[Scheduler] Deploy log reconcile: ${deployLogReconcileEnabled ? deployLogReconcileCron : 'disabled'} [${defaultTimezone}]`); } // ============================================================================= @@ -861,6 +891,23 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea } } +/** + * Manually trigger the deploy log reconcile job. + * + * A dedicated function rather than a jobId branch in triggerSystemJob() above: this job + * has its own scheduleType ('deploy_log_reconcile', not 'system_cleanup'), so its + * DEPLOY_LOG_RECONCILE_ID (1) would otherwise collide with SYSTEM_SCHEDULE_CLEANUP_ID + * (also 1) in that function's flat, type-less jobId dispatch. + */ +export async function triggerDeployLogReconcile(): Promise<{ success: boolean; executionId?: number; error?: string }> { + try { + runDeployLogReconcileJob('manual'); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } +} + // ============================================================================= // UTILITY FUNCTIONS // ============================================================================= @@ -881,6 +928,8 @@ export async function getSystemSchedules(): Promise { const scheduleCleanupEnabled = await getScheduleCleanupEnabled(); const eventCleanupEnabled = await getEventCleanupEnabled(); const scannerCleanupEnabled = await getScannerCleanupEnabled(); + const deployLogReconcileCron = await getDeployLogReconcileCron(); + const deployLogReconcileEnabled = await getDeployLogReconcileEnabled(); return [ { @@ -922,13 +971,23 @@ export async function getSystemSchedules(): Promise { nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null, isSystem: true, enabled: scannerCleanupEnabled + }, + { + id: DEPLOY_LOG_RECONCILE_ID, + type: 'deploy_log_reconcile' as const, + name: 'Deploy log reconcile', + description: 'Reconciles deploy-log files on disk against stack_deploy run records', + cronExpression: deployLogReconcileCron, + nextRun: deployLogReconcileEnabled ? getNextRun(deployLogReconcileCron)?.toISOString() ?? null : null, + isSystem: true, + enabled: deployLogReconcileEnabled } ]; } export interface SystemScheduleInfo { id: number; - type: 'system_cleanup'; + type: 'system_cleanup' | 'deploy_log_reconcile'; name: string; description: string; cronExpression: string; diff --git a/src/lib/server/scheduler/tasks/deploy-log-reconcile.ts b/src/lib/server/scheduler/tasks/deploy-log-reconcile.ts new file mode 100644 index 0000000..164036d --- /dev/null +++ b/src/lib/server/scheduler/tasks/deploy-log-reconcile.ts @@ -0,0 +1,321 @@ +/** + * Deploy Log Reconcile Task + * + * Compares deploy-log files on disk (deploy-log-store.ts) against stack_deploy run + * records in the database and reconciles the two: a file with no record is deleted (an + * orphan with nothing left to explain it); a record with no file is only marked via + * details.logMissing -- never deleted, since the metadata (when, who, what was built, + * which digest) stays valuable even once the log text is gone. See + * deploy-log-reconcile-core.ts for the decision itself; this file only gathers the two + * id sets, guards the one case planReconcile can't know about (see below), and carries + * out the resulting plan. + * + * F5 fix -- reconciled PER ENVIRONMENT, never across environments: deploy-log-store.ts + * now keeps one log directory per environment (envDirName()/logDir()), because a SHARED + * directory and a SHARED size budget let a noisy deploy in one environment silently + * truncate another environment's deploy logs. This job has to honor that same boundary, + * or it would reintroduce the exact cross-environment mixing the size-budget fix closes + * -- e.g. deleting environment B's file because it happens to share a numeric run id + * with an orphan in environment A, or marking environment A's record logMissing because + * environment B happens to hold a same-numbered file. Every id set this file gathers + * (files AND records) is therefore grouped by environment FIRST, and planReconcile() + * (the pure decision core) is called ONCE PER ENVIRONMENT, on that environment's own two + * sets only -- never once on everything flattened together. + * + * The set of environments to look at comes from BOTH sides on purpose: + * - listEnvDirNames() (deploy-log-store.ts) -- every environment that currently has + * ANY file on disk, including one that was later deleted from the database (its + * orphaned files must still be reconciled, so its directory must still be visited). + * - every DISTINCT environmentId among the stack_deploy records themselves -- an + * environment whose only record has no file at all would otherwise never appear + * via listEnvDirNames() (there is no directory for it to discover), and its record + * would never be considered for markMissing. + * The union of the two is walked once each; a directory name that cannot be parsed back + * into an environment id (parseEnvDirName()) is skipped defensively rather than crashing + * the whole job -- see the per-environment loop below. + * + * Record source, verified against Task 10's actual code (deploy-run-record.ts, read + * directly from a sibling clone -- Task 10 had not landed on this branch at the time + * this file was written, so it could not be imported here): createRunRecorder() creates + * a schedule_executions row with scheduleType 'stack_deploy', scheduleId 0 (there is no + * schedule, only individual runs), and DeployRunRecorder sets `this.runId = + * String(executionId)` -- exactly the id this file reads via + * getScheduleExecutionIdsByType('stack_deploy') and converts with String(r.id). Confirmed + * correct, not assumed. + * + * That same reading surfaced a real race this job has to guard against: the row is + * created with status 'running' BEFORE the deploy has produced a single line, and the + * log file itself only comes into existence on the first DeployRunRecorder.line() call + * (appendRunLog() -- deploy-log-store.ts). So there is a real, if narrow, window where a + * 'running' record legitimately has no file yet. A naive reconcile firing in that window + * would mark a deploy that is actively in progress as logMissing -- and nothing in this + * job ever re-checks or clears that flag once the file does show up, so the record would + * carry a false "log missing" forever. Records still in a non-terminal state ('queued'/ + * 'running') are therefore excluded from markMissing candidacy below, while still + * counting toward recordIds (within their own environment's set) so an already-existing + * file for them is protected from deletion either way. + * + * TOCTOU guard on the delete side: `records` (below) is read ONCE, at the very start + * of this job, via a single getScheduleExecutionIdsByType() call -- a point-in-time + * snapshot, not a live view. A deploy whose createRunRecorder() call happens AFTER + * that snapshot has no row in `records` at all, yet its log file can already exist by + * the time THIS environment's fileIds are listed a moment later (appendRunLog() writes + * the file on the very first streamed line, which can arrive within the same tick). + * Nothing in planReconcile() can tell that file apart from a genuine orphan -- both + * simply have no matching id in `records`. Deleting it would tear a live deploy's log + * out from under it while it is still being written to. + * + * Reported independently and confirmed by re-reading this exact snapshot-then-list + * ordering: fixed by treating a delete candidate's file mtime as a second signal + * alongside `records`, not by trying to make the DB read and the directory listing + * atomic with each other (they are two different data sources; no single query spans + * both). ORPHAN_DELETE_GRACE_MS below is deliberately generous compared to how long + * this job actually takes to walk from its `records` snapshot to any one + * environment's listRunLogIds() call (well under a second in the ordinary case) -- + * appendRunLog() keeps bumping a live deploy's file mtime for as long as it keeps + * writing, so the grace period does not need to outlast the whole deploy, only the + * gap between the snapshot and this environment being reached. A file newer than the + * grace period is left alone THIS run; the NEXT scheduled run will see its by-then- + * created record and correctly never consider it for deletion again -- nothing is + * lost, the file is just given one more cycle to prove itself real, exactly the same + * shape of trade-off isEligibleForMissingMark() already makes on the other side of + * this same race (see above). + * + * Each element in both loops below is wrapped in its own try/catch. Without that, one + * throwing deleteRunLog/updateScheduleExecution call would abort the whole run -- and + * since the failing file/record is never removed/marked, every LATER run would fail at + * the exact same element again, permanently. Reported and reproduced independently: a + * harmless orphan file ordered before a failing one was still removed, everything after + * it was not, and the failed run's own `details` came back `null` -- no record of what + * had actually happened. Per-element isolation plus real counts (see below) fixes both -- + * and now applies WITHIN each environment's own loop, so a failure in one environment's + * reconciliation cannot stop a later environment's elements from being tried either. + */ + +import type { ScheduleTrigger } from '../../db'; +import { + getDeployLogReconcileEnabled, + getScheduleExecutionIdsByType, + createScheduleExecution, + updateScheduleExecution, + appendScheduleExecutionLog +} from '../../db'; +import { + listEnvDirNames, + listRunLogIds, + deleteRunLog, + envDirName, + parseEnvDirName, + getRunLogMtimeMs +} from '../../deploy-log-store'; +import { planReconcile, isEligibleForMissingMark } from '../../deploy-log-reconcile-core'; + +// System job ID (own scheduleType, so this never collides with system-cleanup.ts's ids) +export const DEPLOY_LOG_RECONCILE_ID = 1; + +/** + * How much younger than "now" a delete candidate's file mtime may be before this job + * still deletes it. See the TOCTOU guard in the module doc comment above: this only + * needs to outlast the gap between the `records` snapshot and this environment's + * listRunLogIds() call (ordinarily well under a second), never the whole deploy -- + * appendRunLog() keeps re-bumping a live deploy's mtime for as long as it keeps + * writing. Generous on purpose; a false skip just costs one more reconcile cycle, + * while a false delete destroys a live deploy's log outright. + */ +const ORPHAN_DELETE_GRACE_MS = 5 * 60 * 1000; // 5 minutes + +/** + * The filesystem calls this job makes, injectable so a test can force one element + * to fail while its neighbours succeed, and so a test can populate a fake per-environment + * file layout without touching the real filesystem. + * + * They are parameters rather than a mock.module() of '../../deploy-log-store' because that + * call freezes the module's export shape for the WHOLE test process: deploy-run-record.ts + * imports appendRunLog/runLogFileName/SizeBudgetTracker from the same specifier, and a + * partial factory left those missing for every module that resolved it afterwards. + */ +export interface DeployLogReconcileDeps { + listEnvDirNames: () => Promise; + listRunLogIds: (envId: number | null) => Promise; + deleteRunLog: (envId: number | null, runId: string) => Promise; + /** mtime (ms since epoch) of a delete candidate's file, or null if it is already gone. */ + getRunLogMtimeMs: (envId: number | null, runId: string) => Promise; +} + +const defaultDeps: DeployLogReconcileDeps = { + listEnvDirNames, + listRunLogIds, + deleteRunLog, + getRunLogMtimeMs +}; + +/** + * Execute the deploy-log reconcile job. + */ +export async function runDeployLogReconcileJob( + triggeredBy: ScheduleTrigger = 'cron', + deps: DeployLogReconcileDeps = defaultDeps +): Promise { + // Check if reconcile is enabled (skip check if manually triggered) + if (triggeredBy === 'cron') { + const enabled = await getDeployLogReconcileEnabled(); + if (!enabled) { + return; // Skip execution if disabled + } + } + + const startTime = Date.now(); + + // Create execution record + const execution = await createScheduleExecution({ + scheduleType: 'deploy_log_reconcile', + scheduleId: DEPLOY_LOG_RECONCILE_ID, + environmentId: null, + entityName: 'Deploy log reconcile', + triggeredBy, + status: 'running' + }); + + await updateScheduleExecution(execution.id, { + startedAt: new Date().toISOString() + }); + + const log = async (message: string) => { + console.log(`[Deploy Log Reconcile] ${message}`); + await appendScheduleExecutionLog(execution.id, `[${new Date().toISOString()}] ${message}`); + }; + + try { + const records = await getScheduleExecutionIdsByType('stack_deploy'); + + // F5 fix: group records by their OWN environment (via envDirName -- the same + // directory-name mapping deploy-log-store.ts uses on the filesystem side), so + // each environment's records are only ever compared against that SAME + // environment's files below -- never another environment's. + const recordsByEnvKey = new Map(); + for (const record of records) { + const key = envDirName(record.environmentId); + const list = recordsByEnvKey.get(key); + if (list) list.push(record); + else recordsByEnvKey.set(key, [record]); + } + + // The set of environments to actually visit: every environment that has a + // directory on disk (covers orphan files for an environment that no longer + // has ANY record, e.g. a deleted environment) UNION every environment that has + // at least one record (covers a record whose file has never been written, so + // there is no directory for listEnvDirNames() to discover it by -- see the + // module doc comment). + const envKeysOnDisk = await deps.listEnvDirNames(); + const envKeys = new Set([...envKeysOnDisk, ...recordsByEnvKey.keys()]); + + let filesFoundTotal = 0; + // Actually-performed counts, not the plan's sizes -- a failing element must not + // be counted as done, and must not stop the elements after it (in this SAME + // environment, or in a LATER environment) from being tried. + let deletedCount = 0; + let markedCount = 0; + let skippedInProgress = 0; + let skippedRecent = 0; + let failedCount = 0; + + for (const envKey of envKeys) { + let envId: number | null; + try { + envId = parseEnvDirName(envKey); + } catch { + // A directory under deploy-logs/ that isn't one of envDirName()'s own + // outputs (e.g. an unrelated file or a hand-created directory) is not an + // environment this job knows how to reconcile -- skipped defensively + // rather than crashing the whole run over it. + await log(`Skipping unrecognized deploy-logs entry: ${envKey}`); + continue; + } + + const envRecords = recordsByEnvKey.get(envKey) ?? []; + const recordIds = envRecords.map((r) => String(r.id)); + const fileIds = await deps.listRunLogIds(envId); + filesFoundTotal += fileIds.length; + + const plan = planReconcile({ fileIds, recordIds }); + + for (const fileId of plan.deleteFiles) { + try { + // TOCTOU guard (see module doc comment): `records` was snapshotted + // once, before this loop started. A file this fresh may belong to a + // deploy whose record was created AFTER that snapshot -- not to an + // abandoned orphan. Leave it for a later run, which will see its + // by-then-existing record and correctly never touch it again. + const mtimeMs = await deps.getRunLogMtimeMs(envId, fileId); + if (mtimeMs !== null && Date.now() - mtimeMs < ORPHAN_DELETE_GRACE_MS) { + skippedRecent++; + continue; + } + await deps.deleteRunLog(envId, fileId); + deletedCount++; + } catch (error: any) { + failedCount++; + await log(`Failed to delete orphan file for run ${fileId} (environment ${envKey}): ${error.message}`); + } + } + + const byId = new Map(envRecords.map((r) => [String(r.id), r])); + for (const recordId of plan.markMissing) { + const record = byId.get(recordId); + // Still running/queued: it may simply not have written its first line yet + // (see module doc comment). Leave it alone -- a later reconcile run will + // catch it correctly once the run has actually finished. + if (record && !isEligibleForMissingMark(record.status)) { + skippedInProgress++; + continue; + } + try { + await updateScheduleExecution(Number(recordId), { + details: { ...(record?.details ?? {}), logMissing: true } + }); + markedCount++; + } catch (error: any) { + failedCount++; + await log(`Failed to mark record ${recordId} as logMissing (environment ${envKey}): ${error.message}`); + } + } + } + + await log( + `Found ${filesFoundTotal} deploy-log file(s) across ${envKeys.size} environment(s) and ${records.length} stack_deploy record(s)` + ); + await log( + `Reconcile complete: ${deletedCount} orphan file(s) deleted, ${markedCount} record(s) marked logMissing` + + (skippedInProgress > 0 ? `, ${skippedInProgress} still-running record(s) skipped` : '') + + (skippedRecent > 0 ? `, ${skippedRecent} recently-modified file(s) skipped (possible in-flight deploy)` : '') + + (failedCount > 0 ? `, ${failedCount} element(s) failed` : '') + ); + await updateScheduleExecution(execution.id, { + // A run that hit per-element failures did complete, but not cleanly -- it + // should not read as indistinguishable from one that had nothing go wrong. + // 'warning' is exactly this repo's convention for "completed with a caveat" + // (see the ScheduleStatus doc comment in db.ts), as opposed to 'failed', + // which here is reserved for the run itself throwing (the outer catch below). + status: failedCount > 0 ? 'warning' : 'success', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { + filesFound: filesFoundTotal, + recordsFound: records.length, + deletedFiles: deletedCount, + markedRecords: markedCount, + skippedInProgress, + skippedRecent, + failed: failedCount + } + }); + } catch (error: any) { + await log(`Error: ${error.message}`); + await updateScheduleExecution(execution.id, { + status: 'failed', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + errorMessage: error.message + }); + } +} diff --git a/src/lib/server/scheduler/tasks/git-stack-sync.ts b/src/lib/server/scheduler/tasks/git-stack-sync.ts index 2c6300f..4f12b1d 100644 --- a/src/lib/server/scheduler/tasks/git-stack-sync.ts +++ b/src/lib/server/scheduler/tasks/git-stack-sync.ts @@ -48,7 +48,13 @@ export async function runGitStackSync( // Deploy the git stack (only if there are changes). deployGitStack now emits the // git_sync_success/failed/skipped notification itself, so EVERY caller (webhook, // manual, this scheduler) notifies uniformly — we no longer dispatch here (#1295). - const result = await deployGitStack(stackId, { force: false }); + // + // triggeredBy is forwarded, not hardcoded: this function is itself called with + // 'cron' (the nightly scheduler), 'manual' (the schedules page's "run now"), and + // 'webhook' (scheduler/index.ts's triggerGitStackSyncFromWebhook) — passing the + // value straight through gives the stack_deploy run record the same accurate + // trigger this execution's own git_stack_sync record already gets above. + const result = await deployGitStack(stackId, { force: false, triggeredBy }); if (result.success) { if (result.skipped) { diff --git a/src/lib/server/secret-redaction.ts b/src/lib/server/secret-redaction.ts new file mode 100644 index 0000000..bcdb967 --- /dev/null +++ b/src/lib/server/secret-redaction.ts @@ -0,0 +1,105 @@ +/** + * Minimum length for a secret to be replaced in place. Below it, a value like "test" + * would match inside unrelated words ("latest", "testing"), so the whole line is + * withheld instead — we cannot safely tell signal from coincidence. + */ +const MIN_REPLACEABLE_LENGTH = 8; + +/** Placeholder written in place of a secret value. */ +const PLACEHOLDER = '***'; + +/** + * Removes known secret values from a single output line. + * + * This does NOT pattern-match "things that look like secrets" — that approach fails for + * every field nobody thought of. It replaces the exact values we injected ourselves and + * therefore know. + * + * Returns the redacted line, or null when the line must be withheld entirely. + */ +export function redactLine(line: string, secrets: string[]): string | null { + let result = line; + + for (const secret of secrets) { + const value = secret?.trim(); + if (!value) continue; + + if (value.length < MIN_REPLACEABLE_LENGTH) { + // Too short to replace without hitting unrelated substrings. If it appears at all, + // withhold the line: a false positive costs one hidden line, a false negative leaks. + if (line.includes(value)) return null; + continue; + } + + result = result.split(value).join(PLACEHOLDER); + } + + return result; +} + +/** + * Bridges a raw line callback to a consumer, applying redaction on the way. + * Withheld lines (redactLine returning null) are dropped silently — by design: + * a suppressed line is cheaper than a leaked one. + */ +export function makeLineForwarder( + consume: (line: string) => void, + secrets: string[] +): (line: string) => void { + return (line: string) => { + const safe = redactLine(line, secrets); + if (safe !== null) consume(safe); + }; +} + +/** + * Surfaces a final output block as a single redacted line, but ONLY when no line was + * forwarded during the run (lineCount === 0). Some execution paths (e.g. Hawser's REST + * call) report only a block, never individual lines -- without this, the operator sees + * nothing while the run is in progress. A path that already forwarded its own lines + * (or a future streaming path) must not get the block appended on top: that would show + * the same text twice. + */ +export function surfaceBlockIfNoLines( + onLine: ((line: string) => void) | undefined, + lineCount: number, + block: string | undefined, + secrets: string[] +): void { + if (onLine && lineCount === 0 && block) { + makeLineForwarder(onLine, secrets)(block); + } +} + +/** + * A redacting line sink for callers that must also decide, once the run is over, whether to + * surface the final output block. + * + * The two halves belong together because surfaceBlockIfNoLines needs the number of lines that + * were actually forwarded, and only the sink knows it. Counting what the sender emitted would + * be wrong: a line withheld by redaction never reached the operator, so for them the block is + * still the only thing there is to see. + * + * `forward` is undefined when there is no consumer, so callers can pass it straight into an + * optional callback parameter. + */ +export function makeRedactedLineSink( + onLine: ((line: string) => void) | undefined, + secrets: string[] +): { + forward: ((line: string) => void) | undefined; + surfaceBlock: (block: string | undefined) => void; +} { + let forwarded = 0; + const forward = onLine + ? makeLineForwarder((line) => { + forwarded++; + onLine(line); + }, secrets) + : undefined; + + return { + forward, + surfaceBlock: (block) => surfaceBlockIfNoLines(onLine, forwarded, block, secrets) + }; +} diff --git a/src/lib/server/sse.ts b/src/lib/server/sse.ts index fe1008b..84ad0d1 100644 --- a/src/lib/server/sse.ts +++ b/src/lib/server/sse.ts @@ -1,10 +1,53 @@ import { json } from '@sveltejs/kit'; import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs'; import { prefersJSON } from '$lib/server/sse-parser'; +import { shouldRecord } from '$lib/server/deploy-recorder'; // Re-export pure parsing utilities (no server deps) for backward compat export { prefersJSON, sseToJSON } from '$lib/server/sse-parser'; +/** + * Optional hook that mirrors qualifying progress lines into a persisted run record and + * is told how the run ended once it's done. + * + * Built in the ROUTE (see Task 10's deploy-run-record.ts), never here: constructing it + * needs db.ts, and importing that here would make this module unimportable under Bun + * (ERR_DLOPEN_FAILED) -- breaking every unit test that exercises createJobResponse + * directly, this file's own included. + */ +export interface RunRecorder { + /** Called for every send() whose (event, data) passes shouldRecord -- never raw. */ + line(line: string): void; + /** + * Extends the redaction secret list with values that were not yet known when the + * recorder was constructed -- e.g. provider-resolved secrets (Bitwarden/1Password/ + * etc. bulk pulls or inline refs) that deployStack() only resolves internally, + * strictly AFTER the caller built this recorder from the DB-only vars it had at the + * time. Callers MUST call this (if they have anything to add) before end(), which is + * the only method that reads the accumulated secret list -- see deploy-run-record.ts. + */ + addSecrets(values: string[]): void; + /** Called exactly once, on every path out of the operation (success, failure, or throw). */ + end(ok: boolean, exitCode?: number, error?: string): Promise; +} + +/** Extracts the redacted line string from a shouldRecord-qualifying { type: 'line', line } payload. */ +function extractLine(data: unknown): string | undefined { + if (typeof data !== 'object' || data === null) return undefined; + const line = (data as { line?: unknown }).line; + return typeof line === 'string' ? line : undefined; +} + +/** Derives the RunRecorder.end() args from the JSON path's held-back result payload. */ +function endFromResult(resultData: unknown): { ok: boolean; error?: string } { + if (typeof resultData !== 'object' || resultData === null) return { ok: false }; + const obj = resultData as { success?: unknown; error?: unknown }; + if (obj.success === true) return { ok: true }; + const { error } = obj; + if (error === undefined) return { ok: false }; + return { ok: false, error: typeof error === 'string' ? error : String(error) }; +} + /** * Job-based response for long-running operations. * @@ -16,10 +59,17 @@ export { prefersJSON, sseToJSON } from '$lib/server/sse-parser'; * * The send() callback stores lines with { event, data } so the polling client * can reconstruct the same event stream semantics used by the old SSE flow. + * + * `recorder` is optional and, if omitted, changes nothing about the above -- see + * RunRecorder's doc comment for why it's built by the caller, not here. When given, it + * is fed from BOTH send() implementations below (JSON path and streaming path) so that + * every caller of createJobResponse, not just the ones on the streaming path, gets its + * progress lines mirrored. */ export function createJobResponse( operation: (send: (event: string, data: unknown) => void, isCancelled: () => boolean) => Promise, - request?: Request + request?: Request, + recorder?: RunRecorder ): Response { // Backward compat: synchronous JSON path for explicit application/json callers if (prefersJSON(request)) { @@ -29,6 +79,10 @@ export function createJobResponse( let resultData: unknown = { success: false, error: 'No result' }; let sentResult = false; const send = (event: string, data: unknown) => { + if (recorder && shouldRecord(event, data)) { + const line = extractLine(data); + if (line !== undefined) recorder.line(line); + } // Keep the last 'result' payload as the response body. A handler // may send progress events and then throw (e.g. a backup that // returns { status: 'error' } and rethrows), so preserve the @@ -40,6 +94,14 @@ export function createJobResponse( await operation(send, () => false); } catch (error) { if (!sentResult) resultData = { success: false, error: String(error) }; + } finally { + // Awaited here, before enqueue/close: this async start() is the + // only place on the JSON path where "the run is over" exists as a + // moment in time, so the recorder's close is placed right on it. + if (recorder) { + const { ok, error } = endFromResult(resultData); + await recorder.end(ok, undefined, error); + } } controller.enqueue(encoder.encode(JSON.stringify(resultData))); controller.close(); @@ -54,16 +116,44 @@ export function createJobResponse( const job = createJob(); const send = (event: string, data: unknown) => { + if (recorder && shouldRecord(event, data)) { + const line = extractLine(data); + if (line !== undefined) recorder.line(line); + } appendLine(job, { event, data }); }; + // Guards RunRecorder.end()'s "called exactly once" contract: if the .then() + // branch below reaches its recorder.end() call and that call itself throws, + // the rejection lands in .catch(), which must not call end() a second time. + // Set right before invoking end() (not after it resolves), so a throwing + // end() is still counted as "attempted" and never retried. + let recorderEnded = false; + operation(send, () => job.cancelRequested === true) - .then(() => { + .then(async () => { const resultLine = job.lines.findLast((l) => l.event === 'result'); - completeJob(job, resultLine?.data ?? { success: true }); + const resultData = resultLine?.data ?? { success: true }; + completeJob(job, resultData); + // Nothing on this path awaits createJobResponse's return value (it already + // returned { jobId } below), so this runs after the response has gone out. + // The record is still closed reliably -- just not before the client sees it. + // + // A failed compose run does not throw: it sends `result: {success:false, + // error}` and returns normally, landing HERE, not in .catch() below. This is + // the path every browser call takes (neither +page.svelte nor StackModal.svelte + // send an Accept header) -- reuse the JSON path's endFromResult() instead of + // hardcoding end(true), or every such run was recorded as a success. + if (recorder) { + recorderEnded = true; + const { ok, error } = endFromResult(resultData); + await recorder.end(ok, undefined, error); + } }) - .catch((err: unknown) => { - failJob(job, err instanceof Error ? err.message : String(err)); + .catch(async (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + failJob(job, message); + if (recorder && !recorderEnded) await recorder.end(false, undefined, message); }); return json({ jobId: job.id }); diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 173d55d..4f9c406 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -10,6 +10,8 @@ import { join, resolve, dirname, basename, isAbsolute, normalize as pathNormaliz import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { redactSecretVars } from './secret-redact'; +import { collectProcess } from './process-output-core'; +import { makeLineForwarder, makeRedactedLineSink } from './secret-redaction'; import { applyFileDeletions, hashDirFiles, @@ -89,6 +91,24 @@ export interface StackOperationResult { command?: string; /** Result of applying git deletion sync (files removed / kept, with reasons) */ deletion?: DeletionApplyResult; + /** + * The process's real exit code, when one exists to report -- the local/direct + * compose path runs the command itself and knows it. Left unset on a timeout + * (the process was killed, not exited) and on the Hawser path (the agent + * protocol doesn't return one). Callers needing an exit code regardless + * (deploy-run-record.ts) fall back to a value consistent with success/failure. + */ + exitCode?: number; + /** + * Set by deployStack() only: every secret value (DB AND provider-resolved -- + * Bitwarden/1Password/etc. bulk pulls or inline refs, resolveProviderEnvVars) that + * actually reached the container for THIS run. Provider resolution happens inside + * deployStack(), after any caller-built stack_deploy run recorder was already + * constructed from DB-only vars -- callers MUST feed this into the recorder via + * RunRecorder.addSecrets() before closing it, or a provider-resolved secret that + * surfaces in compose's raw error text is stored unredacted (see deploy-run-record.ts). + */ + resolvedSecrets?: string[]; } /** @@ -147,6 +167,8 @@ export interface DeployStackOptions { * (Stack events and Git sync are separate user-facing groups). stack_events is * still recorded regardless. (#1295) */ isGitDeploy?: boolean; + /** Optional callback invoked per redacted output line as the compose command runs. */ + onLine?: (line: string) => void; } // ============================================================================= @@ -242,25 +264,11 @@ function isBinaryContent(bytes: Uint8Array): boolean { } } -/** - * Collect stdout/stderr from a child process and wait for it to exit. - */ -function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); - proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); - proc.on('error', reject); - proc.on('close', (code) => { - resolve({ - exitCode: code ?? 1, - stdout: Buffer.concat(stdoutChunks).toString(), - stderr: Buffer.concat(stderrChunks).toString() - }); - }); - }); -} +// collectProcess lives in ./process-output-core (imported above) -- pure, +// dependency-free, so it stays unit-testable without dragging in the DB +// module chain. Re-exported here so existing call sites (loginToRegistries, +// executeLocalCompose) are unaffected. +export { collectProcess }; /** * Read all files from a directory as a map of relative path -> content. @@ -1162,6 +1170,8 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str * @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk) * @param workingDir - Optional working directory for compose execution (for imported stacks) * @param customComposePath - Optional path to existing compose file (for imported stacks, skips writing) + * @param onLine - Optional callback invoked per output line, redacted against envVars/secretVars + * (NOT spawnEnv — that also carries PATH/HOME/DOCKER_CONFIG and would over-redact) */ async function executeLocalCompose( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', @@ -1185,7 +1195,8 @@ async function executeLocalCompose( // direct-remote only: when the stack folder was staged to on the target // host, rewrite the compose's same-dir relative binds (`./x`) to /x so the // remote daemon binds the staged files. undefined = no staging, compose unchanged. - remoteStackHostDir?: string + remoteStackHostDir?: string, + onLine?: (line: string) => void ): Promise { const logPrefix = `[Stack:${stackName}]`; @@ -1481,7 +1492,15 @@ async function executeLocalCompose( }, COMPOSE_TIMEOUT_MS); try { - const { exitCode: code, stdout, stderr } = await collectProcess(proc); + // Do NOT use spawnEnv! It also carries PATH, HOME, DOCKER_CONFIG, DOCKER_API_VERSION. + // HOME typically falls back to "/root" -- 5 characters, below MIN_REPLACEABLE_LENGTH. + // Under our own rule, that would withhold EVERY line containing "/root". + const secrets = [...Object.values(envVars ?? {}), ...Object.values(secretVars ?? {})] + .filter((v): v is string => typeof v === 'string'); + const { exitCode: code, stdout, stderr } = await collectProcess( + proc, + onLine ? makeLineForwarder(onLine, secrets) : undefined + ); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} COMPOSE PROCESS COMPLETE`); @@ -1510,7 +1529,8 @@ async function executeLocalCompose( return { success: true, output: stdout || stderr || `Stack "${stackName}" ${operation} completed successfully`, - command: commandStr + command: commandStr, + exitCode: code }; } else { // stderr can echo an interpolated secret value (e.g. a failing @@ -1520,7 +1540,8 @@ async function executeLocalCompose( success: false, output: redactSecretVars(stdout, secretVars), error: redactSecretVars(stderr, secretVars) || `docker compose ${operation} exited with code ${code}`, - command: commandStr + command: commandStr, + exitCode: code }; } } finally { @@ -1562,6 +1583,12 @@ async function executeLocalCompose( * * @param envVars - Non-secret environment variables (from .env file) * @param secretVars - Secret environment variables (injected via shell env on Hawser, NEVER in .env) + * @param onLine - Called per redacted output line while the command runs. Hawser's + * `/_hawser/compose` call is a single request/response, but an agent that understands + * `streamOutput` sends its output alongside it as 'stream' messages, which the Edge + * connection routes back here by requestId. An older agent sends none; for it the + * response's `output` block is surfaced as one line instead. Never both -- see + * makeRedactedLineSink. */ async function executeComposeViaHawser( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', @@ -1579,7 +1606,8 @@ async function executeComposeViaHawser( noBuildCache?: boolean, pullPolicy?: string, filesToDelete?: FileToDelete[], - removeFiles?: boolean + removeFiles?: boolean, + onLine?: (line: string) => void ): Promise { const logPrefix = `[Stack:${stackName}]`; // Import dockerFetch dynamically to avoid circular dependency @@ -1589,6 +1617,10 @@ async function executeComposeViaHawser( // Hawser will inject ALL these as shell environment variables (secrets are NOT written to .env) const allEnvVars = { ...(envVars || {}), ...(secretVars || {}) }; const secretCount = secretVars ? Object.keys(secretVars).length : 0; + // Unlike spawnEnv on the local path, allEnvVars is genuinely just the stack's own + // variables -- no PATH/HOME/DOCKER_CONFIG that would withhold half the output. + const secrets = Object.values(allEnvVars).filter((v): v is string => typeof v === 'string'); + const lines = makeRedactedLineSink(onLine, secrets); console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} EXECUTE COMPOSE VIA HAWSER`); @@ -1663,7 +1695,10 @@ async function executeComposeViaHawser( ? filesToDelete.map(f => ({ path: f.path, sha256: f.hash })) : undefined, // Stack deletion (#1162): remove the agent-side stack dir on down - removeFiles: removeFiles || false + removeFiles: removeFiles || false, + // Ask the agent to also send its output line by line while the command runs. + // Old agents ignore the field and just return the block as before. + streamOutput: !!onLine }); console.log(`${logPrefix} Sending request to Hawser agent...`); @@ -1672,7 +1707,8 @@ async function executeComposeViaHawser( { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body + body, + onLine: lines.forward }, envId ); @@ -1696,6 +1732,10 @@ async function executeComposeViaHawser( console.log(`${logPrefix} Error:`, result.error); } + // Only reaches the operator when the agent streamed nothing -- otherwise they would + // see the whole run a second time, appended to the lines they already watched. + lines.surfaceBlock(result.output); + // Git deletion sync: interpret the agent's report. An agent that supports // the feature always returns deletedFiles/skippedFiles (possibly empty // arrays) when filesToDelete was sent. An old agent ignores the field and @@ -1762,13 +1802,16 @@ async function executeComposeViaHawser( * * @param envVars - Non-secret environment variables (from .env file) * @param secretVars - Secret environment variables (from DB, injected via shell env) + * @param onLine - Optional callback invoked per redacted output line as the command runs. + * Forwarded to whichever execution path is chosen (local socket, direct, or Hawser). */ async function executeComposeCommand( operation: 'up' | 'down' | 'stop' | 'start' | 'restart' | 'pull' | 'build', options: ComposeCommandOptions, composeContent: string, envVars?: Record, - secretVars?: Record + secretVars?: Record, + onLine?: (line: string) => void ): Promise { const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options; @@ -1795,7 +1838,9 @@ async function executeComposeCommand( serviceName, build, noBuildCache, - pullPolicy + pullPolicy, + undefined, // remoteStackHostDir + onLine ); } @@ -1861,7 +1906,8 @@ async function executeComposeCommand( noBuildCache, pullPolicy, filesToDelete, - removeFiles + removeFiles, + onLine ); } @@ -1924,7 +1970,8 @@ async function executeComposeCommand( build, noBuildCache, pullPolicy, - remoteStackHostDir + remoteStackHostDir, + onLine ); } @@ -1956,7 +2003,9 @@ async function executeComposeCommand( serviceName, build, noBuildCache, - pullPolicy + pullPolicy, + undefined, // remoteStackHostDir + onLine ); } } @@ -2414,7 +2463,8 @@ async function notifyStackLifecycle(stackName: string, envId: number | null | un export async function startStack( stackName: string, - envId?: number | null + envId?: number | null, + onLine?: (line: string) => void ): Promise { const result = await requireComposeFile(stackName, envId); @@ -2447,7 +2497,8 @@ export async function startStack( opts, result.content!, result.nonSecretVars, - result.secretVars + result.secretVars, + onLine ); await notifyStackLifecycle(stackName, envId, 'stack_started', startResult); return startResult; @@ -2459,7 +2510,8 @@ export async function startStack( */ export async function stopStack( stackName: string, - envId?: number | null + envId?: number | null, + onLine?: (line: string) => void ): Promise { const result = await requireComposeFile(stackName, envId); @@ -2481,7 +2533,8 @@ export async function stopStack( { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) }, result.content!, result.nonSecretVars, - result.secretVars + result.secretVars, + onLine ); // Stop any dynamically-spawned child containers not in the compose file @@ -2507,7 +2560,8 @@ export async function stopStack( export async function restartStack( stackName: string, envId?: number | null, - mode: 'restart' | 'ordered' | 'recreate' = 'restart' + mode: 'restart' | 'ordered' | 'recreate' = 'restart', + onLine?: (line: string) => void ): Promise { const result = await requireComposeFile(stackName, envId); @@ -2528,16 +2582,16 @@ export async function restartStack( if (mode === 'recreate') { // Stop first, then bring up with --force-recreate to ensure new container IDs - await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars); + await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars, onLine); await applyProviderSecretsToComposeResult(result, stackName, envId, `[Stack:${stackName}]`); - composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars); + composeResult = await executeComposeCommand('up', { ...opts, forceRecreate: true }, result.content!, result.nonSecretVars, result.secretVars, onLine); } else if (mode === 'ordered') { // Stop everything, then start in depends_on order (compose start honors the // dependency graph). Same container IDs, no recreate, no re-pull. - await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars); - composeResult = await executeComposeCommand('start', opts, result.content!, result.nonSecretVars, result.secretVars); + await executeComposeCommand('stop', opts, result.content!, result.nonSecretVars, result.secretVars, onLine); + composeResult = await executeComposeCommand('start', opts, result.content!, result.nonSecretVars, result.secretVars, onLine); } else { - composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars); + composeResult = await executeComposeCommand('restart', opts, result.content!, result.nonSecretVars, result.secretVars, onLine); } // Restart any dynamically-spawned child containers not in the compose file @@ -2553,7 +2607,8 @@ export async function restartStack( export async function downStack( stackName: string, envId?: number | null, - removeVolumes = false + removeVolumes = false, + onLine?: (line: string) => void ): Promise { const result = await requireComposeFile(stackName, envId); @@ -2571,7 +2626,8 @@ export async function downStack( { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack, stackFiles: await lifecycleStackFiles(result.stackDir) }, result.content!, result.nonSecretVars, - result.secretVars + result.secretVars, + onLine ); // Remove any dynamically-spawned child containers not in the compose file @@ -2946,7 +3002,7 @@ async function reconcileStackPendingUpdates(stackName: string, envId: number): P * Uses stack locking to prevent concurrent deployments. */ export async function deployStack(options: DeployStackOptions): Promise { - const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete, isGitDeploy } = options; + const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete, isGitDeploy, onLine } = options; const logPrefix = `[Stack:${name}]`; console.log(`${logPrefix} ========================================`); @@ -3178,8 +3234,17 @@ export async function deployStack(options: DeployStackOptions): Promise { + update((current) => { + const newSettings = { ...current, deployLogReconcileCron: value }; + saveSettings({ deployLogReconcileCron: value }); + return newSettings; + }); + }, + setDeployLogReconcileEnabled: (value: boolean) => { + update((current) => { + const newSettings = { ...current, deployLogReconcileEnabled: value }; + saveSettings({ deployLogReconcileEnabled: value }); + return newSettings; + }); + }, setLogBufferSizeKb: (value: number) => { update((current) => { const newSettings = { ...current, logBufferSizeKb: value }; diff --git a/src/lib/utils/clamp-number.ts b/src/lib/utils/clamp-number.ts new file mode 100644 index 0000000..228551d --- /dev/null +++ b/src/lib/utils/clamp-number.ts @@ -0,0 +1,28 @@ +/** + * Clamps a proposed number into `[min, max]`. + * + * A naive `Math.max(min, Math.min(max, value))` looks like it clamps any input, but + * it does not: if `value` is `NaN`, both `Math.min` and `Math.max` propagate the NaN + * -- any NaN operand makes them return NaN, regardless of the other operand -- so the + * "clamped" result is still NaN. That NaN then ends up as a CSS `height: NaN%` (or + * `width: NaNpx`), which the browser treats as invalid and the panel collapses. + * + * This is not a theoretical edge case for a drag-resize handler: the proposed value is + * usually computed from `(cursorPosition - containerEdge) / containerSize` (as a + * percentage) or `containerEdge - cursorPosition` (as a pixel distance), and a + * container's measured size is briefly `0` while a panel is transitioning (e.g. a + * panel appearing/disappearing) -- dividing by that zero produces NaN either way. + * `min`/`max` may be a percentage range (e.g. 15-70 for a split ratio) or a pixel + * range (e.g. 320-560 for a panel width) -- the clamp itself does not care about the + * unit, only about ordering `value` against two bounds, so both use it unchanged. + * + * `Infinity`/`-Infinity` do not need the same special-casing: they are well-ordered + * numbers, so `Math.min`/`Math.max` clamp them to `max`/`min` correctly on their own. + * Only `NaN` breaks the ordinary clamp. + */ +export function clampNumber(value: number, min: number, max: number): number { + if (Number.isNaN(value)) { + return min; + } + return Math.max(min, Math.min(max, value)); +} diff --git a/src/lib/utils/compose-build-detect.ts b/src/lib/utils/compose-build-detect.ts new file mode 100644 index 0000000..00f535e --- /dev/null +++ b/src/lib/utils/compose-build-detect.ts @@ -0,0 +1,26 @@ +import yaml from 'js-yaml'; + +/** + * Whether a compose file declares a `build:` section for any service -- used to + * pre-check "Build images" in the Save & redeploy / Create & Start popover + * (RedeployPopover's defaultBuild). + * + * `build:` can be a bare string (`build: ./dir`) or an object + * (`build: {context: ., dockerfile: ...}`) -- this checks truthiness, the same way + * ComposeGraphViewer.svelte's `config.image || (config.build ? 'build' : 'custom')` + * already does, not `typeof`, so both forms are detected equally. + * + * Parse errors (mid-edit, invalid YAML) fall back to false rather than throwing: an + * unparseable draft shouldn't crash the checkbox, and "Build images" can still be + * checked manually while the operator is still typing. + */ +export function hasBuildSection(composeContent: string): boolean { + try { + const parsed = yaml.load(composeContent) as { services?: Record } | undefined; + const services = parsed?.services; + if (!services || typeof services !== 'object') return false; + return Object.values(services).some((svc) => !!svc?.build); + } catch { + return false; + } +} diff --git a/src/lib/utils/deploy-run-view.ts b/src/lib/utils/deploy-run-view.ts new file mode 100644 index 0000000..0c4bb73 --- /dev/null +++ b/src/lib/utils/deploy-run-view.ts @@ -0,0 +1,231 @@ +/** + * Turns one recorded stack-deploy run (GET /api/stacks/{name}/deploys) into the + * display strings the Deploys tab shows for it. Every function here is pure text + * composition -- no fetch, no store, no formatting that depends on user settings + * (relative/exact timestamps stay in the component, which already has + * formatRelativeTime/formatDateTime available for that). + * + * Deliberately dependency-free (mirrors deploy-summary-core.ts / run-status.ts): + * no import from $lib/server (better-sqlite3 isn't importable under Bun, and would + * drag down every other test file sharing this process) and no import from + * $lib/stores or $app -- those need a mounted Svelte/SvelteKit runtime this test + * file doesn't have. The shapes below intentionally mirror (not import) + * deploy-run-record-core.ts / deploy-summary-core.ts on the server: client code + * never imports $lib/server, even for types (see StackModal.svelte's own local + * BackupConfig for the same pattern). + */ + +export interface DeployRunOptions { + pull: boolean; + build: boolean; + forceRecreate: boolean; +} + +export interface DeployRunSummary { + containersCreated: number; + containersRecreated: number; + containersStarted: number; + imagesBuilt: string[]; + imagesPulled: string[]; + buildSteps: number; + buildStepsCached: number; + digest?: string; +} + +export interface DeployRunDetails { + options?: DeployRunOptions; + summary?: DeployRunSummary; + /** Set when the run's output hit deploy-log-store's size budget and was cut off. */ + truncated?: boolean; + /** Set by deploy-log-reconcile once it confirms the on-disk log file is gone + * (see deploy-log-reconcile.ts) -- never cleared, and never set for a run + * that is still in progress (the reconcile job skips those). */ + logMissing?: boolean; +} + +export interface DeployRun { + id: number; + triggeredBy: string; + /** Null for a run that never got past the queued state. Display-only here -- + * formatting (relative + exact) stays in the component, which already has + * the user's time-format settings available for it. */ + startedAt?: string | null; + duration: number | null; + status: string; + errorMessage?: string | null; + details?: DeployRunDetails | null; +} + +/** Everything the Deploys tab renders for one run, pre-composed so the component + * only ever binds -- it never branches on raw run/details/summary shape itself. */ +export interface DeployRunView { + statusLabel: string; + trigger: string; + duration: string; + containerSummary: string; + buildStatus: string; + optionChips: string[]; + imagesBuilt: string[]; + imagesPulled: string[]; + /** Shortened last line of errorMessage -- only ever set for a failed run. */ + errorSummary: string | null; + truncated: boolean; +} + +export function formatStatusLabel(status: string): string { + switch (status) { + case 'success': + return 'Success'; + case 'failed': + return 'Failed'; + case 'running': + return 'Running'; + default: + // An unrecognized status is a schema drift worth seeing as-is, not one + // more row silently rendered "Success" or hidden entirely. + return status; + } +} + +export function formatTrigger(triggeredBy: string): string { + switch (triggeredBy) { + case 'cron': + return 'Scheduled'; + case 'webhook': + return 'Webhook'; + case 'startup': + return 'Startup'; + case 'manual': + return 'Manual'; + default: + return triggeredBy; + } +} + +/** Mirrors ExecutionHistoryList.svelte's local formatDuration so the two run-history + * surfaces (backups, deploys) read the same way. Kept here (not imported from + * there) because that one is a component-local, unexported function. */ +export function formatRunDuration(ms: number | null | undefined): string { + if (ms === null || ms === undefined) return '—'; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; +} + +/** + * A run with NO summary at all (compose produced zero output lines -- see + * deploy-run-record.ts: `summary: this.lines.length > 0 ? summarize(this.lines) : + * undefined`) is not the same fact as a run whose summary says every count is + * zero. The first means "we don't know what happened"; the second means "we know, + * and nothing changed". Collapsing them into one "0 containers" string would + * report the first case as a fact it never established. + */ +export function formatContainerSummary(summary: DeployRunSummary | undefined): string { + if (!summary) return 'No summary recorded for this run'; + const parts: string[] = []; + if (summary.containersCreated > 0) parts.push(`${summary.containersCreated} created`); + if (summary.containersRecreated > 0) parts.push(`${summary.containersRecreated} recreated`); + if (summary.containersStarted > 0) parts.push(`${summary.containersStarted} started`); + return parts.length > 0 ? parts.join(', ') : 'No container changes'; +} + +/** + * "Was anything built, and how much of that came from the build cache" -- the one + * question this whole feature was built to answer (see the task brief). Same + * unknown-vs-zero distinction as formatContainerSummary: no summary reads as + * "unknown", a summary with buildSteps === 0 reads as "nothing built" -- never the + * same string. + */ +export function formatBuildStatus(summary: DeployRunSummary | undefined): string { + if (!summary) return 'Build status unknown'; + const { buildSteps, buildStepsCached } = summary; + if (buildSteps === 0) return 'Nothing built'; + if (buildStepsCached === buildSteps) { + return buildSteps === 1 ? 'Built from cache' : `Built from cache (${buildSteps} steps)`; + } + if (buildStepsCached === 0) { + return buildSteps === 1 ? 'Built (1 step)' : `Built (${buildSteps} steps)`; + } + return `Built (${buildSteps} steps, ${buildStepsCached} from cache)`; +} + +export function formatOptionChips(options: DeployRunOptions | undefined): string[] { + if (!options) return []; + const chips: string[] = []; + if (options.pull) chips.push('Pull'); + if (options.build) chips.push('Build'); + if (options.forceRecreate) chips.push('Force recreate'); + return chips; +} + +/** + * The last non-empty line of a (possibly multi-line) error, cut to maxLen with a + * trailing "…" marker. errorMessage is compose's raw stdout/stderr tail (already + * redacted server-side, see deploy-run-record.ts) -- often several lines of + * progress before the actual failure, so the LAST line is the one worth showing + * collapsed; the full text stays available via the run's log file. + */ +export function lastErrorLine(errorMessage: string | null | undefined, maxLen = 200): string | null { + if (!errorMessage) return null; + const lines = errorMessage + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); + const last = lines.length > 0 ? lines[lines.length - 1] : errorMessage.trim(); + if (last.length === 0) return null; + if (last.length <= maxLen) return last; + return `${last.slice(0, maxLen - 1)}…`; +} + +/** + * What the Deploys tab's expanded row shows for a run's LOG PANEL specifically -- + * a separate, narrower question from buildDeployRunView's "how does the row + * summarize" above it. Three independent facts, each driving its own piece of + * UI (see the component): whether the log is worth fetching at all, whether a + * truncation notice belongs above it, and whether a delete action makes sense + * for a run that might still be writing to that very file. + * + * logMissing short-circuits the fetch entirely -- a run already marked missing + * by deploy-log-reconcile will 404 on GET .../log every time, so there is no + * reason to make that round trip just to learn what the record already says. + */ +export interface DeployLogPanelState { + /** True when the record already knows the log file is gone (details.logMissing). + * The panel shows "log no longer available" and never fires a GET for it. */ + logMissing: boolean; + /** True when the stored log was cut off at deploy-log-store's size budget -- + * same underlying flag DeployRunView.truncated surfaces for the collapsed + * row, repeated here so the log panel doesn't need the raw run for it too. */ + truncated: boolean; + /** False for a run still in progress. Its log file may still be growing on + * disk, and deleting the record out from under an active write would race + * the writer -- so no delete action is offered until the run has settled. */ + deletable: boolean; +} + +export function buildDeployLogPanelState(run: DeployRun): DeployLogPanelState { + return { + logMissing: run.details?.logMissing === true, + truncated: run.details?.truncated === true, + deletable: run.status !== 'running' + }; +} + +export function buildDeployRunView(run: DeployRun): DeployRunView { + const summary = run.details?.summary; + return { + statusLabel: formatStatusLabel(run.status), + trigger: formatTrigger(run.triggeredBy), + duration: formatRunDuration(run.duration), + containerSummary: formatContainerSummary(summary), + buildStatus: formatBuildStatus(summary), + optionChips: formatOptionChips(run.details?.options), + imagesBuilt: summary?.imagesBuilt ?? [], + imagesPulled: summary?.imagesPulled ?? [], + errorSummary: run.status === 'failed' ? lastErrorLine(run.errorMessage) : null, + truncated: run.details?.truncated === true + }; +} diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index b789be7..d323393 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -1,3 +1,5 @@ +import { parseTimestamp } from './date-format'; + /** * Format a byte count into a human-readable string. */ @@ -36,9 +38,16 @@ export function formatErrorLines(msg: string | null | undefined): string { * Compact relative time like "just now", "5m ago", "2d ago", "3mo ago". Returns '' * for an unparseable/NaN date and "in the future" for a timestamp ahead of now. * Kept in this import-light module so it is unit-testable; stores/settings re-exports it. + * + * Uses parseTimestamp so a naive server timestamp (PG `timestamp without time + * zone`, e.g. "2026-08-30 20:36:11.534") is read as UTC instead of the + * browser's local zone - see date-format.ts and #1183. Every caller of this + * function passes exactly that kind of raw server value alongside + * formatDateTime(), which already goes through parseTimestamp(); this kept + * the two out of sync until now. */ export function formatRelativeTime(date: Date | string | number): string { - const d = date instanceof Date ? date : new Date(date); + const d = parseTimestamp(date); const ms = d.getTime(); if (isNaN(ms)) return ''; const sec = Math.round((Date.now() - ms) / 1000); diff --git a/src/lib/utils/log-download-name.ts b/src/lib/utils/log-download-name.ts new file mode 100644 index 0000000..c8451f9 --- /dev/null +++ b/src/lib/utils/log-download-name.ts @@ -0,0 +1,27 @@ +/** + * Builds the file name offered when downloading a log. The title is caller-supplied + * (a container name today, a stack name tomorrow), so path separators are stripped: + * a download name is not a path, and nothing good comes of letting one look like one. + */ +export function downloadFileName(title: string | undefined): string { + const safe = (title ?? '').replace(/[/\\]/g, '').replace(/\.\./g, '').trim(); + return safe ? `${safe}-logs.txt` : 'logs.txt'; +} + +// A CSI sequence is ESC [ , then parameter bytes (0x30-0x3F, which includes '?' for +// private modes), then intermediate bytes (0x20-0x2F), then one final byte (0x40-0x7E). +// Matching the full grammar rather than just digits and semicolons matters here: compose's +// progress display hides and shows the cursor with ESC[?25l / ESC[?25h on every redraw, and +// '?' is a parameter byte. A pattern that misses those leaves them in the very output this +// viewer exists to show. +// eslint-disable-next-line no-control-regex +const ANSI_PATTERN = /\u001b\[[0-?]*[ -\/]*[@-~]/g; + +/** + * Strips ANSI escape sequences (color codes, cursor movement, etc.) from log text. + * Copied or downloaded logs are read outside a terminal -- a `.log` file full of + * `\u001b[32m` sequences is unreadable with `cat` and unusable in a diff or grep. + */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ''); +} diff --git a/src/lib/utils/run-status.ts b/src/lib/utils/run-status.ts new file mode 100644 index 0000000..322532f --- /dev/null +++ b/src/lib/utils/run-status.ts @@ -0,0 +1,27 @@ +/** + * Formats the status line shown under a compose action's live output window. + * + * Pure and import-free on purpose (see run-status.test.ts / bun test tests/ — + * modules pulled in from src/lib/server or SvelteKit-generated code don't load + * under Bun's test runner). Keep it that way: no imports here. + */ +export interface RunStatus { + running: boolean; + ok?: boolean; + ms?: number; + exitCode?: number; +} + +export function formatRunStatus(status: RunStatus): string { + if (status.running) return 'Running…'; + + const seconds = typeof status.ms === 'number' ? `${(status.ms / 1000).toFixed(1)}s` : undefined; + + if (status.ok) { + return seconds ? `Succeeded · ${seconds}` : 'Succeeded'; + } + + const exitPart = typeof status.exitCode === 'number' ? `exit ${status.exitCode}` : undefined; + const parts = ['Failed', exitPart, seconds].filter((p): p is string => p !== undefined); + return parts.join(' · '); +} diff --git a/src/lib/utils/save-close-policy.ts b/src/lib/utils/save-close-policy.ts new file mode 100644 index 0000000..0dc15cc --- /dev/null +++ b/src/lib/utils/save-close-policy.ts @@ -0,0 +1,33 @@ +/** + * Whether and when the stack modal should close itself after a save/deploy settles. + * + * Superseded design note: an earlier version of this function only distinguished + * "deployed or not" and kept the modal open for every deploy, on the reasoning that + * closing would throw away the compose output now rendered below the editor. The + * operator saw that in practice (30.08.2026) and did not want it: a *successful* + * deploy leaving the modal open forever, needing a manual close every time, was as + * wrong as the previous behaviour of always closing regardless of outcome. Both + * pre-existed this file; this is the fix for both, decided by the operator, not + * inferred from the code. + */ +export type SaveCloseTiming = 'close' | 'close-delayed' | 'stay-open'; + +/** + * - A plain save (`deployed` false) has nothing new to show, so it closes -- same as + * before this file existed. The caller keeps its own short flash-then-close delay + * (500ms) for this case; this function only says "close", not how soon. + * - A deploy that succeeded (`deployed` true, `ok` true) closes too, but only after a + * longer delay, so the success and the compose output are still glimpsed before the + * modal goes away. Trade-off accepted knowingly, not an oversight: that output is + * then gone until the deploy-history view (Milestone 3) exists to show it again. + * - A deploy that failed (`deployed` true, `ok` false) stays open, full stop -- the + * output and the error dialog on top of it are exactly what the operator needs to + * read and act on. + * + * `ok` is meaningless when `deployed` is false (a plain save has no deploy result to + * be ok or not) -- callers may pass anything there, it is never read for that case. + */ +export function saveCloseTiming(deployed: boolean, ok: boolean): SaveCloseTiming { + if (!deployed) return 'close'; + return ok ? 'close-delayed' : 'stay-open'; +} diff --git a/src/lib/utils/scroll-position.ts b/src/lib/utils/scroll-position.ts new file mode 100644 index 0000000..b7c4713 --- /dev/null +++ b/src/lib/utils/scroll-position.ts @@ -0,0 +1,35 @@ +/** + * Whether a scrollable element counts as "at the bottom" for auto-scroll purposes. + * + * Used both to decide whether to pause auto-scroll (the user scrolled up to read + * earlier output) and to decide when to resume it (the user scrolled back down). + * A small threshold rather than an exact match: sub-pixel scroll positions and + * fractional zoom levels mean scrollHeight - scrollTop - clientHeight is rarely + * ever exactly 0 even when the element visually is at the end. + */ +export function isScrolledToBottom( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + threshold = 50 +): boolean { + return scrollHeight - scrollTop - clientHeight <= threshold; +} + +/** + * Whether a scroll-up pause should be lifted because a fresh run is starting. + * + * autoScroll going from false to true is that signal (a stack redeploy sets + * `running = true`, which is what LogViewer's autoScroll prop is bound to, at + * the exact moment its output is cleared and a new run begins) -- so a viewer + * who scrolled up to read one run's output gets auto-scroll back for the next + * one, rather than it staying paused for the rest of the page session. + * + * Deliberately an edge (false -> true), not a level (autoScroll is true): a + * naive "reset whenever autoScroll is true" reads as similar but re-lifts the + * pause on every single update while a run is in progress, which defeats the + * pause entirely. + */ +export function shouldResetScrollPause(previousAutoScroll: boolean, autoScroll: boolean): boolean { + return autoScroll && !previousAutoScroll; +} diff --git a/src/lib/utils/sse-fetch.ts b/src/lib/utils/sse-fetch.ts index 54c38bb..ed40346 100644 --- a/src/lib/utils/sse-fetch.ts +++ b/src/lib/utils/sse-fetch.ts @@ -5,9 +5,12 @@ import type { JobLine } from '$lib/server/jobs'; * Drop-in replacement for readSSEResponse when the endpoint has been migrated to jobs. * * Returns the job's final result (equivalent to the 'result' event data in SSE). + * With onLine, each 'progress' line message ({type:'line', line}) is reported as it + * arrives — callers that don't need live output can omit it. */ export async function readJobResponse( - response: Response + response: Response, + onLine?: (line: string) => void ): Promise<{ success?: boolean; error?: string; [key: string]: unknown }> { // Fall through for non-JSON or error responses if (!response.ok) { @@ -22,8 +25,10 @@ export async function readJobResponse( // If the response is a { jobId } shape, poll the job endpoint if (data && typeof data === 'object' && 'jobId' in data) { - const result = await watchJob(data.jobId as string, () => { - // readJobResponse callers don't need line-by-line updates + const result = await watchJob(data.jobId as string, (msg) => { + if (!onLine) return; + const payload = msg?.data as { type?: string; line?: string } | undefined; + if (payload?.type === 'line' && typeof payload.line === 'string') onLine(payload.line); }); return result as { success?: boolean; error?: string; [key: string]: unknown }; } diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 3989beb..cfbbdbc 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -223,7 +223,11 @@ export const POST: RequestHandler = async (event) => { if (data.deployNow) { return createJobResponse(async (send) => { try { - const deployResult = await deployGitStack(gitStack.id); + const deployResult = await deployGitStack(gitStack.id, { + triggeredBy: 'manual', + userId: auth.user?.id, + onLine: (line) => send('progress', { type: 'line', line }) + }); await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId); send('result', { ...gitStack, diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index eaa211e..0005089 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -217,7 +217,11 @@ export const PUT: RequestHandler = async (event) => { if (data.deployNow) { return createJobResponse(async (send) => { try { - const deployResult = await deployGitStack(id); + const deployResult = await deployGitStack(id, { + triggeredBy: 'manual', + userId: auth.user?.id, + onLine: (line) => send('progress', { type: 'line', line }) + }); await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId); send('result', { ...updated, diff --git a/src/routes/api/git/stacks/[id]/deploy/+server.ts b/src/routes/api/git/stacks/[id]/deploy/+server.ts index 0141e1a..8d8d428 100644 --- a/src/routes/api/git/stacks/[id]/deploy/+server.ts +++ b/src/routes/api/git/stacks/[id]/deploy/+server.ts @@ -34,7 +34,11 @@ export const POST: RequestHandler = async (event) => { return createJobResponse(async (send) => { try { - const result = await deployGitStack(id); + const result = await deployGitStack(id, { + triggeredBy: 'manual', + userId: auth.user?.id, + onLine: (line) => send('progress', { type: 'line', line }) + }); // Audit log await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId); diff --git a/src/routes/api/git/stacks/[id]/webhook/+server.ts b/src/routes/api/git/stacks/[id]/webhook/+server.ts index 84e6c10..cac4a6a 100644 --- a/src/routes/api/git/stacks/[id]/webhook/+server.ts +++ b/src/routes/api/git/stacks/[id]/webhook/+server.ts @@ -65,7 +65,7 @@ export const POST: RequestHandler = async (event) => { } // Deploy the git stack (syncs and deploys only if there are changes) - const result = await deployGitStack(id, { force: false }); + const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' }); await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { method: 'POST', source, result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed' }); @@ -125,7 +125,7 @@ export const GET: RequestHandler = async (event) => { } // Deploy the git stack (syncs and deploys only if there are changes) - const result = await deployGitStack(id, { force: false }); + const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' }); await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { method: 'GET', source: 'get', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed' }); diff --git a/src/routes/api/schedules/+server.ts b/src/routes/api/schedules/+server.ts index cd07585..82ea8e8 100644 --- a/src/routes/api/schedules/+server.ts +++ b/src/routes/api/schedules/+server.ts @@ -29,7 +29,7 @@ import { authorize } from '$lib/server/authorize'; export interface ScheduleInfo { id: number; - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify'; + type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify' | 'stack_deploy' | 'deploy_log_reconcile'; name: string; entityName: string; description?: string; diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts index fc636ee..2abe8f2 100644 --- a/src/routes/api/schedules/[type]/[id]/run/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts @@ -4,13 +4,13 @@ * POST /api/schedules/[type]/[id]/run - Trigger a manual execution * * Path params: - * - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' + * - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'deploy_log_reconcile' * - id: schedule ID */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler'; +import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune, triggerDeployLogReconcile } from '$lib/server/scheduler'; import { getBackupConfig, getBackupDestination } from '$lib/server/db'; import { runScheduledBackup } from '$lib/server/scheduler/tasks/backup'; import { runRepoPrune, runRepoCheck, runRepoVerify } from '$lib/server/scheduler/tasks/repo-maintenance'; @@ -21,7 +21,7 @@ import { BACKUPS_ENABLED } from '$lib/server/features'; /** * @openapi * summary: Manually trigger a single run of a schedule (outside its cron), by type and id - * path: type:string! Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify) + * path: type:string! Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, deploy_log_reconcile) * path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules) * resp-200: {success:boolean!, message:string!} * resp-400: Invalid schedule id/type, or the triggered task itself reported failure @@ -78,6 +78,7 @@ export const POST: RequestHandler = async ({ params, cookies }) => { scheduleEnvId = scheduleId; break; case 'system_cleanup': + case 'deploy_log_reconcile': scheduleEnvId = null; break; case 'backup': { @@ -117,6 +118,9 @@ export const POST: RequestHandler = async ({ params, cookies }) => { case 'system_cleanup': result = await triggerSystemJob(id); break; + case 'deploy_log_reconcile': + result = await triggerDeployLogReconcile(); + break; case 'env_update_check': result = await triggerEnvUpdateCheck(scheduleId); break; diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts index e85a751..83f773c 100644 --- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts +++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts @@ -13,10 +13,10 @@ import { auditBackup, auditBackupDestination } from '$lib/server/audit'; /** * @openapi * summary: Toggle a schedule's enabled/disabled state (registers/unregisters the croner job accordingly) - * path: type:string! Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup) + * path: type:string! Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup, deploy_log_reconcile) * path: id:integer! Schedule id (semantics depend on type) (from GET /api/schedules) * resp-200: {success:boolean!, enabled:boolean!} - * resp-400: Invalid schedule id, unsupported type, or system_cleanup (cannot be paused) + * resp-400: Invalid schedule id, unsupported type, or system_cleanup/deploy_log_reconcile (cannot be paused) * resp-404: Schedule, backup config, or backup destination not found * resp-500: Unexpected error while toggling the schedule */ @@ -173,7 +173,11 @@ export const POST: RequestHandler = async (event) => { await auditBackupDestination(event, 'update', destId, dest.name, { policy: enabledKey, enabled: newEnabled }); return json({ success: true, enabled: newEnabled }); - } else if (type === 'system_cleanup') { + } else if (type === 'system_cleanup' || type === 'deploy_log_reconcile') { + // Both are global, settings-driven system schedules (own enabled flag + + // cron, configured on the schedule settings page) -- not a per-item + // cron the way container_update/git_stack_sync are. stack_deploy never + // reaches here: it has no schedule to toggle (no cron, no settings row). return json({ error: 'System schedules cannot be paused' }, { status: 400 }); } else { return json({ error: 'Invalid schedule type' }, { status: 400 }); diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index c0d23c3..24d4408 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -18,6 +18,10 @@ import { setScannerCleanupCron, getScannerCleanupEnabled, setScannerCleanupEnabled, + getDeployLogReconcileCron, + setDeployLogReconcileCron, + getDeployLogReconcileEnabled, + setDeployLogReconcileEnabled, getDefaultTimezone, setDefaultTimezone, getEventCollectionMode, @@ -69,6 +73,8 @@ export interface GeneralSettings { eventCleanupEnabled: boolean; scannerCleanupCron: string; scannerCleanupEnabled: boolean; + deployLogReconcileCron: string; + deployLogReconcileEnabled: boolean; logBufferSizeKb: number; // legacy logMaxLines: number; // line-count cap for log buffer defaultTimezone: string; @@ -128,7 +134,7 @@ export interface GeneralSettings { defaultScannerDns: string[]; } -const DEFAULT_SETTINGS: Omit = { +const DEFAULT_SETTINGS: Omit = { confirmDestructive: true, showStoppedContainers: true, highlightUpdates: true, @@ -253,6 +259,8 @@ export const GET: RequestHandler = async ({ cookies }) => { eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, + deployLogReconcileCron, + deployLogReconcileEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, @@ -305,6 +313,8 @@ export const GET: RequestHandler = async ({ cookies }) => { getEventCleanupEnabled(), getScannerCleanupCron(), getScannerCleanupEnabled(), + getDeployLogReconcileCron(), + getDeployLogReconcileEnabled(), getSetting('log_buffer_size_kb'), getSetting('log_max_lines'), getDefaultTimezone(), @@ -359,6 +369,8 @@ export const GET: RequestHandler = async ({ cookies }) => { eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, + deployLogReconcileCron, + deployLogReconcileEnabled, logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, logMaxLines: (typeof logMaxLines === 'number' && logMaxLines > 0) ? Math.min(2000, Math.max(100, logMaxLines)) @@ -407,7 +419,7 @@ export const GET: RequestHandler = async ({ cookies }) => { * @openapi * summary: Update global general settings (all fields optional; only supplied keys are written) * description: A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more. - * body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string, actionIconSize:string, compactPorts:boolean, confirmDestructive:boolean, dateFormat:string, defaultBackupImage:string, defaultComposeTemplate:string, defaultGrypeArgs:string, defaultGrypeImage:string, defaultScannerDns:array, defaultScannerNetworkMode:string, defaultTrivyArgs:string, defaultTrivyImage:string, downloadFormat:string, editorFont:string, eventCleanupCron:string, eventCleanupEnabled:boolean, eventCollectionMode:string, eventPollInterval:integer, eventRetentionDays:integer, font:string, fontSize:string, formatLogTimestamps:boolean, gridFontSize:string, highlightUpdates:boolean, honorProxyLabels:boolean, labelFilterMode:string, logMaxLines:integer, metricsCollectionInterval:integer, primaryStackLocation:string, protectScannerImages:boolean, scannerCleanupCron:string, scannerCleanupEnabled:boolean, scheduleCleanupCron:string, scheduleCleanupEnabled:boolean, scheduleRetentionDays:integer, showExposedPorts:boolean, showGitCommitHash:boolean, showImageChangelogLinks:boolean, showStoppedContainers:boolean, showWhatsNew:boolean, terminalFont:string, timeFormat:string, useSelfhstIcons:boolean} + * body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string, actionIconSize:string, compactPorts:boolean, confirmDestructive:boolean, dateFormat:string, defaultBackupImage:string, defaultComposeTemplate:string, defaultGrypeArgs:string, defaultGrypeImage:string, defaultScannerDns:array, defaultScannerNetworkMode:string, defaultTrivyArgs:string, defaultTrivyImage:string, deployLogReconcileCron:string, deployLogReconcileEnabled:boolean, downloadFormat:string, editorFont:string, eventCleanupCron:string, eventCleanupEnabled:boolean, eventCollectionMode:string, eventPollInterval:integer, eventRetentionDays:integer, font:string, fontSize:string, formatLogTimestamps:boolean, gridFontSize:string, highlightUpdates:boolean, honorProxyLabels:boolean, labelFilterMode:string, logMaxLines:integer, metricsCollectionInterval:integer, primaryStackLocation:string, protectScannerImages:boolean, scannerCleanupCron:string, scannerCleanupEnabled:boolean, scheduleCleanupCron:string, scheduleCleanupEnabled:boolean, scheduleRetentionDays:integer, showExposedPorts:boolean, showGitCommitHash:boolean, showImageChangelogLinks:boolean, showStoppedContainers:boolean, showWhatsNew:boolean, terminalFont:string, timeFormat:string, useSelfhstIcons:boolean} * resp-403: Permission denied (needs settings:edit) * resp-500: Failed to save settings */ @@ -419,7 +431,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, deployLogReconcileCron, deployLogReconcileEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -477,6 +489,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => { await setScannerCleanupEnabled(scannerCleanupEnabled); await refreshSystemJobs(); } + if (deployLogReconcileCron !== undefined && typeof deployLogReconcileCron === 'string') { + await setDeployLogReconcileCron(deployLogReconcileCron); + await refreshSystemJobs(); + } + if (deployLogReconcileEnabled !== undefined && typeof deployLogReconcileEnabled === 'boolean') { + await setDeployLogReconcileEnabled(deployLogReconcileEnabled); + await refreshSystemJobs(); + } if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') { // Legacy: clamp to 100KB-5MB range. await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb))); diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 0add289..d597876 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -5,6 +5,9 @@ import { upsertStackSource, getStackSources, secretProviderExists } from '$lib/s import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; +import { createRunRecorder } from '$lib/server/deploy-run-record'; +import { hashComposeContent, hashEnvFingerprint } from '$lib/server/deploy-run-record-core'; +import { parseEnvFileContent } from '$lib/server/git'; import type { RequestHandler } from './$types'; /** @@ -86,7 +89,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { * summary: Create and (optionally) deploy a compose stack * description: Writes the compose + .env to the stack dir, stores secrets in the DB, and with start deploys it. Can bind a secret provider. Target environment comes from the env query param, or from envId/environmentId in the body when the query is absent. * query: env:integer Target environment id (takes precedence over envId/environmentId in the body) - * body: {name:string!, compose:string!, composePath:string, envPath:string, envVars:array, rawEnvContent:string, secretProviderId:integer, start:boolean, envId:integer, environmentId:integer} + * body: {name:string!, compose:string!, composePath:string, envPath:string, envVars:array, rawEnvContent:string, secretProviderId:integer, start:boolean, envId:integer, environmentId:integer, pull:boolean, build:boolean, forceRecreate:boolean} * resp-400: Invalid request (e.g. missing name/compose, or secretProviderId wrong type) * resp-403: Permission denied (needs stacks:create; binding a secret provider also needs secrets:view) * resp-500: Failed to create or deploy the stack @@ -120,7 +123,7 @@ export const POST: RequestHandler = async (event) => { } try { - const { name, compose, start, envVars, rawEnvContent, composePath, envPath, secretProviderId } = body; + const { name, compose, start, envVars, rawEnvContent, composePath, envPath, secretProviderId, pull, build, forceRecreate } = body; if (!name || typeof name !== 'string') { return json({ error: 'Stack name is required' }, { status: 400 }); @@ -236,6 +239,45 @@ export const POST: RequestHandler = async (event) => { secretProviderId }); + // This endpoint has no requireComposeFile() call to hash the way the + // dedicated deploy endpoint does -- compose and the effective env are + // already sitting in the request body. rawEnvContent, when given, is the + // authoritative non-secret source (see the persistence logic above); the + // envVars array's secrets always layer on top of it. + const effectiveEnvVars: Record = {}; + if (rawEnvContent) { + Object.assign(effectiveEnvVars, parseEnvFileContent(rawEnvContent, name)); + } + if (Array.isArray(envVars)) { + for (const v of envVars) { + if (v && typeof v.key === 'string' && typeof v.value === 'string' && (!rawEnvContent || v.isSecret)) { + effectiveEnvVars[v.key] = v.value; + } + } + } + + // Build/pull/forceRecreate come from the caller (StackModal's "Create & Start" + // popover, see RedeployPopover) -- previously this endpoint always deployed with + // build:false and no pullPolicy regardless of what the compose file needed, so a + // service with a `build:` section silently never built on first start. + const pullOpt = !!pull; + const buildOpt = !!build; + const forceRecreateOpt = !!forceRecreate; + + const recorder = await createRunRecorder({ + stackName: name, + envId: envIdNum ?? null, + userId: auth.user?.id, + triggeredBy: 'manual', + options: { pull: pullOpt, build: buildOpt, forceRecreate: forceRecreateOpt }, + composeHash: hashComposeContent(compose), + envHash: hashEnvFingerprint(effectiveEnvVars), + // Same merged set passed to envHash above -- also the redaction list end() + // applies to the stored error text, so a leaked env value never reaches + // errorMessage on schedule_executions. + secrets: Object.values(effectiveEnvVars) + }); + // Deploy via SSE to keep connection alive during long operations return createJobResponse(async (send) => { try { @@ -243,24 +285,38 @@ export const POST: RequestHandler = async (event) => { name, compose, envId: envIdNum, + forceRecreate: forceRecreateOpt, + build: buildOpt, + // pullPolicy undefined (pull unchecked) also skips deployStack's post-deploy + // reconcileStackPendingUpdates() call -- accepted tradeoff, not a bug. + pullPolicy: pullOpt ? 'always' : undefined, composePath: composePath || undefined, - envPath: envPath || undefined + envPath: envPath || undefined, + onLine: (line) => send('progress', { type: 'line', line }) }); + // F4 fix: deployStack resolves the bound secret provider's values + // internally, AFTER `recorder` above was already built from the DB-only + // `effectiveEnvVars`. Feed the provider-resolved values in now, before + // createJobResponse calls recorder.end() below -- see deploy-run-record.ts. + recorder.addSecrets(result.resolvedSecrets ?? []); + if (!result.success) { send('result', { success: false, error: result.error, output: result.output }); return; } // Audit log (create + deploy in one action) - await auditStack(event, 'deploy', name, envIdNum); + await auditStack(event, 'deploy', name, envIdNum, { + pull: pullOpt, build: buildOpt, forceRecreate: forceRecreateOpt + }); send('result', { success: true, started: true, output: result.output }); } catch (error: any) { console.error('Error deploying compose stack:', error); send('result', { success: false, error: error.message || 'Failed to deploy stack' }); } - }, request); + }, request, recorder); } catch (error: any) { console.error('Error creating compose stack:', error); return json({ error: error.message || 'Failed to create stack' }, { status: 500 }); diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index 5b226ac..e8b9664 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -1,8 +1,10 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks'; +import { getStackComposeFile, deployStack, saveStackComposeFile, requireComposeFile } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { createJobResponse } from '$lib/server/sse'; +import { createRunRecorder } from '$lib/server/deploy-run-record'; +import { hashComposeContent, hashEnvFingerprint } from '$lib/server/deploy-run-record-core'; // GET /api/stacks/[name]/compose - Get compose file content /** @@ -58,7 +60,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { * description: Every accepted PUT persists the compose content; with restart it also redeploys. Supports moving the compose/env to a new path and binding a secret provider. * path: name:string The stack name * query: env:integer Environment id the stack belongs to - * body: {content:string!, composePath:string, envPath:string, oldComposePath:string, oldEnvPath:string, moveFromDir:string, restart:boolean, secretProviderId:integer} + * body: {content:string!, composePath:string, envPath:string, oldComposePath:string, oldEnvPath:string, moveFromDir:string, restart:boolean, secretProviderId:integer, pull:boolean, build:boolean, forceRecreate:boolean} * resp-400: Invalid request (e.g. missing content, or secretProviderId wrong type) * resp-403: Permission denied (needs stacks:edit; binding a secret provider also needs secrets:view) * resp-500: Failed to save or deploy the compose file @@ -79,7 +81,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => try { const body = await request.json(); - const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId } = body; + const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath, secretProviderId, pull, build, forceRecreate } = body; if (!content || typeof content !== 'string') { return json({ error: 'Compose file content is required' }, { status: 400 }); @@ -120,10 +122,57 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => } if (restart) { - // Deploy with docker compose up -d --force-recreate. - // Force recreate ensures env var changes are applied. - // Get authoritative paths from DB/filesystem for deploy (now reflects the saved content). - const composeInfo = await getStackComposeFile(name, envIdNum); + // Build/pull/force-recreate come from the caller (StackModal's "Save & redeploy" + // popover, see RedeployPopover) -- previously this branch always deployed with + // build:false and pull skipped regardless of what the compose file needed, so a + // service with a `build:` section silently never rebuilt (the change was saved, + // but `docker compose up` just reused the stale local image). forceRecreate + // defaults to true when the caller omits it, preserving the prior hardcoded + // behavior for any caller that hasn't been updated to send it explicitly -- env + // var changes need --force-recreate to take effect. + const pullOpt = !!pull; + const buildOpt = !!build; + const forceRecreateOpt = forceRecreate === undefined ? true : !!forceRecreate; + + // Get authoritative paths AND effective env vars from DB/filesystem for deploy + // (now reflects the saved content). requireComposeFile() wraps + // getStackComposeFile() and additionally resolves secretVars/nonSecretVars -- + // the same DB-sourced values deployStack itself resolves again internally + // at deploy time (stacks.ts), and the same shape deploy/+server.ts already + // merges for ITS run record below. + const composeInfo = await requireComposeFile(name, envIdNum); + + // Same run record as the dedicated deploy endpoint (deploy/+server.ts:75) -- + // built up front since createJobResponse takes the recorder as an + // already-resolved value, not something it can await for itself. + // + // composeHash is hashed off the just-submitted `content`, NOT + // composeInfo.content: saveStackComposeFile() above wrote this exact string + // verbatim (no reformatting, see stacks.ts), and it's the same string handed + // to the deployStack call below -- hashing it directly ties the recorded hash to + // precisely what gets deployed, instead of a redundant re-read off disk that + // could only ever produce the same bytes anyway. + // + // Skipped entirely when composeInfo.success is false (the compose file is + // unexpectedly unreadable right here, immediately after we just wrote it) -- + // the deploy below proceeds unchanged either way (composePath/envPath already + // tolerate this via `|| undefined`), but recording a run without secretVars + // would mean the recorder's end() redacts against an empty secrets list, + // risking a leaked secret value in the stored error text. + let recorder: Awaited> | undefined; + if (composeInfo.success) { + const effectiveEnvVars = { ...(composeInfo.nonSecretVars ?? {}), ...(composeInfo.secretVars ?? {}) }; + recorder = await createRunRecorder({ + stackName: name, + envId: envIdNum ?? null, + userId: auth.user?.id, + triggeredBy: 'manual', + options: { pull: pullOpt, build: buildOpt, forceRecreate: forceRecreateOpt }, + composeHash: hashComposeContent(content), + envHash: hashEnvFingerprint(effectiveEnvVars), + secrets: Object.values(effectiveEnvVars) + }); + } // Deploy via SSE to keep connection alive during long operations return createJobResponse(async (send) => { @@ -132,11 +181,26 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => name, compose: content, envId: envIdNum, - forceRecreate: true, + forceRecreate: forceRecreateOpt, + build: buildOpt, + // pullPolicy undefined (pull unchecked) means deployStack's post-deploy + // reconcileStackPendingUpdates() call is skipped too -- a stale "update + // available" badge on this stack won't clear until the next pull. Accepted + // tradeoff, not a bug. + pullPolicy: pullOpt ? 'always' : undefined, composePath: composeInfo.composePath || undefined, - envPath: composeInfo.envPath || undefined + envPath: composeInfo.envPath || undefined, + onLine: (line) => send('progress', { type: 'line', line }) }); + // F4 fix: deployStack resolves the bound secret provider's values + // internally, AFTER `recorder` above was already built from + // composeInfo's DB-only vars. Feed the provider-resolved values in + // now, before createJobResponse calls recorder.end() below -- see + // deploy-run-record.ts. recorder may be undefined (composeInfo + // wasn't readable), matching its optional-recorder construction above. + recorder?.addSecrets(result.resolvedSecrets ?? []); + if (!result.success) { send('result', { success: false, error: result.error }); return; @@ -146,7 +210,7 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => console.error(`Error deploying stack ${name}:`, error); send('result', { success: false, error: error.message || 'Failed to deploy stack' }); } - }, request); + }, request, recorder); } // No restart: the content is already persisted above. diff --git a/src/routes/api/stacks/[name]/deploy/+server.ts b/src/routes/api/stacks/[name]/deploy/+server.ts index aa507a8..1bdd899 100644 --- a/src/routes/api/stacks/[name]/deploy/+server.ts +++ b/src/routes/api/stacks/[name]/deploy/+server.ts @@ -3,6 +3,8 @@ import { deployStack, requireComposeFile, ComposeFileNotFoundError } from '$lib/ import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import { createJobResponse } from '$lib/server/sse'; +import { createRunRecorder } from '$lib/server/deploy-run-record'; +import { hashComposeContent, hashEnvFingerprint } from '$lib/server/deploy-run-record-core'; import type { RequestHandler } from './$types'; /** @@ -39,23 +41,50 @@ export const POST: RequestHandler = async (event) => { forceRecreate?: boolean; }; + const stackName = decodeURIComponent(params.name); + + // Read up-front (not inside createJobResponse's callback, as before): the run + // record needs a compose/env hash and a schedule_executions row BEFORE the job + // stream opens, since createJobResponse takes the recorder as a plain (already + // resolved) argument, not something it can await for itself. + let composeResult: Awaited>; + try { + composeResult = await requireComposeFile(stackName, envIdNum); + } catch (error) { + const message = error instanceof ComposeFileNotFoundError + ? error.message + : 'Failed to deploy compose stack'; + return createJobResponse(async (send) => { + send('result', { success: false, error: message }); + }, event.request); + } + + if (!composeResult.success) { + const message = composeResult.needsFileLocation + ? 'Stack compose file location not configured' + : composeResult.error || 'Compose file not found'; + return createJobResponse(async (send) => { + send('result', { success: false, error: message }); + }, event.request); + } + + // Same merged set used for envHash below -- also the redaction list end() applies + // to the stored error text, so a leaked env value never reaches errorMessage. + const effectiveEnvVars = { ...(composeResult.nonSecretVars ?? {}), ...(composeResult.secretVars ?? {}) }; + + const recorder = await createRunRecorder({ + stackName, + envId: envIdNum ?? null, + userId: auth.user?.id, + triggeredBy: 'manual', + options: { pull: !!pull, build: !!build, forceRecreate: !!forceRecreate }, + composeHash: hashComposeContent(composeResult.content!), + envHash: hashEnvFingerprint(effectiveEnvVars), + secrets: Object.values(effectiveEnvVars) + }); + return createJobResponse(async (send) => { try { - const stackName = decodeURIComponent(params.name); - - send('progress', { status: 'Reading compose file...' }); - const composeResult = await requireComposeFile(stackName, envIdNum); - - if (!composeResult.success) { - send('result', { - success: false, - error: composeResult.needsFileLocation - ? 'Stack compose file location not configured' - : composeResult.error || 'Compose file not found' - }); - return; - } - send('progress', { status: 'Deploying stack...' }); const result = await deployStack({ name: stackName, @@ -65,9 +94,18 @@ export const POST: RequestHandler = async (event) => { build, forceRecreate, composePath: composeResult.composePath, - envPath: composeResult.envPath + envPath: composeResult.envPath, + onLine: (line) => send('progress', { type: 'line', line }) }); + // F4 fix: deployStack resolves the bound secret provider's values + // internally, AFTER `recorder` above was already built from the DB-only + // `effectiveEnvVars`. Feed the provider-resolved values in now, before + // createJobResponse calls recorder.end() below (on both the JSON and the + // streaming path) -- otherwise a provider secret leaking into result.error + // would bypass end()'s redaction entirely. See deploy-run-record.ts. + recorder.addSecrets(result.resolvedSecrets ?? []); + // Audit log await auditStack(event, 'deploy', stackName, envIdNum, { pull, build, forceRecreate @@ -86,5 +124,5 @@ export const POST: RequestHandler = async (event) => { console.error('Error deploying compose stack:', error); send('result', { success: false, error: 'Failed to deploy compose stack' }); } - }, event.request); + }, event.request, recorder); }; diff --git a/src/routes/api/stacks/[name]/deploys/+server.ts b/src/routes/api/stacks/[name]/deploys/+server.ts new file mode 100644 index 0000000..8a47677 --- /dev/null +++ b/src/routes/api/stacks/[name]/deploys/+server.ts @@ -0,0 +1,138 @@ +import { json } from '@sveltejs/kit'; +import { getScheduleExecutions } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/stacks/[name]/deploys?env=X + * + * List of deploy runs recorded for this stack. No log text here — the log + * lives in a file (Task 5), not in this response; fetch it separately via + * GET .../deploys/{runId}/log. + * + * This route does NOT inherit the no-auth model of /api/jobs/{id}: a job id + * is an unguessable UUID that lives ten minutes in memory, while a schedule + * execution id is a small sequential integer that persists forever. Every + * route under .../deploys checks stacks:view for itself. + * + * `env` identifies WHICH environment's runs to list, unlike the other two + * routes under .../deploys (which derive the environment from the loaded run + * itself via loadOwnedDeployRun). A stack NAME alone is not unique -- the + * same name can exist in more than one environment -- so a list without a + * definite environment would either merge runs from unrelated environments + * into one response, or get evaluated against the caller's globally-merged + * permission instead of their permission for one specific environment: + * `auth.can(..., undefined)` checks the OR of every role's grant across all + * environments, not "does this caller have stacks:view for env X". A caller + * with stacks:view scoped to exactly one environment could otherwise get + * runs -- including timestamps, status, error text and details.userId -- + * from every environment that happens to have a same-named stack. + * + * Two `env` shapes both resolve to the LOCAL environment (environmentId === + * NULL in schedule_executions): the parameter OMITTED entirely, and the + * literal string "null". This is not a relaxation of the paragraph above -- + * it is the environment id createRunRecorder() actually writes for a deploy + * triggered without an explicit envId, which is the case on a single + * (local-only) environment install: appendEnvParam() in + * $lib/stores/environment.ts only appends `env` to the request when envId is + * truthy, so the UI's own "deploy this stack" call for the local environment + * NEVER sends an `env` param in the first place. Before this NULL shape was + * recognized here, EVERY recorded run on a single-environment install was + * unreachable through this endpoint: any non-null `env` value matches zero + * rows (`env=0` included -- environment ids are never 0), and omitting `env` + * was a hard 400. There is no legitimate case where "list runs, no env + * specified" should silently mean "all environments, merged" -- an explicit, + * distinguishable id (a real integer, or the NULL/local shape) is still + * required for every request; only what counts as that definite id changed. + * Anything that is neither a parseable integer nor the omitted/"null" shape + * remains a hard 400: parsing happens before the permission check, so + * `can()` and `canAccessEnvironment()` always see a definite id (a number, + * or `undefined` for the local case -- never an ambiguous "no filter"). + */ +/** + * @openapi + * summary: List deploy runs recorded for a stack (no log text -- see GET .../{runId}/log) + * path: name:string! Stack name (from GET /api/stacks) + * query: env:integer Environment id the stack belongs to (from GET /api/environments). Omit, or pass the literal string "null", for the local/default environment (deploys triggered without an explicit env, the normal shape on a single-environment install). + * resp-200: {runs:array<{id:integer!, environmentId:integer, triggeredBy:string!, triggeredAt:string!, startedAt:string, completedAt:string, duration:integer, status:string!, errorMessage:string, details:{}}>!} + * resp-400: env query parameter must be an integer, or omitted/"null" for the local environment + * resp-401: Authentication required + * resp-403: Permission denied (needs stacks:view), or access denied to this environment + */ +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const auth = await authorize(cookies); + + // Authentication gate first, checked BEFORE anything else runs: a caller + // without a session gets 401 here, not a 403 indistinguishable from "logged + // in but lacking the permission", and not some later error from touching the + // database without a user. + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + // env is resolved to a definite id -- a real integer, or `null` for the + // local environment (see the module doc comment above) -- BEFORE the + // permission check below, so `can()` never falls back to the caller's + // globally-merged permission across all environments. NEVER left + // `undefined` past this point: passed straight through to + // getScheduleExecutions(), where `environmentId: undefined` means "do not + // filter on environment at all" (every environment's runs), while + // `environmentId: null` means "only NULL-environment runs" -- the two are + // not interchangeable, and only the second is what "local environment" + // must mean here. + const envIdParam = url.searchParams.get('env'); + let envIdNum: number | null; + if (envIdParam === null || envIdParam === 'null') { + envIdNum = null; + } else { + const parsed = parseInt(envIdParam, 10); + if (isNaN(parsed)) { + return json( + { error: 'env query parameter must be an integer, or omitted/"null" for the local environment' }, + { status: 400 } + ); + } + envIdNum = parsed; + } + + // `?? undefined` maps the local environment (null) to the same "no + // environment context" global-permission check `can()` performs for + // git-stack routes with a nullable environmentId (`gitStack.environmentId + // || undefined` in api/git/stacks/[id]/+server.ts) -- there is no + // per-environment role to scope against for a run that was never + // attributed to one. + if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum ?? undefined))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // The local environment has no environment record to check access + // against -- canAccessEnvironment() takes a required numeric id and would + // throw or misbehave on null/undefined, and there is nothing to be denied + // access TO for a run that isn't attributed to any environment. + if (envIdNum !== null && auth.isEnterprise && !(await auth.canAccessEnvironment(envIdNum))) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + + const stackName = decodeURIComponent(params.name); + const { executions } = await getScheduleExecutions({ + scheduleType: 'stack_deploy', + entityName: stackName, + environmentId: envIdNum, + limit: 100 + }); + + return json({ + runs: executions.map((e) => ({ + id: e.id, + environmentId: e.environmentId, + triggeredBy: e.triggeredBy, + triggeredAt: e.triggeredAt, + startedAt: e.startedAt, + completedAt: e.completedAt, + duration: e.duration, + status: e.status, + errorMessage: e.errorMessage, + details: e.details + })) + }); +}; diff --git a/src/routes/api/stacks/[name]/deploys/[runId]/+server.ts b/src/routes/api/stacks/[name]/deploys/[runId]/+server.ts new file mode 100644 index 0000000..d1a426c --- /dev/null +++ b/src/routes/api/stacks/[name]/deploys/[runId]/+server.ts @@ -0,0 +1,141 @@ +import { json } from '@sveltejs/kit'; +import { deleteScheduleExecution } from '$lib/server/db'; +import { authorize } from '$lib/server/authorize'; +import { deleteRunLog } from '$lib/server/deploy-log-store'; +import { loadOwnedDeployRun, isTerminalRunStatus } from '$lib/server/deploy-run-access'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/stacks/[name]/deploys/[runId] + * + * A single deploy run with all its metadata (no log text -- see .../log). + */ +/** + * @openapi + * summary: Get a single deploy run's metadata (no log text -- see GET .../{runId}/log) + * description: 400 ("Invalid run id") and 404 ("Deploy run not found", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of resp-400/resp-404 lines (same pattern as POST /api/backup/destinations/{id}/task's description). + * path: name:string! Stack name (from GET /api/stacks) + * path: runId:integer! Run id (from GET /api/stacks/{name}/deploys) + * resp-200: {id:integer!, environmentId:integer, triggeredBy:string!, triggeredAt:string!, startedAt:string, completedAt:string, duration:integer, status:string!, errorMessage:string, details:{}} + * resp-401: Authentication required + * resp-403: Permission denied (needs stacks:view for the run's own environment), or access denied to this environment + */ +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + // Authentication gate first: a caller without a session gets 401 here, not + // a 403 indistinguishable from "logged in but lacking the permission". + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + // Coarse gate: reject a caller who lacks stacks:view EVERYWHERE, before + // even touching the database. This does not by itself scope to the run's + // environment (see the check below) -- it only screens out someone with + // no stacks:view grant on any role at all. + if (auth.authEnabled && !(await auth.can('stacks', 'view'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const stackName = decodeURIComponent(params.name); + const loaded = await loadOwnedDeployRun(stackName, params.runId, auth); + if ('response' in loaded) return loaded.response; + + const { run } = loaded; + + // Scoped to the RUN's OWN environment, checked ADDITIONALLY and AFTER the + // coarse gate above -- it needs run.environmentId, which only exists once + // loadOwnedDeployRun has resolved it. Without this, a caller with + // stacks:view for SOME environment (which already satisfied the coarse + // gate above via the global OR-across-roles merge) but not for THIS run's + // environment could read a run from an environment they have no + // stacks:view grant on at all. Same idea the sibling list route + // (deploys/+server.ts) already documents for itself, and the same pattern + // api/git/stacks/[id]/+server.ts and env-files/+server.ts use: derive the + // environment from the loaded entity, then check permission against THAT. + // `?? undefined` maps a local run (NULL environmentId) to the same + // "no environment context" global-permission check those routes use for + // their own nullable environmentId (`gitStack.environmentId || undefined`). + if (auth.authEnabled && !(await auth.can('stacks', 'view', run.environmentId ?? undefined))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + return json({ + id: run.id, + environmentId: run.environmentId, + triggeredBy: run.triggeredBy, + triggeredAt: run.triggeredAt, + startedAt: run.startedAt, + completedAt: run.completedAt, + duration: run.duration, + status: run.status, + errorMessage: run.errorMessage, + details: run.details + }); +}; + +/** + * DELETE /api/stacks/[name]/deploys/[runId] + * + * Removes both the database record AND the on-disk log file. Deleting only + * one half would either leave an orphaned log file nobody can reach (record + * gone), or a record that 404s the moment someone tries to read its log (file + * gone) -- so both happen together, file first: if the file delete throws, + * the record still exists and a retry can clean up the rest; the reverse + * order would leave a log file with no record pointing at it, findable only + * by scanning the log directory. + */ +/** + * @openapi + * summary: Delete a deploy run's database record and its on-disk log file + * description: 400 ("Invalid run id") and 404 ("Deploy run not found", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- see GET .../{runId}'s description for why that means prose here instead of resp-400/resp-404 lines. + * path: name:string! Stack name (from GET /api/stacks) + * path: runId:integer! Run id (from GET /api/stacks/{name}/deploys) + * resp-200: {success:boolean!} + * resp-401: Authentication required + * resp-403: Permission denied (needs stacks:edit for the run's own environment), or access denied to this environment + * resp-409: The run has not finished yet (status is not a terminal one) -- deleting a + * still-running run would let its background deploy process keep writing to a log + * file with no record behind it, which the deploy-log reconcile job then purges as an + * orphan, erasing every trace that the deploy ever happened. Retry once it finishes. + */ +export const DELETE: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + // Coarse gate -- see the identical comment on GET above. + if (auth.authEnabled && !(await auth.can('stacks', 'edit'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const stackName = decodeURIComponent(params.name); + const loaded = await loadOwnedDeployRun(stackName, params.runId, auth); + if ('response' in loaded) return loaded.response; + + const { run } = loaded; + + // Scoped to the RUN's OWN environment -- see the identical comment on GET + // above for why this is checked in addition to, and after, the coarse + // gate. Checked before either delete happens: neither the log file nor + // the database record is touched if this denies. + if (auth.authEnabled && !(await auth.can('stacks', 'edit', run.environmentId ?? undefined))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // Finding B fix: refuse to delete a run that has not finished yet. Checked AFTER + // the ownership/permission checks above (a caller who is not allowed to see this + // run at all must not learn its status via a 409 either -- 404/403 still win), and + // BEFORE either delete happens below, same as the permission check it follows. See + // isTerminalRunStatus()'s doc comment (deploy-run-access.ts) for why an unrecognized + // status is treated as NOT finished rather than assumed safe to delete. + if (!isTerminalRunStatus(run.status)) { + return json({ error: 'Cannot delete a running deploy run' }, { status: 409 }); + } + + // F5 fix: scoped to the run's OWN environment directory (deploy-log-store.ts). + await deleteRunLog(run.environmentId, String(run.id)); + await deleteScheduleExecution(run.id); + + return json({ success: true }); +}; diff --git a/src/routes/api/stacks/[name]/deploys/[runId]/log/+server.ts b/src/routes/api/stacks/[name]/deploys/[runId]/log/+server.ts new file mode 100644 index 0000000..b12677d --- /dev/null +++ b/src/routes/api/stacks/[name]/deploys/[runId]/log/+server.ts @@ -0,0 +1,68 @@ +import { json } from '@sveltejs/kit'; +import { authorize } from '$lib/server/authorize'; +import { loadOwnedDeployRun } from '$lib/server/deploy-run-access'; +import { readRunLog } from '$lib/server/deploy-log-store'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/stacks/[name]/deploys/[runId]/log + * + * The run's protocol text, straight from the file (Task 5) as text/plain. + * + * This is the most sensitive of the four operations -- it is the one that can + * actually carry secrets that survived redaction -- so it MUST NOT come out + * looking safe just because a file path happened to be unresolvable. Every + * check below runs BEFORE the file is ever touched: authentication, then + * stacks:view, then ownership+environment access via loadOwnedDeployRun (the + * exact same helper GET/DELETE .../deploys/{runId} use, so this route can + * never drift into trusting a run it shouldn't). Only once all of that has + * passed does readRunLog() run at all. + */ +/** + * @openapi + * summary: Get a deploy run's log text (text/plain, from the on-disk file) + * description: 400 ("Invalid run id") is produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of a resp-400 line (same pattern as POST /api/backup/destinations/{id}/task's description). The 404 below covers BOTH loadOwnedDeployRun's "run not found / belongs to a different stack" AND this handler's own "log file missing" -- the latter IS literal in this file's body, which is why it can stay a resp-404 line. + * path: name:string! Stack name (from GET /api/stacks) + * path: runId:integer! Run id (from GET /api/stacks/{name}/deploys) + * resp-200: Plain-text log content + * resp-401: Authentication required + * resp-403: Permission denied (needs stacks:view for the run's own environment), or access denied to this environment + * resp-404: Deploy run not found (also returned for a run that belongs to a different stack), or log file missing + */ +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + // Coarse gate -- see the identical comment in + // .../deploys/[runId]/+server.ts's GET handler. + if (auth.authEnabled && !(await auth.can('stacks', 'view'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + const stackName = decodeURIComponent(params.name); + const loaded = await loadOwnedDeployRun(stackName, params.runId, auth); + if ('response' in loaded) return loaded.response; + + const { run } = loaded; + + // Scoped to the RUN's OWN environment -- see the identical comment in + // .../deploys/[runId]/+server.ts's GET handler for why this runs in + // addition to, and after, the coarse gate above. This is the MOST + // sensitive of the three routes (it is the one that can carry secrets + // that survived redaction, see the module doc comment below), so this + // check runs BEFORE readRunLog() ever touches the file. + if (auth.authEnabled && !(await auth.can('stacks', 'view', run.environmentId ?? undefined))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + // F5 fix: scoped to the run's OWN environment directory (deploy-log-store.ts) -- + // readRunLog() only reaches a file another environment's run wrote. + const log = await readRunLog(run.environmentId, String(run.id)); + if (log === null) { + return json({ error: 'Log not found' }, { status: 404 }); + } + + return new Response(log, { headers: { 'content-type': 'text/plain' } }); +}; diff --git a/src/routes/api/stacks/[name]/down/+server.ts b/src/routes/api/stacks/[name]/down/+server.ts index 724d9e3..7f9ff05 100644 --- a/src/routes/api/stacks/[name]/down/+server.ts +++ b/src/routes/api/stacks/[name]/down/+server.ts @@ -46,7 +46,7 @@ export const POST: RequestHandler = async (event) => { return createJobResponse(async (send) => { try { const stackName = decodeURIComponent(params.name); - const result = await downStack(stackName, envIdNum, removeVolumes); + const result = await downStack(stackName, envIdNum, removeVolumes, (line) => send('progress', { type: 'line', line })); // Audit log await auditStack(event, 'down', stackName, envIdNum, { removeVolumes }); diff --git a/src/routes/api/stacks/[name]/restart/+server.ts b/src/routes/api/stacks/[name]/restart/+server.ts index 721c892..0ec799a 100644 --- a/src/routes/api/stacks/[name]/restart/+server.ts +++ b/src/routes/api/stacks/[name]/restart/+server.ts @@ -37,7 +37,7 @@ export const POST: RequestHandler = async (event) => { return createJobResponse(async (send) => { try { const stackName = decodeURIComponent(params.name); - const result = await restartStack(stackName, envIdNum, mode); + const result = await restartStack(stackName, envIdNum, mode, (line) => send('progress', { type: 'line', line })); // Audit log await auditStack(event, 'restart', stackName, envIdNum); diff --git a/src/routes/api/stacks/[name]/start/+server.ts b/src/routes/api/stacks/[name]/start/+server.ts index 6de3f20..dfcca07 100644 --- a/src/routes/api/stacks/[name]/start/+server.ts +++ b/src/routes/api/stacks/[name]/start/+server.ts @@ -35,7 +35,7 @@ export const POST: RequestHandler = async (event) => { return createJobResponse(async (send) => { try { const stackName = decodeURIComponent(params.name); - const result = await startStack(stackName, envIdNum); + const result = await startStack(stackName, envIdNum, (line) => send('progress', { type: 'line', line })); // Audit log await auditStack(event, 'start', stackName, envIdNum); diff --git a/src/routes/api/stacks/[name]/stop/+server.ts b/src/routes/api/stacks/[name]/stop/+server.ts index c92434d..4698a77 100644 --- a/src/routes/api/stacks/[name]/stop/+server.ts +++ b/src/routes/api/stacks/[name]/stop/+server.ts @@ -35,7 +35,7 @@ export const POST: RequestHandler = async (event) => { return createJobResponse(async (send) => { try { const stackName = decodeURIComponent(params.name); - const result = await stopStack(stackName, envIdNum); + const result = await stopStack(stackName, envIdNum, (line) => send('progress', { type: 'line', line })); // Audit log await auditStack(event, 'stop', stackName, envIdNum); diff --git a/src/routes/logs/LogViewer.svelte b/src/routes/logs/LogViewer.svelte deleted file mode 100644 index c7e7c4b..0000000 --- a/src/routes/logs/LogViewer.svelte +++ /dev/null @@ -1,374 +0,0 @@ - - -
- -
-
- {#if loading} - - {/if} -
-
- - - - - - fontSize = Number(v)}> - - - {fontSize}px - - - {#each fontSizeOptions as size} - - - {size}px - - {/each} - - - - - - - - {#if logSearchActive} -
- - - - {#if matchCount > 0} - {currentMatchIndex + 1}/{matchCount} - {:else if logSearchQuery} - 0/0 - {/if} - - - -
- {:else} - - {/if} - - - - - - - - -
-
- - -
- {#if logs} -
{@html wrapHtmlLines(highlightedLogs())}
- {:else if loading} -
- - Loading logs... -
- {:else} -

No logs available

- {/if} -
-
- - diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index 7820064..a5bcb48 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -163,7 +163,7 @@ interface ScheduleExecution { id: number; - scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify'; + scheduleType: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify' | 'stack_deploy' | 'deploy_log_reconcile'; scheduleId: number; environmentId: number | null; entityName: string; @@ -182,7 +182,7 @@ interface Schedule { key: string; // Unique key: type-id id: number; - type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify'; + type: 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'repo_prune' | 'repo_check' | 'repo_verify' | 'stack_deploy' | 'deploy_log_reconcile'; name: string; entityName: string; description?: string; @@ -508,9 +508,10 @@ async function toggleHideSystemJobs() { hideSystemJobs = !hideSystemJobs; - // Remove system_cleanup from filter if hiding system jobs - if (hideSystemJobs && filterTypes.includes('system_cleanup')) { - filterTypes = filterTypes.filter(t => t !== 'system_cleanup'); + // Remove system_cleanup and deploy_log_reconcile from filter if hiding system jobs + // (both are system schedules -- isSystem: true -- so they're gone from the list too) + if (hideSystemJobs && (filterTypes.includes('system_cleanup') || filterTypes.includes('deploy_log_reconcile'))) { + filterTypes = filterTypes.filter(t => t !== 'system_cleanup' && t !== 'deploy_log_reconcile'); } // Save preference in background try { @@ -975,6 +976,10 @@ Repo check {:else if filterTypes[0] === 'repo_verify'} Data verify + {:else if filterTypes[0] === 'stack_deploy'} + Stack deploy + {:else if filterTypes[0] === 'deploy_log_reconcile'} + Deploy log reconcile {:else} System jobs {/if} @@ -1009,6 +1014,10 @@ Image prune + + + Stack deploy + {#if $page.data.backupsEnabled} @@ -1033,6 +1042,10 @@ System jobs + + + Deploy log reconcile + {/if} @@ -1604,7 +1617,7 @@ Execution details {#if selectedExecution} - ({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else if selectedExecution.scheduleType === 'backup'}Backup{:else if selectedExecution.scheduleType === 'repo_prune'}Repo prune{:else if selectedExecution.scheduleType === 'repo_check'}Repo check{:else if selectedExecution.scheduleType === 'repo_verify'}Data verify{:else}System job{/if}) + ({#if selectedExecution.scheduleType === 'container_update'}Container update{:else if selectedExecution.scheduleType === 'env_update_check'}Environment update{:else if selectedExecution.scheduleType === 'git_stack_sync'}Git stack sync{:else if selectedExecution.scheduleType === 'backup'}Backup{:else if selectedExecution.scheduleType === 'repo_prune'}Repo prune{:else if selectedExecution.scheduleType === 'repo_check'}Repo check{:else if selectedExecution.scheduleType === 'repo_verify'}Data verify{:else if selectedExecution.scheduleType === 'stack_deploy'}Stack deploy{:else if selectedExecution.scheduleType === 'deploy_log_reconcile'}Deploy log reconcile{:else}System job{/if}) {/if} diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index d1878cd..b71f77b 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -91,6 +91,8 @@ services: let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled); let scannerCleanupCron = $derived($appSettings.scannerCleanupCron); let scannerCleanupEnabled = $derived($appSettings.scannerCleanupEnabled); + let deployLogReconcileCron = $derived($appSettings.deployLogReconcileCron); + let deployLogReconcileEnabled = $derived($appSettings.deployLogReconcileEnabled); let logMaxLines = $derived($appSettings.logMaxLines); let formatLogTimestamps = $derived($appSettings.formatLogTimestamps); let defaultTimezone = $derived($appSettings.defaultTimezone); @@ -187,6 +189,17 @@ services: toast.success(newState ? 'Scanner cleanup enabled' : 'Scanner cleanup disabled'); } + function handleDeployLogReconcileCronChange(cron: string) { + appSettings.setDeployLogReconcileCron(cron); + toast.success('Deploy log reconcile cron updated'); + } + + function handleDeployLogReconcileEnabledChange() { + const newState = !deployLogReconcileEnabled; + appSettings.setDeployLogReconcileEnabled(newState); + toast.success(newState ? 'Deploy log reconcile enabled' : 'Deploy log reconcile disabled'); + } + function handleGrypeImageBlur(e: Event) { const value = (e.target as HTMLInputElement).value.trim(); if (value && value !== defaultGrypeImage) { @@ -1121,6 +1134,26 @@ services: {/if} +
+
+ + +
+

Delete deploy-log files that no longer have a matching deploy record; deploy records that lost their log file are only flagged, never deleted

+ {#if deployLogReconcileEnabled} +
+ +
+ {/if} +
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 8901d86..27277f6 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -29,6 +29,7 @@ import type { ComposeStackInfo, ContainerStats, StackContainer } from '$lib/types'; import StackModal from './StackModal.svelte'; import DeleteStackModal from './DeleteStackModal.svelte'; + import ComposeOutputModal from './ComposeOutputModal.svelte'; import GitSourceBadge from './GitSourceBadge.svelte'; import GitStackModal from './GitStackModal.svelte'; import ImportStackModal from './ImportStackModal.svelte'; @@ -520,6 +521,52 @@ let restartPopoverOpen = $state>({}); let stackDownLoading = $state(null); + // Live compose output window: shown while a stack action (start/stop/restart/ + // redeploy/down) runs, fed line-by-line via readJobResponse's onLine callback. + let composeOutputOpen = $state(false); + let composeOutputTitle = $state(''); + let composeOutputLines = $state([]); + let composeOutputRunning = $state(false); + // Status-line inputs, measured here in the browser (wall-clock incl. the round + // trip through fetch/polling) — NOT the server-side execution time recorded + // separately for the deploy-history dataset (Task 10). Different path, different + // number; don't conflate the two. + let composeOutputStartedAt = 0; + let composeOutputOk = $state(undefined); + let composeOutputMs = $state(undefined); + let composeOutputExitCode = $state(undefined); + + function startComposeOutput(title: string) { + composeOutputTitle = title; + composeOutputLines = []; + composeOutputRunning = true; + composeOutputOpen = true; + composeOutputStartedAt = Date.now(); + composeOutputOk = undefined; + composeOutputMs = undefined; + composeOutputExitCode = undefined; + } + + function appendComposeOutputLine(line: string) { + composeOutputLines = [...composeOutputLines, line]; + } + + // Called once the job settles. Older agent versions (pre line-streaming, see Task 5) + // only return a batched `output` string — show it if nothing arrived incrementally. + // `ok`/`exitCode` come from the job result (or `false`/undefined on a thrown + // network error, from the catch blocks below) — the payload does not carry an + // exit code today (StackOperationResult has no such field, see stacks.ts), so + // `exitCode` stays undefined in practice until the server starts sending one. + function finishComposeOutput(output: string | undefined, ok: boolean, exitCode?: number) { + composeOutputRunning = false; + composeOutputOk = ok; + composeOutputMs = Date.now() - composeOutputStartedAt; + composeOutputExitCode = exitCode; + if (composeOutputLines.length === 0 && output) { + composeOutputLines = output.split('\n'); + } + } + // Container-level confirmation popover state let confirmStopContainerId = $state(null); let confirmRestartContainerId = $state(null); @@ -965,9 +1012,15 @@ async function startStack(name: string) { operationError = null; stackActionLoading = name; + startComposeOutput(`Starting ${name}`); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' }); - const data = await readJobResponse(response); + const data = await readJobResponse(response, appendComposeOutputLine); + finishComposeOutput( + typeof data.output === 'string' ? data.output : undefined, + Boolean(data.success), + typeof data.exitCode === 'number' ? data.exitCode : undefined + ); if (!data.success) { showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack'); return; @@ -975,6 +1028,7 @@ toast.success(`Started ${name}`); await fetchStacks(); } catch (error) { + finishComposeOutput(undefined, false); console.error('Failed to start stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to start stack'; showErrorDialog(`Failed to start ${name}`, errorMsg); @@ -986,9 +1040,15 @@ async function stopStack(name: string) { operationError = null; stackActionLoading = name; + startComposeOutput(`Stopping ${name}`); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' }); - const data = await readJobResponse(response); + const data = await readJobResponse(response, appendComposeOutputLine); + finishComposeOutput( + typeof data.output === 'string' ? data.output : undefined, + Boolean(data.success), + typeof data.exitCode === 'number' ? data.exitCode : undefined + ); if (!data.success) { showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack'); return; @@ -996,6 +1056,7 @@ toast.success(`Stopped ${name}`); await fetchStacks(); } catch (error) { + finishComposeOutput(undefined, false); console.error('Failed to stop stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack'; showErrorDialog(`Failed to stop ${name}`, errorMsg); @@ -1007,13 +1068,19 @@ async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') { operationError = null; stackActionLoading = name; + startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`); try { let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId); if (mode === 'recreate' || mode === 'ordered') { url += (url.includes('?') ? '&' : '?') + `mode=${mode}`; } const response = await fetch(url, { method: 'POST' }); - const data = await readJobResponse(response); + const data = await readJobResponse(response, appendComposeOutputLine); + finishComposeOutput( + typeof data.output === 'string' ? data.output : undefined, + Boolean(data.success), + typeof data.exitCode === 'number' ? data.exitCode : undefined + ); if (!data.success) { showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack'); return; @@ -1021,6 +1088,7 @@ toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`); await fetchStacks(); } catch (error) { + finishComposeOutput(undefined, false); console.error('Failed to restart stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack'; showErrorDialog(`Failed to restart ${name}`, errorMsg); @@ -1032,13 +1100,19 @@ async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) { operationError = null; stackActionLoading = name; + startComposeOutput(`Redeploying ${name}`); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options) }); - const data = await readJobResponse(response); + const data = await readJobResponse(response, appendComposeOutputLine); + finishComposeOutput( + typeof data.output === 'string' ? data.output : undefined, + Boolean(data.success), + typeof data.exitCode === 'number' ? data.exitCode : undefined + ); if (!data.success) { showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack'); return; @@ -1046,6 +1120,7 @@ toast.success(`Redeployed ${name}`); await fetchStacks(); } catch (error) { + finishComposeOutput(undefined, false); console.error('Failed to redeploy stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack'; showErrorDialog(`Failed to redeploy ${name}`, errorMsg); @@ -1058,9 +1133,15 @@ operationError = null; stackActionLoading = name; stackDownLoading = name; + startComposeOutput(`Bringing down ${name}`); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' }); - const data = await readJobResponse(response); + const data = await readJobResponse(response, appendComposeOutputLine); + finishComposeOutput( + typeof data.output === 'string' ? data.output : undefined, + Boolean(data.success), + typeof data.exitCode === 'number' ? data.exitCode : undefined + ); if (!data.success) { showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack'); return; @@ -1068,6 +1149,7 @@ toast.success(`Brought down ${name}`); await fetchStacks(); } catch (error) { + finishComposeOutput(undefined, false); console.error('Failed to bring down stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack'; showErrorDialog(`Failed to bring down ${name}`, errorMsg); @@ -2764,6 +2846,16 @@ onConfirm={(opts) => removeStack(deleteStackName, opts)} /> + + + import { onMount } from 'svelte'; + import * as Dialog from '$lib/components/ui/dialog'; + import { Button } from '$lib/components/ui/button'; + import { Loader2 } from 'lucide-svelte'; + import LogViewer from '$lib/components/LogViewer.svelte'; + import { formatRunStatus } from '$lib/utils/run-status'; + + // A plain display window for compose output. It owns none of the polling — + // the parent pushes lines in as they arrive via readJobResponse's onLine callback + // and flips `running` to false once the job settles, at which point it also + // supplies `ok`/`ms`/`exitCode` for the status line below. + interface Props { + open: boolean; + title: string; + lines: string[]; + running: boolean; + ok?: boolean; + ms?: number; + exitCode?: number; + } + + let { open = $bindable(false), title, lines, running, ok, ms, exitCode }: Props = $props(); + + let statusLine = $derived(formatRunStatus({ running, ok, ms, exitCode })); + + // This modal is opened straight from the stack list, with no editor in scope to + // borrow a theme from (unlike the docked panel and Deploys tab inside StackModal, + // which both follow `dockhand-editor-theme`). Its Dialog.Content already tracks + // the app-wide light/dark switch via bg-background, so the log viewer inside + // follows the same switch instead of introducing a third, unrelated toggle. + // Same read-once-on-mount + system-preference-fallback pattern as + // ComposeGraphViewer's graphTheme -- this component is mounted once at the page + // root and toggled via `open`, so onMount runs once per page load. + let outputTheme = $state<'light' | 'dark'>('dark'); + + onMount(() => { + const appTheme = localStorage.getItem('theme'); + if (appTheme === 'dark' || appTheme === 'light') { + outputTheme = appTheme; + } else { + outputTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + }); + + function handleClose() { + // Closing only hides this window. The operation it is watching keeps running + // against the server regardless (watchJob's poll loop in sse-fetch.ts has no + // abort mechanism and isn't meant to gain one here) — never stop it on close. + open = false; + } + + + + + + + + {#if running} + + {/if} + {title} + + + {statusLine} + + + + + + + + + + diff --git a/src/routes/stacks/DeploysPanel.svelte b/src/routes/stacks/DeploysPanel.svelte new file mode 100644 index 0000000..a444c4d --- /dev/null +++ b/src/routes/stacks/DeploysPanel.svelte @@ -0,0 +1,355 @@ + + +{#if loading} +
+{:else if loadError} +
+ +

{loadError}

+
+{:else if runs.length === 0} +
+ +

No deploy runs yet.

+

Deploy this stack — its run history appears here.

+
+{:else} +
+ {#each runs as run (run.id)} + {@const view = buildDeployRunView(run)} + {@const badge = statusBadge(run.status)} + {@const BadgeIcon = badge.icon} + {@const TrigIcon = triggerIcon(run.triggeredBy)} + {@const isOpen = expanded.has(run.id)} + {@const panelState = buildDeployLogPanelState(run)} + {@const logEntry = logs.get(run.id)} +
+ + {#if isOpen} +
+
+
+
Containers
+
{view.containerSummary}
+
+
+
Build
+
{view.buildStatus}
+
+
+
Triggered by
+
{view.trigger}
+
+
+ {#if view.optionChips.length > 0} +
+ {#each view.optionChips as chip} + {chip} + {/each} +
+ {/if} + {#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0} +
+ {#if view.imagesBuilt.length > 0} +
Built: {view.imagesBuilt.join(', ')}
+ {/if} + {#if view.imagesPulled.length > 0} +
Pulled: {view.imagesPulled.join(', ')}
+ {/if} +
+ {/if} + {#if view.errorSummary} + +
+ {view.errorSummary} +
+ {/if} +
+
+ Log + {#if panelState.deletable} + deleteRun(run)} + onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)} + > + {#snippet children({ open })} + {#if deletingIds.has(run.id)} + + {:else} + + {/if} + {/snippet} + + {/if} +
+ {#if panelState.truncated} + +
+ Log truncated +
+ {/if} + {#if panelState.logMissing || logEntry?.status === 'missing'} + +
+ Protocol no longer available. +
+ {:else if logEntry?.status === 'error'} +
+ {logEntry.error} +
+ {:else} + + {/if} +
+
+ {/if} +
+ {/each} +
+{/if} diff --git a/src/routes/stacks/RedeployPopover.svelte b/src/routes/stacks/RedeployPopover.svelte index 84befc9..a518184 100644 --- a/src/routes/stacks/RedeployPopover.svelte +++ b/src/routes/stacks/RedeployPopover.svelte @@ -1,10 +1,11 @@ @@ -54,10 +96,16 @@ {#snippet child({ props })} @@ -80,6 +128,9 @@ Build images + {#if reason} +

{reason}

+ {/if}
+ +
{#if errors.compose} @@ -2288,11 +2522,53 @@ onTally={(t) => (backupTally = t)} />
+ {:else if activeTab === 'deploys' && !needsFileLocation} + +
+ +
{/if}
{/if}
+ + {#if outputRunning || outputLines.length > 0} + + +
+
+ {#if outputRunning} + + {/if} + {outputTitle} — {outputStatusLine} +
+ +
+ {/if} + +
@@ -2325,15 +2601,41 @@ Create {/if} - + +
+ + handleCreate(true, false, options)} + > + {#snippet children()} + + {/snippet} + +
{:else if !readonly} - + +
+ + handleSave(true, undefined, false, options)} + > + {#snippet children()} + + {/snippet} + +
{/if}
diff --git a/static/openapi.json b/static/openapi.json index 0161454..f872a4b 100644 --- a/static/openapi.json +++ b/static/openapi.json @@ -22110,7 +22110,7 @@ "schema": { "type": "string" }, - "description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify)" + "description": "Schedule type (container_update, git_stack_sync, system_cleanup, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, deploy_log_reconcile)" }, { "name": "id", @@ -22184,7 +22184,7 @@ "schema": { "type": "string" }, - "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup)" + "description": "Schedule type (container_update, git_stack_sync, env_update_check, image_prune, backup, repo_prune, repo_check, repo_verify, system_cleanup, deploy_log_reconcile)" }, { "name": "id", @@ -22224,7 +22224,7 @@ } }, "400": { - "description": "Invalid schedule id, unsupported type, or system_cleanup (cannot be paused)" + "description": "Invalid schedule id, unsupported type, or system_cleanup/deploy_log_reconcile (cannot be paused)" }, "404": { "description": "Schedule, backup config, or backup destination not found" @@ -23777,6 +23777,12 @@ "defaultTrivyImage": { "type": "string" }, + "deployLogReconcileCron": { + "type": "string" + }, + "deployLogReconcileEnabled": { + "type": "boolean" + }, "downloadFormat": { "type": "string" }, @@ -23895,6 +23901,8 @@ "defaultScannerNetworkMode": "string", "defaultTrivyArgs": "string", "defaultTrivyImage": "string", + "deployLogReconcileCron": "string", + "deployLogReconcileEnabled": true, "downloadFormat": "string", "editorFont": "string", "eventCleanupCron": "string", @@ -24675,6 +24683,15 @@ }, "environmentId": { "type": "integer" + }, + "pull": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "forceRecreate": { + "type": "boolean" } }, "required": [ @@ -24694,7 +24711,10 @@ "secretProviderId": 0, "start": true, "envId": 0, - "environmentId": 0 + "environmentId": 0, + "pull": true, + "build": true, + "forceRecreate": true } } } @@ -25036,6 +25056,15 @@ }, "secretProviderId": { "type": "integer" + }, + "pull": { + "type": "boolean" + }, + "build": { + "type": "boolean" + }, + "forceRecreate": { + "type": "boolean" } }, "required": [ @@ -25050,7 +25079,10 @@ "oldEnvPath": "string", "moveFromDir": "string", "restart": true, - "secretProviderId": 0 + "secretProviderId": 0, + "pull": true, + "build": true, + "forceRecreate": true } } } @@ -25221,6 +25253,357 @@ } } }, + "/api/stacks/{name}/deploys": { + "get": { + "operationId": "get_api_stacks_name_deploys", + "tags": [ + "stacks" + ], + "summary": "List deploy runs recorded for a stack (no log text -- see GET .../{runId}/log)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Environment id the stack belongs to (from GET /api/environments). Omit, or pass the literal string \"null\", for the local/default environment (deploys triggered without an explicit env, the normal shape on a single-environment install)." + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + }, + "triggeredBy": { + "type": "string" + }, + "triggeredAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "completedAt": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "errorMessage": { + "type": "string" + }, + "details": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "triggeredBy", + "triggeredAt", + "status" + ] + } + } + }, + "required": [ + "runs" + ] + }, + "example": { + "runs": [ + { + "id": 0, + "environmentId": 0, + "triggeredBy": "string", + "triggeredAt": "string", + "startedAt": "string", + "completedAt": "string", + "duration": 0, + "status": "string", + "errorMessage": "string", + "details": {} + } + ] + } + } + } + }, + "400": { + "description": "env query parameter must be an integer, or omitted/\"null\" for the local environment" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:view), or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/stacks/{name}/deploys/{runId}": { + "get": { + "operationId": "get_api_stacks_name_deploys_runId", + "tags": [ + "stacks" + ], + "summary": "Get a single deploy run's metadata (no log text -- see GET .../{runId}/log)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Run id (from GET /api/stacks/{name}/deploys)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "environmentId": { + "type": "integer" + }, + "triggeredBy": { + "type": "string" + }, + "triggeredAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "completedAt": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "errorMessage": { + "type": "string" + }, + "details": { + "type": "object", + "properties": {} + } + }, + "required": [ + "id", + "triggeredBy", + "triggeredAt", + "status" + ] + }, + "example": { + "id": 0, + "environmentId": 0, + "triggeredBy": "string", + "triggeredAt": "string", + "startedAt": "string", + "completedAt": "string", + "duration": 0, + "status": "string", + "errorMessage": "string", + "details": {} + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of resp-400/resp-404 lines (same pattern as POST /api/backup/destinations/{id}/task's description)." + }, + "delete": { + "operationId": "delete_api_stacks_name_deploys_runId", + "tags": [ + "stacks" + ], + "summary": "Delete a deploy run's database record and its on-disk log file", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Run id (from GET /api/stacks/{name}/deploys)" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "example": { + "success": true + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:edit for the run's own environment), or access denied to this environment" + }, + "409": { + "description": "The run has not finished yet (status is not a terminal one) -- deleting a" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "400 (\"Invalid run id\") and 404 (\"Deploy run not found\", also returned for a run that belongs to a different stack) are produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- see GET .../{runId}'s description for why that means prose here instead of resp-400/resp-404 lines." + } + }, + "/api/stacks/{name}/deploys/{runId}/log": { + "get": { + "operationId": "get_api_stacks_name_deploys_runId_log", + "tags": [ + "stacks" + ], + "summary": "Get a deploy run's log text (text/plain, from the on-disk file)", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stack name (from GET /api/stacks)" + }, + { + "name": "runId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Run id (from GET /api/stacks/{name}/deploys)" + } + ], + "responses": { + "200": { + "description": "Plain-text log content" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (needs stacks:view for the run's own environment), or access denied to this environment" + }, + "404": { + "description": "Deploy run not found (also returned for a run that belongs to a different stack), or log file missing" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "description": "400 (\"Invalid run id\") is produced by the shared loadOwnedDeployRun() helper (deploy-run-access.ts), not literally in this handler's body -- the openapi generator's static scanner cannot see status()/error() calls in a different file, hence the prose here instead of a resp-400 line (same pattern as POST /api/backup/destinations/{id}/task's description). The 404 below covers BOTH loadOwnedDeployRun's \"run not found / belongs to a different stack\" AND this handler's own \"log file missing\" -- the latter IS literal in this file's body, which is why it can stay a resp-404 line." + } + }, "/api/stacks/{name}/down": { "post": { "operationId": "post_api_stacks_name_down", diff --git a/tests/backups/route-guards.test.ts b/tests/backups/route-guards.test.ts index 943dc5b..a7c406a 100644 --- a/tests/backups/route-guards.test.ts +++ b/tests/backups/route-guards.test.ts @@ -4,10 +4,15 @@ * #14). Security-critical + fail-closed, so every deny branch is pinned here. * * The module transitively imports the DB/engine via ./index, so we mock.module() - * ./index and ../../db BEFORE importing it — the guard LOGIC under test is pure - * (decisions over an injected AuthorizationContext + the two resolver results). + * ./index and register a getBackupConfig fake via ../helpers/db-fake (see that + * file for why: mock.module() replaces $lib/server/db WHOLESALE for the entire + * process, so this file no longer registers its own separate mock.module() for + * it — that used to collide with any other file doing the same) — the guard + * LOGIC under test is pure (decisions over an injected AuthorizationContext + + * the two resolver results). */ import { describe, it, expect, mock, beforeAll } from 'bun:test'; +import { registerDbFake } from '../helpers/db-fake'; // Controllable fakes for the heavy imports, set per-test. let resolveResult: { envId: number | null | undefined; resolved: boolean } | (() => never) = { envId: 5, resolved: true }; @@ -30,9 +35,7 @@ mock.module('../../src/lib/server/backups/index', () => ({ return out; }, })); -mock.module('../../src/lib/server/db', () => ({ - getBackupConfig: async (id: number) => configById(id), -})); +registerDbFake('getBackupConfig', async (id: number) => configById(id)); let rg: typeof import('../../src/lib/server/backups/route-guards'); beforeAll(async () => { rg = await import('../../src/lib/server/backups/route-guards'); }); diff --git a/tests/clamp-number.test.ts b/tests/clamp-number.test.ts new file mode 100644 index 0000000..4f0b5c8 --- /dev/null +++ b/tests/clamp-number.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test'; +import { clampNumber } from '../src/lib/utils/clamp-number'; + +// One test per case (not one test with several assertions) -- same reasoning as +// save-close-policy.test.ts: a failure in one case must not hide the others behind a +// single aborted test run. +describe('clampNumber', () => { + test('a value below the minimum is pulled up to the minimum', () => { + expect(clampNumber(5, 15, 70)).toBe(15); + }); + + test('a value above the maximum is pulled down to the maximum', () => { + expect(clampNumber(90, 15, 70)).toBe(70); + }); + + test('a value already inside the bounds is left unchanged', () => { + expect(clampNumber(42, 15, 70)).toBe(42); + }); + + test('NaN is not passed through -- it falls back to the minimum', () => { + // This is the real-world trigger: the dragged container's height (or width) is + // briefly 0 (e.g. mid-drag while a panel is transitioning), so the ratio/distance + // computation divides by zero and produces NaN. Math.max(30, Math.min(80, NaN)) + // itself evaluates to NaN -- any NaN operand makes both Math.min and Math.max + // return NaN, so the naive clamp does not clamp at all. + expect(clampNumber(NaN, 15, 70)).toBe(15); + }); + + test('a negative proposal is clamped to the minimum, not left negative', () => { + expect(clampNumber(-20, 15, 70)).toBe(15); + }); + + test('positive Infinity clamps to the maximum via the ordinary Math.min/max path', () => { + // Unlike NaN, Infinity is a well-ordered number -- Math.min(max, Infinity) is + // max, so no special-casing is needed for it to land on the maximum. + expect(clampNumber(Infinity, 15, 70)).toBe(70); + }); + + // The bounds above (15-70) are a percentage split ratio. This repo has a second, + // independent caller with pixel bounds (the validate-panel width resizer, + // 320-560px) -- proving the same function clamps correctly regardless of unit, + // since it only compares `value` against `min`/`max` and never assumes a unit. + test('pixel-range bounds (not a percentage) clamp the same way', () => { + expect(clampNumber(200, 320, 560)).toBe(320); + expect(clampNumber(9999, 320, 560)).toBe(560); + expect(clampNumber(NaN, 320, 560)).toBe(320); + }); +}); diff --git a/tests/compose-build-detect.test.ts b/tests/compose-build-detect.test.ts new file mode 100644 index 0000000..2a6c6db --- /dev/null +++ b/tests/compose-build-detect.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { hasBuildSection } from '../src/lib/utils/compose-build-detect'; + +describe('hasBuildSection', () => { + test('no services at all -- false', () => { + expect(hasBuildSection('networks:\n default: {}\n')).toBe(false); + }); + + test('every service uses only image: -- false', () => { + const compose = 'services:\n app:\n image: demo:1\n db:\n image: postgres:16\n'; + expect(hasBuildSection(compose)).toBe(false); + }); + + // build: can be a bare string (`build: ./dir`)... + test('build: as a bare string on one service -- true', () => { + const compose = 'services:\n app:\n build: ./app\n db:\n image: postgres:16\n'; + expect(hasBuildSection(compose)).toBe(true); + }); + + // ...or an object (`build: {context: ., dockerfile: ...}`) -- both forms must be + // detected the same way (truthy check, not typeof), matching + // ComposeGraphViewer.svelte's `config.image || (config.build ? 'build' : 'custom')`. + test('build: as an object with context/dockerfile on one service -- true', () => { + const compose = 'services:\n app:\n build:\n context: .\n dockerfile: Dockerfile\n db:\n image: postgres:16\n'; + expect(hasBuildSection(compose)).toBe(true); + }); + + test('only the SECOND service has build: -- still true (checks every service, not just the first)', () => { + const compose = 'services:\n db:\n image: postgres:16\n app:\n build: ./app\n'; + expect(hasBuildSection(compose)).toBe(true); + }); + + test('invalid YAML mid-edit -- false, does not throw', () => { + expect(hasBuildSection('services:\n app:\n build: [unterminated')).toBe(false); + }); + + test('empty content -- false', () => { + expect(hasBuildSection('')).toBe(false); + }); +}); diff --git a/tests/deploy-duration.test.ts b/tests/deploy-duration.test.ts new file mode 100644 index 0000000..0af2468 --- /dev/null +++ b/tests/deploy-duration.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'bun:test'; +import { formatRunStatus } from '../src/lib/utils/run-status'; + +describe('formatRunStatus', () => { + test('reports success with a duration', () => { + expect(formatRunStatus({ running: false, ok: true, ms: 6200 })).toBe('Succeeded · 6.2s'); + }); + + test('reports failure with the exit code when one is known', () => { + expect(formatRunStatus({ running: false, ok: false, ms: 900, exitCode: 1 })) + .toBe('Failed · exit 1 · 0.9s'); + }); + + test('says nothing about duration while still running', () => { + expect(formatRunStatus({ running: true })).toBe('Running…'); + }); +}); diff --git a/tests/deploy-endpoints.test.ts b/tests/deploy-endpoints.test.ts new file mode 100644 index 0000000..d01899d --- /dev/null +++ b/tests/deploy-endpoints.test.ts @@ -0,0 +1,569 @@ +/** + * Authorization + data-shape tests for the three deploy-run routes under + * api/stacks/[name]/deploys/. These do NOT inherit the no-auth model of + * GET/DELETE /api/jobs/{id} (unguessable UUID, ten-minute in-memory job) -- + * every route here checks stacks:view/stacks:edit for itself, against a + * small sequential integer id that persists forever in schedule_executions. + * + * $lib/server/db transitively loads better-sqlite3 (ERR_DLOPEN_FAILED under + * Bun), so it has to be faked before the route modules are imported -- same + * problem tests/backups/route-guards.test.ts already solved. Fakes for it are + * registered via ../helpers/db-fake (registerDbFake), NOT via a separate + * mock.module('$lib/server/db', ...) call here -- see that helper's doc + * comment for why a second direct call collides with route-guards.test.ts's + * (mock.module() replaces the whole module for the whole process; whichever + * of the two files registers last wins for BOTH, deterministically, and the + * loser's import throws "Export named 'X' not found"). + * + * $lib/server/authorize is faked via tests/helpers/authorize-fake.ts (same + * collision reason as the db fake above -- see that helper's doc comment), driven + * by a per-test `authState`. $lib/server/deploy-log-store is NOT mocked: it only + * touches node:fs/promises, so real file operations against a throwaway DATA_DIR + * exercise the actual read/delete paths. + */ +import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { registerDbFake } from './helpers/db-fake'; +import { registerAuthorizeFake } from './helpers/authorize-fake'; + +// -- $lib/server/authorize: fully replaced, driven by `authState` ---------- + +let authState: { + authEnabled: boolean; + isAuthenticated: boolean; + isEnterprise: boolean; + can: boolean; + // H1 regression coverage needs `can()` to answer DIFFERENTLY depending on + // which environmentId it's asked about (a caller can have stacks:view for + // environment 1 but not environment 9) -- a single flat boolean can't + // express that. Left undefined, `can` (the flat boolean below) is the + // answer for every environmentId, unchanged from before -- every + // pre-existing test in this file that never touches canByEnv keeps its + // original behavior. Set only by the tests that specifically exercise + // per-environment scoping. + canByEnv?: (environmentId: number | undefined) => boolean; + accessibleEnvs: number[] | 'all'; +}; + +function resetAuthState() { + authState = { authEnabled: true, isAuthenticated: true, isEnterprise: false, can: true, canByEnv: undefined, accessibleEnvs: 'all' }; +} +resetAuthState(); + +registerAuthorizeFake(async () => ({ + authEnabled: authState.authEnabled, + isAuthenticated: authState.isAuthenticated, + isEnterprise: authState.isEnterprise, + can: async (_resource: string, _action: string, environmentId?: number) => + authState.canByEnv ? authState.canByEnv(environmentId) : authState.can, + canAccessEnvironment: async (id: number) => + authState.accessibleEnvs === 'all' || authState.accessibleEnvs.includes(id) +})); + +// -- $lib/server/db: an in-memory schedule_executions fake ----------------- + +let execStore: Map; +let deletedIds: number[]; +let lastListFilters: any; + +function resetDbState() { + execStore = new Map(); + deletedIds = []; + lastListFilters = undefined; +} +resetDbState(); + +registerDbFake('getScheduleExecution', async (id: number) => execStore.get(id)); +registerDbFake('deleteScheduleExecution', async (id: number) => { + deletedIds.push(id); + execStore.delete(id); +}); +registerDbFake('getScheduleExecutions', async (filters: any) => { + lastListFilters = filters; + const all = [...execStore.values()].filter( + (e) => + (filters.scheduleType === undefined || e.scheduleType === filters.scheduleType) && + (filters.entityName === undefined || e.entityName === filters.entityName) && + (filters.environmentId === undefined || e.environmentId === filters.environmentId) + ); + return { executions: all, total: all.length, limit: filters.limit ?? 50, offset: 0 }; +}); + +// -- Real deploy-log-store against a throwaway DATA_DIR --------------------- +// +// DATA_DIR is process-global and read at call time by every other test file +// too (belatedly discovered the hard way for exactly this variable in +// tests/fs-guard.test.ts / tests/selfhst-icons.test.ts -- a file that sets it +// and never restores it can break an unrelated, later-running file). Save and +// restore the prior value so this file leaves no trace once its tests finish. + +const previousDataDir = process.env.DATA_DIR; +let dataDir: string; +beforeAll(async () => { + dataDir = await mkdtemp(join(tmpdir(), 'dv-t12-deploy-logs-')); + process.env.DATA_DIR = dataDir; +}); +afterAll(async () => { + await rm(dataDir, { recursive: true, force: true }); + if (previousDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = previousDataDir; + } +}); + +const { appendRunLog, readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store'); + +// -- Routes under test, imported AFTER all the fakes above are registered --- + +const listRoute = await import('../src/routes/api/stacks/[name]/deploys/+server'); +const runRoute = await import('../src/routes/api/stacks/[name]/deploys/[runId]/+server'); +const logRoute = await import('../src/routes/api/stacks/[name]/deploys/[runId]/log/+server'); + +// -- Fixtures ---------------------------------------------------------------- + +const STACK = 'demo-stack'; + +const RUN = { + id: 1, + scheduleType: 'stack_deploy', + scheduleId: 0, + environmentId: null, + entityName: STACK, + triggeredBy: 'manual', + triggeredAt: '2026-01-01T00:00:00.000Z', + startedAt: null, + completedAt: null, + duration: 4200, + status: 'success', + errorMessage: null, + details: { summary: 'ok' }, + logs: null, + createdAt: null +}; + +function makeEvent(over: { params?: Record; url?: string } = {}) { + return { + params: { name: STACK, runId: '1', ...over.params }, + url: new URL(over.url ?? 'http://x/'), + cookies: { get: () => undefined } as any + } as any; +} + +beforeEach(async () => { + resetAuthState(); + resetDbState(); + // Every test defaults to runId '1' -- clear its log file so tests don't + // leak content into each other via the shared DATA_DIR. F5 fix: the log + // file now lives under its OWN environment's directory, and this file's + // tests use both RUN's default (environmentId: null) and an overridden + // environmentId: 9 -- clear both so leftovers from either can't leak + // between tests. + await deleteRunLog(null, '1'); + await deleteRunLog(9, '1'); +}); + +// ============================================================================= +// GET /api/stacks/[name]/deploys (list) +// ============================================================================= + +describe('GET /api/stacks/[name]/deploys (list)', () => { + test('no session -> 401 at the permission check, not a later error', async () => { + authState.isAuthenticated = false; + const res = await listRoute.GET(makeEvent()); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('Authentication required'); + }); + + // H3 regression: on a single (local-only) environment install, EVERY + // recorded run had environmentId === NULL and was completely unreachable + // through this route -- `env` was hard-required, and there was no value a + // caller could pass that matched a NULL row (env=0 included; environment + // ids are never 0). See the module doc comment for the full chain. + test('H3: omitted env -> 200, treated as the LOCAL environment (environmentId null), not a 400', async () => { + execStore.set(1, { ...RUN, environmentId: null }); + const res = await listRoute.GET(makeEvent()); // no ?env= + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.runs).toHaveLength(1); + expect(body.runs[0].id).toBe(1); + expect(lastListFilters.environmentId).toBe(null); + }); + + test('H3: env=null (the literal string) -> the SAME local-environment result as omitting env', async () => { + execStore.set(1, { ...RUN, environmentId: null }); + const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=null' })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.runs).toHaveLength(1); + expect(lastListFilters.environmentId).toBe(null); + }); + + test('non-numeric, non-"null" env -> 400 (still not a value getScheduleExecutions can filter on)', async () => { + const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=abc' })); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe( + 'env query parameter must be an integer, or omitted/"null" for the local environment' + ); + }); + + test('cross-environment leak: omitting env must NOT surface a run from a DIFFERENT (non-null) environment', async () => { + authState.isEnterprise = true; + authState.accessibleEnvs = 'all'; // access is not the point of this test -- the null filter itself is + execStore.set(1, { ...RUN, environmentId: null }); // local run, matches the omitted-env filter + execStore.set(2, { ...RUN, id: 2, environmentId: 9 }); // foreign, non-null env, same stack name + const res = await listRoute.GET(makeEvent()); // no ?env= + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.runs).toHaveLength(1); + expect(body.runs[0].id).toBe(1); + expect(JSON.stringify(body)).not.toContain('"environmentId":9'); + }); + + test('caller lacking stacks:view for the local environment (global merge) -> 403, even though env is omitted', async () => { + authState.can = false; + const res = await listRoute.GET(makeEvent()); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); + + test('authenticated but lacking permission -> 403', async () => { + authState.can = false; + const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' })); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); + + test('lists only runs for THIS stack, without log text', async () => { + execStore.set(1, { ...RUN, environmentId: 1 }); + execStore.set(2, { ...RUN, id: 2, environmentId: 1, entityName: 'other-stack' }); + const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.runs).toHaveLength(1); + expect(body.runs[0].id).toBe(1); + expect(body.runs[0]).not.toHaveProperty('logs'); + expect(lastListFilters.scheduleType).toBe('stack_deploy'); + expect(lastListFilters.entityName).toBe(STACK); + expect(lastListFilters.environmentId).toBe(1); + }); + + test('skips a non-deploy schedule execution that happens to share the entity name', async () => { + execStore.set(1, { ...RUN, environmentId: 1, scheduleType: 'backup' }); + const res = await listRoute.GET(makeEvent({ url: 'http://x/?env=1' })); + const body = await res.json(); + expect(body.runs).toHaveLength(0); + }); +}); + +// ============================================================================= +// GET /api/stacks/[name]/deploys/[runId] (single) +// ============================================================================= + +describe('GET /api/stacks/[name]/deploys/[runId] (single)', () => { + test('no session -> 401 at the permission check, not a later error', async () => { + authState.isAuthenticated = false; + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('Authentication required'); + }); + + test('authenticated but lacking permission -> 403', async () => { + authState.can = false; + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); + + test('nonexistent run -> 404', async () => { + const res = await runRoute.GET(makeEvent({ params: { runId: '999' } })); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe('Deploy run not found'); + }); + + test('run belonging to a DIFFERENT stack -> the SAME 404 as nonexistent', async () => { + execStore.set(1, { ...RUN, entityName: 'other-stack' }); + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe('Deploy run not found'); + }); + + test('non-numeric run id -> 400', async () => { + const res = await runRoute.GET(makeEvent({ params: { runId: 'abc' } })); + expect(res.status).toBe(400); + }); + + test('cross-environment bypass: a caller-accessible env does not grant access to a DIFFERENT run env', async () => { + authState.isEnterprise = true; + authState.accessibleEnvs = [1]; // caller can access env 1 + execStore.set(1, { ...RUN, environmentId: 9 }); // but this run belongs to env 9 + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(403); + }); + + test('accessible run -> 200 with metadata, no log text', async () => { + execStore.set(1, RUN); + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe(1); + expect(body.duration).toBe(4200); + expect(body).not.toHaveProperty('logs'); + }); +}); + +// ============================================================================= +// DELETE /api/stacks/[name]/deploys/[runId] +// ============================================================================= + +describe('DELETE /api/stacks/[name]/deploys/[runId]', () => { + test('no session -> 401 at the permission check, not a later error', async () => { + authState.isAuthenticated = false; + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('Authentication required'); + }); + + test('authenticated but lacking permission -> 403', async () => { + authState.can = false; + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); + + test('run belonging to a DIFFERENT stack -> 404, nothing deleted', async () => { + execStore.set(1, { ...RUN, entityName: 'other-stack' }); + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(404); + expect(deletedIds).toEqual([]); + expect(execStore.has(1)).toBe(true); + }); + + test('deletes the DB record AND the log file', async () => { + execStore.set(1, RUN); // RUN.environmentId is null + await appendRunLog(null, '1', 'some deploy output'); + expect(await readRunLog(null, '1')).toBe('some deploy output'); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + expect(deletedIds).toEqual([1]); + expect(execStore.has(1)).toBe(false); + expect(await readRunLog(null, '1')).toBeNull(); + }); + + test('a terminal, non-success status (failed) is still deletable', async () => { + execStore.set(1, { ...RUN, status: 'failed' }); + await appendRunLog(null, '1', 'compose exploded'); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + expect(deletedIds).toEqual([1]); + expect(execStore.has(1)).toBe(false); + expect(await readRunLog(null, '1')).toBeNull(); + }); + + // Finding B: DELETE had no status check at all -- a caller could delete + // their OWN still-running deploy's record. The background deploy process + // keeps writing to the log file after the record is gone (appendRunLog + // recreates it), updateScheduleExecution() on end() becomes a silent + // no-op (UPDATE WHERE id=x, 0 rows), and the default-enabled reconcile + // job then purges the recreated file as an orphan -- erasing every trace + // of a deploy that actually ran and had real host side effects. + test('Finding B: a RUNNING run cannot be deleted -> 409, nothing touched', async () => { + execStore.set(1, { ...RUN, status: 'running' }); + await appendRunLog(null, '1', 'still going...'); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe('Cannot delete a running deploy run'); + expect(deletedIds).toEqual([]); + expect(execStore.has(1)).toBe(true); + expect(await readRunLog(null, '1')).toBe('still going...'); + }); + + test('Finding B: a QUEUED run cannot be deleted -> 409, nothing touched', async () => { + execStore.set(1, { ...RUN, status: 'queued' }); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(409); + expect(deletedIds).toEqual([]); + expect(execStore.has(1)).toBe(true); + }); + + // Fail-safe direction: a status this repo's ScheduleStatus type doesn't + // even list (never actually produced by this code, but the column is + // free text -- see db.ts's ScheduleStatus comment) must NOT be treated as + // terminal just because it isn't 'queued'/'running'. Guessing "finished" + // for an unrecognized status is exactly the kind of assumption that lets + // a genuinely in-progress run slip through under a status this code + // hasn't been taught about yet. + test('Finding B: an unrecognized status is treated as NOT terminal (fail safe) -> 409', async () => { + execStore.set(1, { ...RUN, status: 'some-future-status' }); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(409); + expect(deletedIds).toEqual([]); + expect(execStore.has(1)).toBe(true); + }); + + test('Finding B: the 404 (wrong stack) still wins over the running-run 409 -- ownership is checked first', async () => { + execStore.set(1, { ...RUN, status: 'running', entityName: 'other-stack' }); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe('Deploy run not found'); + }); + + test('Finding B: the 403 (permission denied) still wins over the running-run 409', async () => { + authState.can = false; + execStore.set(1, { ...RUN, status: 'running' }); + + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); +}); + +// ============================================================================= +// GET /api/stacks/[name]/deploys/[runId]/log +// ============================================================================= + +describe('GET /api/stacks/[name]/deploys/[runId]/log', () => { + test('no session -> 401 at the permission check, not a later error', async () => { + authState.isAuthenticated = false; + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('Authentication required'); + }); + + test('authenticated but lacking permission -> 403', async () => { + authState.can = false; + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); + + test('run belonging to a DIFFERENT stack -> 404, log file never touched', async () => { + execStore.set(1, { ...RUN, entityName: 'other-stack' }); // still environmentId: null + await appendRunLog(null, '1', 'secret content for the other stack'); + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe('Deploy run not found'); + }); + + test('run exists but the log file does not -> 404, does not wrongly look safe', async () => { + execStore.set(1, RUN); + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe('Log not found'); + }); + + test('cross-environment bypass applies here too', async () => { + authState.isEnterprise = true; + authState.accessibleEnvs = [1]; + execStore.set(1, { ...RUN, environmentId: 9 }); + await appendRunLog(9, '1', 'should not be reachable'); + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(403); + }); + + test('returns the log text as text/plain', async () => { + execStore.set(1, RUN); // RUN.environmentId is null + await appendRunLog(null, '1', 'line one\nline two'); + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/plain'); + expect(await res.text()).toBe('line one\nline two'); + }); +}); + + +// ============================================================================= +// H1 regression: env-scoped permission on the three single-run routes +// +// Reproduces the branch-review scenario: a caller has stacks:view/edit for +// SOME environment (which alone is enough to pass the coarse, un-scoped +// `can('stacks', 'view'|'edit')` gate every route still runs first -- see +// "authenticated but lacking permission -> 403" above) but NOT for the +// environment the run under test actually belongs to. Before the fix, none +// of the three routes checked permission against run.environmentId at all, +// so the coarse gate's global OR-across-roles merge was the only check -- +// a caller scoped to environment 1 could read/delete a run that belongs to +// environment 9. +// ============================================================================= + +describe('H1: permission is scoped to the RUN\'s own environment, not the global merge', () => { + beforeEach(() => { + authState.isEnterprise = true; + authState.accessibleEnvs = 'all'; // isolate this from the SEPARATE canAccessEnvironment check (already correct, see "kein Befund") + }); + + test('GET single run: caller has stacks:view broadly (passes the coarse gate) but NOT for env 9, and the run belongs to env 9 -> 403, not the 200 the pre-fix global merge produced', async () => { + authState.canByEnv = (envId) => envId !== 9; // granted everywhere EXCEPT env 9 -- still passes the coarse can('stacks','view') gate (called with environmentId=undefined), so this isolates the NEW scoped check + execStore.set(1, { ...RUN, environmentId: 9 }); + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(403); + expect((await res.json()).error).toBe('Permission denied'); + }); + + test('GET single run: caller has stacks:view for the run\'s OWN env -> 200', async () => { + authState.canByEnv = (envId) => envId === undefined || envId === 9; // undefined: satisfies the coarse gate; 9: satisfies the scoped check + execStore.set(1, { ...RUN, environmentId: 9 }); + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(200); + expect((await res.json()).id).toBe(1); + }); + + test('DELETE: caller has stacks:edit broadly but NOT for env 9, and the run belongs to env 9 -> 403, nothing deleted', async () => { + authState.canByEnv = (envId) => envId !== 9; + execStore.set(1, { ...RUN, environmentId: 9 }); + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(403); + expect(deletedIds).toEqual([]); + expect(execStore.has(1)).toBe(true); + }); + + test('DELETE: caller has stacks:edit for the run\'s OWN env -> 200, both halves deleted', async () => { + authState.canByEnv = (envId) => envId === undefined || envId === 9; + execStore.set(1, { ...RUN, environmentId: 9 }); + await appendRunLog(9, '1', 'output'); + const res = await runRoute.DELETE(makeEvent()); + expect(res.status).toBe(200); + expect(deletedIds).toEqual([1]); + expect(await readRunLog(9, '1')).toBeNull(); + }); + + test('GET log: caller has stacks:view broadly but NOT for env 9, and the run belongs to env 9 -> 403, log file never read', async () => { + authState.canByEnv = (envId) => envId !== 9; + execStore.set(1, { ...RUN, environmentId: 9 }); + await appendRunLog(9, '1', 'env-9-only secret output'); + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(403); + }); + + test('GET log: caller has stacks:view for the run\'s OWN env -> 200 with the log text', async () => { + authState.canByEnv = (envId) => envId === undefined || envId === 9; + execStore.set(1, { ...RUN, environmentId: 9 }); + await appendRunLog(9, '1', 'env-9 output'); + const res = await logRoute.GET(makeEvent()); + expect(res.status).toBe(200); + expect(await res.text()).toBe('env-9 output'); + }); + + test('a run with NO environment (local install, environmentId null) checks against the global merge, not against a specific env', async () => { + // null run.environmentId maps to `can(..., undefined)` (the `?? undefined` + // in the fix) -- there is no per-environment role to scope against for a + // run that was never attributed to one. canByEnv(undefined) is the same + // check the coarse gate above already performs, so this is really + // confirming the scoped check does not ITSELF reject a local run once the + // coarse gate already let it through. + authState.canByEnv = (envId) => envId === undefined; + execStore.set(1, { ...RUN, environmentId: null }); + const res = await runRoute.GET(makeEvent()); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/deploy-log-reconcile-job.test.ts b/tests/deploy-log-reconcile-job.test.ts new file mode 100644 index 0000000..5b9a3eb --- /dev/null +++ b/tests/deploy-log-reconcile-job.test.ts @@ -0,0 +1,317 @@ +/** + * Tests for runDeployLogReconcileJob() itself -- the database/filesystem-touching glue + * around planReconcile() (see deploy-log-reconcile.test.ts for the pure decision core). + * + * $lib/server/db transitively loads better-sqlite3 (ERR_DLOPEN_FAILED under Bun), so it + * has to be faked via ../helpers/db-fake (registerDbFake), same as + * tests/deploy-endpoints.test.ts -- NOT via a separate mock.module('$lib/server/db', ...) + * call here, see that helper's doc comment for why a second direct call collides. + * + * The three deploy-log-store calls are INJECTED (runDeployLogReconcileJob's `deps` param) + * rather than mock.module()'d. An earlier version did mock the module, reasoning that no + * other test file claimed it -- but the collision that matters is with SOURCE modules, not + * test files: mock.module() freezes the export shape process-wide, and deploy-run-record.ts + * imports appendRunLog/runLogFileName/SizeBudgetTracker from the same specifier. Whether it + * blew up depended on the order bun walks tests/, so it was green locally and red in CI. + * Injection has no process-wide effect at all. + * + * F5 fix: the fake filesystem layer below is keyed PER ENVIRONMENT (envDirName(envId) -> + * fileIds[]), mirroring deploy-log-store.ts's real per-environment directories -- this is + * what lets the tests below prove the reconcile job never mixes one environment's files + * with another's, even when two environments happen to hold a file with the exact same + * numeric run id (envDirName/parseEnvDirName are imported for real -- pure functions, no + * filesystem -- purely to build the fake's keys the same way production code would). + */ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { registerDbFake } from './helpers/db-fake'; +import { envDirName } from '../src/lib/server/deploy-log-store'; + +// -- $lib/server/db: an in-memory schedule_executions fake ----------------- + +let nextExecId: number; +let executions: Map; +let stackDeployRecords: Array<{ id: number; status: string; details: any; environmentId: number | null }>; +let throwOnUpdate: Set; + +function resetDbState() { + nextExecId = 1; + executions = new Map(); + stackDeployRecords = []; + throwOnUpdate = new Set(); +} +resetDbState(); + +registerDbFake('getDeployLogReconcileEnabled', async () => true); +registerDbFake('getScheduleExecutionIdsByType', async (scheduleType: string) => + scheduleType === 'stack_deploy' ? stackDeployRecords : [] +); +registerDbFake('createScheduleExecution', async (data: any) => { + const id = nextExecId++; + const row = { id, ...data, details: data.details ?? null }; + executions.set(id, row); + return row; +}); +registerDbFake('updateScheduleExecution', async (id: number, data: any) => { + if (throwOnUpdate.has(id)) throw new Error(`update failed for record ${id}`); + const row = executions.get(id) ?? { id }; + const updated = { ...row, ...data }; + executions.set(id, updated); + return updated; +}); +registerDbFake('appendScheduleExecutionLog', async () => {}); + +// -- $lib/server/deploy-log-store: fully replaced (no other file mocks it) - +// +// Keyed by envDirName(envId) -> fileIds[], the same per-environment shape +// deploy-log-store.ts's real listRunLogIds()/deleteRunLog() work against (F5 fix). + +let filesByEnvKey: Map; +let deletedFiles: Array<{ envId: number | null; id: string }>; +let throwOnDeleteKeys: Set; // `${envDirName(envId)}:${fileId}` +// mtime (ms since epoch) per delete candidate, keyed the same way as throwOnDeleteKeys. +// Defaults (see getRunLogMtimeMs below) to something WELL outside the reconcile job's +// grace period, so every existing "orphan gets deleted" assertion in this file keeps +// meaning exactly what it always did -- only a test that explicitly seeds a RECENT +// mtime here is exercising the TOCTOU guard at all. +let mtimeMsByKey: Map; + +function resetFsState() { + filesByEnvKey = new Map(); + deletedFiles = []; + throwOnDeleteKeys = new Set(); + mtimeMsByKey = new Map(); +} +resetFsState(); + +const storeDeps = { + listEnvDirNames: async () => [...filesByEnvKey.keys()], + listRunLogIds: async (envId: number | null) => filesByEnvKey.get(envDirName(envId)) ?? [], + deleteRunLog: async (envId: number | null, fileId: string) => { + const key = `${envDirName(envId)}:${fileId}`; + if (throwOnDeleteKeys.has(key)) throw new Error(`delete failed for ${key}`); + deletedFiles.push({ envId, id: fileId }); + }, + getRunLogMtimeMs: async (envId: number | null, fileId: string) => { + const key = `${envDirName(envId)}:${fileId}`; + return mtimeMsByKey.get(key) ?? Date.now() - 24 * 60 * 60 * 1000; // default: a day old + } +}; + +// runDeployLogReconcileJob is imported AFTER both mocks are registered above, so its own +// import of '../../db' / '../../deploy-log-store' resolves to the faked module records. +const { runDeployLogReconcileJob } = await import('../src/lib/server/scheduler/tasks/deploy-log-reconcile'); + +beforeEach(() => { + resetDbState(); + resetFsState(); +}); + +/** The reconcile job's own execution row -- the one created inside runDeployLogReconcileJob. */ +function ownExecution(): any { + return [...executions.values()].find((e) => e.scheduleType === 'deploy_log_reconcile'); +} + +describe('runDeployLogReconcileJob', () => { + test('one failing delete does not stop later orphan files from being removed', async () => { + // All three are orphans: no stack_deploy record exists for any of them, all + // in the SAME (null) environment. + filesByEnvKey.set(envDirName(null), ['a', 'b', 'c']); + throwOnDeleteKeys = new Set([`${envDirName(null)}:b`]); + + await runDeployLogReconcileJob('manual', storeDeps); + + // The one AFTER the failing element must still have been reached. + expect(deletedFiles).toEqual([ + { envId: null, id: 'a' }, + { envId: null, id: 'c' } + ]); + + const exec = ownExecution(); + // Real outcome (2), not the planned one (3) -- the point of Befund 2. + expect(exec.details.deletedFiles).toBe(2); + expect(exec.details.failed).toBe(1); + // A run that hit a per-element failure is not indistinguishable from a clean one. + expect(exec.status).toBe('warning'); + }); + + test('one failing mark does not stop later records from being marked logMissing', async () => { + // No files on disk at all -- every record below is missing its file. + stackDeployRecords = [ + { id: 10, status: 'success', details: null, environmentId: null }, + { id: 11, status: 'success', details: null, environmentId: null }, + { id: 12, status: 'success', details: null, environmentId: null } + ]; + for (const r of stackDeployRecords) executions.set(r.id, { ...r }); + throwOnUpdate = new Set([11]); + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(executions.get(10).details.logMissing).toBe(true); + expect(executions.get(12).details.logMissing).toBe(true); + // The failing record's details were never reached, so they stay as seeded. + expect(executions.get(11).details).toBe(null); + + const exec = ownExecution(); + expect(exec.details.markedRecords).toBe(2); + expect(exec.details.failed).toBe(1); + expect(exec.status).toBe('warning'); + }); + + test('a clean run with nothing to fail is reported as success, not warning', async () => { + filesByEnvKey.set(envDirName(null), ['a']); + stackDeployRecords = [{ id: 20, status: 'success', details: null, environmentId: null }]; + executions.set(20, { id: 20, status: 'success', details: null }); + + await runDeployLogReconcileJob('manual', storeDeps); + + const exec = ownExecution(); + expect(exec.status).toBe('success'); + expect(exec.details.failed).toBe(0); + // 'a' has no record and gets deleted; the id-20 record has no file and gets marked. + expect(exec.details.deletedFiles).toBe(1); + expect(exec.details.markedRecords).toBe(1); + }); + + // ------------------------------------------------------------------------- + // F5 fix: per-environment reconciliation. THE cross-environment isolation + // tests -- every id below is deliberately reused ACROSS environments to + // prove the reconcile job never confuses one environment's file/record with + // another's just because they share a run id. + // ------------------------------------------------------------------------- + + test("env A's orphan cleanup never deletes env B's file, even when they share a run id and env B's file legitimately belongs to a record", async () => { + filesByEnvKey.set(envDirName(1), ['42']); // orphan in env 1: no record anywhere for id 42 in env 1 + filesByEnvKey.set(envDirName(2), ['42']); // env 2's OWN file for its OWN record with the same numeric id + stackDeployRecords = [{ id: 42, status: 'success', details: null, environmentId: 2 }]; + executions.set(42, { id: 42, status: 'success', details: null }); + + await runDeployLogReconcileJob('manual', storeDeps); + + // Env 1's orphan is deleted... + expect(deletedFiles).toEqual([{ envId: 1, id: '42' }]); + // ...but env 2's file, which legitimately belongs to record 42, is NOT + // treated as missing (it has a matching file in ITS OWN environment) and + // is never touched by the delete pass either. + expect(executions.get(42).details).toBeNull(); + }); + + test('an orphan file living under one environment is never confused with a DIFFERENT environment holding the same run id', async () => { + // Both environments have a file with the SAME id and NEITHER has any record + // at all -- both are genuinely orphans, each in its own right. + filesByEnvKey.set(envDirName(1), ['shared-id']); + filesByEnvKey.set(envDirName(2), ['shared-id']); + stackDeployRecords = []; + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(deletedFiles).toContainEqual({ envId: 1, id: 'shared-id' }); + expect(deletedFiles).toContainEqual({ envId: 2, id: 'shared-id' }); + expect(deletedFiles).toHaveLength(2); + }); + + test("a record's own file, missing from its OWN environment, is correctly marked logMissing even when an unrelated environment holds a same-numbered file", async () => { + // Record id 501 deliberately avoids id 1 -- this fake's createScheduleExecution + // always assigns the reconcile job's OWN execution row id 1 first (nextExecId + // starts at 1 every test), which would otherwise clobber a manually seeded + // `executions.set(1, ...)` entry for an unrelated stack_deploy record. + filesByEnvKey.set(envDirName(2), ['501']); // orphan file living in env 2, wrong environment for record id 501 + stackDeployRecords = [{ id: 501, status: 'success', details: null, environmentId: 1 }]; + executions.set(501, { id: 501, status: 'success', details: null }); + + await runDeployLogReconcileJob('manual', storeDeps); + + // The record belongs to env 1, which has NO file -- correctly marked + // missing, not "found" via env 2's unrelated same-numbered file. + expect(executions.get(501).details.logMissing).toBe(true); + // Env 2's file has no record in env 2 either -- it is correctly deleted as + // its OWN environment's orphan, not because it was mistaken for record 501's. + expect(deletedFiles).toEqual([{ envId: 2, id: '501' }]); + }); + + test('a still-running record in one environment is not marked missing, while a DIFFERENT environment\'s terminal record with the same id correctly is', async () => { + // No files anywhere -- both records are "missing their file" by definition, + // but only the terminal one should actually be marked. + stackDeployRecords = [ + { id: 5, status: 'running', details: null, environmentId: 1 }, + { id: 5, status: 'success', details: null, environmentId: 2 } + ]; + executions.set(5, { id: 5, status: 'running', details: null }); // last-write-wins in this fake, matches env 2's terminal status for the assertion below + + await runDeployLogReconcileJob('manual', storeDeps); + + const exec = ownExecution(); + // One skipped (still running, env 1), one marked (terminal, env 2). + expect(exec.details.skippedInProgress).toBe(1); + expect(exec.details.markedRecords).toBe(1); + }); + + test('an environment that only has a directory on disk (no records at all, e.g. a deleted environment) still has its orphan files reconciled', async () => { + filesByEnvKey.set(envDirName(7), ['old-run']); + stackDeployRecords = []; // environment 7 has no records in the database at all + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(deletedFiles).toEqual([{ envId: 7, id: 'old-run' }]); + }); + + test('an environment that only has a record (no directory on disk yet) still gets that record considered for markMissing', async () => { + // listEnvDirNames() would never discover environment 3 on its own -- it has + // no directory at all -- so the record-side union is what makes this work. + stackDeployRecords = [{ id: 99, status: 'success', details: null, environmentId: 3 }]; + executions.set(99, { id: 99, status: 'success', details: null }); + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(executions.get(99).details.logMissing).toBe(true); + }); + + // ------------------------------------------------------------------------- + // TOCTOU guard: `records` is a single snapshot taken at the start of the job + // (getScheduleExecutionIdsByType), while a NEW deploy's file can appear on disk + // (listRunLogIds) moments later, for a run whose own record simply postdates + // that snapshot -- indistinguishable from a genuine orphan by id alone. See the + // module doc comment for the full race. + // ------------------------------------------------------------------------- + + test("a file with no matching record but a RECENT mtime is left alone -- it may belong to a deploy that started after the records snapshot", async () => { + filesByEnvKey.set(envDirName(null), ['fresh']); + // No record for 'fresh' in `stackDeployRecords` at all -- exactly what an + // in-flight deploy whose row postdates the snapshot looks like. + mtimeMsByKey.set(`${envDirName(null)}:fresh`, Date.now() - 1000); // 1s old + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(deletedFiles).toEqual([]); + const exec = ownExecution(); + expect(exec.details.skippedRecent).toBe(1); + expect(exec.details.deletedFiles).toBe(0); + // A recency skip is not a failure -- the run still reports success. + expect(exec.status).toBe('success'); + }); + + test('a genuinely old orphan file (no record, no recent write) is still deleted -- the grace period does not protect real orphans forever', async () => { + filesByEnvKey.set(envDirName(null), ['stale']); + mtimeMsByKey.set(`${envDirName(null)}:stale`, Date.now() - 60 * 60 * 1000); // 1h old + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(deletedFiles).toEqual([{ envId: null, id: 'stale' }]); + const exec = ownExecution(); + expect(exec.details.skippedRecent).toBe(0); + expect(exec.details.deletedFiles).toBe(1); + }); + + test('a recency skip in one environment does not stop a genuinely old orphan in ANOTHER environment from being deleted', async () => { + filesByEnvKey.set(envDirName(1), ['fresh-in-env-1']); + mtimeMsByKey.set(`${envDirName(1)}:fresh-in-env-1`, Date.now()); + filesByEnvKey.set(envDirName(2), ['stale-in-env-2']); + mtimeMsByKey.set(`${envDirName(2)}:stale-in-env-2`, Date.now() - 60 * 60 * 1000); + + await runDeployLogReconcileJob('manual', storeDeps); + + expect(deletedFiles).toEqual([{ envId: 2, id: 'stale-in-env-2' }]); + const exec = ownExecution(); + expect(exec.details.skippedRecent).toBe(1); + expect(exec.details.deletedFiles).toBe(1); + }); +}); diff --git a/tests/deploy-log-reconcile.test.ts b/tests/deploy-log-reconcile.test.ts new file mode 100644 index 0000000..30cafb9 --- /dev/null +++ b/tests/deploy-log-reconcile.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test'; +import { planReconcile, isEligibleForMissingMark } from '../src/lib/server/deploy-log-reconcile-core'; + +describe('planReconcile', () => { + test('deletes a file whose run record is gone', () => { + const plan = planReconcile({ fileIds: ['a', 'b'], recordIds: ['a'] }); + expect(plan.deleteFiles).toEqual(['b']); + expect(plan.markMissing).toEqual([]); + }); + + test('marks a record whose file is gone but never deletes the record', () => { + // The metadata -- when, who, what was built, which digest -- stays valuable + // even when the log itself is gone. + const plan = planReconcile({ fileIds: [], recordIds: ['a'] }); + expect(plan.markMissing).toEqual(['a']); + expect(plan.deleteFiles).toEqual([]); + }); + + test('leaves matching pairs alone', () => { + const plan = planReconcile({ fileIds: ['a'], recordIds: ['a'] }); + expect(plan.deleteFiles).toEqual([]); + expect(plan.markMissing).toEqual([]); + }); +}); + +describe('isEligibleForMissingMark', () => { + // A still-running deploy may simply not have written its first log line yet -- + // marking it here would produce a false "log missing" that nothing later clears. + test('excludes still in-progress runs', () => { + expect(isEligibleForMissingMark('queued')).toBe(false); + expect(isEligibleForMissingMark('running')).toBe(false); + }); + + test('includes every terminal state', () => { + for (const status of ['success', 'failed', 'skipped', 'warning', 'error', 'cancelled', 'stale']) { + expect(isEligibleForMissingMark(status)).toBe(true); + } + }); +}); diff --git a/tests/deploy-log-store.test.ts b/tests/deploy-log-store.test.ts new file mode 100644 index 0000000..a32e8fc --- /dev/null +++ b/tests/deploy-log-store.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; +import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + runLogFileName, + envDirName, + parseEnvDirName, + SizeBudgetTracker, + usedBytesExcluding +} from '../src/lib/server/deploy-log-store'; + +describe('runLogFileName', () => { + test('builds a name from a plain run id', () => { + expect(runLogFileName('a1b2c3')).toBe('a1b2c3.log'); + }); + + test('rejects anything that is not a plain id, so a run id can never become a path', () => { + // The run id reaches this function from a route parameter. A traversal attempt must + // throw rather than resolve to a file outside the log directory. + expect(() => runLogFileName('../../etc/passwd')).toThrow(); + expect(() => runLogFileName('a/b')).toThrow(); + expect(() => runLogFileName('')).toThrow(); + }); +}); + +describe('envDirName / parseEnvDirName -- F5 per-environment directory naming', () => { + test('a numeric environment id becomes its own string, an environment-less run becomes "null"', () => { + expect(envDirName(1)).toBe('1'); + expect(envDirName(42)).toBe('42'); + expect(envDirName(null)).toBe('null'); + }); + + test('rejects a non-integer environment id rather than silently stringifying it', () => { + // environmentId always comes from a database column (number | null) -- this is a + // defensive backstop, the same posture runLogFileName's ID regex takes for runId. + expect(() => envDirName(1.5)).toThrow(); + expect(() => envDirName(NaN)).toThrow(); + }); + + test('parseEnvDirName is the exact inverse of envDirName for every value envDirName can produce', () => { + expect(parseEnvDirName(envDirName(1))).toBe(1); + expect(parseEnvDirName(envDirName(42))).toBe(42); + expect(parseEnvDirName(envDirName(null))).toBe(null); + }); + + test('parseEnvDirName rejects a directory name that is not one of envDirName\'s own outputs', () => { + // listEnvDirNames() (deploy-log-store.ts) only returns directories, so this should + // not normally see a stray file name -- but a hand-created or unexpected directory + // must not be silently misread as some arbitrary environment id. + expect(() => parseEnvDirName('not-an-env')).toThrow(); + expect(() => parseEnvDirName('-1')).toThrow(); + expect(() => parseEnvDirName('1.5')).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// SizeBudgetTracker -- see deploy-log-store.ts's doc comment on the class for +// the performance problem this replaces (a full directory re-scan on every +// appended line) and the concurrency trade-off it makes instead. The F5 fix +// (per-environment directory/budget scoping) is covered by its own describe +// block further down. +// --------------------------------------------------------------------------- + +// deploy-log-store.ts reads process.env.DATA_DIR on every call (logDir()), not at +// import time -- so it's enough to point it at a fresh scratch dir per test and put +// the previous value back afterwards. Bun runs a whole test file in ONE process: a +// DATA_DIR left set would leak into unrelated suites (see +// test-coverage-pflicht.md's "prozessweite Zustände werden geklammert" -- this is +// exactly the trap fs-guard.test.ts fell into once already). +const previousDataDir = process.env.DATA_DIR; +let scratchDir: string; + +beforeEach(async () => { + scratchDir = await mkdtemp(join(tmpdir(), 'deploy-log-store-')); + process.env.DATA_DIR = scratchDir; +}); +afterEach(async () => { + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; + await rm(scratchDir, { recursive: true, force: true }); +}); + +/** Writes N other-run log files of `sizeEach` bytes each directly into ONE + * environment's scratch deploy-logs// dir, bypassing appendRunLog so the + * byte count is exact and no SizeBudgetTracker is involved in creating the fixture. */ +async function seedOtherRunFiles(envId: number | null, count: number, sizeEach: number): Promise { + const dir = join(scratchDir, 'deploy-logs', envDirName(envId)); + await mkdir(dir, { recursive: true }); + for (let i = 0; i < count; i++) { + await writeFile(join(dir, `other-${i}.log`), 'x'.repeat(sizeEach)); + } +} + +describe('SizeBudgetTracker', () => { + test('does NOT flag a run that stays well under the budget', async () => { + await seedOtherRunFiles(null, 5, 1000); // 5 KB of "other" runs already on disk + const tracker = new SizeBudgetTracker(null, 'run-under', 1_000_000); // 1 MB budget + let exceeded = false; + for (let i = 0; i < 20; i++) { + exceeded = await tracker.recordAppend(100); // 20 * 100 B = 2 KB of own output + } + expect(exceeded).toBe(false); + }); + + test('flags a run once its own output plus the rest of the directory crosses the budget', async () => { + await seedOtherRunFiles(null, 3, 300); // 900 B of "other" runs already on disk + const tracker = new SizeBudgetTracker(null, 'run-over', 1000); // 1000 B budget + let exceeded = false; + // 900 B baseline + 10*100 B own = 1900 B, well past the 1000 B budget. + for (let i = 0; i < 10 && !exceeded; i++) { + exceeded = await tracker.recordAppend(100); + } + expect(exceeded).toBe(true); + }); + + // Gegenversuch (test-coverage-pflicht.md): a tracker that never re-reads the + // directory -- cachedOtherBytes stuck at 0 forever, e.g. because refresh() was + // never called -- would let a run with a large pre-existing "other" total sail + // straight past the budget using only its own (small) byte count. This is the + // mutation the previous test guards against; this test isolates the SAME + // mechanism from a different angle (pre-existing total alone is already over + // budget, before this run writes a single byte). + test('flags a run immediately if the directory is already over budget before this run writes anything', async () => { + await seedOtherRunFiles(null, 2, 600); // 1200 B of "other" runs, already over a 1000 B budget + const tracker = new SizeBudgetTracker(null, 'run-preexisting', 1000); + const exceeded = await tracker.recordAppend(1); // first byte this run ever writes + expect(exceeded).toBe(true); + }); + + test('re-scans the directory on the FIRST call, then NOT again until the refresh threshold is crossed', async () => { + await seedOtherRunFiles(null, 500, 10); // matches the reviewer's 500-file benchmark shape + let scans = 0; + const countingMeasure = async (envId: number | null, excludeRunId: string) => { + scans++; + return usedBytesExcluding(envId, excludeRunId); + }; + // Refresh threshold set to 1000 B of own output for this test, independent of + // the 1 MiB production constant -- REFRESH_AFTER_OWN_BYTES is not exported, so + // this drives the same code path (loaded flag + threshold check) at a size + // this test can actually cross without allocating a real MiB of strings. + const tracker = new SizeBudgetTracker(null, 'run-scan-count', 10_000_000, countingMeasure); + + // 200 appends of 10 B each = 2000 B of own output, matching the reviewer's + // 200-line benchmark. This is the exact scenario that used to cost one full + // directory scan PER call (200 scans of 500 files); a working tracker must + // not do that. + for (let i = 0; i < 200; i++) { + await tracker.recordAppend(10); + } + expect(scans).toBe(1); // one lazy scan on the first call, none after + expect(scans).toBeLessThan(200); // the actual regression this fixes + }); + + test('re-scans again once accumulated own bytes cross the refresh threshold', async () => { + await seedOtherRunFiles(null, 5, 10); + let scans = 0; + const countingMeasure = async (envId: number | null, excludeRunId: string) => { + scans++; + return usedBytesExcluding(envId, excludeRunId); + }; + // A tiny budget just to construct the tracker; refresh cadence is driven by + // the module-private REFRESH_AFTER_OWN_BYTES (1 MiB in production), not by + // this budget value. + const tracker = new SizeBudgetTracker(null, 'run-refresh', 100_000_000, countingMeasure); + + await tracker.recordAppend(10); // scan #1 (lazy load) + expect(scans).toBe(1); + + // Push well past 1 MiB of own output in one shot -- the tracker must re-scan + // rather than trust a MiB-stale cache indefinitely. + await tracker.recordAppend(2 * 1024 * 1024); + expect(scans).toBe(2); + }); + + test('concurrent runs are invisible to each other between refreshes -- documented trade-off, not a bug', async () => { + // Two trackers for two DIFFERENT runs sharing the same directory. Tracker A's + // baseline is measured before B writes anything; B's own bytes are then + // invisible to A until A refreshes again. This is exactly the imprecision + // SizeBudgetTracker's doc comment describes -- this test pins the size of + // that gap so a future change can't silently widen it without this test + // noticing. + const trackerA = new SizeBudgetTracker(null, 'run-a', 10_000_000); + + await trackerA.recordAppend(100); // A's baseline measured now: directory is empty + + // B writes 50 KB of real output AFTER A's baseline was taken, into the SAME + // environment (null) as A -- this test is about same-environment concurrency, + // not cross-environment isolation (see the F5 describe block below for that). + const bDir = join(scratchDir, 'deploy-logs', envDirName(null)); + await mkdir(bDir, { recursive: true }); + await writeFile(join(bDir, 'run-b.log'), 'y'.repeat(50_000)); + + // A has not crossed its own refresh threshold (well under 1 MiB of its own + // output), so its cached view of "everything else" still does not include + // B's 50 KB -- that is the documented gap, reproduced here. + const stillBlind = await trackerA.recordAppend(100); + expect(stillBlind).toBe(false); + + // A fresh tracker (or a refreshed one) DOES see it -- the gap is temporary, + // not a permanent miss. + const freshView = await usedBytesExcluding(null, 'run-a'); + expect(freshView).toBeGreaterThanOrEqual(50_000); + }); +}); + +// --------------------------------------------------------------------------- +// F5 fix: per-environment directory/budget scoping. THE key regression test -- +// see F5-REPORT.md for the counter-test run against this block with the fix +// reverted (envDirName() collapsed to a single shared directory). +// --------------------------------------------------------------------------- + +describe('SizeBudgetTracker -- per-environment scoping (F5)', () => { + test('filling environment A\'s budget does not truncate environment B\'s run (separate directories) -- THE key F5 regression test', async () => { + // Environment A already has 950 B of ANOTHER run's output on disk -- right up + // against a 1000 B budget, entirely on its own, before environment A's own + // run under test has written a single byte. This is deliberately close to the + // budget by itself: under the pre-fix shared directory, these 950 B would be + // visible to EVERY environment's tracker, not just environment A's own. + await seedOtherRunFiles(1, 1, 950); + const trackerA = new SizeBudgetTracker(1, 'run-a', 1000); + // Environment A's own run pushes ITS OWN environment over budget, as expected. + const exceededA = await trackerA.recordAppend(100); // 950 + 100 = 1050 > 1000 + expect(exceededA).toBe(true); + + // Environment B has NOTHING of its own on disk and the SAME 1000 B budget. + // Its run writes a small, unrelated 100 B chunk -- nowhere near 1000 B on its + // own. Against the pre-fix SHARED directory/budget, environment A's 950 B + // fixture above would land in the exact same directory environment B's + // tracker scans, so B's tiny write would ALSO read as over budget (950 + 100 + // = 1050 > 1000) even though environment B's run never came close to writing + // 1000 B itself -- exactly the silent cross-tenant truncation F5 fixes. + const trackerB = new SizeBudgetTracker(2, 'run-b', 1000); + const exceededB = await trackerB.recordAppend(100); + expect(exceededB).toBe(false); + }); + + test('two environments\' own files never appear in each other\'s usedBytesExcluding() total', async () => { + await seedOtherRunFiles(1, 3, 1000); // 3 KB in environment 1 + await seedOtherRunFiles(2, 1, 50); // 50 B in environment 2 + + const totalForEnv1 = await usedBytesExcluding(1, 'irrelevant-run-id'); + const totalForEnv2 = await usedBytesExcluding(2, 'irrelevant-run-id'); + + expect(totalForEnv1).toBe(3000); + expect(totalForEnv2).toBe(50); + }); + + test('a run\'s own log file lives under its own environment\'s directory, and NOT under a different environment\'s directory with the same run id', async () => { + const { appendRunLog, runLogPath } = await import('../src/lib/server/deploy-log-store'); + await appendRunLog(1, 'shared-id', 'env-1 output\n'); + await appendRunLog(2, 'shared-id', 'env-2 output\n'); + + const { readFile } = await import('node:fs/promises'); + expect(await readFile(runLogPath(1, 'shared-id'), 'utf8')).toBe('env-1 output\n'); + expect(await readFile(runLogPath(2, 'shared-id'), 'utf8')).toBe('env-2 output\n'); + expect(runLogPath(1, 'shared-id')).not.toBe(runLogPath(2, 'shared-id')); + }); +}); + +describe('listEnvDirNames -- F5 environment discovery for the reconcile job', () => { + test('lists every environment directory currently on disk, and nothing else', async () => { + const { listEnvDirNames } = await import('../src/lib/server/deploy-log-store'); + await seedOtherRunFiles(1, 1, 10); + await seedOtherRunFiles(2, 1, 10); + await seedOtherRunFiles(null, 1, 10); + + const names = (await listEnvDirNames()).sort(); + expect(names).toEqual(['1', '2', 'null']); + }); + + test('excludes a stray FILE directly under deploy-logs/ (the pre-F5 flat legacy layout)', async () => { + const { listEnvDirNames } = await import('../src/lib/server/deploy-log-store'); + const dir = join(scratchDir, 'deploy-logs'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'legacy-run.log'), 'old flat-layout content'); + await seedOtherRunFiles(1, 1, 10); + + const names = await listEnvDirNames(); + expect(names).toEqual(['1']); + }); + + test('returns an empty list when deploy-logs/ does not exist yet', async () => { + const { listEnvDirNames } = await import('../src/lib/server/deploy-log-store'); + expect(await listEnvDirNames()).toEqual([]); + }); +}); + +describe('readRunLog -- F5 back-compat fallback to the pre-F5 flat legacy path', () => { + test('finds a file at the new per-environment path first', async () => { + const { appendRunLog, readRunLog } = await import('../src/lib/server/deploy-log-store'); + await appendRunLog(1, 'r1', 'new layout content'); + expect(await readRunLog(1, 'r1')).toBe('new layout content'); + }); + + test('falls back to the pre-F5 flat path when no per-environment file exists', async () => { + const { readRunLog } = await import('../src/lib/server/deploy-log-store'); + const dir = join(scratchDir, 'deploy-logs'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'legacy.log'), 'old flat layout content'); + + expect(await readRunLog(1, 'legacy')).toBe('old flat layout content'); + }); + + test('a run with a file at NEITHER the per-environment nor the legacy path returns null', async () => { + const { readRunLog } = await import('../src/lib/server/deploy-log-store'); + expect(await readRunLog(1, 'nowhere')).toBeNull(); + }); +}); + +describe('deleteRunLog -- F5 clears both the per-environment AND the legacy flat path', () => { + test('removes a file at the new per-environment path', async () => { + const { appendRunLog, readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store'); + await appendRunLog(1, 'r1', 'content'); + await deleteRunLog(1, 'r1'); + expect(await readRunLog(1, 'r1')).toBeNull(); + }); + + test('also removes a leftover legacy flat-path file for the same run id, so it cannot outlive its own deletion', async () => { + const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store'); + const dir = join(scratchDir, 'deploy-logs'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, 'legacy.log'), 'stale legacy content'); + + await deleteRunLog(1, 'legacy'); + + expect(await readRunLog(1, 'legacy')).toBeNull(); + }); + + test('deleting a nonexistent run\'s log is a no-op, not an error', async () => { + const { deleteRunLog } = await import('../src/lib/server/deploy-log-store'); + await expect(deleteRunLog(1, 'never-existed')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/deploy-recorder.test.ts b/tests/deploy-recorder.test.ts new file mode 100644 index 0000000..52cf0ed --- /dev/null +++ b/tests/deploy-recorder.test.ts @@ -0,0 +1,28 @@ +// tests/deploy-recorder.test.ts +import { describe, expect, test } from 'bun:test'; +import { shouldRecord } from '../src/lib/server/deploy-recorder'; + +describe('shouldRecord', () => { + test('records redacted output lines', () => { + expect(shouldRecord('progress', { type: 'line', line: 'Container web Started' })).toBe(true); + }); + + test('never records the result event, which carries the unredacted output block', () => { + // deploy/+server.ts sends { success, output: result.output }, and result.output is + // stdout || stderr straight from compose -- it never passes through redactLine. + // Recording it would put secrets on disk permanently. + expect(shouldRecord('result', { success: true, output: 'DB_PASSWORD=hunter2' })).toBe(false); + }); + + test('does not record the static status labels', () => { + expect(shouldRecord('progress', { status: 'Deploying stack...' })).toBe(false); + }); + + // TWO conditions carry this guard, and each needs its own case -- otherwise removing + // one of them leaves every test green. The case below is reached only by the event + // check: a 'result' payload that DOES carry type: 'line' passes the second condition + // and is stopped by the first one alone. + test('rejects a result event even when it carries a line-shaped payload', () => { + expect(shouldRecord('result', { type: 'line', line: 'Container web Started' })).toBe(false); + }); +}); diff --git a/tests/deploy-run-openapi-drift.test.ts b/tests/deploy-run-openapi-drift.test.ts new file mode 100644 index 0000000..d793333 --- /dev/null +++ b/tests/deploy-run-openapi-drift.test.ts @@ -0,0 +1,67 @@ +/** + * B1 regression: the three single-run deploy routes' @openapi annotations + * must not claim a response code that never appears literally in the + * handler's own body -- that is exactly Gate 4 of + * scripts/generate-openapi.ts's `--check` (statusCodes drift), the CI tor + * .github/workflows/openapi.yml:49 runs. This file reproduces that one + * gate's logic directly against these three routes' actual source (via the + * SAME parseAnnotations/splitMethodBodies/analyzeHandlerBody the generator + * itself uses), rather than shelling out to the full generator -- which + * scans and regenerates the ENTIRE route tree and would make this a slow, + * broad-scoped test for what is a narrow, three-file regression. + * + * Before the fix: GET and DELETE .../deploys/{runId} documented resp-400 and + * resp-404 as `resp-NNN:` lines, and GET .../deploys/{runId}/log documented + * resp-400 -- but all three of those specific codes are produced by the + * SHARED loadOwnedDeployRun() helper (deploy-run-access.ts), not by a + * literal `status: 400` / `error(400)` in the handler's own body text, which + * is all the generator's static scanner can see. Fixed by moving that + * information into the `description:` field as prose (the same pattern + * POST /api/backup/destinations/{id}/task already uses for its own + * guard-produced codes), which this test also confirms is still readable. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseAnnotations, splitMethodBodies, analyzeHandlerBody } from '../scripts/openapi/lib'; + +const ROUTES: { file: string; methods: ('GET' | 'DELETE')[] }[] = [ + { file: 'src/routes/api/stacks/[name]/deploys/[runId]/+server.ts', methods: ['GET', 'DELETE'] }, + { file: 'src/routes/api/stacks/[name]/deploys/[runId]/log/+server.ts', methods: ['GET'] } +]; + +describe('B1: deploy-run routes have no status-code drift (Gate 4)', () => { + for (const { file, methods } of ROUTES) { + const content = readFileSync(join(import.meta.dir, '..', file), 'utf-8'); + const annotations = parseAnnotations(content); + const bodies = splitMethodBodies(content); + + for (const method of methods) { + test(`${method} ${file}: every documented response code appears literally in the handler body`, () => { + const ann = annotations[method]; + const body = bodies[method]; + expect(ann).toBeDefined(); + expect(body).toBeDefined(); + if (!ann || !body) return; // unreachable, satisfies the type checker + + const analysis = analyzeHandlerBody(body); + const codeCodes = new Set(analysis.statusCodes.filter((c) => c !== '200')); + const docCodes = Object.keys(ann.responses).filter((c) => c !== '200'); + + // This IS Gate 4's own drift condition, reproduced directly: a + // documented code with no matching status()/error() call in the + // SAME handler's body text is exactly what turned CI red. + const stale = docCodes.filter((c) => !codeCodes.has(c)); + expect(stale).toEqual([]); + }); + } + + test(`${file}: the 400/404 codes loadOwnedDeployRun() produces are still documented, in prose`, () => { + for (const method of methods) { + const ann = annotations[method]; + expect(ann?.description).toBeDefined(); + expect(ann!.description).toMatch(/loadOwnedDeployRun/); + } + }); + } +}); diff --git a/tests/deploy-run-record-core.test.ts b/tests/deploy-run-record-core.test.ts new file mode 100644 index 0000000..d20d2b7 --- /dev/null +++ b/tests/deploy-run-record-core.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { buildRunDetails } from '../src/lib/server/deploy-run-record-core'; + +describe('buildRunDetails', () => { + test('carries the options, the summary and the log file name', () => { + const d = buildRunDetails({ + options: { pull: false, build: true, forceRecreate: true }, + summary: { containersCreated: 2, containersStarted: 2, containersRecreated: 0, + imagesBuilt: ['app:1'], imagesPulled: [], buildSteps: 3, buildStepsCached: 1, + digest: undefined }, + exitCode: 0, + composeHash: 'aaa', + envHash: 'bbb', + logFile: '01J8-abc.log' + }); + expect(d.options.build).toBe(true); + expect(d.summary?.containersStarted).toBe(2); + expect(d.logFile).toBe('01J8-abc.log'); + expect(d.exitCode).toBe(0); + }); + + // The log file name is what ties record and file together. Task 13 deletes any file + // whose id has no record -- if the name is missing here, the reconciler eats the log. + test('refuses to build details without a log file name', () => { + expect(() => buildRunDetails({ options: { pull: false, build: false, forceRecreate: false }, + summary: undefined, exitCode: 0, composeHash: 'a', envHash: 'b', logFile: '' })) + .toThrow(/logFile/); + }); +}); diff --git a/tests/deploy-run-record.test.ts b/tests/deploy-run-record.test.ts new file mode 100644 index 0000000..1ae5c93 --- /dev/null +++ b/tests/deploy-run-record.test.ts @@ -0,0 +1,476 @@ +// tests/deploy-run-record.test.ts +/** + * Exercises the database-touching half of the stack_deploy run record + * (deploy-run-record.ts) against a faked $lib/server/db -- see + * tests/helpers/db-fake.ts's doc comment for why a fake is unavoidable under Bun + * (db.ts transitively loads better-sqlite3, ERR_DLOPEN_FAILED) and why it must be + * registered through registerDbFake() rather than a second mock.module() call. + * + * Deliberately never calls recorder.line(): end() awaits `this.tail`, which is + * `Promise.resolve()` when line() was never invoked, so these tests never touch + * deploy-log-store.ts (no real filesystem needed). + */ +import { describe, test, expect } from 'bun:test'; +import { registerDbFake } from './helpers/db-fake'; + +// -- $lib/server/db fake: an in-memory schedule_executions row ------------ + +let stored: { + created?: Record; + updates: Array>; +}; + +function resetDbState() { + stored = { created: undefined, updates: [] }; +} +resetDbState(); + +registerDbFake('createScheduleExecution', async (data: Record) => { + stored.created = data; + return { + id: 1, + ...data, + triggeredAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + duration: null, + errorMessage: null, + details: data.details ?? null, + logs: null, + createdAt: null + }; +}); +registerDbFake('updateScheduleExecution', async (id: number, data: Record) => { + stored.updates.push(data); + return { id, ...data }; +}); + +// Imported AFTER the fakes are registered above -- same ordering constraint +// tests/deploy-endpoints.test.ts documents for $lib/server/db. +const { createRunRecorder } = await import('../src/lib/server/deploy-run-record'); + +/** The db-fake update() call that closed the row (status !== 'running'), i.e. the + * one written by end(), as opposed to createRunRecorder's own startedAt update. */ +function endUpdate() { + return stored.updates.find((u) => u.status !== undefined); +} + +describe('DeployRunRecorder.end() -- secret redaction of the stored error text', () => { + // A known, invented value that can't coincidentally appear in a timestamp or id. + const SECRET = 'zzz-kanarienvogel-4711'; + + test('a failed run whose error text carries a known secret does NOT store that secret', async () => { + resetDbState(); + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [SECRET] + }); + + await recorder.end(false, undefined, `compose up failed: DB_PASSWORD=${SECRET} rejected`); + + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain(SECRET); + expect(JSON.stringify(update?.details)).not.toContain(SECRET); + }); + + + test('status is "failed" and NOT the redacted-line placeholder itself', async () => { + resetDbState(); + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [SECRET] + }); + + await recorder.end(false, undefined, `boom: ${SECRET}`); + + const update = endUpdate(); + expect(update?.status).toBe('failed'); + // Asserting presence of the placeholder alone would stay green even if no + // secret had ever been in the line (test-coverage-pflicht.md's warning about + // "enthaelt ***" tests) -- so this only checks the redacted text is safe to + // store, not that a placeholder specifically appears. + expect(String(update?.errorMessage)).toContain('***'); + }); + + test('a successful run stores errorMessage: null regardless of secrets', async () => { + resetDbState(); + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [SECRET] + }); + + await recorder.end(true); + + const update = endUpdate(); + expect(update?.status).toBe('success'); + expect(update?.errorMessage).toBeNull(); + }); + + // redactLine() withholds the WHOLE line (returns null) when a too-short secret + // value appears, rather than risk replacing an unrelated substring. The run must + // still close as "failed" -- a run stuck as "running" forever is worse than a + // run with a generic message. + test('when redaction withholds the whole line, the run still ends "failed" with a safe, non-null message', async () => { + resetDbState(); + const shortSecret = 'ab12cd'; // < MIN_REPLACEABLE_LENGTH (8) in secret-redaction.ts + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [shortSecret] + }); + + await recorder.end(false, undefined, `token ${shortSecret} invalid`); + + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(update?.errorMessage).not.toBeNull(); + expect(String(update?.errorMessage)).not.toContain(shortSecret); + }); + + test('an error with no secrets configured is stored unchanged (no false positives)', async () => { + resetDbState(); + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [] + }); + + await recorder.end(false, undefined, 'exit code 1'); + + const update = endUpdate(); + expect(update?.errorMessage).toBe('exit code 1'); + }); +}); + +// F4 fix: deployStack() resolves a bound secret provider's values (Bitwarden/ +// 1Password/etc. bulk pulls or inline refs) INTERNALLY, strictly after a caller +// (a route, or deployGitStack in git.ts) already built its recorder from the +// DB-only vars it had at construction time. addSecrets() is how the caller feeds +// those later-known values in before end() redacts the stored error text. +describe('DeployRunRecorder.addSecrets() -- provider-resolved secrets added AFTER construction', () => { + const LATE_SECRET = 'zzz-spaetzuendung-9000'; + + test('a secret added via addSecrets() after construction (with an EMPTY construction-time list) is still redacted from the stored error', async () => { + resetDbState(); + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + // Mirrors the route's construction-time list BEFORE deployStack() resolves + // provider secrets -- this is the F4 bug's exact starting condition. + secrets: [] + }); + + recorder.addSecrets([LATE_SECRET]); + + await recorder.end(false, undefined, `compose up failed: PROVIDER_TOKEN=${LATE_SECRET} rejected`); + + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain(LATE_SECRET); + expect(String(update?.errorMessage)).toContain('***'); + }); + + test('addSecrets() ADDS to, rather than replaces, secrets already known at construction', async () => { + resetDbState(); + const CONSTRUCTION_SECRET = 'yyy-bekannt-von-anfang-an'; + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [CONSTRUCTION_SECRET] + }); + + recorder.addSecrets([LATE_SECRET]); + + await recorder.end( + false, + undefined, + `compose up failed: A=${CONSTRUCTION_SECRET} B=${LATE_SECRET} rejected` + ); + + const update = endUpdate(); + expect(String(update?.errorMessage)).not.toContain(CONSTRUCTION_SECRET); + expect(String(update?.errorMessage)).not.toContain(LATE_SECRET); + }); + + test('calling addSecrets() with an empty array changes nothing (the common case: deployStack() failed before resolving any provider secrets)', async () => { + resetDbState(); + const recorder = await createRunRecorder({ + stackName: 'demo', + envId: null, + triggeredBy: 'manual', + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [] + }); + + recorder.addSecrets([]); + + await recorder.end(false, undefined, 'exit code 1'); + + const update = endUpdate(); + expect(update?.errorMessage).toBe('exit code 1'); + }); +}); + +// --------------------------------------------------------------------------- +// DeployRunRecorder.line() -- the size-budget path (SizeBudgetTracker, +// deploy-log-store.ts). Unlike every test above, THIS block deliberately DOES +// call line() and DOES touch the real filesystem (via appendRunLog) -- it is +// the exception to this file's opening doc comment, and exists specifically +// to prove the budget still triggers truncation after replacing the old +// per-line full-directory-scan with SizeBudgetTracker's cached/incremental +// check. A tiny injected SizeBudgetTracker (see DeployRunRecorder's +// constructor) stands in for BUDGET_BYTES (2 GiB in production) -- nothing +// here writes gigabytes of data to prove the same code path. +// --------------------------------------------------------------------------- +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, afterEach } from 'bun:test'; +import { SizeBudgetTracker } from '../src/lib/server/deploy-log-store'; + +const previousDataDirForLineTests = process.env.DATA_DIR; +let lineTestsScratchDir: string; + +beforeEach(async () => { + lineTestsScratchDir = await mkdtemp(join(tmpdir(), 'deploy-run-record-')); + process.env.DATA_DIR = lineTestsScratchDir; +}); +afterEach(async () => { + if (previousDataDirForLineTests === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDirForLineTests; + await rm(lineTestsScratchDir, { recursive: true, force: true }); +}); + +describe('DeployRunRecorder.line() -- size-budget truncation', () => { + test('a run whose output crosses a tiny injected budget ends up truncated: true', async () => { + resetDbState(); + const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record'); + // 50 B budget -- five ~11 B lines ("line-N\n") cross it comfortably without + // waiting on anything close to the real 2 GiB BUDGET_BYTES. + const tinyBudget = new SizeBudgetTracker(null, '1', 50); + const recorder = new DeployRunRecorder( + 1, + Date.now(), + { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: null + }, + tinyBudget + ); + + for (let i = 0; i < 10; i++) { + recorder.line(`line-${i}`); + } + await recorder.end(true); + + const update = endUpdate(); + expect((update?.details as Record | undefined)?.truncated).toBe(true); + }); + + // Gegenversuch counterpart to the test above, and the "doesn't trigger too + // early" half of the requirement: a run whose total output stays under the + // budget must NOT be marked truncated. + test('a run whose output stays under the injected budget ends up truncated: false', async () => { + resetDbState(); + const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record'); + const generousBudget = new SizeBudgetTracker(null, '2', 1_000_000); + const recorder = new DeployRunRecorder( + 2, + Date.now(), + { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: null + }, + generousBudget + ); + + for (let i = 0; i < 10; i++) { + recorder.line(`line-${i}`); + } + await recorder.end(true); + + const update = endUpdate(); + // buildRunDetails() only puts `truncated` in the object when it's true (see + // deploy-run-record-core.ts) -- so "not truncated" is undefined, not false. + expect((update?.details as Record | undefined)?.truncated).toBeUndefined(); + }); + + test('once truncated, further line() calls stop appending -- the truncation notice is the last thing written', async () => { + resetDbState(); + const { readRunLog } = await import('../src/lib/server/deploy-log-store'); + const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record'); + const tinyBudget = new SizeBudgetTracker(null, '3', 20); // crosses after ~2 lines + const recorder = new DeployRunRecorder( + 3, + Date.now(), + { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: null + }, + tinyBudget + ); + + for (let i = 0; i < 20; i++) { + recorder.line(`this-line-should-not-all-land-${i}`); + } + await recorder.end(true); + + const log = await readRunLog(null, '3'); + expect(log).not.toBeNull(); + expect(log).toContain('[deploy log truncated: size budget exceeded]'); + // Not every one of the 20 lines can have landed -- otherwise truncation + // never actually stopped anything, it just decorated a full log. + expect((log ?? '').split('\n').length).toBeLessThan(22); + }); +}); + +// --------------------------------------------------------------------------- +// F5 fix: DeployRunRecorder threads its envId through to BOTH the log file's +// on-disk path AND its SizeBudgetTracker's default construction -- proven +// end to end here, without injecting a custom SizeBudgetTracker (so this +// exercises DeployRunRecorder's OWN `new SizeBudgetTracker(this.envId, ...)` +// default, not a test double standing in for it). +// --------------------------------------------------------------------------- + +describe('DeployRunRecorder -- envId threading to the log file path (F5)', () => { + test('two runs in DIFFERENT environments write to two DIFFERENT log files, never colliding even with the same executionId space', async () => { + resetDbState(); + const { readRunLog, runLogPath } = await import('../src/lib/server/deploy-log-store'); + const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record'); + + const recorderA = new DeployRunRecorder(4001, Date.now(), { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: 1 + }); + const recorderB = new DeployRunRecorder(4002, Date.now(), { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: 2 + }); + + recorderA.line('environment 1 output'); + recorderB.line('environment 2 output'); + await recorderA.end(true); + await recorderB.end(true); + + expect(await readRunLog(1, '4001')).toContain('environment 1 output'); + expect(await readRunLog(2, '4002')).toContain('environment 2 output'); + // Not merely different content -- genuinely different files on disk. + expect(runLogPath(1, '4001')).not.toBe(runLogPath(2, '4002')); + }); + + // THE end-to-end counterpart to the pure SizeBudgetTracker-level regression + // test in deploy-log-store.test.ts: environment A's run is deliberately + // pushed PAST its own size budget and truncated. Environment B's run, with + // the SAME budget and an unrelated one-line output, must NOT be affected -- + // proving DeployRunRecorder.line()'s real truncation path (appendRunLog + + // SizeBudgetTracker.recordAppend together, not either in isolation) is + // scoped per environment, exactly as task-lead described: "filling A's + // budget does NOT truncate B's run". + test('truncating environment A\'s run does not truncate environment B\'s unrelated run, even with the same tiny budget', async () => { + resetDbState(); + const { readRunLog } = await import('../src/lib/server/deploy-log-store'); + const { DeployRunRecorder } = await import('../src/lib/server/deploy-run-record'); + + const budgetA = new SizeBudgetTracker(1, '4101', 50); + const budgetB = new SizeBudgetTracker(2, '4102', 50); + + const recorderA = new DeployRunRecorder( + 4101, + Date.now(), + { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: 1 + }, + budgetA + ); + const recorderB = new DeployRunRecorder( + 4102, + Date.now(), + { + options: { pull: false, build: false, forceRecreate: false }, + composeHash: 'aaa', + envHash: 'bbb', + secrets: [], + envId: 2 + }, + budgetB + ); + + // Environment A runs to completion FIRST and fully lands on disk (including + // its truncation notice) before environment B writes a single byte -- this + // removes any ambiguity about interleaving between the two recorders' + // independent async append chains, so a failure below can only mean B's + // directory scan actually saw A's bytes, not a timing accident. + for (let i = 0; i < 10; i++) { + recorderA.line(`line-${i}`); + } + await recorderA.end(true); + + // Environment B: ONE short line, nowhere near 50 B on its own, written + // strictly AFTER environment A has already exceeded its own budget. + recorderB.line('untouched'); + await recorderB.end(true); + + const logA = await readRunLog(1, '4101'); + const logB = await readRunLog(2, '4102'); + + expect(logA).toContain('[deploy log truncated: size budget exceeded]'); + // Environment B's single line survives completely, with no truncation + // notice -- it was never anywhere near ITS OWN 50 B budget, and + // environment A's bytes (in a DIFFERENT directory) never counted + // against it. + expect(logB).toBe('untouched\n'); + }); +}); diff --git a/tests/deploy-run-view.test.ts b/tests/deploy-run-view.test.ts new file mode 100644 index 0000000..c1a5cbb --- /dev/null +++ b/tests/deploy-run-view.test.ts @@ -0,0 +1,291 @@ +/** + * deploy-run-view — pure text/label composition for the Stack modal's Deploys tab. + * Import-free by design (see deploy-run-view.ts's doc comment) so it runs under Bun + * without pulling in $lib/server (better-sqlite3, ERR_DLOPEN_FAILED). + * + * Run with: bun test tests/deploy-run-view.test.ts + */ +import { describe, test, expect } from 'bun:test'; +import { + formatStatusLabel, + formatTrigger, + formatRunDuration, + formatContainerSummary, + formatBuildStatus, + formatOptionChips, + lastErrorLine, + buildDeployRunView, + buildDeployLogPanelState, + type DeployRun, + type DeployRunSummary +} from '../src/lib/utils/deploy-run-view'; + +const summary = (over: Partial = {}): DeployRunSummary => ({ + containersCreated: 0, + containersRecreated: 0, + containersStarted: 0, + imagesBuilt: [], + imagesPulled: [], + buildSteps: 0, + buildStepsCached: 0, + ...over +}); + +const run = (over: Partial = {}): DeployRun => ({ + id: 1, + triggeredBy: 'manual', + duration: 1500, + status: 'success', + errorMessage: null, + details: { options: { pull: false, build: false, forceRecreate: false } }, + ...over +}); + +describe('formatBuildStatus', () => { + test('every build step came from cache', () => { + expect(formatBuildStatus(summary({ buildSteps: 3, buildStepsCached: 3 }))).toBe( + 'Built from cache (3 steps)' + ); + }); + + test('a single cached step is not pluralized wrong', () => { + expect(formatBuildStatus(summary({ buildSteps: 1, buildStepsCached: 1 }))).toBe( + 'Built from cache' + ); + }); + + test('partially built — some steps from cache, some not', () => { + expect(formatBuildStatus(summary({ buildSteps: 4, buildStepsCached: 1 }))).toBe( + 'Built (4 steps, 1 from cache)' + ); + }); + + test('built with no cache hits at all', () => { + expect(formatBuildStatus(summary({ buildSteps: 2, buildStepsCached: 0 }))).toBe( + 'Built (2 steps)' + ); + }); + + test('nothing built — a run that never touched the build step', () => { + expect(formatBuildStatus(summary({ buildSteps: 0, buildStepsCached: 0 }))).toBe( + 'Nothing built' + ); + }); + + test('no summary at all reads as unknown, not as "nothing built"', () => { + expect(formatBuildStatus(undefined)).toBe('Build status unknown'); + }); +}); + +describe('formatContainerSummary', () => { + test('no summary recorded says so explicitly — never "0 containers"', () => { + const text = formatContainerSummary(undefined); + expect(text).toBe('No summary recorded for this run'); + expect(text).not.toMatch(/0/); + }); + + test('a run with zero container changes reads distinctly from "no summary"', () => { + expect(formatContainerSummary(summary())).toBe('No container changes'); + }); + + test('created and started are joined, recreated omitted when zero', () => { + expect( + formatContainerSummary(summary({ containersCreated: 3, containersStarted: 1 })) + ).toBe('3 created, 1 started'); + }); + + test('all three counts present appear in created, recreated, started order', () => { + expect( + formatContainerSummary( + summary({ containersCreated: 1, containersRecreated: 2, containersStarted: 3 }) + ) + ).toBe('1 created, 2 recreated, 3 started'); + }); +}); + +describe('formatOptionChips', () => { + test('no options recorded yields no chips', () => { + expect(formatOptionChips(undefined)).toEqual([]); + }); + + test('all flags false yields no chips', () => { + expect(formatOptionChips({ pull: false, build: false, forceRecreate: false })).toEqual([]); + }); + + test('every true flag becomes its own chip, in a fixed order', () => { + expect(formatOptionChips({ pull: true, build: true, forceRecreate: true })).toEqual([ + 'Pull', + 'Build', + 'Force recreate' + ]); + }); + + test('a single flag yields a single chip', () => { + expect(formatOptionChips({ pull: true, build: false, forceRecreate: false })).toEqual(['Pull']); + }); +}); + +describe('formatTrigger', () => { + test.each([ + ['manual', 'Manual'], + ['cron', 'Scheduled'], + ['webhook', 'Webhook'], + ['startup', 'Startup'] + ])('%s -> %s', (input: string, expected: string) => { + expect(formatTrigger(input)).toBe(expected); + }); + + test('an unrecognized trigger value is not swallowed silently', () => { + expect(formatTrigger('something-new')).toBe('something-new'); + }); +}); + +describe('formatStatusLabel', () => { + test.each([ + ['success', 'Success'], + ['failed', 'Failed'], + ['running', 'Running'] + ])('%s -> %s', (input: string, expected: string) => { + expect(formatStatusLabel(input)).toBe(expected); + }); + + test('an unrecognized status value is passed through, not hidden', () => { + expect(formatStatusLabel('mystery')).toBe('mystery'); + }); +}); + +describe('formatRunDuration', () => { + test('null/undefined duration (a run still in progress or never started)', () => { + expect(formatRunDuration(null)).toBe('—'); + expect(formatRunDuration(undefined)).toBe('—'); + }); + + test('sub-second durations render in milliseconds', () => { + expect(formatRunDuration(340)).toBe('340ms'); + }); + + test('sub-minute durations render with one decimal of seconds', () => { + expect(formatRunDuration(13515)).toBe('13.5s'); + }); + + test('minute-scale durations render as Nm Ns', () => { + expect(formatRunDuration(125000)).toBe('2m 5s'); + }); +}); + +describe('lastErrorLine', () => { + test('no error message at all', () => { + expect(lastErrorLine(null)).toBeNull(); + expect(lastErrorLine(undefined)).toBeNull(); + expect(lastErrorLine('')).toBeNull(); + }); + + test('a short single-line message is returned unchanged', () => { + expect(lastErrorLine('exit code 1')).toBe('exit code 1'); + }); + + test('a multi-line message keeps only its last non-empty line', () => { + const msg = 'Pulling image...\nBuilding...\nError: container failed to start\n'; + expect(lastErrorLine(msg)).toBe('Error: container failed to start'); + }); + + test('a long single-line message is cut to maxLen with an ellipsis marker', () => { + const long = 'x'.repeat(300); + const result = lastErrorLine(long, 50); + expect(result?.length).toBe(50); + expect(result?.endsWith('…')).toBe(true); + expect(result?.startsWith('x'.repeat(49))).toBe(true); + }); +}); + +describe('buildDeployRunView', () => { + test('a run without a summary (no output at all) never claims to know container/build facts', () => { + const view = buildDeployRunView(run({ details: { options: { pull: true, build: false, forceRecreate: false } } })); + expect(view.containerSummary).toBe('No summary recorded for this run'); + expect(view.buildStatus).toBe('Build status unknown'); + expect(view.imagesBuilt).toEqual([]); + expect(view.imagesPulled).toEqual([]); + }); + + test('truncated is surfaced only when the record actually says so', () => { + expect(buildDeployRunView(run()).truncated).toBe(false); + expect( + buildDeployRunView( + run({ details: { options: { pull: false, build: false, forceRecreate: false }, truncated: true } }) + ).truncated + ).toBe(true); + }); + + test('a failed run with a long error message carries the shortened last line', () => { + const longError = `Step 1 ok\nStep 2 ok\n${'boom '.repeat(60)}`; + const view = buildDeployRunView(run({ status: 'failed', errorMessage: longError })); + expect(view.errorSummary).not.toBeNull(); + expect(view.errorSummary!.length).toBeLessThanOrEqual(200); + expect(view.statusLabel).toBe('Failed'); + }); + + test('a successful run never surfaces an error summary even if errorMessage is set', () => { + const view = buildDeployRunView(run({ status: 'success', errorMessage: 'leftover text' })); + expect(view.errorSummary).toBeNull(); + }); + + test('a running run in progress has no error summary and reads as Running', () => { + const view = buildDeployRunView(run({ status: 'running', duration: null, errorMessage: null })); + expect(view.errorSummary).toBeNull(); + expect(view.statusLabel).toBe('Running'); + expect(view.duration).toBe('—'); + }); + + test('option chips and images pass through from the run summary', () => { + const view = buildDeployRunView( + run({ + details: { + options: { pull: true, build: true, forceRecreate: false }, + summary: summary({ imagesBuilt: ['app:1'], imagesPulled: ['redis:7'] }) + } + }) + ); + expect(view.optionChips).toEqual(['Pull', 'Build']); + expect(view.imagesBuilt).toEqual(['app:1']); + expect(view.imagesPulled).toEqual(['redis:7']); + }); +}); + +describe('buildDeployLogPanelState', () => { + test('a normal completed run: log fetchable, not truncated, deletable', () => { + const state = buildDeployLogPanelState(run({ status: 'success' })); + expect(state).toEqual({ logMissing: false, truncated: false, deletable: true }); + }); + + test('a run reconciled as logMissing short-circuits to logMissing -- regardless of truncated', () => { + const state = buildDeployLogPanelState( + run({ status: 'success', details: { logMissing: true, truncated: true } }) + ); + expect(state.logMissing).toBe(true); + }); + + test('a truncated run surfaces truncated independently of logMissing', () => { + const state = buildDeployLogPanelState(run({ status: 'failed', details: { truncated: true } })); + expect(state.logMissing).toBe(false); + expect(state.truncated).toBe(true); + }); + + test('a running run is never deletable, even though its log is not missing', () => { + const state = buildDeployLogPanelState(run({ status: 'running', details: undefined })); + expect(state.deletable).toBe(false); + expect(state.logMissing).toBe(false); + }); + + test('mutating the run to running after it was deletable flips deletable back off', () => { + // Regression guard for a hand-rolled `run.status !== "running"` check drifting + // out of sync with this function if someone inlines the condition again later. + const base = run({ status: 'success' }); + expect(buildDeployLogPanelState(base).deletable).toBe(true); + expect(buildDeployLogPanelState({ ...base, status: 'running' }).deletable).toBe(false); + }); + + test('a run with no details at all reads as available, not truncated, deletable', () => { + const state = buildDeployLogPanelState(run({ status: 'success', details: null })); + expect(state).toEqual({ logMissing: false, truncated: false, deletable: true }); + }); +}); diff --git a/tests/deploy-stack-online.test.ts b/tests/deploy-stack-online.test.ts new file mode 100644 index 0000000..51abb5a --- /dev/null +++ b/tests/deploy-stack-online.test.ts @@ -0,0 +1,89 @@ +/** + * Regression guard: every deployStack() call in an API route must pass onLine. + * + * The compose output window is fed by 'progress' messages of shape + * {type:'line', line}. A route that runs deployStack inside createJobResponse but + * forgets `onLine: (line) => send('progress', { type: 'line', line })` still works + * — it just shows the user an empty output window until the job settles. Nothing + * fails, so the omission is invisible: POST /api/stacks (create & start) and + * PUT /api/stacks/{name}/compose (save & redeploy) both shipped without it while + * the five stack-row actions had it. + * + * deployStack talks to the Docker socket and the DB, so we can't drive these + * handlers end-to-end here. Instead we assert at the source level that every + * deployStack call under src/routes/api carries onLine — the same shape of check + * as tests/encryption-rotation-coverage.test.ts (a list that silently omitted + * one entry). + */ +// @ts-expect-error -- bun:test is a runtime built-in with no types installed +import { describe, test, expect } from 'bun:test'; +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const apiRoot = join(here, '..', 'src', 'routes', 'api'); + +function serverFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) found.push(...serverFiles(full)); + else if (entry.name === '+server.ts') found.push(full); + } + return found; +} + +// Returns the text of each deployStack(...) argument list, paren-balanced so a +// nested call or object literal doesn't cut the match short. +function deployStackCalls(source: string): string[] { + const calls: string[] = []; + const marker = 'deployStack('; + + for (let at = source.indexOf(marker); at !== -1; at = source.indexOf(marker, at + 1)) { + // Skip identifiers that merely end in "deployStack(" (e.g. runRedeployStack(). + const before = source[at - 1]; + if (before && /[A-Za-z0-9_$]/.test(before)) continue; + + let depth = 0; + for (let i = at + marker.length - 1; i < source.length; i++) { + if (source[i] === '(') depth++; + else if (source[i] === ')') { + depth--; + if (depth === 0) { + calls.push(source.slice(at, i + 1)); + break; + } + } + } + } + return calls; +} + +const callsByFile = new Map(); +for (const file of serverFiles(apiRoot)) { + const calls = deployStackCalls(readFileSync(file, 'utf8')); + if (calls.length > 0) callsByFile.set(relative(join(here, '..'), file), calls); +} + +describe('API routes stream compose output', () => { + // Without this the suite would pass vacuously if the scan ever stopped + // finding anything (renamed directory, changed route layout). + test('the scan finds the known deployStack routes', () => { + expect([...callsByFile.keys()].sort()).toEqual([ + 'src/routes/api/stacks/+server.ts', + 'src/routes/api/stacks/[name]/compose/+server.ts', + 'src/routes/api/stacks/[name]/deploy/+server.ts' + ]); + }); + + test('every deployStack call passes onLine', () => { + const missing: string[] = []; + for (const [file, calls] of callsByFile) { + for (const call of calls) { + if (!call.includes('onLine')) missing.push(file); + } + } + expect(missing).toEqual([]); + }); +}); diff --git a/tests/deploy-stack-resolved-secrets-source.test.ts b/tests/deploy-stack-resolved-secrets-source.test.ts new file mode 100644 index 0000000..6b4b29e --- /dev/null +++ b/tests/deploy-stack-resolved-secrets-source.test.ts @@ -0,0 +1,115 @@ +/** + * Source guard: deployStack() must record the SAME secret set it redacts against. + * + * F4 makes `deployStack()` attach the resolved secret VALUES to its result so the + * per-route stack_deploy recorder can redact them out of a stored deploy error + * (deploy-run-record.ts -> RunRecorder.addSecrets -> redactLine). The single + * production line that carries F4 is, in src/lib/server/stacks.ts: + * + * result.resolvedSecrets = Object.values(secretVars); + * + * where `secretVars` is the post-`resolveProviderEnvVars` set that + * `executeComposeCommand(...)` just redacted the streamed lines against. If that + * one line is deleted, resolvedSecrets stays undefined and the recorder redacts + * against nothing -- a provider-resolved secret in a thrown/returned deploy error + * would be stored in cleartext. A code review demonstrated the gap by mutation: + * deleting the line left the entire targeted suite green, because the two tests + * that reference `resolvedSecrets` (git-stack-deploy-run-record.test.ts, + * stack-compose-redeploy-run-record.test.ts) fake `deployStack` wholesale and + * hand-set the field in their fixtures -- they prove the DOWNSTREAM wiring, never + * that the real stacks.ts populates it. + * + * WHY THIS IS A SOURCE GUARD AND NOT A BEHAVIOUR TEST (empirically established, + * not assumed): + * 1. `tests/helpers/stacks-fake.ts` calls mock.module('$lib/server/stacks', ...) + * at import time, which -- per its own doc comment and Bun's single-process + * test model -- freezes that specifier's exports for the ENTIRE run. Two + * existing suites import it, so in a full `bun test` run `import { deployStack }` + * resolves to the fake dispatcher, never the real function. There is no + * per-file process isolation in Bun to escape this. + * 2. Independently: pulling the real `$lib/server/stacks` into a test's import + * graph evaluates a module chain that instantiates `better-sqlite3`, which + * Bun does not support ("'better-sqlite3' is not yet supported in Bun") and + * crashes at load. Reproduced twice while investigating this fix. + * Either blocker alone makes a real-`deployStack` behaviour test impossible here; + * that is why the whole suite fakes deployStack and guards its internal invariants + * at the source level (see deploy-stack-online.test.ts, stack-create-start-build- + * options.test.ts). This guard follows that established pattern. + * + * WHAT THIS GUARD CANNOT PROVE (honest limits of source matching): it matches + * TEXT, so it cannot prove `secretVars` is populated correctly at runtime (covered + * by resolveProviderEnvVars' own tests and the redaction tests), and a matching + * string inside a comment would satisfy it -- so comments are stripped before + * matching. It proves only the wiring: the derivation of `secretVars`, its use as + * the executeComposeCommand secret argument, and its assignment to resolvedSecrets. + */ +// @ts-expect-error -- bun:test is a runtime built-in with no types installed +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const stacksPath = join(here, '..', 'src', 'lib', 'server', 'stacks.ts'); + +// Strip // line comments and /* */ block comments so a matching string inside a +// comment cannot satisfy the guard (the trap called out in test-coverage-pflicht). +function stripComments(src: string): string { + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // avoid eating "http://" etc. (rare here) +} + +// Extract the brace-balanced body of `export async function deployStack(...) { ... }`. +function deployStackBody(src: string): string { + const sig = src.indexOf('export async function deployStack('); + if (sig === -1) return ''; + const open = src.indexOf('{', sig); + if (open === -1) return ''; + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) return src.slice(open + 1, i); + } + } + return ''; +} + +const raw = readFileSync(stacksPath, 'utf8'); +const body = stripComments(deployStackBody(raw)); + +describe('deployStack records the secret set it redacts against (F4 source guard)', () => { + // Vacuity guard: if the function can no longer be located, every assertion + // below would pass on an empty string. Fail loudly instead. + test('the deployStack body is located and non-trivial', () => { + expect(body.length).toBeGreaterThan(500); + }); + + test('secretVars is derived from resolveProviderEnvVars (the redaction input)', () => { + const ok = /\bsecretVars\b[\s\S]{0,80}=\s*await\s+resolveProviderEnvVars\s*\(/.test(body); + expect(ok, `${stacksPath}: deployStack no longer derives \`secretVars\` from ` + + `\`await resolveProviderEnvVars(...)\`. F4 records exactly the set that was ` + + `redacted; if the derivation changed, update this guard AND confirm the recorder ` + + `still receives the redacted set.`).toBe(true); + }); + + test('the same secretVars is passed to executeComposeCommand as its secret argument', () => { + const call = body.match(/executeComposeCommand\(([\s\S]*?)\n\t*\);/); + expect(call, `${stacksPath}: could not find the executeComposeCommand(...) call in ` + + `deployStack -- the F4 guard cannot verify the redaction/record share \`secretVars\`.`).toBeTruthy(); + expect(/\bsecretVars\b/.test(call ? call[1] : ''), + `${stacksPath}: deployStack's executeComposeCommand(...) call no longer passes ` + + `\`secretVars\` as the secret argument. The recorded set (result.resolvedSecrets) ` + + `must be the SAME set the compose lines were redacted against.`).toBe(true); + }); + + test('result.resolvedSecrets is assigned Object.values(secretVars) (the F4 line)', () => { + const ok = /result\.resolvedSecrets\s*=\s*Object\.values\(\s*secretVars\s*\)/.test(body); + expect(ok, `${stacksPath}: the F4 line \`result.resolvedSecrets = Object.values(secretVars)\` ` + + `is gone from deployStack. Without it the stack_deploy recorder redacts against nothing ` + + `and a provider-resolved secret can be stored in a deploy error in cleartext. If you ` + + `intentionally moved this wiring, update this guard to match the new location.`).toBe(true); + }); +}); diff --git a/tests/deploy-summary-core.test.ts b/tests/deploy-summary-core.test.ts new file mode 100644 index 0000000..b3e32f8 --- /dev/null +++ b/tests/deploy-summary-core.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test'; +import { summarize } from '../src/lib/server/deploy-summary-core'; + +const REAL_RUN = [ + ' Image alpine:3.21 Pulling ', + ' Image alpine:3.21 Pulled ', + ' Image progprobe-built:1 Building ', + ' Image progprobe-built:1 Built ', + ' Network probea_default Creating ', + ' Network probea_default Creating ', + ' Network probea_default Created ', + ' Network probea_default Created ', + ' Container probea-plain-1 Creating ', + ' Container probea-built-1 Creating ', + ' Container probea-built-1 Created ', + ' Container probea-plain-1 Created ', + ' Container probea-built-1 Starting ', + ' Container probea-plain-1 Starting ', + ' Container probea-plain-1 Started ', + ' Container probea-built-1 Started ', + '#5 CACHED', + '#6 DONE 1.0s', + '#7 DONE 2.7s', + '#9 exporting manifest list sha256:3e3a42937811aaaabbbbccccddddeeeeffff0000111122223333444455556666' +]; + +describe('summarize', () => { + test('counts each container once in the measured run', () => { + const s = summarize(REAL_RUN); + expect(s.containersCreated).toBe(2); + expect(s.containersStarted).toBe(2); + expect(s.containersRecreated).toBe(0); + }); + + // REAL_RUN cannot prove deduplication: in the measured output every container line + // appears exactly once (only the *network* line repeats, and CONTAINER does not match + // network lines). Replacing the Set with a counter leaves the test above green. This + // case supplies the repetition explicitly -- it is the only one that fails when the + // Set is removed. + test('counts a container once even when compose repeats the same transition', () => { + const s = summarize([ + ' Container web-1 Created ', + ' Container web-1 Created ', + ' Container web-1 Started ', + ' Container web-1 Started ' + ]); + expect(s.containersCreated).toBe(1); + expect(s.containersStarted).toBe(1); + }); + + test('separates images built from images pulled', () => { + const s = summarize(REAL_RUN); + expect(s.imagesBuilt).toEqual(['progprobe-built:1']); + expect(s.imagesPulled).toEqual(['alpine:3.21']); + }); + + test('reports how much of the build came from cache', () => { + const s = summarize(REAL_RUN); + expect(s.buildSteps).toBe(3); + expect(s.buildStepsCached).toBe(1); + }); + + test('picks up the image digest from the buildkit export line', () => { + expect(summarize(REAL_RUN).digest) + .toBe('sha256:3e3a42937811aaaabbbbccccddddeeeeffff0000111122223333444455556666'); + }); + + test('counts recreated containers separately from merely started ones', () => { + const s = summarize([ + ' Container a Recreate ', + ' Container a Recreated ', + ' Container a Starting ', + ' Container a Started ' + ]); + expect(s.containersRecreated).toBe(1); + expect(s.containersCreated).toBe(0); + }); + + test('an image that was already local produces no pull entry at all', () => { + // Measured: compose emits neither Pulling nor Pulled for an image that is present. + // Absence is the only signal, so the summary must not invent one. + expect(summarize([' Container a Started ']).imagesPulled).toEqual([]); + }); +}); diff --git a/tests/format-relative-time.test.ts b/tests/format-relative-time.test.ts index 30c3bb3..104304e 100644 --- a/tests/format-relative-time.test.ts +++ b/tests/format-relative-time.test.ts @@ -4,7 +4,7 @@ * * Run with: bun test tests/unit/format-relative-time.test.ts */ -import { describe, test, expect } from 'bun:test'; +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { formatRelativeTime } from '../src/lib/utils/format'; // Build a timestamp `n` seconds in the past relative to now, so the tests are @@ -68,3 +68,57 @@ describe('formatRelativeTime — input forms & edges', () => { expect(formatRelativeTime(undefined as unknown as string)).toBe(''); }); }); + +// #1183 follow-up (reported live: a deploy from ~2 minutes ago showed "2h ago"): +// the server hands back a NAIVE timestamp (PG `timestamp without time zone`, +// e.g. "2026-08-30 20:36:11.534" - space separator, no "Z"/offset). Every +// caller of formatRelativeTime passes exactly that value, right alongside +// formatDateTime() which already reads it as UTC via parseTimestamp() (see +// date-format-tz.test.ts). formatRelativeTime used to parse it with a bare +// `new Date(...)`, which treats a zone-less string as browser-local time - +// correct only when the browser happens to run in UTC, which is exactly why +// this stayed invisible in a UTC test browser. +// +// TZ is forced to a non-UTC zone for this block only, so the bug reproduces +// regardless of the host/CI timezone. Restored in afterAll: bun runs every +// test file in one process, so a dangling override would leak into whichever +// file happens to run next. +describe('formatRelativeTime — a naive server timestamp is read as UTC, not local time', () => { + const originalTZ = process.env.TZ; + + beforeAll(() => { + process.env.TZ = 'America/New_York'; // UTC-4 in August - deliberately not UTC + }); + + afterAll(() => { + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; + }); + + // Same instant in two forms: a proper zoned ISO string, and the naive + // space-separated form Drizzle hands back for a `timestamp without time + // zone` column. Deriving the expectation from the zoned form (rather than + // hardcoding a bucket string) keeps the assertion meaningful independent + // of whichever zone the suite happens to run under. + const naiveFrom = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', ''); + + test('a naive PG-style string reports the same age as its zoned equivalent (5m ago)', () => { + const instant = new Date(Date.now() - 5 * 60 * 1000); + expect(formatRelativeTime(naiveFrom(instant))).toBe(formatRelativeTime(instant.toISOString())); + expect(formatRelativeTime(naiveFrom(instant))).toBe('5m ago'); + }); + + test('a two-minute-old naive deploy timestamp reads "2m ago" - the reported bug', () => { + // Under the bug, "20:36:11" gets read as 20:36:11 America/New_York and + // re-anchored 4h later in UTC than the true instant, so + // Date.now() - ms goes negative and this would report "in the future" + // instead of "2m ago". + const instant = new Date(Date.now() - 2 * 60 * 1000); + expect(formatRelativeTime(naiveFrom(instant))).toBe('2m ago'); + }); + + test('a naive timestamp from several hours ago still reads the correct bucket', () => { + const instant = new Date(Date.now() - 3 * 60 * 60 * 1000); + expect(formatRelativeTime(naiveFrom(instant))).toBe('3h ago'); + }); +}); diff --git a/tests/fs-guard-deploy-logs.test.ts b/tests/fs-guard-deploy-logs.test.ts new file mode 100644 index 0000000..b5d83d8 --- /dev/null +++ b/tests/fs-guard-deploy-logs.test.ts @@ -0,0 +1,35 @@ +// @ts-expect-error -- bun:test is a runtime built-in with no types installed +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { isProtectedPath } from '../src/lib/server/fs-guard'; + +// protectedPaths() reads process.env.DATA_DIR on every call, not at import time, so a plain +// import is enough -- but the variable has to be set while these assertions run, and put back +// afterwards. Bun runs every test file in ONE process: a stray DATA_DIR leaks into unrelated +// suites, and selfhst-icons creates its cache directory under DATA_DIR, which fails when that +// points somewhere this machine does not have. +const previousDataDir = process.env.DATA_DIR; +beforeAll(() => { + process.env.DATA_DIR = '/app/data'; +}); +afterAll(() => { + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; +}); + +describe('isProtectedPath', () => { + test('protects the deploy log directory', () => { + // Deploy logs can carry secrets. The file browser is open by design, so the + // directory has to be named here or the API's permission check is bypassable. + expect(isProtectedPath('/app/data/deploy-logs')).toBe(true); + expect(isProtectedPath('/app/data/deploy-logs/abc.log')).toBe(true); + }); + + test('still protects what it protected before', () => { + expect(isProtectedPath('/app/data/db')).toBe(true); + expect(isProtectedPath('/proc/1/environ')).toBe(true); + }); + + test('leaves the rest of the container browsable', () => { + expect(isProtectedPath('/app/data/stacks')).toBe(false); + }); +}); diff --git a/tests/fs-guard.test.ts b/tests/fs-guard.test.ts index 617d7b9..147f330 100644 --- a/tests/fs-guard.test.ts +++ b/tests/fs-guard.test.ts @@ -3,20 +3,26 @@ import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync, rmSync } from 'node import { join } from 'node:path'; import { tmpdir } from 'node:os'; -// Pin DATA_DIR before the import below so the guard computes /app/data paths. This runs -// at module load (Bun runs all test files in one process), so it must be RESTORED after -// this file or it leaks /app/data into every later file - e.g. selfhst-icons then does -// mkdirSync('/app/data/...') and fails EACCES on CI (#1535). -const __prevDataDir = process.env.DATA_DIR; -process.env.DATA_DIR = '/app/data'; +// Pin DATA_DIR via beforeAll/afterAll so the guard computes /app/data paths for this +// file's tests, and put it back afterwards so the change doesn't leak into unrelated +// suites. Bun runs every test file in one process: selfhst-icons reads DATA_DIR and +// mkdirSync's a cache directory under it, which fails with EACCES on a machine that has +// no /app. +// +// isProtectedPath() only reads process.env.DATA_DIR lazily, inside its own call -- not +// at import time -- so beforeAll (which runs before any test body, hence before any +// isProtectedPath() call) is sufficient on its own; no separate assignment before the +// import below is needed. Measured, not assumed: this file passes standalone, paired +// with selfhst-icons.test.ts in both file orders, and as part of the full suite. +const previousDataDir = process.env.DATA_DIR; +beforeAll(() => { process.env.DATA_DIR = '/app/data'; }); +afterAll(() => { + if (previousDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previousDataDir; +}); const { isProtectedPath } = await import('../src/lib/server/fs-guard'); -afterAll(() => { - if (__prevDataDir === undefined) delete process.env.DATA_DIR; - else process.env.DATA_DIR = __prevDataDir; -}); - describe('isProtectedPath (file-browser secret guard, H1)', () => { test('blocks the database directory and its contents', () => { expect(isProtectedPath('/app/data/db')).toBe(true); diff --git a/tests/git-stack-deploy-run-record.test.ts b/tests/git-stack-deploy-run-record.test.ts new file mode 100644 index 0000000..649d709 --- /dev/null +++ b/tests/git-stack-deploy-run-record.test.ts @@ -0,0 +1,529 @@ +/** + * Wires the stack_deploy run recorder into `deployGitStack()` (git.ts) itself, not + * into any single HTTP route. deployGitStack is the function every git-stack deploy + * trigger EXCEPT ONE funnels through -- a config-update-then-redeploy, both webhook + * methods, create-and-deploy, and (via runGitStackSync) the cron scheduler and the + * schedules page's "run now". Wiring the recorder at a single route (the way + * compose/+server.ts does it for non-git stacks) would leave every OTHER caller + * unrecorded -- crucially the cron- and webhook-triggered ones, the truly unattended + * runs the design doc's Meilenstein 5 is about. + * + * The one exception is the UI's own "Deploy" button, which streams live progress via + * deployGitStackWithProgress() -- a SEPARATE clone/read/deploy pipeline (it does not + * call deployGitStack at all) that therefore needed, and was missing, its OWN copy of + * this same recorder wiring. Reported by a reviewer and confirmed by reading both + * functions side by side: deployGitStack's recorder wiring never touches it. See the + * second describe block below for its coverage, and the Gegenprobe block at the + * bottom for why the guard there now expects TWO createRunRecorder call sites, not + * one. + * + * `syncGitStack()` (also in git.ts) is a private collaborator of deployGitStack, + * called as a plain in-module function reference, not through an imported + * specifier -- so unlike the non-git deploy routes' $lib/server/stacks dependency, + * it CANNOT be faked via mock.module(). This suite therefore drives a REAL git + * sync against a throwaway local repository served over `git://` by a real `git + * daemon` subprocess (loopback only, ephemeral port) -- a bare filesystem path or + * `file://` URL is deliberately rejected by git-url-safety.ts (SSRF guard) and + * this test does not, and should not, work around that. Only the boundary below + * deployGitStack -- $lib/server/stacks' deployStack() -- is faked, exactly the + * boundary the compose-PUT/dedicated-deploy-route tests already fake for the + * same reason (no real `docker compose` in a unit test). + * + * $lib/server/db and $lib/server/stacks are faked via the shared helpers -- see + * their doc comments for why a second direct mock.module() call for either would + * collide with other files that already own those specifiers for this process. + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach, mock } from 'bun:test'; +import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { registerDbFake } from './helpers/db-fake'; +import { registerStacksFake } from './helpers/stacks-fake'; + +// notifyGitSync (git.ts) wraps sendEventNotification in its own try/catch and never +// lets it affect the deploy outcome, so a no-op stub is all this suite needs -- and +// all it should assert on; which notification fired for which trigger belongs to a +// test of notifyGitSync itself, not this one. No other file in the suite mocks this +// specifier (as of this writing), so a single direct mock.module() call here is safe +// -- see tests/helpers/stacks-fake.ts's doc comment for why db/stacks can't do the same. +mock.module('$lib/server/notifications', () => ({ + sendEventNotification: async () => {} +})); + +// -- $lib/server/db: an in-memory schedule_executions fake, plus the git-stack ----- +// -- lookups deployGitStack/syncGitStack need. Same shape as +// tests/stack-compose-redeploy-run-record.test.ts's fake for the schedule_executions +// half; the git-specific reads/writes below are new to this file. + +let created: Array>; +let updates: Array>; +let nextId: number; +let gitStackUpdates: Array>; +let upsertedSources: Array>; + +function resetDbState() { + created = []; + updates = []; + nextId = 1; + gitStackUpdates = []; + upsertedSources = []; +} +resetDbState(); + +registerDbFake('createScheduleExecution', async (data: Record) => { + const row = { + id: nextId++, + ...data, + triggeredAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + duration: null, + errorMessage: null, + details: data.details ?? null, + logs: null, + createdAt: null + }; + created.push(row); + return row; +}); +registerDbFake('updateScheduleExecution', async (id: number, data: Record) => { + updates.push({ id, ...data }); + return { id, ...data }; +}); + +/** The update() call that CLOSED the row (status !== 'running'), i.e. the one written + * by DeployRunRecorder.end() -- same helper shape as the compose-redeploy suite's + * endUpdate(). Only ever at most one per test: every case here deploys exactly once. */ +function endUpdate() { + return updates.find((u) => u.status !== undefined); +} + +/** All stack_deploy rows created for THIS suite's stack, as opposed to the + * git_stack_sync bookkeeping row a caller like git-stack-sync.ts creates separately + * (a different scheduleType, not exercised by this file -- this suite calls + * deployGitStack directly, the layer below that bookkeeping). */ +function stackDeployRows() { + return created.filter((r) => r.scheduleType === 'stack_deploy'); +} + +let nonSecretVars: Record; +let secretVars: Record; + +registerDbFake('getSecretEnvVarsAsRecord', async () => secretVars); +registerDbFake('getNonSecretEnvVarsAsRecord', async () => nonSecretVars); +registerDbFake('updateGitStack', async (id: number, data: Record) => { + gitStackUpdates.push({ id, ...data }); + return null; +}); +registerDbFake('upsertStackSource', async (data: Record) => { + upsertedSources.push(data); +}); + +// -- $lib/server/stacks: only deployStack (and getStackDir, used for the post- +// -- success stack_sources bookkeeping) are faked -- everything ABOVE that +// -- boundary (deployGitStack, syncGitStack) runs for real. + +let deployStackCalls: Array<{ onLine?: (line: string) => void }>; +let deployStackResult: { success: boolean; output?: string; error?: string; resolvedSecrets?: string[] }; +let deployStackOnLines: string[]; + +function resetStacksState() { + deployStackCalls = []; + deployStackResult = { success: true, output: 'deployed' }; + deployStackOnLines = ['Container demo-stack-app-1 Started']; +} +resetStacksState(); + +registerStacksFake('deployStack', async (options: { onLine?: (line: string) => void }) => { + deployStackCalls.push(options); + for (const line of deployStackOnLines) options.onLine?.(line); + return deployStackResult; +}); +registerStacksFake('getStackDir', async (stackName: string) => join('/tmp/stub-stacks', stackName)); + +// -- A real local git remote, served over git:// by a real `git daemon` ----------- +// +// DATA_DIR is process-global and read at IMPORT time by git.ts (GIT_REPOS_DIR) and +// at call time by deploy-log-store.ts -- same precaution tests/deploy-endpoints.test.ts +// and the compose-redeploy suite already document for this shared env var. + +const previousDataDir = process.env.DATA_DIR; +let workDir: string; +let daemon: ChildProcess; +let daemonPort: number; +const STACK = 'demo-git-stack'; + +function run(cmd: string, args: string[], cwd: string): void { + const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(' ')} failed: ${result.stderr}`); + } +} + +/** Waits for the daemon to actually accept connections (spawn is async) by polling + * a real `git ls-remote` against it, rather than a fixed sleep. */ +async function waitForDaemon(port: number, deadlineMs: number): Promise { + const start = Date.now(); + let lastError: string | undefined; + while (Date.now() - start < deadlineMs) { + const probe = spawnSync('git', ['ls-remote', `git://127.0.0.1:${port}/remote.git`], { + encoding: 'utf8', + timeout: 2000 + }); + if (probe.status === 0) return; + lastError = probe.stderr; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`git daemon on port ${port} never became ready: ${lastError}`); +} + +beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), 'dv-git-deploy-run-record-')); + process.env.DATA_DIR = workDir; + + // Seed the bare "remote" repo with one commit on main, containing a compose file + // at the repo-relative path the fixture GitStackWithRepo below points at. + const bareDir = join(workDir, 'remote.git'); + const scratchDir = join(workDir, 'scratch'); + await mkdir(bareDir, { recursive: true }); + run('git', ['init', '--bare', '-b', 'main', bareDir], workDir); + run('git', ['clone', bareDir, scratchDir], workDir); + run('git', ['config', 'user.email', 'test@example.invalid'], scratchDir); + run('git', ['config', 'user.name', 'Test'], scratchDir); + await writeFile(join(scratchDir, 'compose.yaml'), 'services:\n app:\n image: demo:1\n'); + run('git', ['add', 'compose.yaml'], scratchDir); + run('git', ['commit', '-m', 'initial'], scratchDir); + run('git', ['push', 'origin', 'main'], scratchDir); + + daemonPort = 20000 + Math.floor(Math.random() * 20000); + daemon = spawn( + 'git', + [ + 'daemon', + '--reuseaddr', + '--export-all', + `--base-path=${workDir}`, + `--port=${daemonPort}`, + '--listen=127.0.0.1' + ], + { stdio: 'ignore' } + ); + await waitForDaemon(daemonPort, 5000); +}, 15000); + +afterAll(async () => { + daemon?.kill(); + await rm(workDir, { recursive: true, force: true }); + if (previousDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = previousDataDir; + } +}); + +// -- Route/function under test, imported AFTER DATA_DIR is set and all fakes are --- +// -- registered (git.ts reads DATA_DIR at module-load time for GIT_REPOS_DIR). + +const { deployGitStack, deployGitStackWithProgress } = await import('../src/lib/server/git'); +const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store'); +const { hashComposeContent } = await import('../src/lib/server/deploy-run-record-core'); + +let gitStackId: number; + +function repoUrl(): string { + return `git://127.0.0.1:${daemonPort}/remote.git`; +} + +function makeGitStack(overrides: Partial> = {}) { + return { + id: gitStackId, + stackName: STACK, + environmentId: null, + repositoryId: 1, + composePath: 'compose.yaml', + branch: null, + envFilePath: null, + autoUpdate: false, + autoUpdateSchedule: 'daily' as const, + autoUpdateCron: '', + webhookEnabled: false, + webhookSecret: null, + contextDir: null, + buildOnDeploy: false, + noBuildCache: false, + repullImages: false, + forceRedeploy: false, + lastSync: null, + lastCommit: null, + syncStatus: 'idle', + syncError: null, + syncedFiles: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + repository: { + id: 1, + name: 'test-repo', + url: repoUrl(), + branch: 'main', + credentialId: null + }, + ...overrides + }; +} + +let nextGitStackId = 1; + +beforeEach(async () => { + resetDbState(); + resetStacksState(); + // A fresh, never-before-used stack id per test -- getStackRepoPath() derives the + // on-disk clone path from it (stack-, since environmentId is null), so this + // also gives every test its own clone directory. Without this, a later test could + // inherit an earlier test's already-cloned repo at the SAME commit and silently + // take the "no changes" skip path instead of the deploy path it meant to test. + gitStackId = nextGitStackId++; + nonSecretVars = { PUBLIC_VAR: 'v1' }; + secretVars = { DB_PASSWORD: 's3cr3t-value' }; + + registerDbFake('getGitStack', async () => makeGitStack()); + registerDbFake('getGitRepository', async () => makeGitStack().repository); + + // nextId always restarts at 1 above, so every test's (at most one) row reuses + // runId '1' -- clear its log file so tests don't leak content into each other via + // the shared DATA_DIR (same precaution the compose-redeploy suite documents). + await deleteRunLog(null, '1'); +}); + +describe('deployGitStack -- stack_deploy run record (git-triggered deploys)', () => { + test('a manual-triggered deploy creates exactly one stack_deploy row, closed as success, with a readable log', async () => { + const result = await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true }); + + expect(result.success).toBe(true); + expect(deployStackCalls).toHaveLength(1); // the sync->deploy path really ran + + const rows = stackDeployRows(); + expect(rows).toHaveLength(1); + expect(rows[0].entityName).toBe(STACK); + expect(rows[0].triggeredBy).toBe('manual'); + expect(rows[0].scheduleId).toBe(0); + + const update = endUpdate(); + expect(update?.status).toBe('success'); + expect(update?.errorMessage).toBeNull(); + + const runId = String(rows[0].id); + const log = await readRunLog(null, runId); + expect(log).toContain('Container demo-stack-app-1 Started'); + }); + + test('composeHash matches the content actually synced from the git repository', async () => { + await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true }); + + const update = endUpdate(); + const details = update?.details as { composeHash: string }; + expect(details.composeHash).toBe(hashComposeContent('services:\n app:\n image: demo:1\n')); + }); + + test('a cron-triggered deploy (the unattended case this wiring exists for) is recorded with triggeredBy=cron', async () => { + await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false }); + + const rows = stackDeployRows(); + expect(rows).toHaveLength(1); + expect(rows[0].triggeredBy).toBe('cron'); + }); + + test('a webhook-triggered deploy is recorded with triggeredBy=webhook and no userId', async () => { + await deployGitStack(gitStackId, { triggeredBy: 'webhook', force: false }); + + const rows = stackDeployRows(); + expect(rows).toHaveLength(1); + expect(rows[0].triggeredBy).toBe('webhook'); + const update = endUpdate(); + const details = update?.details as { userId?: number }; + expect(details.userId).toBeUndefined(); + }); + + test('a secret value in the deploy error text is redacted before it is stored', async () => { + deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' }; + deployStackOnLines = []; + + const result = await deployGitStack(gitStackId, { triggeredBy: 'manual', force: true }); + + expect(result.success).toBe(false); + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain('s3cr3t-value'); + }); + + // F4 fix: deployStack() resolves a bound secret provider's values INTERNALLY, + // after deployGitStack()'s `recorder` above was already built from the DB-only + // nonSecretVars/secretVars this suite's fixture returns. A provider-resolved + // secret (never in that DB-only set) is simulated via the fake's + // resolvedSecrets -- exactly StackOperationResult's real shape -- and must still + // be redacted from the stored error, via recorder.addSecrets(). This is the + // unattended (cron/webhook) path deployGitStack's own doc comment calls out -- + // exactly where a bound secret provider is most likely to be in play unattended. + test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => { + const PROVIDER_SECRET = 'zzz-bulk-provider-9000'; + deployStackResult = { + success: false, + error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`, + resolvedSecrets: [PROVIDER_SECRET] + }; + deployStackOnLines = []; + + const result = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: true }); + + expect(result.success).toBe(false); + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET); + }); + + test('a second, unchanged sync (force=false, nothing to deploy) creates NO additional stack_deploy row', async () => { + // First call: the clone directory for this test's stack id does not exist yet, + // so getPreviousCommit() (git.ts) returns null -> commitChanged is always true + // on a first sync -> updated=true -> shouldDeploy=true even with force:false. + // This deploys for real and records exactly one row, same as the tests above. + const first = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false }); + expect(first.success).toBe(true); + expect(first.skipped).toBeUndefined(); + expect(stackDeployRows()).toHaveLength(1); + + // Second call, same stack id, remote repository untouched in between: + // syncGitStack reads the ACTUAL previous commit off the clone directory the + // first call just left on disk (real git, not a fake), re-clones, and finds + // the same commit -> commitChanged=false -> updated=false -> with force:false + // and this fixture's forceRedeploy:false, shouldDeploy is false -> skip. The + // recorder-creation block in git.ts sits AFTER that `if (!shouldDeploy) return` + // branch, so a skip must create no new row at all. + const second = await deployGitStack(gitStackId, { triggeredBy: 'cron', force: false }); + expect(second.success).toBe(true); + expect(second.skipped).toBe(true); + expect(deployStackCalls).toHaveLength(1); // still just the first call's deploy + expect(stackDeployRows()).toHaveLength(1); // no row added for the skipped second call + }); +}); + +describe("deployGitStackWithProgress -- stack_deploy run record (the UI 'Deploy' button)", () => { + // deployGitStackWithProgress runs its OWN clone/read/deploy pipeline (it does not + // call deployGitStack), reporting 5 discrete stages via onProgress rather than + // deployGitStack's onLine -- see its doc comment in git.ts. It was, until this + // fix, the one caller of deployStack() that never went through createRunRecorder + // at all: every test below is red without the fix (verified by reverting it and + // re-running this file), because no stack_deploy row is created in the first + // place. + + test('a manual UI deploy creates exactly one stack_deploy row, closed as success, with a readable log', async () => { + const progressEvents: Array<{ status: string; message?: string }> = []; + const result = await deployGitStackWithProgress(gitStackId, (data) => progressEvents.push(data)); + + expect(result.success).toBe(true); + expect(deployStackCalls).toHaveLength(1); // the clone->deploy path really ran + + const rows = stackDeployRows(); + expect(rows).toHaveLength(1); + expect(rows[0].entityName).toBe(STACK); + expect(rows[0].triggeredBy).toBe('manual'); + expect(rows[0].scheduleId).toBe(0); + + const update = endUpdate(); + expect(update?.status).toBe('success'); + expect(update?.errorMessage).toBeNull(); + + const runId = String(rows[0].id); + const log = await readRunLog(null, runId); + expect(log).toContain('Container demo-stack-app-1 Started'); + + // onLine mirrors deploy output into the run record's log file WITHOUT being + // forwarded to onProgress -- that callback's own contract is the 5 discrete + // stage messages this function sends itself, not raw compose output lines. + expect(progressEvents.some((e) => e.message?.includes('Container demo-stack-app-1'))).toBe(false); + expect(progressEvents.some((e) => e.status === 'complete')).toBe(true); + }); + + test('composeHash matches the content actually read from the cloned repository', async () => { + await deployGitStackWithProgress(gitStackId, () => {}); + + const update = endUpdate(); + const details = update?.details as { composeHash: string }; + expect(details.composeHash).toBe(hashComposeContent('services:\n app:\n image: demo:1\n')); + }); + + test('a secret value in the deploy error text is redacted before it is stored', async () => { + deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' }; + deployStackOnLines = []; + + const result = await deployGitStackWithProgress(gitStackId, () => {}); + + expect(result.success).toBe(false); + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain('s3cr3t-value'); + }); + + // F4 fix, same rationale as deployGitStack's own regression test above: a + // provider-resolved secret is only known AFTER deployStack() returns, so it must + // be fed into the recorder via addSecrets() before end() closes the row, or it + // bypasses redaction entirely. + test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => { + const PROVIDER_SECRET = 'zzz-bulk-provider-9000'; + deployStackResult = { + success: false, + error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`, + resolvedSecrets: [PROVIDER_SECRET] + }; + deployStackOnLines = []; + + const result = await deployGitStackWithProgress(gitStackId, () => {}); + + expect(result.success).toBe(false); + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET); + }); +}); + +describe('deployGitStack / deployGitStackWithProgress -- Gegenprobe: removing either recorder wiring turns its own describe block above red', () => { + async function readGitTsSource(): Promise { + const { readFile } = await import('node:fs/promises'); + const { dirname } = await import('node:path'); + const { fileURLToPath } = await import('node:url'); + const here = dirname(fileURLToPath(import.meta.url)); + return readFile(join(here, '..', 'src', 'lib', 'server', 'git.ts'), 'utf8'); + } + + /** + * Isolates one exported function's own body: from its exact `export async + * function NAME(` signature up to (not including) the next top-level `export `. + * Good enough for these two specific, non-nested, sequentially-declared + * functions in this one file -- NOT a general-purpose brace matcher, and this + * guard cannot tell WHICH call site broke if the slicing itself were ever wrong. + * The behavioral tests in both describe blocks above are what actually prove + * each function records a run; this is a second, cheap line of defense on top + * of them, not a replacement for them. + */ + function functionBody(source: string, name: string): string { + const signature = `export async function ${name}(`; + const start = source.indexOf(signature); + if (start === -1) throw new Error(`function ${name} not found in git.ts`); + const nextExport = source.indexOf('\nexport ', start + signature.length); + return nextExport === -1 ? source.slice(start) : source.slice(start, nextExport); + } + + test('deployGitStack calls createRunRecorder', async () => { + const body = functionBody(await readGitTsSource(), 'deployGitStack'); + expect(body.match(/createRunRecorder\(/g) ?? []).toHaveLength(1); + }); + + test('deployGitStackWithProgress ALSO calls createRunRecorder -- this is the exact gap Finding 2 closes', async () => { + const body = functionBody(await readGitTsSource(), 'deployGitStackWithProgress'); + expect(body.match(/createRunRecorder\(/g) ?? []).toHaveLength(1); + }); + + test('exactly two createRunRecorder call sites in the whole file, one per function', async () => { + const matches = (await readGitTsSource()).match(/createRunRecorder\(/g) ?? []; + expect(matches).toHaveLength(2); + }); +}); diff --git a/tests/hawser-line-routing.test.ts b/tests/hawser-line-routing.test.ts new file mode 100644 index 0000000..e4e95a7 --- /dev/null +++ b/tests/hawser-line-routing.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for dispatchStreamMessage — the PURE 'stream' message routing logic, extracted + * from hawser.ts's handleHawserWsMessage into hawser-core.ts. No DB import, so it is + * unit-testable directly (importing hawser.ts pulls in db/drizzle -> better-sqlite3, which + * bun's test runner can't load — see nav-preferences-core.test.ts for the same pattern). + * hawser.ts re-exports dispatchStreamMessage so callers get it from one import site. + * + * Compose requests are sent with streaming: false and land in connection.pendingRequests + * only. The message dispatcher's 'stream' case used to look up pendingStreamRequests + * exclusively, so any line message for a non-streaming request vanished silently -- no error, + * no warning. dispatchStreamMessage is the fix: it also checks a second, independent registry + * (lineHandlers) for a matching callback. + */ + +import { describe, expect, test } from 'bun:test'; +import { dispatchStreamMessage, type StreamDispatchTarget } from '../src/lib/server/hawser-core.js'; + +describe('dispatchStreamMessage', () => { + test('decodes a line message and passes it to the callback', () => { + const seen: string[] = []; + const conn: StreamDispatchTarget = { pendingStreamRequests: new Map(), lineHandlers: new Map() }; + conn.lineHandlers!.set('req-1', (l: string) => seen.push(l)); + + // This is how it really arrives: Go encodes []byte as base64. + dispatchStreamMessage(conn, { requestId: 'req-1', data: Buffer.from('first line').toString('base64') }); + + expect(seen).toEqual(['first line']); + }); + + test('silently drops line messages with no registered callback', () => { + const conn: StreamDispatchTarget = { pendingStreamRequests: new Map(), lineHandlers: new Map() }; + expect(() => dispatchStreamMessage(conn, { requestId: 'unknown', data: 'eA==' })).not.toThrow(); + }); + + test('prefers pendingStreamRequests over lineHandlers for the same requestId', () => { + const streamSeen: unknown[] = []; + const lineSeen: string[] = []; + const conn: StreamDispatchTarget = { pendingStreamRequests: new Map(), lineHandlers: new Map() }; + conn.pendingStreamRequests!.set('req-2', { onData: (d: unknown) => streamSeen.push(d) }); + conn.lineHandlers!.set('req-2', (l: string) => lineSeen.push(l)); + + dispatchStreamMessage(conn, { requestId: 'req-2', data: 'raw-not-decoded' }); + + expect(streamSeen).toEqual(['raw-not-decoded']); + expect(lineSeen).toEqual([]); + }); + + test('tolerates a missing lineHandlers map (defensive, matches pendingStreamRequests handling)', () => { + const conn: StreamDispatchTarget = { pendingStreamRequests: new Map() }; + expect(() => + dispatchStreamMessage(conn, { requestId: 'req-3', data: Buffer.from('x').toString('base64') }) + ).not.toThrow(); + }); +}); diff --git a/tests/helpers/authorize-fake.ts b/tests/helpers/authorize-fake.ts new file mode 100644 index 0000000..709e26e --- /dev/null +++ b/tests/helpers/authorize-fake.ts @@ -0,0 +1,42 @@ +import { mock } from 'bun:test'; + +/** + * Shared registration point for faking `$lib/server/authorize`. + * + * Same collision problem tests/helpers/db-fake.ts documents for $lib/server/db: + * mock.module() replaces a module's exports WHOLESALE for the entire test process, + * and Bun freezes the exported shape the first time ANY file resolves the specifier + * -- so a second, independent `mock.module('$lib/server/authorize', ...)` call + * elsewhere in the suite would silently clobber (or be clobbered by) this one, + * depending on which file's import happens to run first. + * + * `authorize` is the module's only export, so a single dispatcher is enough: it + * looks up `impl` AT CALL TIME (not at import time), so registerAuthorizeFake() + * works no matter which test file calls it, or when, exactly like db-fake.ts's + * per-name dispatchers. + * + * Do NOT add a separate mock.module('$lib/server/authorize', ...) or + * mock.module('.../authorize', ...) call anywhere else in the suite -- that + * reintroduces the collision this file exists to avoid. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFn = (...args: any[]) => any; + +let impl: AnyFn | undefined; + +export function registerAuthorizeFake(fn: AnyFn): void { + impl = fn; +} + +mock.module('$lib/server/authorize', () => ({ + authorize: (...args: unknown[]) => { + if (!impl) { + throw new Error( + 'authorize-fake: authorize() was called but no test file has registered an implementation yet ' + + '(registerAuthorizeFake(fn) must run before the test that calls it)' + ); + } + return impl(...args); + } +})); diff --git a/tests/helpers/db-fake.ts b/tests/helpers/db-fake.ts new file mode 100644 index 0000000..2595d8b --- /dev/null +++ b/tests/helpers/db-fake.ts @@ -0,0 +1,96 @@ +import { mock } from 'bun:test'; + +/** + * Shared registration point for faking `$lib/server/db`. + * + * $lib/server/db can't be imported for real under Bun (it transitively loads + * better-sqlite3, which throws ERR_DLOPEN_FAILED), so every test file that + * exercises a route/module built on top of it has to fake the whole thing. + * + * Two Bun behaviours make a naive shared registry fail, both confirmed + * empirically (undocumented): + * + * 1. mock.module() replaces a module's exports WHOLESALE for the ENTIRE test + * process -- no per-file scoping without `bun test --isolate` (not used + * by this suite). Two files independently calling + * `mock.module('$lib/server/db', () => ({ onlyWhatIMeed }))` clobber each + * other completely -- whichever registers "later" wins for BOTH files. + * + * 2. Bun does not evaluate every test file's top-level code before running + * any test body. It runs files one at a time. The FIRST time ANY code, + * from ANY file, resolves the `$lib/server/db` specifier (`import(...)` + * or a static import chain reaching it), Bun freezes the resulting module + * namespace object for the specifier -- confirmed by trying (a) a Proxy + * that reads a shared, still-mutable bag live, and (b) re-calling + * mock.module() with a fresh object literal right before each file's own + * import: neither picks up a key added by a file that runs AFTER the + * first-ever resolution. Once resolved, a specifier's export set is fixed + * for the rest of the process, no matter how many more times + * mock.module() is called for it afterwards. + * + * The fix that DOES work: pre-declare every export name any test file will + * ever need, ONCE, right here, each as a thin dispatcher function that looks + * up its real implementation in `impls` AT CALL TIME (not at import time). + * These dispatcher functions themselves are stable references, present in + * the very first module snapshot regardless of which file resolves the + * specifier first. `registerDbFake(name, fn)` only ever changes what a + * dispatcher DELEGATES to -- it never needs to add a new key to the frozen + * snapshot, so it works no matter which file calls it, or when. + * + * Adding a new $lib/server/db export a test needs? Add its name to + * KNOWN_EXPORTS below, then call `registerDbFake('yourExportName', fn)` from + * your test file. Do NOT add a separate `mock.module('$lib/server/db', ...)` + * or `mock.module('.../db', ...)` call anywhere else in the suite -- that + * reintroduces the collision this file exists to avoid. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFn = (...args: any[]) => any; + +/** Every $lib/server/db export any test file fakes, across the whole suite. */ +const KNOWN_EXPORTS = [ + 'getBackupConfig', + 'getScheduleExecution', + 'deleteScheduleExecution', + 'getScheduleExecutions', + 'getDeployLogReconcileEnabled', + 'getScheduleExecutionIdsByType', + 'createScheduleExecution', + 'updateScheduleExecution', + 'appendScheduleExecutionLog', + 'getGitStack', + 'getGitRepository', + 'getGitCredential', + 'updateGitRepository', + 'updateGitStack', + 'upsertStackSource', + 'getEnvironment', + 'getSecretEnvVarsAsRecord', + 'getNonSecretEnvVarsAsRecord' +] as const; + +const impls: Record = {}; + +export function registerDbFake(name: (typeof KNOWN_EXPORTS)[number], fn: AnyFn): void { + impls[name] = fn; +} + +function dispatcher(name: string): AnyFn { + return (...args: unknown[]) => { + const impl = impls[name]; + if (!impl) { + throw new Error( + `db-fake: '${name}' was called but no test file has registered an implementation for it yet ` + + `(registerDbFake('${name}', ...) must run before the test that calls it)` + ); + } + return impl(...args); + }; +} + +const moduleShape: Record = {}; +for (const name of KNOWN_EXPORTS) { + moduleShape[name] = dispatcher(name); +} + +mock.module('$lib/server/db', () => moduleShape); diff --git a/tests/helpers/stacks-fake.ts b/tests/helpers/stacks-fake.ts new file mode 100644 index 0000000..e9b68a0 --- /dev/null +++ b/tests/helpers/stacks-fake.ts @@ -0,0 +1,57 @@ +import { mock } from 'bun:test'; + +/** + * Shared registration point for faking `$lib/server/stacks`. + * + * Mirrors tests/helpers/db-fake.ts exactly, for the same two reasons documented + * there: mock.module() replaces a module's exports WHOLESALE for the entire test + * process, and once ANY file resolves the '$lib/server/stacks' specifier its export + * set is frozen for the rest of the run. A second direct `mock.module('$lib/server/ + * stacks', ...)` call anywhere in the suite collides with whichever one resolved + * first -- exactly the situation this file exists to avoid (see + * tests/stack-compose-redeploy-run-record.test.ts's doc comment, which predicted this + * exact need before a second file existed). + * + * Adding a new $lib/server/stacks export a test needs? Add its name to + * KNOWN_EXPORTS below, then call `registerStacksFake('yourExportName', fn)` from your + * test file. Do NOT add a separate mock.module('$lib/server/stacks', ...) call + * anywhere else in the suite. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFn = (...args: any[]) => any; + +/** Every $lib/server/stacks export any test file fakes, across the whole suite. */ +const KNOWN_EXPORTS = [ + 'getStackComposeFile', + 'saveStackComposeFile', + 'requireComposeFile', + 'deployStack', + 'getStackDir' +] as const; + +const impls: Record = {}; + +export function registerStacksFake(name: (typeof KNOWN_EXPORTS)[number], fn: AnyFn): void { + impls[name] = fn; +} + +function dispatcher(name: string): AnyFn { + return (...args: unknown[]) => { + const impl = impls[name]; + if (!impl) { + throw new Error( + `stacks-fake: '${name}' was called but no test file has registered an implementation for it yet ` + + `(registerStacksFake('${name}', ...) must run before the test that calls it)` + ); + } + return impl(...args); + }; +} + +const moduleShape: Record = {}; +for (const name of KNOWN_EXPORTS) { + moduleShape[name] = dispatcher(name); +} + +mock.module('$lib/server/stacks', () => moduleShape); diff --git a/tests/log-viewer-props.test.ts b/tests/log-viewer-props.test.ts new file mode 100644 index 0000000..20f28a3 --- /dev/null +++ b/tests/log-viewer-props.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { downloadFileName, stripAnsi } from '../src/lib/utils/log-download-name'; + +describe('downloadFileName', () => { + test('uses the title when one is given', () => { + expect(downloadFileName('web-1')).toBe('web-1-logs.txt'); + }); + + test('falls back to a neutral name when no title is given', () => { + expect(downloadFileName(undefined)).toBe('logs.txt'); + }); + + test('strips path separators so a title can never escape the download name', () => { + expect(downloadFileName('../../etc/passwd')).toBe('etcpasswd-logs.txt'); + expect(downloadFileName('..\\..\\windows\\system32')).toBe('windowssystem32-logs.txt'); + }); + + // One case per transformation -- otherwise removing .trim() leaves every test green. + test('trims surrounding whitespace', () => { + expect(downloadFileName(' web-1 ')).toBe('web-1-logs.txt'); + }); +}); + +describe('stripAnsi', () => { + test('removes ANSI escape sequences', () => { + expect(stripAnsi('\u001b[32mok\u001b[0m')).toBe('ok'); + }); + + // docker compose hides and shows the cursor on every progress redraw. '?' is a CSI + // parameter byte, so a digits-and-semicolons pattern leaves these behind -- in exactly + // the output this viewer is built to show. + test('removes private-mode sequences, which is what compose emits', () => { + expect(stripAnsi('\u001b[?25lbuilding\u001b[?25h')).toBe('building'); + expect(stripAnsi('\u001b[?2004hx')).toBe('x'); + }); + + test('leaves text without escape sequences untouched', () => { + expect(stripAnsi('Container web-1 Started')).toBe('Container web-1 Started'); + }); +}); diff --git a/tests/save-close-policy.test.ts b/tests/save-close-policy.test.ts new file mode 100644 index 0000000..8a094d7 --- /dev/null +++ b/tests/save-close-policy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import { saveCloseTiming } from '../src/lib/utils/save-close-policy'; + +// Three separate tests, one per case (not one test with three assertions) -- so a +// failure in one case doesn't hide the other two behind a single aborted test run. +describe('saveCloseTiming', () => { + test('a plain save closes', () => { + expect(saveCloseTiming(false, true)).toBe('close'); + }); + + test('a successful deploy closes, but only after a delay', () => { + expect(saveCloseTiming(true, true)).toBe('close-delayed'); + }); + + test('a failed deploy stays open', () => { + expect(saveCloseTiming(true, false)).toBe('stay-open'); + }); +}); diff --git a/tests/scroll-position.test.ts b/tests/scroll-position.test.ts new file mode 100644 index 0000000..bd6866b --- /dev/null +++ b/tests/scroll-position.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from 'bun:test'; +import { isScrolledToBottom, shouldResetScrollPause } from '../src/lib/utils/scroll-position'; + +describe('isScrolledToBottom', () => { + test('is true when the element is exactly at the end', () => { + expect(isScrolledToBottom(400, 500, 100)).toBe(true); + }); + + test('is true within the default 50px threshold', () => { + expect(isScrolledToBottom(370, 500, 100)).toBe(true); + }); + + // One case at the boundary and one just past it -- otherwise an off-by-one in the + // comparison (< vs <=) leaves every other test green. + test('is true exactly at the threshold and false one pixel past it', () => { + expect(isScrolledToBottom(350, 500, 100)).toBe(true); + expect(isScrolledToBottom(349, 500, 100)).toBe(false); + }); + + test('is false once the user has scrolled well up from the end', () => { + expect(isScrolledToBottom(100, 500, 100)).toBe(false); + }); + + test('accepts a custom threshold', () => { + expect(isScrolledToBottom(390, 500, 100, 5)).toBe(false); + expect(isScrolledToBottom(395, 500, 100, 5)).toBe(true); + }); +}); + +describe('shouldResetScrollPause', () => { + test('lifts the pause when a fresh run starts (false -> true)', () => { + expect(shouldResetScrollPause(false, true)).toBe(true); + }); + + // This is the case that catches the bug reported live: a viewer scrolls up during one + // run, and every later run of the same page session stays paused because nothing ever + // clears it. A naive "reset whenever autoScroll is true" implementation returns true + // here too -- which reads as similar but re-lifts the pause on every update while a run + // is already in progress, defeating the pause entirely. Only the false -> true edge + // (a *new* run starting) may lift it. + test('does not re-lift the pause on every update while a run is already in progress', () => { + expect(shouldResetScrollPause(true, true)).toBe(false); + }); + + test('does nothing when a run ends (true -> false)', () => { + expect(shouldResetScrollPause(true, false)).toBe(false); + }); + + test('does nothing while idle (false -> false)', () => { + expect(shouldResetScrollPause(false, false)).toBe(false); + }); +}); diff --git a/tests/secret-redaction.test.ts b/tests/secret-redaction.test.ts new file mode 100644 index 0000000..eb5cdd5 --- /dev/null +++ b/tests/secret-redaction.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect } from 'bun:test'; +import { redactLine } from '../src/lib/server/secret-redaction'; + +describe('redactLine', () => { + test('replaces a known value exactly', () => { + const line = 'Error: connection to postgres://user:sup3rgeheim-passwort-42@db failed'; + expect(redactLine(line, ['sup3rgeheim-passwort-42'])).toBe( + 'Error: connection to postgres://user:***@db failed' + ); + }); + + test('replaces every occurrence, not just the first', () => { + expect(redactLine('a=tokenwert-lang-genug b=tokenwert-lang-genug', ['tokenwert-lang-genug'])).toBe( + 'a=*** b=***' + ); + }); + + test('withholds the whole line when a secret is too short to replace safely', () => { + // "test" would match inside "latest", "testing", "attest" -- replacing would be nonsense + expect(redactLine('Pulling image alpine:latest', ['test'])).toBeNull(); + }); + + test('leaves a line without any secret unchanged', () => { + const line = 'Container app-1 Started'; + expect(redactLine(line, ['sup3rgeheim-passwort-42'])).toBe(line); + }); + + test('ignores empty and blank secrets', () => { + const line = 'Container app-1 Started'; + expect(redactLine(line, ['', ' '])).toBe(line); + }); +}); diff --git a/tests/sse-fetch-lines.test.ts b/tests/sse-fetch-lines.test.ts new file mode 100644 index 0000000..5e2541e --- /dev/null +++ b/tests/sse-fetch-lines.test.ts @@ -0,0 +1,69 @@ +/** + * readJobResponse's optional line callback — the "live output" hookup used by the UI. + * Mocks global fetch to drive watchJob's polling loop through a scripted sequence of + * job states, without a real server. + * + * Run with: bun test tests/sse-fetch-lines.test.ts + */ + +import { describe, test, expect } from 'bun:test'; +import { readJobResponse } from '../src/lib/utils/sse-fetch'; + +interface JobLine { + event?: string; + data: unknown; +} + +interface JobState { + status: 'running' | 'done' | 'error'; + lines: JobLine[]; + result?: unknown; +} + +// Not production code — a small test double for the /api/jobs/{jobId} polling +// endpoint that watchJob() calls. Returns the given states in order, then keeps +// returning the last one if polled again. +function mockJobPolling(states: JobState[]): typeof fetch { + let call = 0; + return (async () => { + const state = states[Math.min(call, states.length - 1)]; + call++; + return new Response(JSON.stringify({ id: 'x', ...state }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }) as typeof fetch; +} + +describe('readJobResponse with a line callback', () => { + test('reports line messages to the callback', async () => { + const seen: string[] = []; + // Simulate the job poll response: first running with one line, then done with both. + globalThis.fetch = mockJobPolling([ + { status: 'running', lines: [{ event: 'progress', data: { type: 'line', line: 'first' } }] }, + { + status: 'done', + lines: [ + { event: 'progress', data: { type: 'line', line: 'first' } }, + { event: 'progress', data: { type: 'line', line: 'second' } } + ], + result: { success: true } + } + ]); + + await readJobResponse(new Response(JSON.stringify({ jobId: 'x' })), (l) => seen.push(l)); + expect(seen).toEqual(['first', 'second']); + }); + + test('stays unchanged without a callback', async () => { + globalThis.fetch = mockJobPolling([ + { + status: 'done', + lines: [{ event: 'progress', data: { type: 'line', line: 'ignored' } }], + result: { success: true } + } + ]); + const result = await readJobResponse(new Response(JSON.stringify({ jobId: 'x' }))); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/sse-run-recorder.test.ts b/tests/sse-run-recorder.test.ts new file mode 100644 index 0000000..ed78c11 --- /dev/null +++ b/tests/sse-run-recorder.test.ts @@ -0,0 +1,188 @@ +// tests/sse-run-recorder.test.ts +import { describe, expect, test } from 'bun:test'; +import { createJobResponse, type RunRecorder } from '../src/lib/server/sse'; + +/** A fake RunRecorder that records every line() call and resolves `ended` when end() fires. */ +function createFakeRecorder() { + const lines: string[] = []; + let resolveEnded!: (v: { ok: boolean; exitCode?: number; error?: string }) => void; + const ended = new Promise<{ ok: boolean; exitCode?: number; error?: string }>((resolve) => { + resolveEnded = resolve; + }); + const recorder: RunRecorder = { + line(line: string) { + lines.push(line); + }, + addSecrets() { + // Not exercised by this file's tests -- they cover createJobResponse's own + // line()/end() plumbing, not the F4 addSecrets() wiring (see + // deploy-run-record.test.ts and the route-level regression tests for that). + }, + async end(ok: boolean, exitCode?: number, error?: string) { + resolveEnded({ ok, exitCode, error }); + } + }; + return { recorder, lines, ended }; +} + +/** Sends a status line (not recordable), two progress lines (recordable), and an + * unredacted result (never recordable) -- exercises shouldRecord's real filter. */ +const runOperation = async (send: (event: string, data: unknown) => void) => { + send('progress', { status: 'Deploying stack...' }); + send('progress', { type: 'line', line: 'Container web Started' }); + send('progress', { type: 'line', line: 'Container web Healthy' }); + send('result', { success: true, output: 'DB_PASSWORD=hunter2' }); +}; + +describe('createJobResponse RunRecorder hook', () => { + test('records qualifying lines on the JSON path (Accept: application/json)', async () => { + const { recorder, lines, ended } = createFakeRecorder(); + const request = new Request('http://x', { headers: { Accept: 'application/json' } }); + + const response = createJobResponse(runOperation, request, recorder); + await response.json(); + const end = await ended; + + expect(lines).toEqual(['Container web Started', 'Container web Healthy']); + expect(end.ok).toBe(true); + expect(end.error).toBeUndefined(); + }); + + test('records the SAME qualifying lines on the streaming path (no Accept header)', async () => { + const { recorder, lines, ended } = createFakeRecorder(); + const request = new Request('http://x'); + + createJobResponse(runOperation, request, recorder); + await ended; + + expect(lines).toEqual(['Container web Started', 'Container web Healthy']); + }); + + test('both send() implementations feed the recorder identical lines', async () => { + const jsonSide = createFakeRecorder(); + const streamSide = createFakeRecorder(); + + const jsonResponse = createJobResponse( + runOperation, + new Request('http://x', { headers: { Accept: 'application/json' } }), + jsonSide.recorder + ); + await jsonResponse.json(); + await jsonSide.ended; + + createJobResponse(runOperation, new Request('http://x'), streamSide.recorder); + await streamSide.ended; + + expect(streamSide.lines).toEqual(jsonSide.lines); + expect(jsonSide.lines).toEqual(['Container web Started', 'Container web Healthy']); + }); + + test('streaming path: end() reports ok=false with the error message when the operation throws', async () => { + const { recorder, ended } = createFakeRecorder(); + + createJobResponse( + async () => { + throw new Error('boom'); + }, + new Request('http://x'), + recorder + ); + + const end = await ended; + expect(end.ok).toBe(false); + expect(end.error).toBe('boom'); + }); + + test('streaming path: end() reports ok=false with the error message on a failed result that RESOLVES (no throw) -- this is the path every browser call takes, since neither +page.svelte nor StackModal.svelte set an Accept header', async () => { + const { recorder, ended } = createFakeRecorder(); + const request = new Request('http://x'); // no Accept header -> fire-and-forget path + + createJobResponse( + async (send) => { + // A failed docker compose up does NOT throw -- it sends a failed result and + // returns normally. That lands this in the .then() branch below, never .catch(). + send('result', { success: false, error: 'compose up failed: exit 1' }); + }, + request, + recorder + ); + + const end = await ended; + expect(end.ok).toBe(false); + expect(end.error).toBe('compose up failed: exit 1'); + }); + + // Companion to the test above: a resolving (non-throwing) SUCCESS must still end up + // ok=true on the streaming path, so the fix doesn't flip the polarity for the common case. + test('streaming path: end() reports ok=true on a successful result that resolves (no throw)', async () => { + const { recorder, ended } = createFakeRecorder(); + const request = new Request('http://x'); + + createJobResponse( + async (send) => { + send('result', { success: true, output: 'done' }); + }, + request, + recorder + ); + + const end = await ended; + expect(end.ok).toBe(true); + expect(end.error).toBeUndefined(); + }); + + test('JSON path: end() reports ok=false with the error message on a failed result', async () => { + const { recorder, ended } = createFakeRecorder(); + const request = new Request('http://x', { headers: { Accept: 'application/json' } }); + + const response = createJobResponse( + async (send) => { + send('result', { success: false, error: 'compose failed' }); + }, + request, + recorder + ); + await response.json(); + + const end = await ended; + expect(end.ok).toBe(false); + expect(end.error).toBe('compose failed'); + }); + + test('streaming path: a recorder.end() that itself throws is never called twice', async () => { + // Guards RunRecorder's "end() is called exactly once" contract from sse.ts's own + // side: if the .then() branch's recorder.end() call throws, that rejection must + // not cause .catch() to invoke end() again. + let calls = 0; + const recorder: RunRecorder = { + line() {}, + addSecrets() {}, + async end() { + calls++; + throw new Error('recorder failure'); + } + }; + const request = new Request('http://x'); // no Accept header -> fire-and-forget path + + createJobResponse( + async (send) => { + send('result', { success: true, output: 'done' }); + }, + request, + recorder + ); + + // Let the .then()/.catch() microtasks (including the rejected recorder.end() call + // and its handling) settle before asserting. + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(calls).toBe(1); + }); + + test('omitting the recorder changes nothing (backward compat)', async () => { + const request = new Request('http://x', { headers: { Accept: 'application/json' } }); + const response = createJobResponse(runOperation, request); + const body = await response.json(); + expect(body).toEqual({ success: true, output: 'DB_PASSWORD=hunter2' }); + }); +}); diff --git a/tests/stack-compose-redeploy-run-record.test.ts b/tests/stack-compose-redeploy-run-record.test.ts new file mode 100644 index 0000000..f8d1570 --- /dev/null +++ b/tests/stack-compose-redeploy-run-record.test.ts @@ -0,0 +1,427 @@ +/** + * Task 21: `PUT /api/stacks/[name]/compose` (Save & redeploy) must produce the same + * stack_deploy schedule_executions row + log file the dedicated deploy endpoint + * (deploy/+server.ts, Task 10 / deploy-run-record.ts) already produces. + * + * Before this task, restart:true saved the compose file and redeployed WITHOUT ever + * building a run recorder: the deploy ran, but afterwards no row and no log file + * existed. Found live against the running test instance (task-21-brief.md), not by a + * failing test -- deploy-stack-online.test.ts's own doc comment already notes routes + * silently ship without wiring like this, invisibly, because nothing fails when it's + * missing. + * + * This calls the route's PUT handler DIRECTLY (composeRoute.PUT), never through any + * router/dispatch layer -- so "this test accidentally exercises the deploy endpoint + * instead" is structurally impossible; the object under test IS this file's export. + * What every "row created" assertion is instead paired with is a restart:false + * counterpart proving the SAME fakes yield ZERO rows when there is no deploy -- so a + * spurious pass can't hide behind lenient fakes that always produce a row regardless + * of what was actually requested. + * + * $lib/server/db and $lib/server/authorize are faked via the shared helpers -- see + * their doc comments for why a second direct mock.module() call for either would + * collide with tests/deploy-endpoints.test.ts, which already owns both specifiers for + * this process (mock.module() replaces a module's exports WHOLESALE for the entire + * test run, and Bun freezes the exported shape at first resolution). + * + * $lib/server/stacks is faked via tests/helpers/stacks-fake.ts, the same shared, + * collision-safe registration point db-fake.ts/authorize-fake.ts already use for + * their specifiers -- like db.ts it pulls in better-sqlite3 (ERR_DLOPEN_FAILED under + * Bun), so it can't be imported for real here either. This used to be a direct + * mock.module() call in this file; it moved into the shared helper once a second + * file (tests/git-stack-deploy-run-record.test.ts) needed to fake $lib/server/stacks + * too -- exactly the situation this doc comment originally predicted. + * + * (A second consumer for this fake was considered -- POST /api/stacks/+server.ts -- + * but that route also drags in $lib/server/docker -> hawser.ts -> ./db/drizzle.js, a + * SEPARATE specifier from $lib/server/db that touches better-sqlite3 directly and + * can't be faked the same way. That route's build/pull/forceRecreate coverage is a + * source-level check instead, see stack-create-start-build-options.test.ts.) + * + * The dedicated deploy endpoint (deploy/+server.ts) is deliberately NOT imported here + * to double-check its own recording: it additionally pulls in $lib/server/audit -> + * $lib/server/license -> $lib/server/notifications/index.ts, none of which this task + * touches, and faking that whole chain just to re-prove an unrelated route wasn't + * broken would be its own maintenance burden. The "still records exactly once, not + * two" check for that route is done at the SOURCE level instead, at the bottom of this + * file -- deploy/+server.ts is untouched by this task's diff, so a source-level guard + * is sufficient evidence, not a shortcut. + */ +import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test'; +import { mkdtemp, rm, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { registerDbFake } from './helpers/db-fake'; +import { registerAuthorizeFake } from './helpers/authorize-fake'; +import { registerStacksFake } from './helpers/stacks-fake'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// -- $lib/server/authorize: always permitted (auth disabled) ---------------- + +registerAuthorizeFake(async () => ({ + authEnabled: false, + isAuthenticated: true, + isEnterprise: false, + can: async () => true, + canAccessEnvironment: async () => true, + // upstream/main added this gate (requireEnvAccess) to compose/+server.ts after this + // fake was written; authEnabled is false here, so the real implementation would be a + // no-op (see src/lib/server/authorize.ts) -- mirror that instead of leaving it undefined. + requireEnvAccess: async () => null +})); + +// -- $lib/server/db: an in-memory schedule_executions fake ------------------- + +let created: Array>; +let updates: Array>; +let nextId: number; + +function resetDbState() { + created = []; + updates = []; + nextId = 1; +} +resetDbState(); + +registerDbFake('createScheduleExecution', async (data: Record) => { + const row = { + id: nextId++, + ...data, + triggeredAt: new Date().toISOString(), + startedAt: null, + completedAt: null, + duration: null, + errorMessage: null, + details: data.details ?? null, + logs: null, + createdAt: null + }; + created.push(row); + return row; +}); +registerDbFake('updateScheduleExecution', async (id: number, data: Record) => { + updates.push({ id, ...data }); + return { id, ...data }; +}); + +/** The update() call that CLOSED the row (status !== 'running'), i.e. the one written + * by DeployRunRecorder.end() -- as opposed to createRunRecorder's own startedAt + * update. Same helper shape as tests/deploy-run-record.test.ts's endUpdate(). */ +function endUpdate() { + return updates.find((u) => u.status !== undefined); +} + +// -- Real deploy-log-store against a throwaway DATA_DIR ---------------------- +// +// DATA_DIR is process-global and read at call time by every other test file too +// (tests/fs-guard.test.ts's doc comment, referenced by tests/deploy-endpoints.test.ts) +// -- save and restore the prior value so this file leaves no trace once it finishes. + +const previousDataDir = process.env.DATA_DIR; +let dataDir: string; +beforeAll(async () => { + dataDir = await mkdtemp(join(tmpdir(), 'dv-t21-deploy-logs-')); + process.env.DATA_DIR = dataDir; +}); +afterAll(async () => { + await rm(dataDir, { recursive: true, force: true }); + if (previousDataDir === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = previousDataDir; + } +}); + +const { readRunLog, deleteRunLog } = await import('../src/lib/server/deploy-log-store'); +const { hashComposeContent, hashEnvFingerprint } = await import('../src/lib/server/deploy-run-record-core'); + +// -- $lib/server/stacks: mocked WHOLESALE ------------------------------------ + +let saveCalls: Array<{ name: string; content: string; envId: number | null | undefined }>; +let requireComposeResult: Record; +let deployStackCalls: Array<{ onLine?: (line: string) => void; build?: boolean; pullPolicy?: string; forceRecreate?: boolean }>; +let deployStackResult: { success: boolean; output?: string; error?: string; resolvedSecrets?: string[] }; +let deployStackOnLines: string[]; + +function resetStacksState() { + saveCalls = []; + deployStackCalls = []; + deployStackResult = { success: true, output: 'deployed' }; + deployStackOnLines = ['Container demo-stack-app-1 Started']; + requireComposeResult = { + success: true, + content: 'services:\n app:\n image: demo:1\n', + nonSecretVars: { PUBLIC_VAR: 'v1' }, + secretVars: { DB_PASSWORD: 's3cr3t-value' }, + stackDir: '/tmp/demo-stack', + composePath: '/tmp/demo-stack/compose.yaml', + envPath: '/tmp/demo-stack/.env', + sourceType: 'internal' + }; +} +resetStacksState(); + +registerStacksFake('getStackComposeFile', async () => { + throw new Error('getStackComposeFile: not exercised here -- only the GET handler uses it, this suite only calls PUT'); +}); +registerStacksFake('saveStackComposeFile', async (name: string, content: string, _create: boolean, envId?: number | null) => { + saveCalls.push({ name, content, envId }); + return { success: true }; +}); +registerStacksFake('requireComposeFile', async (_name: string, _envId?: number | null) => requireComposeResult); +registerStacksFake('deployStack', async (options: { onLine?: (line: string) => void; build?: boolean; pullPolicy?: string; forceRecreate?: boolean }) => { + deployStackCalls.push(options); + for (const line of deployStackOnLines) options.onLine?.(line); + return deployStackResult; +}); + +// -- Route under test, imported AFTER all the fakes above are registered ---- + +const composeRoute = await import('../src/routes/api/stacks/[name]/compose/+server'); + +// -- Fixtures ------------------------------------------------------------------ + +const STACK = 'demo-stack'; + +/** `Accept: application/json` forces createJobResponse's synchronous JSON path + * (sse-parser.ts prefersJSON()), so the returned Response only resolves once the + * whole operation -- deploy AND recorder.end() -- has actually finished. Without it + * the "fire and forget" path would return {jobId} before the row closes, and every + * assertion below would race the background completion. */ +function makeComposeEvent(body: Record) { + const request = new Request('http://x/', { + method: 'PUT', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(body) + }); + return { + params: { name: STACK }, + request, + url: new URL('http://x/'), + cookies: { get: () => undefined } as any + } as any; +} + +beforeEach(async () => { + resetDbState(); + resetStacksState(); + // nextId always restarts at 1 above, so every test's (at most one) row reuses + // runId '1' -- clear its log file so tests don't leak content into each other via + // the shared DATA_DIR (same precaution tests/deploy-endpoints.test.ts documents). + await deleteRunLog(null, '1'); +}); + +describe('PUT /api/stacks/[name]/compose -- restart:true (Save & redeploy)', () => { + test('creates exactly one stack_deploy schedule_executions row, closed as success, with a readable log', async () => { + const content = 'services:\n app:\n image: demo:2\n'; + + const res = await composeRoute.PUT(makeComposeEvent({ content, restart: true })); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + + expect(created).toHaveLength(1); + expect(created[0].scheduleType).toBe('stack_deploy'); + expect(created[0].scheduleId).toBe(0); + expect(created[0].entityName).toBe(STACK); + expect(created[0].triggeredBy).toBe('manual'); + + const update = endUpdate(); + expect(update?.status).toBe('success'); + expect(update?.errorMessage).toBeNull(); + + const runId = String(created[0].id); + const log = await readRunLog(null, runId); + expect(log).toContain('Container demo-stack-app-1 Started'); + }); + + test('composeHash is derived from the just-SUBMITTED content, not from requireComposeFile()\'s (possibly stale) content', async () => { + const submitted = 'services:\n app:\n image: demo:3\n'; + // Deliberately different from `submitted` -- if the route ever hashed + // composeInfo.content instead of the request body's `content`, this test + // would catch it: the stored hash would match THIS string, not `submitted`. + requireComposeResult = { ...requireComposeResult, content: 'STALE-CONTENT-NEVER-HASHED' }; + + // Draining the body (.json()) is required, not cosmetic: createJobResponse's JSON + // path only finishes recorder.end() inside the ReadableStream's start(), which + // only runs to completion once the stream is actually consumed. + await (await composeRoute.PUT(makeComposeEvent({ content: submitted, restart: true }))).json(); + + const update = endUpdate(); + const details = update?.details as { composeHash: string }; + expect(details.composeHash).toBe(hashComposeContent(submitted)); + expect(details.composeHash).not.toBe(hashComposeContent('STALE-CONTENT-NEVER-HASHED')); + }); + + test('envHash covers the MERGE of non-secret and secret vars, same shape deploy/+server.ts already uses', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json(); + + const update = endUpdate(); + const details = update?.details as { envHash: string }; + const expected = hashEnvFingerprint({ PUBLIC_VAR: 'v1', DB_PASSWORD: 's3cr3t-value' }); + expect(details.envHash).toBe(expected); + }); + + test('a secret value in the deploy error text is redacted before it is stored (the envHash-covered vars double as the redaction list)', async () => { + deployStackResult = { success: false, error: 'compose up failed: DB_PASSWORD=s3cr3t-value rejected' }; + deployStackOnLines = []; + + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json(); + + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain('s3cr3t-value'); + }); + + // F4 fix: deployStack() resolves a bound secret provider's values INTERNALLY, after + // the route's `recorder` above was already built from requireComposeResult's DB-only + // secretVars/nonSecretVars. A provider-resolved secret (never in that DB-only set) is + // simulated via the fake's resolvedSecrets -- exactly StackOperationResult's shape -- + // and must still be redacted from the stored error, via recorder.addSecrets(). + test('F4 regression: a provider-resolved secret (unknown at recorder construction) is redacted before it is stored', async () => { + const PROVIDER_SECRET = 'zzz-bulk-provider-7000'; + deployStackResult = { + success: false, + error: `compose up failed: PROVIDER_TOKEN=${PROVIDER_SECRET} rejected`, + resolvedSecrets: [PROVIDER_SECRET] + }; + deployStackOnLines = []; + + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json(); + + const update = endUpdate(); + expect(update?.status).toBe('failed'); + expect(String(update?.errorMessage)).not.toContain(PROVIDER_SECRET); + }); + + test('a failed deploy still closes the row as "failed", never left "running"', async () => { + deployStackResult = { success: false, error: 'exit code 1' }; + deployStackOnLines = []; + + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json(); + + expect(created).toHaveLength(1); + const update = endUpdate(); + expect(update?.status).toBe('failed'); + }); +}); + +/** + * Before this fix, restart:true ALWAYS deployed with build:false and no pullPolicy, + * regardless of what the caller sent -- a stack whose compose declares a `build:` + * section silently never rebuilt on "Save & redeploy", the saved compose change just + * ran against the stale local image. These assertions are made at the deployStack() + * CALL, not the HTTP response's success flag: a response can be {success:true} even + * when build/pull were dropped on the floor (that IS the bug this fixes), so + * status:200 alone proves nothing here. + */ +describe('PUT /api/stacks/[name]/compose -- restart:true build/pull/forceRecreate options', () => { + test('build:true in the request body reaches deployStack({build:true})', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, build: true }))).json(); + + expect(deployStackCalls).toHaveLength(1); + expect(deployStackCalls[0].build).toBe(true); + }); + + test('omitting build deploys with build:false -- an explicit opt-out, not a broken default', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json(); + + expect(deployStackCalls[0].build).toBe(false); + }); + + test('pull:true is translated to pullPolicy:"always" -- deployStack has no separate pull flag, only pullPolicy', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, pull: true }))).json(); + + expect(deployStackCalls[0].pullPolicy).toBe('always'); + }); + + test('pull:false leaves pullPolicy undefined (not "never" or "missing") -- deployStack treats a missing pullPolicy as "do not pull", which is also why it skips its own post-deploy pending-update badge reconciliation in that case (stacks.ts, untouched by this task)', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, pull: false }))).json(); + + expect(deployStackCalls[0].pullPolicy).toBeUndefined(); + }); + + test('forceRecreate:false is honored -- previously this branch hardcoded true unconditionally, so opting out was impossible', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, forceRecreate: false }))).json(); + + expect(deployStackCalls[0].forceRecreate).toBe(false); + }); + + test('omitting forceRecreate entirely still force-recreates -- preserves the prior hardcoded behavior for a caller that has not been updated to send it explicitly (env var changes need --force-recreate to take effect)', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true }))).json(); + + expect(deployStackCalls[0].forceRecreate).toBe(true); + }); + + test('all three options are recorded on the run (details.options), matching what deployStack actually ran with -- not just passed through in memory', async () => { + await (await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: true, pull: true, build: true, forceRecreate: false }))).json(); + + const update = endUpdate(); + const details = update?.details as { options?: { pull: boolean; build: boolean; forceRecreate: boolean } } | undefined; + expect(details?.options).toEqual({ pull: true, build: true, forceRecreate: false }); + }); +}); + +describe('PUT /api/stacks/[name]/compose -- restart:false (save only, no deploy)', () => { + test('creates NO schedule_executions row -- a save without a deploy must not become a run', async () => { + const res = await composeRoute.PUT(makeComposeEvent({ content: 'x', restart: false })); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + + expect(saveCalls).toHaveLength(1); // the save itself DID happen + expect(deployStackCalls).toHaveLength(0); // but no deploy + expect(created).toHaveLength(0); // and therefore no run record either + expect(updates).toHaveLength(0); + }); + + test('omitting restart entirely (defaults to false) behaves identically', async () => { + const res = await composeRoute.PUT(makeComposeEvent({ content: 'x' })); + expect(res.status).toBe(200); + expect(created).toHaveLength(0); + expect(deployStackCalls).toHaveLength(0); + }); +}); + +describe('src/routes/api/stacks/[name]/deploy/+server.ts -- untouched by this task', () => { + test('still calls createRunRecorder exactly once (source-level regression guard against double-recording)', async () => { + const source = await readFile( + join(here, '..', 'src', 'routes', 'api', 'stacks', '[name]', 'deploy', '+server.ts'), + 'utf8' + ); + const matches = source.match(/createRunRecorder\(/g) ?? []; + expect(matches).toHaveLength(1); + }); +}); + +/** + * F4 fix source-level guards. deploy/+server.ts and POST /api/stacks/+server.ts are + * not exercised as real integration tests in this file (see this file's top doc + * comment for why: the former pulls in $lib/server/audit -> ...license -> + * ...notifications, the latter pulls in $lib/server/docker -> hawser.ts -> + * ./db/drizzle.js -- neither faked here). A source-level guard cannot prove the wiring + * REDACTS correctly (that's what the real integration test above proves, for + * compose/+server.ts, which shares the exact same pattern) -- it only proves the call + * exists exactly once, so a future refactor can't silently drop it back out. It cannot + * distinguish a real call from the same text inside a comment or string; the call + * itself deliberately never appears verbatim in comments in either file, to keep this + * guard meaningful. + */ +describe('F4 fix -- recorder.addSecrets() wiring exists on the routes not covered by a real integration test here', () => { + test('deploy/+server.ts calls recorder.addSecrets(result.resolvedSecrets ?? []) exactly once, before recorder.end()', async () => { + const source = await readFile( + join(here, '..', 'src', 'routes', 'api', 'stacks', '[name]', 'deploy', '+server.ts'), + 'utf8' + ); + const matches = source.match(/recorder\.addSecrets\(/g) ?? []; + expect(matches).toHaveLength(1); + expect(source.indexOf('recorder.addSecrets(')).toBeLessThan(source.indexOf('}, event.request, recorder)')); + }); + + test('POST /api/stacks/+server.ts calls recorder.addSecrets(result.resolvedSecrets ?? []) exactly once, before recorder.end()', async () => { + const source = await readFile(join(here, '..', 'src', 'routes', 'api', 'stacks', '+server.ts'), 'utf8'); + const matches = source.match(/recorder\.addSecrets\(/g) ?? []; + expect(matches).toHaveLength(1); + expect(source.indexOf('recorder.addSecrets(')).toBeLessThan(source.indexOf('}, request, recorder)')); + }); +}); diff --git a/tests/stack-create-start-build-options.test.ts b/tests/stack-create-start-build-options.test.ts new file mode 100644 index 0000000..60fef8a --- /dev/null +++ b/tests/stack-create-start-build-options.test.ts @@ -0,0 +1,81 @@ +/** + * Before this fix, POST /api/stacks (Create & Start) ALWAYS deployed with + * build:false and no pullPolicy, regardless of what the caller sent -- a stack whose + * compose declares a `build:` section silently never built on its first start. + * + * This is a SOURCE-LEVEL check, not a route-level one, for the same reason + * tests/stack-compose-redeploy-run-record.test.ts's own doc comment gives for NOT + * importing the dedicated deploy endpoint: importing this route for real pulls in + * $lib/server/audit -> $lib/server/license -> $lib/server/notifications/index.ts for + * the auditStack() call this route makes -- and, worse, $lib/server/docker -> + * hawser.ts -> `./db/drizzle.js`, a SEPARATE module specifier from $lib/server/db + * that instantiates a real better-sqlite3 connection at import time (confirmed + * empirically: faking $lib/server/db alone is not enough, the drizzle specifier + * throws ERR_DLOPEN_FAILED under Bun regardless). Faking that whole chain just to + * prove three destructured request fields reach one function call would be a much + * bigger maintenance burden than the guard itself is worth. + * + * What IS covered by a real route-level test: the identical pull/build/forceRecreate + * plumbing in PUT /api/stacks/[name]/compose (Save & redeploy), which has no such + * import chain -- see the "restart:true build/pull/forceRecreate options" describe + * block in stack-compose-redeploy-run-record.test.ts. Both routes were changed + * together, from the same requestBody shape RedeployPopover.svelte now sends; this + * file's job is only to catch this route's copy of that change regressing + * independently of the other. + */ +import { describe, test, expect } from 'bun:test'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const routePath = join(here, '..', 'src', 'routes', 'api', 'stacks', '+server.ts'); + +async function readRoute(): Promise { + return readFile(routePath, 'utf8'); +} + +describe('POST /api/stacks -- build/pull/forceRecreate options (source-level)', () => { + test('the request body destructuring includes pull, build, and forceRecreate', async () => { + const source = await readRoute(); + // Single destructuring statement for the whole POST body -- match it as one + // block so a future refactor that drops one of the three fields (while keeping + // the others) still fails this test, instead of three independent substring + // checks that could each pass against three DIFFERENT destructuring statements. + const match = source.match(/const\s*\{([^}]*)\}\s*=\s*body;/); + expect(match).not.toBeNull(); + const fields = match![1]; + expect(fields).toContain('pull'); + expect(fields).toContain('build'); + expect(fields).toContain('forceRecreate'); + }); + + test('deployStack() is called with build and pullPolicy derived from the request, not hardcoded', async () => { + const source = await readRoute(); + const deployCall = source.match(/const result = await deployStack\(\{[\s\S]*?\}\);/); + expect(deployCall).not.toBeNull(); + const body = deployCall![0]; + + // build: must reference the coerced request value, not a literal false -- + // `build: false` would match `build:` alone, so anchor on the variable name. + expect(body).toMatch(/build:\s*buildOpt/); + // pullPolicy must be conditional on the request's pull flag, not omitted or a + // hardcoded 'always'/undefined -- the ternary is the actual translation from + // deployOptions.pull (boolean) to deployStack's pullPolicy (string | undefined). + expect(body).toMatch(/pullPolicy:\s*pullOpt\s*\?\s*'always'\s*:\s*undefined/); + expect(body).toMatch(/forceRecreate:\s*forceRecreateOpt/); + }); + + test('buildOpt/pullOpt/forceRecreateOpt are coerced booleans of the destructured request fields, not the raw (possibly undefined/truthy-string) body values', async () => { + const source = await readRoute(); + expect(source).toMatch(/const\s+pullOpt\s*=\s*!!pull;/); + expect(source).toMatch(/const\s+buildOpt\s*=\s*!!build;/); + expect(source).toMatch(/const\s+forceRecreateOpt\s*=\s*!!forceRecreate;/); + }); + + test('the run recorder options match what deployStack is actually called with -- the same three variables, not separately hardcoded values that could drift from the deploy call', async () => { + const source = await readRoute(); + const recorderCall = source.match(/options:\s*\{\s*pull:\s*pullOpt,\s*build:\s*buildOpt,\s*forceRecreate:\s*forceRecreateOpt\s*\}/); + expect(recorderCall).not.toBeNull(); + }); +}); diff --git a/tests/stacks-collect-process.test.ts b/tests/stacks-collect-process.test.ts new file mode 100644 index 0000000..69444e6 --- /dev/null +++ b/tests/stacks-collect-process.test.ts @@ -0,0 +1,25 @@ +import { describe, test, expect } from 'bun:test'; +import { spawn } from 'child_process'; +import { collectProcess } from '../src/lib/server/process-output-core'; + +describe('collectProcess with a line callback', () => { + test('emits each line separately and in order', async () => { + const lines: string[] = []; + const child = spawn('sh', ['-c', 'printf "eins\\nzwei\\ndrei\\n" >&2']); + await collectProcess(child, (line) => lines.push(line)); + expect(lines).toEqual(['eins', 'zwei', 'drei']); + }); + + test('emits a trailing line that has no newline', async () => { + const lines: string[] = []; + const child = spawn('sh', ['-c', 'printf "ohne-umbruch" >&2']); + await collectProcess(child, (line) => lines.push(line)); + expect(lines).toEqual(['ohne-umbruch']); + }); + + test('behaves exactly as before when no callback is given', async () => { + const child = spawn('sh', ['-c', 'printf "unveraendert\\n" >&2']); + const result = await collectProcess(child); + expect(result.stderr).toContain('unveraendert'); + }); +}); diff --git a/tests/stacks-compat.test.ts b/tests/stacks-compat.test.ts new file mode 100644 index 0000000..b716e98 --- /dev/null +++ b/tests/stacks-compat.test.ts @@ -0,0 +1,34 @@ +import { describe, test, expect } from 'bun:test'; +import { surfaceBlockIfNoLines } from '../src/lib/server/secret-redaction'; + +describe('surfaceBlockIfNoLines (old-agent compatibility)', () => { + test('reports the response block as one line when no lines arrived', () => { + const seen: string[] = []; + surfaceBlockIfNoLines((line) => seen.push(line), 0, 'Container probe-app-1 Started', []); + expect(seen).toEqual(['Container probe-app-1 Started']); + }); + + test('does not report the block again once lines already arrived', () => { + const seen: string[] = ['first', 'second']; + surfaceBlockIfNoLines((line) => seen.push(line), seen.length, 'first\nsecond', []); + expect(seen).toEqual(['first', 'second']); + }); + + test('does nothing without an onLine callback', () => { + expect(() => surfaceBlockIfNoLines(undefined, 0, 'some output', [])).not.toThrow(); + }); + + test('does nothing when the block is empty or undefined', () => { + const seen: string[] = []; + surfaceBlockIfNoLines((line) => seen.push(line), 0, '', []); + surfaceBlockIfNoLines((line) => seen.push(line), 0, undefined, []); + expect(seen).toEqual([]); + }); + + test('redacts secrets in the surfaced block, same as a streamed line', () => { + const seen: string[] = []; + const secret = 'sup3rsecret-password-42'; + surfaceBlockIfNoLines((line) => seen.push(line), 0, `Error: password=${secret} rejected`, [secret]); + expect(seen).toEqual(['Error: password=*** rejected']); + }); +}); diff --git a/tests/stacks-hawser-lines.test.ts b/tests/stacks-hawser-lines.test.ts new file mode 100644 index 0000000..f06c4c8 --- /dev/null +++ b/tests/stacks-hawser-lines.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for line consumption on the Hawser compose path. A new agent sends its output as + * 'stream' messages while the command runs AND still returns the whole block in its final + * response. Only one of the two may reach the operator: the lines win, the block is the + * fallback for an agent that sends none. Without that rule the operator sees everything twice. + * + * executeComposeViaHawser cannot be imported here -- stacks.ts pulls in db/drizzle -> + * better-sqlite3, which bun's test runner cannot load, and a failed import poisons unrelated + * tests in the same process (see stacks-collect-process.test.ts for the same constraint). + * What it composes is testable, so this wires those pieces the way stacks.ts wires them: the + * real message dispatcher, base64 decode included, feeding the real sink. + */ + +import { describe, expect, test } from 'bun:test'; +import { makeRedactedLineSink } from '../src/lib/server/secret-redaction.js'; +import { dispatchStreamMessage, type StreamDispatchTarget } from '../src/lib/server/hawser-core.js'; + +const REQUEST_ID = 'compose-1'; + +/** + * Stands in for a Hawser agent: each line arrives as a 'stream' message carrying base64 data + * (Go marshals []byte that way), then the response arrives with the final output block. + */ +function runFakeAgent( + sink: ReturnType, + agent: { lines: string[]; output?: string } +): void { + const connection: StreamDispatchTarget = { + pendingStreamRequests: new Map(), + lineHandlers: new Map() + }; + if (sink.forward) connection.lineHandlers!.set(REQUEST_ID, sink.forward); + + for (const line of agent.lines) { + dispatchStreamMessage(connection, { + requestId: REQUEST_ID, + data: Buffer.from(line).toString('base64') + }); + } + + // The 'response' case in hawser.ts drops the handler once the request is done. + connection.lineHandlers!.delete(REQUEST_ID); + sink.surfaceBlock(agent.output); +} + +describe('compose lines from a Hawser agent', () => { + test('forwards lines from a new agent redacted', () => { + const seen: string[] = []; + const secret = 'sup3rsecret-password-42'; + const sink = makeRedactedLineSink((l) => seen.push(l), [secret]); + + runFakeAgent(sink, { lines: [`password=${secret}`, 'Container app-1 Started'] }); + + expect(seen.join('\n')).not.toContain(secret); + expect(seen).toEqual(['password=***', 'Container app-1 Started']); + }); + + test('does not append the output block on top of the streamed lines', () => { + const seen: string[] = []; + const sink = makeRedactedLineSink((l) => seen.push(l), []); + + // A new agent sends both: the lines as they happen, plus the whole block at the end. + runFakeAgent(sink, { + lines: ['Container app-1 Recreate', 'Container app-1 Started'], + output: 'Container app-1 Recreate\nContainer app-1 Started' + }); + + expect(seen).toEqual(['Container app-1 Recreate', 'Container app-1 Started']); + }); + + test('surfaces the block when an old agent sends no lines at all', () => { + const seen: string[] = []; + const sink = makeRedactedLineSink((l) => seen.push(l), []); + + runFakeAgent(sink, { lines: [], output: 'Container app-1 Started' }); + + expect(seen).toEqual(['Container app-1 Started']); + }); + + test('surfaces the block when every line was withheld by redaction', () => { + const seen: string[] = []; + // Shorter than MIN_REPLACEABLE_LENGTH, so the whole line is withheld rather than patched. + const sink = makeRedactedLineSink((l) => seen.push(l), ['pw123']); + + runFakeAgent(sink, { lines: ['token=pw123'], output: 'Container app-1 Started' }); + + // A withheld line never reached the operator, so it must not count as one seen -- + // otherwise this run would show nothing at all. + expect(seen).toEqual(['Container app-1 Started']); + }); +}); diff --git a/tests/stacks-line-forwarding.test.ts b/tests/stacks-line-forwarding.test.ts new file mode 100644 index 0000000..c895e92 --- /dev/null +++ b/tests/stacks-line-forwarding.test.ts @@ -0,0 +1,27 @@ +import { describe, test, expect } from 'bun:test'; +import { makeLineForwarder } from '../src/lib/server/secret-redaction'; + +describe('line forwarding with redaction', () => { + test('forwards lines redacted, not raw', async () => { + const seen: string[] = []; + const secret = 'sup3rsecret-password-42'; + + // makeLineForwarder is the small bridge between collectProcess and send() + const forward = makeLineForwarder(seen.push.bind(seen), [secret]); + forward(`Error: password=${secret} rejected`); + forward('Container app-1 Started'); + + expect(seen).toEqual([ + 'Error: password=*** rejected', + 'Container app-1 Started' + ]); + expect(seen.join('\n')).not.toContain(secret); + }); + + test('drops a withheld line entirely', () => { + const seen: string[] = []; + const forward = makeLineForwarder(seen.push.bind(seen), ['test']); + forward('Pulling alpine:latest'); + expect(seen).toEqual([]); + }); +});