Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

1073 lines
35 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import path from 'node:path'
import { loadEnvConfig } from '@next/env'
import {
CORE_CONFIGURATION_KEYS,
DEPLOYMENT_CONFIGURATION_KEYS,
} from '../../apps/sim/lib/core/config/env-capabilities.ts'
import { isPlaceholder, parseEnv, ROOT, SHARED_KEYS } from './env-files.ts'
export type ConfigurationSourceKind = 'dev' | 'compose' | 'helm'
export interface ConfigurationSource {
kind: ConfigurationSourceKind
label: string
location: string
values: Map<string, string> | null
warning?: string
configurationIssues?: readonly string[]
managedByCurrentCheckout: boolean
}
export interface ConfigurationCommandResult {
status: number | null
stdout: string
stderr: string
}
export type ConfigurationCommandRunner = (
command: string,
args: readonly string[],
cwd: string
) => ConfigurationCommandResult
export interface ConfigurationSourceDiscoveryOptions {
root?: string
runner?: ConfigurationCommandRunner
processEnvironment?: Readonly<NodeJS.ProcessEnv>
}
export type KubernetesResourceKind = 'secret' | 'configmap'
export type KubernetesResourceLookupResult =
| { state: 'found'; resource: unknown }
| { state: 'missing' }
| { state: 'inaccessible' }
export type KubernetesResourceLookup = (
kind: KubernetesResourceKind,
name: string
) => KubernetesResourceLookupResult
export interface EnvironmentResolution {
values: Map<string, string> | null
warning?: string
}
interface ComposeProject {
name: string
configFiles: string[]
}
interface DiscoveredComposeProject {
project: ComposeProject
configFiles: string[]
source: ConfigurationSource
}
interface ComposeDiscovery {
projects: DiscoveredComposeProject[]
}
interface HelmRelease {
name: string
namespace: string
chart: string
}
const SIM_COMPOSE_MARKERS = ['ghcr.io/simstudioai/simstudio', 'docker/app.Dockerfile'] as const
const COMPOSE_FILES = ['docker-compose.prod.yml', 'docker-compose.local.yml'] as const
const DEVELOPMENT_ENV_FILES = [
'.env.development.local',
'.env.local',
'.env.development',
'.env',
] as const
const DISCOVERY_COMMAND_TIMEOUT_MS = 10_000
const DEPLOYMENT_CONFIGURATION_KEY_SET = new Set(DEPLOYMENT_CONFIGURATION_KEYS)
const SPLIT_REQUIRED_KEYS = {
sim: CORE_CONFIGURATION_KEYS,
realtime: [
'DATABASE_URL',
'BETTER_AUTH_URL',
'BETTER_AUTH_SECRET',
'INTERNAL_API_SECRET',
'NEXT_PUBLIC_APP_URL',
],
db: ['DATABASE_URL'],
} as const
function defaultRunner(
command: string,
args: readonly string[],
cwd: string
): ConfigurationCommandResult {
const result = spawnSync(command, [...args], {
cwd,
encoding: 'utf8',
timeout: DISCOVERY_COMMAND_TIMEOUT_MS,
})
return {
status: result.status,
stdout: typeof result.stdout === 'string' ? result.stdout : '',
stderr: typeof result.stderr === 'string' ? result.stderr : '',
}
}
function runSafely(
runner: ConfigurationCommandRunner,
command: string,
args: readonly string[],
cwd: string
): ConfigurationCommandResult {
try {
return runner(command, args, cwd)
} catch {
return { status: null, stdout: '', stderr: '' }
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function parseJson(value: string): unknown | null {
try {
return JSON.parse(value) as unknown
} catch {
return null
}
}
function canonicalPath(value: string): string {
const resolved = path.resolve(value)
try {
return realpathSync(resolved)
} catch {
return resolved
}
}
function readEnv(file: string): Map<string, string> {
return parseEnv(readFileSync(file, 'utf8'))
}
function filterConfigurationValues(values: ReadonlyMap<string, string>): Map<string, string> {
return new Map([...values].filter(([key]) => DEPLOYMENT_CONFIGURATION_KEY_SET.has(key)))
}
function inspectSplitConfiguration(root: string): string[] {
const targets = [
{ id: 'sim', label: 'apps/sim/.env', file: path.join(root, 'apps/sim/.env') },
{
id: 'realtime',
label: 'apps/realtime/.env',
file: path.join(root, 'apps/realtime/.env'),
},
{ id: 'db', label: 'packages/db/.env', file: path.join(root, 'packages/db/.env') },
] as const
const issues: string[] = []
const values = new Map<(typeof targets)[number]['id'], Map<string, string>>()
for (const target of targets) {
if (!existsSync(target.file)) {
issues.push(`${target.label} is missing`)
continue
}
const targetValues = readEnv(target.file)
values.set(target.id, targetValues)
for (const key of SPLIT_REQUIRED_KEYS[target.id]) {
const value = targetValues.get(key)
if (!value || isPlaceholder(value)) {
issues.push(`${target.label}: ${key} is missing, empty, or a placeholder`)
}
}
}
const sim = values.get('sim')
const realtime = values.get('realtime')
if (sim && realtime) {
for (const key of SHARED_KEYS) {
const simValue = sim.get(key)
const realtimeValue = realtime.get(key)
if (simValue && realtimeValue && simValue !== realtimeValue) {
issues.push(`${key} differs between apps/sim/.env and apps/realtime/.env`)
}
}
}
const db = values.get('db')
const simDatabaseUrl = sim?.get('DATABASE_URL')
const dbDatabaseUrl = db?.get('DATABASE_URL')
if (simDatabaseUrl && dbDatabaseUrl && simDatabaseUrl !== dbDatabaseUrl) {
issues.push('DATABASE_URL differs between apps/sim/.env and packages/db/.env')
}
return issues
}
function restoreProcessEnvironment(original: NodeJS.ProcessEnv): void {
for (const key of Object.keys(process.env)) {
if (!(key in original)) delete process.env[key]
}
Object.assign(process.env, original)
}
function loadDevelopmentEnvironment(
appDirectory: string,
processEnvironment: Readonly<NodeJS.ProcessEnv>
): {
values: Map<string, string>
loadedFiles: string[]
hasProcessOverrides: boolean
} {
const originalEnvironment = { ...process.env }
const configuredEnvironment = { ...processEnvironment }
let loadFailed = false
try {
restoreProcessEnvironment(configuredEnvironment)
process.env.NODE_ENV = 'development'
const result = loadEnvConfig(
appDirectory,
true,
{
info: () => undefined,
error: () => {
loadFailed = true
},
},
true
)
if (loadFailed) {
throw new Error('One or more development environment files could not be loaded')
}
const loadedKeys = new Set(result.loadedEnvFiles.flatMap((file) => Object.keys(file.env)))
const values = new Map<string, string>()
for (const key of loadedKeys) {
const loadedValue = result.combinedEnv[key]
if (loadedValue !== undefined) values.set(key, loadedValue)
}
for (const key of DEPLOYMENT_CONFIGURATION_KEYS) {
const processValue = configuredEnvironment[key]
if (processValue !== undefined) values.set(key, processValue)
}
const hasProcessOverrides = DEPLOYMENT_CONFIGURATION_KEYS.some(
(key) => configuredEnvironment[key] !== undefined
)
return {
values,
loadedFiles: result.loadedEnvFiles.map((file) => path.join(appDirectory, file.path)),
hasProcessOverrides,
}
} finally {
restoreProcessEnvironment(originalEnvironment)
}
}
function hasSimComposeMarker(file: string): boolean {
try {
const contents = readFileSync(file, 'utf8')
return SIM_COMPOSE_MARKERS.some((marker) => contents.includes(marker))
} catch {
return false
}
}
/** Parses Docker's `KEY=value` environment representation without logging values. */
export function parseEnvironmentEntries(entries: readonly unknown[]): Map<string, string> | null {
const values = new Map<string, string>()
for (const entry of entries) {
if (typeof entry !== 'string') return null
const separator = entry.indexOf('=')
if (separator <= 0) return null
values.set(entry.slice(0, separator), entry.slice(separator + 1))
}
return values
}
/** Extracts the newest running, or newest stopped, container's effective environment. */
export function parseDockerInspectEnvironment(output: string): Map<string, string> | null {
const parsed = parseJson(output)
if (!Array.isArray(parsed)) return null
const candidates: Array<{
running: boolean
created: string
values: Map<string, string>
}> = []
for (const item of parsed) {
const record = asRecord(item)
const config = asRecord(record?.Config)
if (!config || !Array.isArray(config.Env)) continue
const values = parseEnvironmentEntries(config.Env)
if (!values) continue
const state = asRecord(record?.State)
candidates.push({
running: state?.Running === true,
created: typeof record?.Created === 'string' ? record.Created : '',
values,
})
}
candidates.sort((left, right) => {
if (left.running !== right.running) return left.running ? -1 : 1
return right.created.localeCompare(left.created)
})
return candidates[0]?.values ?? null
}
function parseComposeProjects(output: string): ComposeProject[] | null {
const parsed = parseJson(output)
if (!Array.isArray(parsed)) return null
const projects: ComposeProject[] = []
for (const item of parsed) {
const record = asRecord(item)
const name = record?.Name
const configFiles = record?.ConfigFiles
if (typeof name !== 'string' || typeof configFiles !== 'string') return null
const files = configFiles
.split(',')
.map((file) => file.trim())
.filter(Boolean)
if (files.length === 0) return null
projects.push({ name, configFiles: files })
}
return projects
}
/** Reads `docker compose config --format json` and returns the resolved app environment. */
export function parseComposeConfigEnvironment(output: string): Map<string, string> | null {
const parsed = asRecord(parseJson(output))
const services = asRecord(parsed?.services)
const app = asRecord(services?.simstudio)
if (!app) return null
if (Array.isArray(app.environment)) return parseEnvironmentEntries(app.environment)
const environment = asRecord(app.environment)
if (!environment) return null
const values = new Map<string, string>()
for (const [key, value] of Object.entries(environment)) {
if (value === null) continue
if (typeof value !== 'string') return null
values.set(key, value)
}
return values
}
function stripYamlScalar(value: string): string {
const trimmed = value.trim()
if (trimmed.length >= 2) {
const first = trimmed[0]
const last = trimmed[trimmed.length - 1]
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return trimmed.slice(1, -1)
}
}
return trimmed
}
function normalizeComposeEnvFilePath(value: string): string {
const withoutPathKey = value.replace(/^path:\s*/, '')
return stripYamlScalar(withoutPathKey).replace(/^\.\//, '')
}
function serviceRootEnvFileUsage(
lines: readonly string[],
serviceStart: number,
serviceEnd: number
): boolean | null {
const envFileStart = lines.findIndex(
(line, index) => index > serviceStart && index < serviceEnd && /^ {4}env_file:\s*/.test(line)
)
if (envFileStart === -1) return false
const inline = /^ {4}env_file:\s*(.+)$/.exec(lines[envFileStart])
if (inline) return normalizeComposeEnvFilePath(inline[1]) === '.env' ? true : null
const referencedFiles: string[] = []
for (let index = envFileStart + 1; index < serviceEnd; index += 1) {
const line = lines[index]
if (/^ {4}\S/.test(line)) break
const listEntry = /^ {6}-\s+(.+)$/.exec(line)
const nestedPath = /^ {8}path:\s*(.+)$/.exec(line)
if (listEntry) referencedFiles.push(normalizeComposeEnvFilePath(listEntry[1]))
else if (nestedPath) referencedFiles.push(normalizeComposeEnvFilePath(nestedPath[1]))
}
if (referencedFiles.length !== 1) return null
return referencedFiles[0] === '.env' ? true : null
}
function expandComposeValue(raw: string, variables: ReadonlyMap<string, string>): string | null {
let valid = true
const expanded = raw.replace(
/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|-|:\+|\+|:\?|\?)([^}]*))?\}/g,
(_match, key: string, operator: string | undefined, operand: string | undefined) => {
const value = variables.get(key)
const set = value !== undefined
const nonEmpty = set && value !== ''
if (!operator) return value ?? ''
if (operator === ':-') return nonEmpty ? value : (operand ?? '')
if (operator === '-') return set ? value : (operand ?? '')
if (operator === ':+') return nonEmpty ? (operand ?? '') : ''
if (operator === '+') return set ? (operand ?? '') : ''
if (operator === ':?' && !nonEmpty) {
valid = false
return ''
}
if (operator === '?' && !set) {
valid = false
return ''
}
return value ?? ''
}
)
if (!valid || expanded.includes('${')) return null
return expanded.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, key: string) => {
return variables.get(key) ?? ''
})
}
/**
* Resolves the current repository's simple Compose app environment when Docker
* is unavailable. The checked-in files use list-form `KEY=value` entries.
*/
export function parseComposeFileEnvironment(
contents: string,
envFileValues: ReadonlyMap<string, string>
): Map<string, string> | null {
const lines = contents.split('\n')
const serviceStart = lines.findIndex((line) => /^ {2}simstudio:\s*$/.test(line))
if (serviceStart === -1) return null
let serviceEnd = lines.length
for (let index = serviceStart + 1; index < lines.length; index += 1) {
if (/^ {2}[A-Za-z0-9_.-]+:\s*$/.test(lines[index])) {
serviceEnd = index
break
}
}
const rootEnvFileUsage = serviceRootEnvFileUsage(lines, serviceStart, serviceEnd)
if (rootEnvFileUsage === null) return null
const values = rootEnvFileUsage ? new Map(envFileValues) : new Map<string, string>()
let environmentStart = -1
for (let index = serviceStart + 1; index < serviceEnd; index += 1) {
const environment = /^ {4}environment:\s*(.*)$/.exec(lines[index])
if (environment && environment[1] !== '') return null
if (environment) {
environmentStart = index
break
}
}
if (environmentStart === -1) return values
for (let index = environmentStart + 1; index < serviceEnd; index += 1) {
const line = lines[index]
if (/^ {4}\S/.test(line) && !/^ {6}/.test(line)) break
const listEntry = /^ {6}-\s+(.+)$/.exec(line)
const mapEntry = /^ {6}([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line)
let key: string
let raw: string
if (listEntry) {
const entry = stripYamlScalar(listEntry[1])
const separator = entry.indexOf('=')
if (separator === -1) {
if (envFileValues.has(entry)) values.set(entry, envFileValues.get(entry) ?? '')
continue
}
key = entry.slice(0, separator)
raw = entry.slice(separator + 1)
} else if (mapEntry) {
key = mapEntry[1]
raw = stripYamlScalar(mapEntry[2])
} else {
continue
}
const expanded = expandComposeValue(raw, envFileValues)
if (expanded === null) return null
values.set(key, expanded)
}
return values
}
function preparedComposeSource(root: string, managed: boolean): ConfigurationSource | null {
const envPath = path.join(root, '.env')
if (!existsSync(envPath)) return null
const envValues = readEnv(envPath)
const composeFiles = COMPOSE_FILES.map((file) => path.join(root, file)).filter(
(file) => existsSync(file) && hasSimComposeMarker(file)
)
if (composeFiles.length === 0) {
return {
kind: 'compose',
label: 'Current checkout (Compose prepared)',
location: envPath,
values: null,
warning:
'No checked-in Sim Compose file could be read; effective container values are unknown.',
managedByCurrentCheckout: managed,
}
}
if (composeFiles.length > 1) {
return {
kind: 'compose',
label: 'Current checkout (Compose prepared)',
location: `${envPath} · candidates ${composeFiles.join(', ')}`,
values: null,
warning:
'More than one Sim Compose variant is present and no live project identifies which one is selected.',
managedByCurrentCheckout: managed,
}
}
const composeFile = composeFiles[0]
const resolvedValues = parseComposeFileEnvironment(readFileSync(composeFile, 'utf8'), envValues)
const values = resolvedValues ? filterConfigurationValues(resolvedValues) : null
return {
kind: 'compose',
label: 'Current checkout (Compose prepared)',
location: `${envPath} · ${composeFile}`,
values,
...(values
? {}
: {
warning:
'The Sim app environment could not be resolved from the Compose file; run Docker Compose before relying on this status.',
}),
managedByCurrentCheckout: managed,
}
}
function localSources(
root: string,
processEnvironment: Readonly<NodeJS.ProcessEnv>
): {
sources: ConfigurationSource[]
setupSplitExists: boolean
prepared: ConfigurationSource | null
} {
const appDirectory = path.join(root, 'apps/sim')
const simEnv = path.join(appDirectory, '.env')
const setupSplitFiles = [
simEnv,
path.join(root, 'apps/realtime/.env'),
path.join(root, 'packages/db/.env'),
]
const setupSplitExists = setupSplitFiles.some(existsSync)
const developmentExists =
setupSplitExists ||
DEVELOPMENT_ENV_FILES.some((file) => existsSync(path.join(appDirectory, file)))
const sources: ConfigurationSource[] = []
if (developmentExists) {
const development = loadDevelopmentEnvironment(appDirectory, processEnvironment)
const hasHigherPrecedenceFile = development.loadedFiles.some(
(file) => canonicalPath(file) !== canonicalPath(simEnv)
)
sources.push({
kind: 'dev',
label: 'Current checkout (development)',
location:
development.loadedFiles.length > 0
? `process environment + ${development.loadedFiles.join(', ')}`
: `${appDirectory} (process environment only)`,
values: development.values,
...(development.loadedFiles.length > 0
? {}
: { warning: 'No application env file was loaded; only the process environment applies.' }),
configurationIssues: inspectSplitConfiguration(root),
managedByCurrentCheckout:
setupSplitExists && !hasHigherPrecedenceFile && !development.hasProcessOverrides,
})
}
return {
sources,
setupSplitExists,
prepared: preparedComposeSource(root, !setupSplitExists),
}
}
function resolveComposeProjectEnvironment(
runner: ConfigurationCommandRunner,
root: string,
project: ComposeProject,
configFiles: readonly string[]
): EnvironmentResolution {
const containers = runSafely(
runner,
'docker',
[
'ps',
'-a',
'--filter',
`label=com.docker.compose.project=${project.name}`,
'--filter',
'label=com.docker.compose.service=simstudio',
'--format',
'{{.ID}}',
],
root
)
if (containers.status !== 0) {
return {
values: null,
warning: 'The Compose app containers could not be enumerated; effective values are unknown.',
}
}
const ids = containers.stdout
.split('\n')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length > 0) {
const inspect = runSafely(runner, 'docker', ['inspect', ...ids], root)
if (inspect.status !== 0) {
return {
values: null,
warning: 'The Compose app container could not be inspected; effective values are unknown.',
}
}
const values = parseDockerInspectEnvironment(inspect.stdout)
if (values) return { values: filterConfigurationValues(values) }
return {
values: null,
warning:
'Docker returned an invalid Compose app container definition; effective values are unknown.',
}
}
const composeArgs = ['compose', '-p', project.name]
for (const file of configFiles) composeArgs.push('-f', file)
composeArgs.push('config', '--format', 'json')
const config = runSafely(runner, 'docker', composeArgs, path.dirname(configFiles[0]))
if (config.status === 0) {
const values = parseComposeConfigEnvironment(config.stdout)
if (values) return { values: filterConfigurationValues(values) }
}
return {
values: null,
warning:
'The simstudio container and resolved Compose configuration could not be read; effective values are unknown.',
}
}
function discoverComposeProjects(
runner: ConfigurationCommandRunner,
root: string
): ComposeDiscovery {
const info = runSafely(runner, 'docker', ['info'], root)
if (info.status !== 0) return { projects: [] }
const listed = runSafely(runner, 'docker', ['compose', 'ls', '-a', '--format', 'json'], root)
if (listed.status !== 0) return { projects: [] }
const projects = parseComposeProjects(listed.stdout)
if (!projects) throw new Error('Docker Compose returned an invalid project list')
const discovered: DiscoveredComposeProject[] = []
const seen = new Set<string>()
for (const project of projects) {
const configFiles = project.configFiles.map((file) => path.resolve(root, file))
if (!configFiles.some(hasSimComposeMarker)) continue
const identity = `${project.name}\0${configFiles.map(canonicalPath).sort().join('\0')}`
if (seen.has(identity)) continue
seen.add(identity)
const resolution = resolveComposeProjectEnvironment(runner, root, project, configFiles)
discovered.push({
project,
configFiles,
source: {
kind: 'compose',
label: `Compose project "${project.name}"`,
location: configFiles.join(', '),
values: resolution.values,
...(resolution.warning ? { warning: resolution.warning } : {}),
managedByCurrentCheckout: false,
},
})
}
return { projects: discovered }
}
function decodeBase64(value: string): string | null {
if (
value !== '' &&
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
) {
return null
}
return Buffer.from(value, 'base64').toString('utf8')
}
function resourceEnvironment(
kind: KubernetesResourceKind,
resource: unknown
): EnvironmentResolution {
const record = asRecord(resource)
if (!record) return { values: null, warning: 'Kubernetes returned an invalid resource.' }
const values = new Map<string, string>()
const data = record.data === undefined ? {} : asRecord(record.data)
if (!data) return { values: null, warning: 'Kubernetes returned invalid resource data.' }
for (const [key, value] of Object.entries(data)) {
if (typeof value !== 'string') {
return { values: null, warning: 'Kubernetes returned a non-string environment value.' }
}
if (kind === 'secret') {
const decoded = decodeBase64(value)
if (decoded === null) {
return { values: null, warning: 'Kubernetes returned invalid Secret data.' }
}
values.set(key, decoded)
} else {
values.set(key, value)
}
}
const secondaryKey = kind === 'secret' ? 'stringData' : 'binaryData'
const secondary = record[secondaryKey] === undefined ? {} : asRecord(record[secondaryKey])
if (!secondary) return { values: null, warning: 'Kubernetes returned invalid resource data.' }
for (const [key, value] of Object.entries(secondary)) {
if (typeof value !== 'string') {
return { values: null, warning: 'Kubernetes returned a non-string environment value.' }
}
if (kind === 'configmap') {
const decoded = decodeBase64(value)
if (decoded === null) {
return { values: null, warning: 'Kubernetes returned invalid ConfigMap binary data.' }
}
values.set(key, decoded)
} else {
values.set(key, value)
}
}
return { values }
}
function inaccessibleResourceWarning(kind: KubernetesResourceKind, name: string): string {
const displayKind = kind === 'secret' ? 'Secret' : 'ConfigMap'
return `Cannot read ${displayKind} "${name}"; configuration status is unknown. Verify kubectl RBAC allows get access in this namespace.`
}
function missingResourceWarning(kind: KubernetesResourceKind, name: string): string {
const displayKind = kind === 'secret' ? 'Secret' : 'ConfigMap'
return `${displayKind} "${name}" is required by the app Deployment but does not exist; configuration status is unknown.`
}
function loadKubernetesResource(
kind: KubernetesResourceKind,
name: string,
optional: boolean,
lookup: KubernetesResourceLookup
): EnvironmentResolution & { missing?: boolean } {
const result = lookup(kind, name)
if (result.state === 'inaccessible') {
return { values: null, warning: inaccessibleResourceWarning(kind, name) }
}
if (result.state === 'missing') {
return optional
? { values: new Map(), missing: true }
: { values: null, warning: missingResourceWarning(kind, name) }
}
const resolution = resourceEnvironment(kind, result.resource)
return resolution.values
? resolution
: {
values: null,
warning: `${resolution.warning ?? 'Kubernetes resource could not be decoded'} Configuration status is unknown.`,
}
}
/** Resolves Kubernetes `envFrom` then explicit `env`, matching Kubernetes precedence. */
export function resolveKubernetesContainerEnvironment(
container: unknown,
lookup: KubernetesResourceLookup
): EnvironmentResolution {
const record = asRecord(container)
if (!record) return { values: null, warning: 'The app container definition is invalid.' }
const values = new Map<string, string>()
const envFrom = record.envFrom === undefined ? [] : record.envFrom
if (!Array.isArray(envFrom)) {
return { values: null, warning: 'The app container envFrom definition is invalid.' }
}
for (const source of envFrom) {
const sourceRecord = asRecord(source)
if (!sourceRecord)
return { values: null, warning: 'The app container envFrom entry is invalid.' }
const secretRef = asRecord(sourceRecord.secretRef)
const configMapRef = asRecord(sourceRecord.configMapRef)
const kind: KubernetesResourceKind | null = secretRef
? 'secret'
: configMapRef
? 'configmap'
: null
const reference = secretRef ?? configMapRef
if (!kind || !reference || typeof reference.name !== 'string') {
return { values: null, warning: 'The app container envFrom reference is invalid.' }
}
const loaded = loadKubernetesResource(kind, reference.name, reference.optional === true, lookup)
if (!loaded.values) return loaded
const prefix = sourceRecord.prefix === undefined ? '' : sourceRecord.prefix
if (typeof prefix !== 'string') {
return { values: null, warning: 'The app container envFrom prefix is invalid.' }
}
for (const [key, value] of loaded.values) values.set(`${prefix}${key}`, value)
}
const explicitEnv = record.env === undefined ? [] : record.env
if (!Array.isArray(explicitEnv)) {
return { values: null, warning: 'The app container env definition is invalid.' }
}
for (const entry of explicitEnv) {
const env = asRecord(entry)
if (!env || typeof env.name !== 'string' || env.name === '') {
return { values: null, warning: 'The app container has an invalid explicit env entry.' }
}
if (env.value !== undefined) {
if (typeof env.value !== 'string' || env.valueFrom !== undefined) {
return { values: null, warning: `Explicit env "${env.name}" is invalid.` }
}
values.set(env.name, env.value)
continue
}
if (env.valueFrom === undefined) {
values.set(env.name, '')
continue
}
const valueFrom = asRecord(env.valueFrom)
const secretKeyRef = asRecord(valueFrom?.secretKeyRef)
const configMapKeyRef = asRecord(valueFrom?.configMapKeyRef)
const kind: KubernetesResourceKind | null = secretKeyRef
? 'secret'
: configMapKeyRef
? 'configmap'
: null
const reference = secretKeyRef ?? configMapKeyRef
if (
!kind ||
!reference ||
typeof reference.name !== 'string' ||
typeof reference.key !== 'string'
) {
return {
values: null,
warning: `Explicit env "${env.name}" uses an unsupported or invalid valueFrom source.`,
}
}
const loaded = loadKubernetesResource(kind, reference.name, reference.optional === true, lookup)
if (!loaded.values) return loaded
if (loaded.missing) continue
const value = loaded.values.get(reference.key)
if (value === undefined) {
if (reference.optional === true) continue
return {
values: null,
warning: `${kind === 'secret' ? 'Secret' : 'ConfigMap'} "${reference.name}" does not contain key "${reference.key}"; configuration status is unknown.`,
}
}
values.set(env.name, value)
}
return { values }
}
function parseHelmReleases(output: string): HelmRelease[] | null {
const parsed = parseJson(output)
if (!Array.isArray(parsed)) return null
const releases: HelmRelease[] = []
for (const item of parsed) {
const record = asRecord(item)
if (
typeof record?.name !== 'string' ||
typeof record.namespace !== 'string' ||
typeof record.chart !== 'string'
) {
return null
}
if (record.chart.startsWith('sim-')) {
releases.push({ name: record.name, namespace: record.namespace, chart: record.chart })
}
}
return releases
}
function helmLocation(context: string, release: HelmRelease): string {
return `context ${context} · namespace ${release.namespace} · release ${release.name}`
}
function helmUnknown(release: HelmRelease, context: string, warning: string): ConfigurationSource {
return {
kind: 'helm',
label: `Helm release "${release.name}"`,
location: helmLocation(context, release),
values: null,
warning,
managedByCurrentCheckout: false,
}
}
function discoverHelmReleases(
runner: ConfigurationCommandRunner,
root: string
): ConfigurationSource[] {
const contextResult = runSafely(runner, 'kubectl', ['config', 'current-context'], root)
if (contextResult.status !== 0) return []
const context = contextResult.stdout.trim()
if (!context) return []
const listed = runSafely(
runner,
'helm',
['list', '-A', '--deployed', '--failed', '-o', 'json', '--kube-context', context],
root
)
if (listed.status !== 0) return []
const releases = parseHelmReleases(listed.stdout)
if (!releases) throw new Error('Helm returned an invalid release list')
const seen = new Set<string>()
const sources: ConfigurationSource[] = []
for (const release of releases) {
const identity = `${release.namespace}\0${release.name}`
if (seen.has(identity)) continue
seen.add(identity)
const selector = `app.kubernetes.io/instance=${release.name},app.kubernetes.io/component=app`
const deploymentResult = runSafely(
runner,
'kubectl',
[
'get',
'deployments',
'-n',
release.namespace,
'-l',
selector,
'-o',
'json',
'--context',
context,
],
root
)
if (deploymentResult.status !== 0) {
sources.push(
helmUnknown(
release,
context,
`Cannot read the app Deployment in namespace "${release.namespace}"; verify kubectl access to the current context.`
)
)
continue
}
const deploymentList = asRecord(parseJson(deploymentResult.stdout))
const items = deploymentList?.items
const itemCount = Array.isArray(items) ? items.length : null
if (!Array.isArray(items) || items.length !== 1) {
sources.push(
helmUnknown(
release,
context,
itemCount === 0
? 'No app Deployment matched the Sim instance/component labels.'
: 'More than one app Deployment matched the Sim instance/component labels.'
)
)
continue
}
const deployment = asRecord(items[0])
const spec = asRecord(deployment?.spec)
const template = asRecord(spec?.template)
const podSpec = asRecord(template?.spec)
const containers = podSpec?.containers
if (!Array.isArray(containers)) {
sources.push(
helmUnknown(release, context, 'The app Deployment has no readable containers list.')
)
continue
}
const appContainer = containers.find((container) => asRecord(container)?.name === 'app')
if (!appContainer) {
sources.push(
helmUnknown(release, context, 'The app Deployment has no container named "app".')
)
continue
}
const resourceCache = new Map<string, KubernetesResourceLookupResult>()
const lookup: KubernetesResourceLookup = (kind, name) => {
const key = `${kind}\0${name}`
const cached = resourceCache.get(key)
if (cached) return cached
const result = runSafely(
runner,
'kubectl',
[
'get',
kind,
name,
'-n',
release.namespace,
'--ignore-not-found',
'-o',
'json',
'--context',
context,
],
root
)
let resolved: KubernetesResourceLookupResult
if (result.status !== 0) resolved = { state: 'inaccessible' }
else if (result.stdout.trim() === '') resolved = { state: 'missing' }
else {
const resource = parseJson(result.stdout)
resolved = resource === null ? { state: 'inaccessible' } : { state: 'found', resource }
}
resourceCache.set(key, resolved)
return resolved
}
const resolution = resolveKubernetesContainerEnvironment(appContainer, lookup)
sources.push({
kind: 'helm',
label: `Helm release "${release.name}"`,
location: helmLocation(context, release),
values: resolution.values ? filterConfigurationValues(resolution.values) : null,
...(resolution.warning ? { warning: resolution.warning } : {}),
managedByCurrentCheckout: false,
})
}
return sources
}
/** Discovers every configuration snapshot without writing or displaying environment values. */
export function discoverConfigurationSources(
options: ConfigurationSourceDiscoveryOptions = {}
): ConfigurationSource[] {
const root = path.resolve(options.root ?? ROOT)
const runner = options.runner ?? defaultRunner
const local = localSources(root, options.processEnvironment ?? process.env)
const compose = discoverComposeProjects(runner, root)
const rootPath = canonicalPath(root)
const currentRootProjects = compose.projects.filter((project) => {
return project.configFiles.some((file) => canonicalPath(path.dirname(file)) === rootPath)
})
const knownCurrentFiles = new Set(
COMPOSE_FILES.map((file) => canonicalPath(path.join(root, file)))
)
const rootEnvExists = existsSync(path.join(root, '.env'))
const canManageCurrentCompose =
!local.setupSplitExists && rootEnvExists && currentRootProjects.length === 1
for (const project of currentRootProjects) {
const exactCurrentFileSet =
project.configFiles.length === 1 &&
knownCurrentFiles.has(canonicalPath(project.configFiles[0]))
project.source.managedByCurrentCheckout = canManageCurrentCompose && exactCurrentFileSet
}
const sources = [...local.sources]
if (currentRootProjects.length === 0 && local.prepared) sources.push(local.prepared)
sources.push(...compose.projects.map((project) => project.source))
sources.push(...discoverHelmReleases(runner, root))
return sources
}