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
628 lines
24 KiB
TypeScript
628 lines
24 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'bun:test'
|
|
import {
|
|
type ConfigurationCommandRunner,
|
|
type ConfigurationSourceDiscoveryOptions,
|
|
discoverConfigurationSources as discoverConfigurationSourcesFromEnvironment,
|
|
parseComposeFileEnvironment,
|
|
resolveKubernetesContainerEnvironment,
|
|
} from './configuration-sources.ts'
|
|
|
|
const temporaryDirectories: string[] = []
|
|
|
|
function temporaryDirectory(): string {
|
|
const directory = mkdtempSync(path.join(tmpdir(), 'sim-configuration-sources-'))
|
|
temporaryDirectories.push(directory)
|
|
return directory
|
|
}
|
|
|
|
function commandResult(status: number, stdout = '') {
|
|
return { status, stdout, stderr: '' }
|
|
}
|
|
|
|
function discoverConfigurationSources(options: ConfigurationSourceDiscoveryOptions) {
|
|
return discoverConfigurationSourcesFromEnvironment({ processEnvironment: {}, ...options })
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
describe('discoverConfigurationSources', () => {
|
|
it('enumerates split development and prepared Compose configuration separately', () => {
|
|
const root = temporaryDirectory()
|
|
mkdirSync(path.join(root, 'apps/sim'), { recursive: true })
|
|
mkdirSync(path.join(root, 'apps/realtime'), { recursive: true })
|
|
mkdirSync(path.join(root, 'packages/db'), { recursive: true })
|
|
writeFileSync(path.join(root, 'apps/sim/.env'), 'RESEND_API_KEY=dev-email\n')
|
|
writeFileSync(path.join(root, 'apps/realtime/.env'), 'REDIS_URL=redis://localhost:6379\n')
|
|
writeFileSync(path.join(root, 'packages/db/.env'), 'DATABASE_URL=postgresql://dev\n')
|
|
writeFileSync(path.join(root, '.env'), 'RESEND_API_KEY=compose-email\n')
|
|
writeFileSync(
|
|
path.join(root, 'docker-compose.prod.yml'),
|
|
`services:
|
|
simstudio:
|
|
image: ghcr.io/simstudioai/simstudio:latest
|
|
env_file:
|
|
- .env
|
|
environment:
|
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/simstudio
|
|
- NEXT_PUBLIC_APP_URL=\${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
|
- BETTER_AUTH_URL=\${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
|
- REDIS_URL=\${REDIS_URL:-redis://redis:6379}
|
|
`
|
|
)
|
|
const runner: ConfigurationCommandRunner = () => commandResult(1)
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(2)
|
|
expect(sources[0]).toMatchObject({ kind: 'dev', managedByCurrentCheckout: true })
|
|
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('dev-email')
|
|
expect(sources[0].values?.has('DATABASE_URL')).toBe(false)
|
|
expect(sources[1]).toMatchObject({ kind: 'compose', managedByCurrentCheckout: false })
|
|
expect(sources[1].values?.get('RESEND_API_KEY')).toBe('compose-email')
|
|
expect(sources[1].values?.get('REDIS_URL')).toBe('redis://redis:6379')
|
|
expect(sources[1].values?.get('DATABASE_URL')).toBe(
|
|
'postgresql://postgres:postgres@db:5432/simstudio'
|
|
)
|
|
})
|
|
|
|
it('uses the last active value from a prepared Compose .env file', () => {
|
|
const root = temporaryDirectory()
|
|
writeFileSync(path.join(root, '.env'), 'RESEND_API_KEY=old\nRESEND_API_KEY=current\n')
|
|
writeFileSync(
|
|
path.join(root, 'docker-compose.prod.yml'),
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n env_file: .env\n'
|
|
)
|
|
|
|
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('current')
|
|
})
|
|
|
|
it('uses the effective environment of a stopped Compose app container', () => {
|
|
const parent = temporaryDirectory()
|
|
const root = path.join(parent, 'checkout')
|
|
const deployment = path.join(parent, 'deployment')
|
|
mkdirSync(root)
|
|
mkdirSync(deployment)
|
|
const composeFile = path.join(deployment, 'compose.yml')
|
|
writeFileSync(
|
|
composeFile,
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
|
|
)
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ Name: 'external-sim', Status: 'exited', ConfigFiles: composeFile }])
|
|
)
|
|
}
|
|
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
|
|
if (command === 'docker' && args[0] === 'inspect') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([
|
|
{
|
|
Created: '2026-01-01T00:00:00Z',
|
|
State: { Running: false },
|
|
Config: { Env: ['RESEND_API_KEY=secret', 'REDIS_URL=redis://redis:6379'] },
|
|
},
|
|
])
|
|
)
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0]).toMatchObject({
|
|
kind: 'compose',
|
|
label: 'Compose project "external-sim"',
|
|
managedByCurrentCheckout: false,
|
|
})
|
|
expect(sources[0].values?.get('REDIS_URL')).toBe('redis://redis:6379')
|
|
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('secret')
|
|
})
|
|
|
|
it('replaces the prepared root source with one unambiguous live project', () => {
|
|
const root = temporaryDirectory()
|
|
writeFileSync(path.join(root, '.env'), 'RESEND_API_KEY=prepared\n')
|
|
const composeFile = path.join(root, 'docker-compose.prod.yml')
|
|
writeFileSync(
|
|
composeFile,
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
|
|
)
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ Name: 'current-sim', Status: 'running', ConfigFiles: composeFile }])
|
|
)
|
|
}
|
|
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
|
|
if (command === 'docker' && args[0] === 'inspect') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([
|
|
{
|
|
Created: '2026-01-01T00:00:00Z',
|
|
State: { Running: true },
|
|
Config: { Env: ['RESEND_API_KEY=effective'] },
|
|
},
|
|
])
|
|
)
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0]).toMatchObject({
|
|
label: 'Compose project "current-sim"',
|
|
managedByCurrentCheckout: true,
|
|
})
|
|
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('effective')
|
|
})
|
|
|
|
it('does not claim a live current-checkout project is setup-managed without root .env', () => {
|
|
const root = temporaryDirectory()
|
|
const composeFile = path.join(root, 'docker-compose.prod.yml')
|
|
writeFileSync(
|
|
composeFile,
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
|
|
)
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ Name: 'current-sim', Status: 'running', ConfigFiles: composeFile }])
|
|
)
|
|
}
|
|
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
|
|
if (command === 'docker' && args[0] === 'inspect') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ State: { Running: true }, Config: { Env: ['NODE_ENV=production'] } }])
|
|
)
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0].managedByCurrentCheckout).toBe(false)
|
|
})
|
|
|
|
it('loads development env files with Next precedence', () => {
|
|
const root = temporaryDirectory()
|
|
const appDirectory = path.join(root, 'apps/sim')
|
|
mkdirSync(appDirectory, { recursive: true })
|
|
writeFileSync(path.join(appDirectory, '.env'), 'STATUS_PRECEDENCE=base\n')
|
|
writeFileSync(path.join(appDirectory, '.env.development'), 'STATUS_PRECEDENCE=development\n')
|
|
writeFileSync(path.join(appDirectory, '.env.local'), 'STATUS_PRECEDENCE=local\n')
|
|
writeFileSync(
|
|
path.join(appDirectory, '.env.development.local'),
|
|
'STATUS_PRECEDENCE=development-local\n'
|
|
)
|
|
|
|
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0].values?.get('STATUS_PRECEDENCE')).toBe('development-local')
|
|
expect(sources[0].location).toContain('.env.development.local')
|
|
expect(sources[0].location).toContain('process environment')
|
|
expect(sources[0].managedByCurrentCheckout).toBe(false)
|
|
})
|
|
|
|
it('does not offer setup writes when a process-only capability value wins', () => {
|
|
const root = temporaryDirectory()
|
|
const appDirectory = path.join(root, 'apps/sim')
|
|
mkdirSync(appDirectory, { recursive: true })
|
|
writeFileSync(path.join(appDirectory, '.env'), 'SLACK_CLIENT_SECRET=file-secret\n')
|
|
const sources = discoverConfigurationSources({
|
|
root,
|
|
runner: () => commandResult(1),
|
|
processEnvironment: { SLACK_CLIENT_ID: 'process-client-id' },
|
|
})
|
|
|
|
expect(sources[0].values?.get('SLACK_CLIENT_ID')).toBe('process-client-id')
|
|
expect(sources[0].managedByCurrentCheckout).toBe(false)
|
|
})
|
|
|
|
it('reports missing files in the split development configuration', () => {
|
|
const root = temporaryDirectory()
|
|
const appDirectory = path.join(root, 'apps/sim')
|
|
mkdirSync(appDirectory, { recursive: true })
|
|
writeFileSync(
|
|
path.join(appDirectory, '.env'),
|
|
[
|
|
'DATABASE_URL=postgresql://localhost/sim',
|
|
`BETTER_AUTH_SECRET=${'a'.repeat(32)}`,
|
|
'BETTER_AUTH_URL=http://localhost:3000',
|
|
'NEXT_PUBLIC_APP_URL=http://localhost:3000',
|
|
`ENCRYPTION_KEY=${'b'.repeat(64)}`,
|
|
`INTERNAL_API_SECRET=${'c'.repeat(32)}`,
|
|
].join('\n')
|
|
)
|
|
|
|
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0].configurationIssues).toEqual(
|
|
expect.arrayContaining(['apps/realtime/.env is missing', 'packages/db/.env is missing'])
|
|
)
|
|
})
|
|
|
|
it('reports shared-value drift across split development env files', () => {
|
|
const root = temporaryDirectory()
|
|
mkdirSync(path.join(root, 'apps/sim'), { recursive: true })
|
|
mkdirSync(path.join(root, 'apps/realtime'), { recursive: true })
|
|
mkdirSync(path.join(root, 'packages/db'), { recursive: true })
|
|
writeFileSync(
|
|
path.join(root, 'apps/sim/.env'),
|
|
[
|
|
'DATABASE_URL=postgresql://localhost/sim',
|
|
`BETTER_AUTH_SECRET=${'a'.repeat(32)}`,
|
|
'BETTER_AUTH_URL=http://localhost:3000',
|
|
'NEXT_PUBLIC_APP_URL=http://localhost:3000',
|
|
`ENCRYPTION_KEY=${'b'.repeat(64)}`,
|
|
`INTERNAL_API_SECRET=${'c'.repeat(32)}`,
|
|
].join('\n')
|
|
)
|
|
writeFileSync(
|
|
path.join(root, 'apps/realtime/.env'),
|
|
[
|
|
'DATABASE_URL=postgresql://localhost/sim',
|
|
`BETTER_AUTH_SECRET=${'d'.repeat(32)}`,
|
|
'BETTER_AUTH_URL=http://localhost:3000',
|
|
'NEXT_PUBLIC_APP_URL=http://localhost:3000',
|
|
`INTERNAL_API_SECRET=${'c'.repeat(32)}`,
|
|
].join('\n')
|
|
)
|
|
writeFileSync(path.join(root, 'packages/db/.env'), 'DATABASE_URL=postgresql://localhost/other')
|
|
|
|
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
|
|
|
|
expect(sources[0].configurationIssues).toEqual(
|
|
expect.arrayContaining([
|
|
'BETTER_AUTH_SECRET differs between apps/sim/.env and apps/realtime/.env',
|
|
'DATABASE_URL differs between apps/sim/.env and packages/db/.env',
|
|
])
|
|
)
|
|
})
|
|
|
|
it('identifies a Helm release by current context, namespace, and release', () => {
|
|
const root = temporaryDirectory()
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'helm') {
|
|
if (args.includes('-a') || !args.includes('--deployed') || !args.includes('--failed')) {
|
|
return commandResult(1)
|
|
}
|
|
if (args.at(-2) !== '--kube-context' || args.at(-1) !== 'production-cluster') {
|
|
return commandResult(1)
|
|
}
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ name: 'sim-prod', namespace: 'production', chart: 'sim-1.2.3' }])
|
|
)
|
|
}
|
|
if (command === 'kubectl' && args[0] === 'config') {
|
|
return commandResult(0, 'production-cluster\n')
|
|
}
|
|
if (!args.includes('--context') || !args.includes('production-cluster')) {
|
|
return commandResult(1)
|
|
}
|
|
if (command === 'kubectl' && args[1] === 'deployments') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify({
|
|
items: [
|
|
{
|
|
spec: {
|
|
template: {
|
|
spec: {
|
|
containers: [
|
|
{
|
|
name: 'app',
|
|
env: [{ name: 'NEXT_PUBLIC_APP_URL', value: 'https://sim.example.com' }],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
})
|
|
)
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0]).toMatchObject({
|
|
kind: 'helm',
|
|
label: 'Helm release "sim-prod"',
|
|
location: 'context production-cluster · namespace production · release sim-prod',
|
|
managedByCurrentCheckout: false,
|
|
})
|
|
expect(sources[0].values?.get('NEXT_PUBLIC_APP_URL')).toBe('https://sim.example.com')
|
|
})
|
|
|
|
it('keeps every Compose config file after one file identifies Sim', () => {
|
|
const parent = temporaryDirectory()
|
|
const root = path.join(parent, 'checkout')
|
|
const deployment = path.join(parent, 'deployment')
|
|
mkdirSync(root)
|
|
mkdirSync(deployment)
|
|
const baseFile = path.join(deployment, 'compose.yml')
|
|
const overrideFile = path.join(deployment, 'compose.override.yml')
|
|
writeFileSync(
|
|
baseFile,
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
|
|
)
|
|
writeFileSync(
|
|
overrideFile,
|
|
'services:\n simstudio:\n environment:\n - REDIS_URL=redis://override\n'
|
|
)
|
|
let renderedWithOverride = false
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([
|
|
{
|
|
Name: 'external-sim',
|
|
Status: 'running',
|
|
ConfigFiles: `${baseFile},${overrideFile}`,
|
|
},
|
|
])
|
|
)
|
|
}
|
|
if (command === 'docker' && args[0] === 'ps') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args.includes('config')) {
|
|
renderedWithOverride = args.includes(overrideFile)
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify({
|
|
services: { simstudio: { environment: { REDIS_URL: 'redis://override' } } },
|
|
})
|
|
)
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(renderedWithOverride).toBe(true)
|
|
expect(sources[0].location).toContain(overrideFile)
|
|
expect(sources[0].values?.get('REDIS_URL')).toBe('redis://override')
|
|
})
|
|
|
|
it('fails fast when Docker Compose returns malformed discovery output', () => {
|
|
const root = temporaryDirectory()
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(0, 'not-json')
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
expect(() => discoverConfigurationSources({ root, runner })).toThrow(
|
|
'Docker Compose returned an invalid project list'
|
|
)
|
|
})
|
|
|
|
it('fails fast when a Docker Compose project entry changes shape', () => {
|
|
const root = temporaryDirectory()
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(0, JSON.stringify([{ Name: 'sim-without-config-files' }]))
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
expect(() => discoverConfigurationSources({ root, runner })).toThrow(
|
|
'Docker Compose returned an invalid project list'
|
|
)
|
|
})
|
|
|
|
it('does not treat unrelated Docker tooling as a Sim configuration source', () => {
|
|
const root = temporaryDirectory()
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === '--version') return commandResult(0)
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(0)
|
|
})
|
|
|
|
it('does not treat an unrelated Kubernetes context as a Sim configuration source', () => {
|
|
const root = temporaryDirectory()
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'kubectl' && args[0] === 'config') {
|
|
return commandResult(0, 'production-cluster\n')
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(0)
|
|
})
|
|
|
|
it('does not substitute desired Compose values when a known container cannot be inspected', () => {
|
|
const root = temporaryDirectory()
|
|
const composeFile = path.join(root, 'docker-compose.prod.yml')
|
|
writeFileSync(
|
|
composeFile,
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
|
|
)
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ Name: 'known-sim', Status: 'running', ConfigFiles: composeFile }])
|
|
)
|
|
}
|
|
if (command === 'docker' && args[0] === 'ps') return commandResult(0, 'container-id\n')
|
|
if (command === 'docker' && args[0] === 'inspect') return commandResult(1)
|
|
if (command === 'docker' && args[0] === 'compose') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify({
|
|
services: { simstudio: { environment: { RESEND_API_KEY: 'desired' } } },
|
|
})
|
|
)
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0].values).toBeNull()
|
|
expect(sources[0].warning).toContain('could not be inspected')
|
|
})
|
|
|
|
it('reports a known Compose project as unknown when its containers cannot be enumerated', () => {
|
|
const root = temporaryDirectory()
|
|
const composeFile = path.join(root, 'docker-compose.prod.yml')
|
|
writeFileSync(
|
|
composeFile,
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n'
|
|
)
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'docker' && args[0] === 'info') return commandResult(0)
|
|
if (command === 'docker' && args[0] === 'compose' && args[1] === 'ls') {
|
|
return commandResult(
|
|
0,
|
|
JSON.stringify([{ Name: 'known-sim', Status: 'running', ConfigFiles: composeFile }])
|
|
)
|
|
}
|
|
if (command === 'docker' && args[0] === 'ps') return commandResult(1)
|
|
return commandResult(1)
|
|
}
|
|
|
|
const sources = discoverConfigurationSources({ root, runner })
|
|
|
|
expect(sources).toHaveLength(1)
|
|
expect(sources[0].values).toBeNull()
|
|
expect(sources[0].warning).toContain('could not be enumerated')
|
|
})
|
|
|
|
it('fails fast when a Helm release entry changes shape', () => {
|
|
const root = temporaryDirectory()
|
|
const runner: ConfigurationCommandRunner = (command, args) => {
|
|
if (command === 'kubectl' && args[0] === 'config') {
|
|
return commandResult(0, 'production-cluster\n')
|
|
}
|
|
if (command === 'helm' && args[0] === 'list') {
|
|
return commandResult(0, JSON.stringify([{ name: 'sim-prod', namespace: 'production' }]))
|
|
}
|
|
return commandResult(1)
|
|
}
|
|
|
|
expect(() => discoverConfigurationSources({ root, runner })).toThrow(
|
|
'Helm returned an invalid release list'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('parseComposeFileEnvironment', () => {
|
|
it('does not inject root .env when the service has no env_file', () => {
|
|
const values = parseComposeFileEnvironment(
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n environment:\n - REDIS_URL=redis://redis:6379\n',
|
|
new Map([['RESEND_API_KEY', 'must-not-be-injected']])
|
|
)
|
|
|
|
expect(values?.get('REDIS_URL')).toBe('redis://redis:6379')
|
|
expect(values?.has('RESEND_API_KEY')).toBe(false)
|
|
})
|
|
|
|
it('returns unknown for unsupported inline environment syntax', () => {
|
|
const values = parseComposeFileEnvironment(
|
|
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n env_file: .env\n environment: { RESEND_API_KEY: override }\n',
|
|
new Map([['RESEND_API_KEY', 'root-value']])
|
|
)
|
|
|
|
expect(values).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('resolveKubernetesContainerEnvironment', () => {
|
|
it('applies envFrom order and then explicit env/valueFrom precedence', () => {
|
|
const resources = new Map([
|
|
[
|
|
'secret/app-secret',
|
|
{
|
|
data: {
|
|
SHARED: Buffer.from('secret').toString('base64'),
|
|
SECRET_ONLY: Buffer.from('secret-only').toString('base64'),
|
|
EXPLICIT_SOURCE: Buffer.from('from-secret-key').toString('base64'),
|
|
},
|
|
},
|
|
],
|
|
['configmap/app-config', { data: { SHARED: 'configmap', CONFIG_ONLY: 'config-only' } }],
|
|
])
|
|
const resolution = resolveKubernetesContainerEnvironment(
|
|
{
|
|
envFrom: [{ secretRef: { name: 'app-secret' } }, { configMapRef: { name: 'app-config' } }],
|
|
env: [
|
|
{ name: 'SHARED', value: 'explicit' },
|
|
{
|
|
name: 'FROM_SECRET',
|
|
valueFrom: { secretKeyRef: { name: 'app-secret', key: 'EXPLICIT_SOURCE' } },
|
|
},
|
|
],
|
|
},
|
|
(kind, name) => {
|
|
const resource = resources.get(`${kind}/${name}`)
|
|
return resource ? { state: 'found', resource } : { state: 'missing' }
|
|
}
|
|
)
|
|
|
|
expect(resolution.warning).toBeUndefined()
|
|
expect(resolution.values).toEqual(
|
|
new Map([
|
|
['SHARED', 'explicit'],
|
|
['SECRET_ONLY', 'secret-only'],
|
|
['EXPLICIT_SOURCE', 'from-secret-key'],
|
|
['CONFIG_ONLY', 'config-only'],
|
|
['FROM_SECRET', 'from-secret-key'],
|
|
])
|
|
)
|
|
})
|
|
|
|
it('returns unknown instead of claiming missing configuration when a Secret is inaccessible', () => {
|
|
const resolution = resolveKubernetesContainerEnvironment(
|
|
{ envFrom: [{ secretRef: { name: 'restricted-secret' } }] },
|
|
() => ({ state: 'inaccessible' })
|
|
)
|
|
|
|
expect(resolution.values).toBeNull()
|
|
expect(resolution.warning).toContain('RBAC')
|
|
expect(resolution.warning).not.toContain('secret-value')
|
|
})
|
|
})
|