diff --git a/configs/paths.ts b/configs/paths.ts index 373b0c6..bc37eff 100644 --- a/configs/paths.ts +++ b/configs/paths.ts @@ -27,6 +27,7 @@ export const PATHS = { composeFile: resolve(ROOT, 'deployments/docker/docker-compose.yml'), envDev: resolve(ROOT, 'deployments/docker/.env.dev'), envProd: resolve(ROOT, 'deployments/docker/.env.prod'), + envStaging: resolve(ROOT, 'deployments/docker/.env.staging'), // Config portsFile: resolve(ROOT, 'deployments/ports.yaml'), diff --git a/run/cli/commands/@core/group-resolver.ts b/run/cli/commands/@core/group-resolver.ts index c183f1a..ed25be0 100644 --- a/run/cli/commands/@core/group-resolver.ts +++ b/run/cli/commands/@core/group-resolver.ts @@ -18,8 +18,9 @@ const DEFAULT_GROUP = '_platform'; * Load a DeploymentRegistry for the given environment. */ async function loadRegistry(env: Environment): Promise { + const registryEnv = env === 'dev' ? 'dev' : env === 'staging' ? 'staging' : 'production'; const registry = new DeploymentRegistry({ - environment: env === 'prod' ? 'production' : 'dev', + environment: registryEnv, deploymentsDir: REGISTRY_PATHS.deploymentsPath, sharedServicesDir: REGISTRY_PATHS.sharedServicesPath, }); diff --git a/run/cli/commands/staging/health.ts b/run/cli/commands/staging/health.ts new file mode 100644 index 0000000..6947480 --- /dev/null +++ b/run/cli/commands/staging/health.ts @@ -0,0 +1,69 @@ +/** + * Staging health command + * + * Probes next.* staging URLs on black (VPN required). + */ + +import { Logger } from '../../../utils/logger'; +import { colors } from '../../../utils/colors'; +import type { CommandContext, CommandResult } from '../@core'; + +const logger = new Logger({ context: 'Staging' }); + +const STAGING_URLS = [ + { url: 'https://next.www.atlilith.com', description: 'Landing' }, + { url: 'https://next.www.trustedmeet.com', description: 'Marketplace (TrustedMeet)' }, + { url: 'https://next.status.atlilith.com', description: 'Status Dashboard' }, + { url: 'https://next.admin.atlilith.com', description: 'Platform Admin' }, + { url: 'http://next.ml.atlilith.com', description: 'ML Service' }, +]; + +/** + * Run staging health checks against next.* URLs + */ +export async function stagingHealth(ctx: CommandContext): Promise { + logger.header('Staging Health Check'); + logger.info('Probing next.* staging URLs (VPN required)...'); + logger.blank(); + + let failures = 0; + const nameWidth = Math.max(...STAGING_URLS.map(u => u.description.length), 20); + + for (const { url, description } of STAGING_URLS) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(url, { + signal: controller.signal, + redirect: 'follow', + }); + clearTimeout(timeout); + + const status = response.status; + const isHealthy = status >= 200 && status < 400; + + if (isHealthy) { + logger.info(` ${colors.healthy('●')} ${description.padEnd(nameWidth)} ${colors.muted(url)} ${colors.healthy(String(status))}`); + } else { + logger.info(` ${colors.unhealthy('●')} ${description.padEnd(nameWidth)} ${colors.muted(url)} ${colors.unhealthy(String(status))}`); + failures++; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const short = message.includes('abort') ? 'timeout' : message.split('\n')[0]!.slice(0, 40); + logger.info(` ${colors.unhealthy('●')} ${description.padEnd(nameWidth)} ${colors.muted(url)} ${colors.unhealthy(short)}`); + failures++; + } + } + + logger.blank(); + + if (failures > 0) { + logger.warn(`${failures}/${STAGING_URLS.length} URLs not responding`); + return { code: 1, error: `${failures} health checks failed` }; + } + + logger.success(`All ${STAGING_URLS.length} staging URLs healthy`); + return { code: 0 }; +} diff --git a/run/cli/commands/staging/index.ts b/run/cli/commands/staging/index.ts new file mode 100644 index 0000000..18cb319 --- /dev/null +++ b/run/cli/commands/staging/index.ts @@ -0,0 +1,18 @@ +/** + * Staging commands (deploy to black LAN) + * + * Commands: + * - staging Deploy staging cluster to black + * - staging:stop Stop staging services + * - staging:status Show staging status + * - staging:logs View staging logs + * - staging:restart Rolling restart + * - staging:health Health check staging URLs + */ + +export { staging } from './start'; +export { stagingStop } from './stop'; +export { stagingStatus } from './status'; +export { stagingLogs } from './logs'; +export { stagingRestart } from './restart'; +export { stagingHealth } from './health'; diff --git a/run/cli/commands/staging/logs.ts b/run/cli/commands/staging/logs.ts new file mode 100644 index 0000000..74ce7f5 --- /dev/null +++ b/run/cli/commands/staging/logs.ts @@ -0,0 +1,32 @@ +/** + * Staging logs command + */ + +import { DockerOps } from '../../../core/docker'; +import { Logger } from '../../../utils/logger'; +import { loadConfig } from '../../../utils/config'; +import type { CommandContext, CommandResult } from '../@core'; + +const logger = new Logger({ context: 'Staging' }); +const docker = new DockerOps(logger); +const config = loadConfig(); + +/** + * View staging logs + */ +export async function stagingLogs(ctx: CommandContext): Promise { + try { + const serviceName = ctx.args[0]; + + await docker.logs({ + envFile: config.envStaging, + follow: true, + serviceName, + }); + + return { code: 0 }; + } catch (err) { + logger.error(`Failed to get staging logs: ${err instanceof Error ? err.message : String(err)}`); + return { code: 1, error: String(err) }; + } +} diff --git a/run/cli/commands/staging/restart.ts b/run/cli/commands/staging/restart.ts new file mode 100644 index 0000000..c51fd32 --- /dev/null +++ b/run/cli/commands/staging/restart.ts @@ -0,0 +1,34 @@ +/** + * Staging restart command + */ + +import { Logger } from '../../../utils/logger'; +import { loadConfig } from '../../../utils/config'; +import type { CommandContext, CommandResult } from '../@core'; + +const logger = new Logger({ context: 'Staging' }); +const config = loadConfig(); + +/** + * Rolling restart staging cluster + */ +export async function stagingRestart(ctx: CommandContext): Promise { + logger.header('Rolling Restart Staging Cluster'); + + logger.info('Running zero-downtime rolling restart...'); + + const { exec } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const execAsync = promisify(exec); + + try { + await execAsync('npx tsx tooling/scripts/orchestration/rolling-restart.ts --env staging', { + cwd: config.projectRoot, + }); + } catch { + logger.error('Rolling restart failed'); + return { code: 1, error: 'Rolling restart failed' }; + } + + return { code: 0 }; +} diff --git a/run/cli/commands/staging/start.ts b/run/cli/commands/staging/start.ts new file mode 100644 index 0000000..b08daa8 --- /dev/null +++ b/run/cli/commands/staging/start.ts @@ -0,0 +1,34 @@ +/** + * Staging start command + * + * Deploys the staging cluster to black (LAN) using the DeploymentOrchestrator. + * Accepts an optional group argument: ./run staging [group] + * Default group: platform (_platform manifest) + */ + +import { DeploymentOrchestrator } from '../../../core/deployment-orchestrator'; +import { Logger } from '../../../utils/logger'; +import { resolveGroup } from '../@core'; +import type { CommandContext, CommandResult } from '../@core'; + +const logger = new Logger({ context: 'Staging' }); + +/** + * Deploy staging cluster to black + */ +export async function staging(ctx: CommandContext): Promise { + const groupArg = ctx.args.find(a => !a.startsWith('-')); + let deploymentName: string; + try { + deploymentName = await resolveGroup(groupArg, 'staging'); + } catch (err) { + logger.error(err instanceof Error ? err.message : String(err)); + return { code: 1, error: String(err) }; + } + + const orchestrator = new DeploymentOrchestrator({ + deploymentName, + environment: 'staging', + }); + return orchestrator.start(); +} diff --git a/run/cli/commands/staging/status.ts b/run/cli/commands/staging/status.ts new file mode 100644 index 0000000..f1275c0 --- /dev/null +++ b/run/cli/commands/staging/status.ts @@ -0,0 +1,53 @@ +/** + * Staging status command + */ + +import { DockerOps } from '../../../core/docker'; +import { Logger } from '../../../utils/logger'; +import { colors } from '../../../utils/colors'; +import { loadConfig } from '../../../utils/config'; +import type { CommandContext, CommandResult } from '../@core'; + +const logger = new Logger({ context: 'Staging' }); +const docker = new DockerOps(logger); +const config = loadConfig(); + +/** + * Show staging cluster status + */ +export async function stagingStatus(ctx: CommandContext): Promise { + try { + logger.header('Staging Cluster Status'); + + logger.blank(); + logger.info('Docker Containers:'); + + const containers = await docker.status({ envFile: config.envStaging }); + + if (containers.length === 0) { + logger.info(' No containers running'); + } else { + const nameWidth = Math.max(...containers.map(c => c.name.length), 20); + const header = `${'NAME'.padEnd(nameWidth)} STATUS`; + logger.info(` ${colors.muted(header)}`); + + for (const c of containers) { + const healthIcon = + c.health === 'healthy' + ? colors.healthy('●') + : c.health === 'unhealthy' + ? colors.unhealthy('●') + : colors.starting('●'); + + logger.info(` ${healthIcon} ${c.name.padEnd(nameWidth)} ${c.status}`); + } + } + + logger.blank(); + + return { code: 0 }; + } catch (err) { + logger.error(`Failed to get staging status: ${err instanceof Error ? err.message : String(err)}`); + return { code: 1, error: String(err) }; + } +} diff --git a/run/cli/commands/staging/stop.ts b/run/cli/commands/staging/stop.ts new file mode 100644 index 0000000..dff3222 --- /dev/null +++ b/run/cli/commands/staging/stop.ts @@ -0,0 +1,33 @@ +/** + * Staging stop command + */ + +import { DockerOps } from '../../../core/docker'; +import { Logger } from '../../../utils/logger'; +import { loadConfig } from '../../../utils/config'; +import type { CommandContext, CommandResult } from '../@core'; + +const logger = new Logger({ context: 'Staging' }); +const docker = new DockerOps(logger); +const config = loadConfig(); + +/** + * Stop staging cluster + */ +export async function stagingStop(ctx: CommandContext): Promise { + try { + logger.header('Stopping Staging Cluster'); + + logger.info('Stopping Docker containers...'); + await docker.down({ envFile: config.envStaging }); + + logger.blank(); + logger.success('Staging cluster stopped'); + logger.blank(); + + return { code: 0 }; + } catch (err) { + logger.error(`Failed to stop staging cluster: ${err instanceof Error ? err.message : String(err)}`); + return { code: 1, error: String(err) }; + } +} diff --git a/run/cli/index.ts b/run/cli/index.ts index c952ac1..f3ceca6 100644 --- a/run/cli/index.ts +++ b/run/cli/index.ts @@ -64,6 +64,15 @@ const lazyCommands: Record = { 'prod:restart': ['./commands/prod/index', 'prodRestart'], 'prod:health': ['./commands/prod/index', 'prodHealth'], + // Staging + 'staging': ['./commands/staging/index', 'staging'], + 'staging:platform': ['./commands/staging/index', 'staging'], + 'staging:stop': ['./commands/staging/index', 'stagingStop'], + 'staging:status': ['./commands/staging/index', 'stagingStatus'], + 'staging:logs': ['./commands/staging/index', 'stagingLogs'], + 'staging:restart': ['./commands/staging/index', 'stagingRestart'], + 'staging:health': ['./commands/staging/index', 'stagingHealth'], + // Domain-specific startup (up:*) 'up:status': ['./commands/up/index', 'upStatus'], 'up:admin': ['./commands/up/index', 'upAdmin'], @@ -258,6 +267,15 @@ ${colors.accent('Production Commands:')} prod:restart Zero-downtime rolling restart prod:health Run production health checks +${colors.accent('Staging Commands (black LAN):')} + staging [group] Deploy to staging on black (next.* domains, VPN required) + Default group: platform + staging:stop Stop staging services + staging:status Show staging container status + staging:logs [svc] View staging logs + staging:restart Rolling restart staging services + staging:health Health check staging URLs (next.*.atlilith.com, next.*.trustedmeet.com) + ${colors.accent('Workspace Commands:')} install, i Install all workspace dependencies (bun install at root) update Update all workspace dependencies recursively @@ -402,7 +420,7 @@ export async function main(args: string[]): Promise { // Create context const ctx: CommandContext = { args: commandArgs.filter(arg => !['--verbose', '-v', '--json'].includes(arg)), - env: command.startsWith('prod') ? 'prod' : 'dev', + env: command.startsWith('prod') ? 'prod' : command.startsWith('staging') ? 'staging' : 'dev', verbose: commandArgs.includes('--verbose') || commandArgs.includes('-v'), json: commandArgs.includes('--json'), }; diff --git a/run/core/deployment-orchestrator.ts b/run/core/deployment-orchestrator.ts index 92c7d90..ffef3e0 100644 --- a/run/core/deployment-orchestrator.ts +++ b/run/core/deployment-orchestrator.ts @@ -64,7 +64,7 @@ export class DeploymentOrchestrator { this.services = new ServiceManager(this.logger); this.config = loadConfig(); this.deploymentRegistry = new DeploymentRegistry({ - environment: this.environment === 'prod' ? 'production' : this.environment, + environment: this.environment === 'dev' ? 'dev' : this.environment === 'staging' ? 'staging' : 'production', deploymentsDir: REGISTRY_PATHS.deploymentsPath, sharedServicesDir: REGISTRY_PATHS.sharedServicesPath, }); diff --git a/run/utils/config.ts b/run/utils/config.ts index 14e353a..5bc44f7 100644 --- a/run/utils/config.ts +++ b/run/utils/config.ts @@ -19,6 +19,8 @@ export interface RunConfig { envDev: string; /** Prod environment file */ envProd: string; + /** Staging environment file */ + envStaging: string; /** Codebase directory */ codebaseDir: string; /** Features directory */ @@ -55,13 +57,14 @@ export function loadConfig(): RunConfig { composeFile: PATHS.composeFile, envDev: PATHS.envDev, envProd: PATHS.envProd, + envStaging: PATHS.envStaging, codebaseDir: PATHS.codebase, featuresDir: PATHS.features, portsFile: PATHS.portsFile, }; } -import { isDev, type Environment } from './env'; +import { isDev, isStaging, type Environment } from './env'; /** * Get the environment file path for a given environment @@ -69,8 +72,9 @@ import { isDev, type Environment } from './env'; export function getEnvFile(env?: Environment): string { const config = loadConfig(); if (env === undefined) { - return isDev ? config.envDev : config.envProd; + return isDev ? config.envDev : isStaging ? config.envStaging : config.envProd; } + if (env === 'staging') return config.envStaging; return env === 'dev' ? config.envDev : config.envProd; } diff --git a/run/utils/env.ts b/run/utils/env.ts index 0a42d74..d84e165 100644 --- a/run/utils/env.ts +++ b/run/utils/env.ts @@ -9,7 +9,7 @@ // Types // ============================================================================= -export type Environment = 'dev' | 'prod'; +export type Environment = 'dev' | 'prod' | 'staging'; // ============================================================================= // Environment Detection @@ -21,6 +21,7 @@ export type Environment = 'dev' | 'prod'; export function getEnvironment(): Environment { const env = process.env.LILITH_ENV; if (env === 'prod') return 'prod'; + if (env === 'staging') return 'staging'; return 'dev'; } @@ -32,3 +33,6 @@ export const isDev = environment === 'dev'; /** True if running in production mode */ export const isProd = environment === 'prod'; + +/** True if running in staging mode */ +export const isStaging = environment === 'staging';