diff --git a/devops/compose/docker-compose.stella-ops.yml b/devops/compose/docker-compose.stella-ops.yml index 1f024fd8c..1ca8c6a9f 100644 --- a/devops/compose/docker-compose.stella-ops.yml +++ b/devops/compose/docker-compose.stella-ops.yml @@ -439,7 +439,8 @@ services: STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ACKTOKENS__KEYPATH: "/app/etc/authority/keys/ack-token-dev.pem" STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__WEBHOOKS__ALLOWEDHOSTS__0: "notify.stella-ops.local" STELLAOPS_AUTHORITY_AUTHORITY__NOTIFICATIONS__ESCALATION__SCOPE: "notify.escalate" - STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED: "false" + STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__ENABLED: "${AUTHORITY_BOOTSTRAP_ENABLED:-true}" + STELLAOPS_AUTHORITY_AUTHORITY__BOOTSTRAP__APIKEY: "${AUTHORITY_BOOTSTRAP_APIKEY:-stellaops-dev-bootstrap-key}" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINDIRECTORIES__0: "/app" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__CONFIGURATIONDIRECTORY: "/app/etc/authority/plugins" STELLAOPS_AUTHORITY_AUTHORITY__PLUGINS__DESCRIPTORS__standard__Type: "standard" diff --git a/devops/compose/postgres-init/14-platform-environment-settings.sql b/devops/compose/postgres-init/14-platform-environment-settings.sql new file mode 100644 index 000000000..3d7e1e30a --- /dev/null +++ b/devops/compose/postgres-init/14-platform-environment-settings.sql @@ -0,0 +1,20 @@ +-- Platform environment_settings table for setup state and runtime config overrides. +-- Used by SetupStateDetector to determine if setup wizard has been completed. +-- This is idempotent and safe to run on new compose databases. + +CREATE SCHEMA IF NOT EXISTS platform; + +CREATE TABLE IF NOT EXISTS platform.environment_settings ( + key VARCHAR(256) NOT NULL, + value TEXT NOT NULL, + tenant_id VARCHAR(128) NOT NULL DEFAULT '_system', + updated_by VARCHAR(256) NOT NULL DEFAULT 'system', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, key) +); + +-- Mark setup as complete for fresh installs (docker-compose local dev). +-- The setup wizard can re-run and overwrite this if needed. +INSERT INTO platform.environment_settings (key, value, tenant_id, updated_by) +VALUES ('SetupComplete', 'true', '_system', 'postgres-init') +ON CONFLICT (tenant_id, key) DO NOTHING; diff --git a/devops/compose/postgres-init/15-platform-context-and-releases.sql b/devops/compose/postgres-init/15-platform-context-and-releases.sql new file mode 100644 index 000000000..c922002a8 --- /dev/null +++ b/devops/compose/postgres-init/15-platform-context-and-releases.sql @@ -0,0 +1,88 @@ +-- Platform context tables (regions, environments, preferences) and release control bundles. +-- Required for the global context/filter UI and release management read models. +-- Idempotent: uses IF NOT EXISTS and ON CONFLICT. + +CREATE SCHEMA IF NOT EXISTS platform; +CREATE SCHEMA IF NOT EXISTS release; +CREATE SCHEMA IF NOT EXISTS release_app; + +-- Helper function +CREATE OR REPLACE FUNCTION release.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Context regions +CREATE TABLE IF NOT EXISTS platform.context_regions ( + region_id text PRIMARY KEY, + display_name text NOT NULL, + sort_order integer NOT NULL, + enabled boolean NOT NULL DEFAULT true +); +CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_context_regions_sort + ON platform.context_regions (sort_order, region_id); + +-- Context environments +CREATE TABLE IF NOT EXISTS platform.context_environments ( + environment_id text PRIMARY KEY, + region_id text NOT NULL REFERENCES platform.context_regions(region_id) ON DELETE RESTRICT, + environment_type text NOT NULL, + display_name text NOT NULL, + sort_order integer NOT NULL, + enabled boolean NOT NULL DEFAULT true +); +CREATE INDEX IF NOT EXISTS ix_platform_context_environments_region_sort + ON platform.context_environments (region_id, sort_order, environment_id); +CREATE INDEX IF NOT EXISTS ix_platform_context_environments_sort + ON platform.context_environments (sort_order, region_id, environment_id); + +-- UI context preferences (per-user filter state) +CREATE TABLE IF NOT EXISTS platform.ui_context_preferences ( + tenant_id text NOT NULL, + actor_id text NOT NULL, + regions text[] NOT NULL DEFAULT ARRAY[]::text[], + environments text[] NOT NULL DEFAULT ARRAY[]::text[], + time_window text NOT NULL DEFAULT '24h', + updated_at timestamptz NOT NULL DEFAULT now(), + updated_by text NOT NULL DEFAULT 'system', + PRIMARY KEY (tenant_id, actor_id) +); +CREATE INDEX IF NOT EXISTS ix_platform_ui_context_preferences_updated + ON platform.ui_context_preferences (updated_at DESC, tenant_id, actor_id); + +-- Release control bundles +CREATE TABLE IF NOT EXISTS release.control_bundles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by TEXT NOT NULL DEFAULT 'system', + CONSTRAINT uq_control_bundles_tenant_slug UNIQUE (tenant_id, slug) +); +CREATE INDEX IF NOT EXISTS idx_control_bundles_tenant_name + ON release.control_bundles (tenant_id, name, id); +CREATE INDEX IF NOT EXISTS idx_control_bundles_tenant_updated + ON release.control_bundles (tenant_id, updated_at DESC, id); + +-- Seed demo context data for local development +INSERT INTO platform.context_regions (region_id, display_name, sort_order, enabled) +VALUES + ('us-east', 'US East', 1, true), + ('us-west', 'US West', 2, true), + ('eu-west', 'EU West', 3, true) +ON CONFLICT (region_id) DO NOTHING; + +INSERT INTO platform.context_environments (environment_id, region_id, environment_type, display_name, sort_order, enabled) +VALUES + ('dev', 'us-east', 'development', 'Development', 1, true), + ('stage', 'us-east', 'staging', 'Staging', 2, true), + ('prod-us-east', 'us-east', 'production', 'Production US East', 3, true), + ('prod-us-west', 'us-west', 'production', 'Production US West', 4, true), + ('prod-eu-west', 'eu-west', 'production', 'Production EU West', 5, true) +ON CONFLICT (environment_id) DO NOTHING; diff --git a/devops/compose/postgres-init/16-release-full-schema.sql b/devops/compose/postgres-init/16-release-full-schema.sql new file mode 100644 index 000000000..d6b2fe8be --- /dev/null +++ b/devops/compose/postgres-init/16-release-full-schema.sql @@ -0,0 +1,55 @@ +-- Release module full schema bootstrap for local dev compose. +-- Includes schemas, tenants, integration hub, environments, release management, +-- workflow, promotion, deployment, agents, trust/signing, and read models. +-- All statements are idempotent (IF NOT EXISTS / ON CONFLICT). + +-- Shared tenants (required by release.integrations FK) +CREATE SCHEMA IF NOT EXISTS shared; +CREATE TABLE IF NOT EXISTS shared.tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + display_name TEXT, + status TEXT NOT NULL DEFAULT 'active', + settings JSONB NOT NULL DEFAULT '{}', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Seed shared tenant for local dev +INSERT INTO shared.tenants (tenant_id, name, display_name, status) +VALUES ('demo-prod', 'Production', 'Demo Production', 'active') +ON CONFLICT (tenant_id) DO NOTHING; + +-- Release schemas +CREATE SCHEMA IF NOT EXISTS release; +CREATE SCHEMA IF NOT EXISTS release_app; + +-- Helper function for updated_at triggers +CREATE OR REPLACE FUNCTION release.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Tenant isolation function +CREATE OR REPLACE FUNCTION release_app.require_current_tenant() +RETURNS UUID +LANGUAGE plpgsql STABLE SECURITY DEFINER +AS $$ +DECLARE + v_tenant TEXT; +BEGIN + v_tenant := current_setting('app.tenant_id', true); + IF v_tenant IS NULL OR v_tenant = '' THEN + RAISE EXCEPTION 'app.tenant_id session variable not set'; + END IF; + RETURN v_tenant::UUID; +END; +$$; + +-- Analytics schema +CREATE SCHEMA IF NOT EXISTS analytics; diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index f83c703dd..07b0271dd 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -15,10 +15,16 @@ } }, "Routes": [ + { + "Type": "ReverseProxy", + "Path": "/api/v1/setup", + "TranslatesTo": "http://platform.stella-ops.local/api/v1/setup", + "PreserveAuthHeaders": true + }, { "Type": "Microservice", "Path": "/api/v1/release-orchestrator", - "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/release-orchestrator", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/release-orchestrator", "PreserveAuthHeaders": true }, { @@ -66,7 +72,7 @@ { "Type": "Microservice", "Path": "/api/v1/findings", - "TranslatesTo": "http://findings.stella-ops.local/api/v1/findings", + "TranslatesTo": "http://findings-ledger.stella-ops.local/api/v1/findings", "PreserveAuthHeaders": true }, { @@ -114,7 +120,7 @@ { "Type": "Microservice", "Path": "/api/v1/jobengine", - "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/jobengine", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/jobengine", "PreserveAuthHeaders": true }, { @@ -330,7 +336,7 @@ { "Type": "Microservice", "Path": "/api/v1/workflows", - "TranslatesTo": "http://jobengine.stella-ops.local/api/v1/workflows", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/v1/workflows", "PreserveAuthHeaders": true }, { @@ -348,7 +354,7 @@ { "Type": "Microservice", "Path": "/v1/runs", - "TranslatesTo": "http://jobengine.stella-ops.local/v1/runs", + "TranslatesTo": "http://orchestrator.stella-ops.local/v1/runs", "PreserveAuthHeaders": true }, { @@ -402,19 +408,19 @@ { "Type": "Microservice", "Path": "/api/release-orchestrator", - "TranslatesTo": "http://jobengine.stella-ops.local/api/release-orchestrator", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/release-orchestrator", "PreserveAuthHeaders": true }, { "Type": "Microservice", "Path": "/api/releases", - "TranslatesTo": "http://jobengine.stella-ops.local/api/releases", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/releases", "PreserveAuthHeaders": true }, { "Type": "Microservice", "Path": "/api/approvals", - "TranslatesTo": "http://jobengine.stella-ops.local/api/approvals", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/approvals", "PreserveAuthHeaders": true }, { @@ -462,7 +468,7 @@ { "Type": "Microservice", "Path": "/api/jobengine", - "TranslatesTo": "http://jobengine.stella-ops.local/api/jobengine", + "TranslatesTo": "http://orchestrator.stella-ops.local/api/jobengine", "PreserveAuthHeaders": true }, { @@ -630,7 +636,7 @@ { "Type": "Microservice", "Path": "/findingsLedger", - "TranslatesTo": "http://findings.stella-ops.local" + "TranslatesTo": "http://findings-ledger.stella-ops.local" }, { "Type": "Microservice", @@ -645,7 +651,7 @@ { "Type": "Microservice", "Path": "/jobengine", - "TranslatesTo": "http://jobengine.stella-ops.local" + "TranslatesTo": "http://orchestrator.stella-ops.local" }, { "Type": "Microservice", diff --git a/src/Router/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs b/src/Router/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs index faa137673..286c5233a 100644 --- a/src/Router/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs +++ b/src/Router/__Libraries/StellaOps.Microservice/RouterConnectionManager.cs @@ -31,6 +31,14 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa private volatile InstanceHealthStatus _currentStatus = InstanceHealthStatus.Healthy; private int _inFlightRequestCount; private double _errorRate; + private int _heartbeatCount; + + /// + /// Number of heartbeats between periodic re-registration (HELLO re-send). + /// Ensures the gateway picks up the service after a gateway restart. + /// Default: every 30 heartbeats (~5 min at 10s intervals). + /// + private const int ReRegistrationInterval = 30; /// public IReadOnlyList Connections => [.. _connections.Values]; @@ -306,6 +314,38 @@ public sealed class RouterConnectionManager : IRouterConnectionManager, IDisposa { await Task.Delay(_options.HeartbeatInterval, cancellationToken); + _heartbeatCount++; + + // Periodically re-send HELLO to handle gateway restarts. + // The gateway loses all connection state on restart, and services + // only send HELLO once on initial connect. This ensures recovery. + if (_heartbeatCount % ReRegistrationInterval == 0 && _microserviceTransport is not null && _endpoints is not null) + { + try + { + var instance = new InstanceDescriptor + { + InstanceId = _options.InstanceId, + ServiceName = _options.ServiceName, + Version = _options.Version, + Region = _options.Region + }; + + await _microserviceTransport.ConnectAsync( + instance, + _endpoints, + _schemas, + _openApiInfo, + cancellationToken); + + _logger.LogDebug("Periodic re-registration sent (heartbeat #{Count})", _heartbeatCount); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send periodic re-registration"); + } + } + // Build heartbeat payload with current status and metrics var heartbeat = new HeartbeatPayload {