dagger-env
Version:
A type-safe, reusable environment configuration abstraction for Dagger modules.
378 lines (327 loc) • 11.1 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod/v4'
import { createDaggerEnv } from './dagger-env'
import { createDaggerCommandRunner } from './run-dagger-cmd'
import type { OPItem } from './op'
import type { RunDaggerCommandConfig } from './run-dagger-cmd'
const mocks = vi.hoisted(() => ({
/** Resolves the JSON payload returned by the secrets CLI (`infisical export` / `op item get`) */
fetchJson: vi.fn(),
/** Records the rendered secrets fetch command string */
fetchCmd: vi.fn(),
/** Records the final command execution: (options, argv) */
spawn: vi.fn(
async (_opts: { env: Record<string, string | undefined> }, _argv: string[]) => undefined
),
/** Mock for fs.exists() (docker socket probe) */
exists: vi.fn(async () => false),
}))
vi.mock('zx', () => {
const $ = (firstArg: unknown, ...vals: unknown[]) => {
if (Array.isArray(firstArg)) {
// Direct template call: $`infisical export ...` / $`op item get ...`
const pieces = firstArg as readonly string[]
const cmd = pieces.reduce(
(acc, piece, i) => acc + piece + (i < vals.length ? String(vals[i]) : ''),
''
)
mocks.fetchCmd(cmd)
return { json: mocks.fetchJson }
}
// Options call: $({ env, stdio }) returns a template executor
return (_pieces: TemplateStringsArray, ...templateVals: unknown[]) =>
mocks.spawn(
firstArg as { env: Record<string, string | undefined> },
templateVals[0] as string[]
)
}
return { $, fs: { exists: mocks.exists } }
})
/**
* Environment variables passed to the spawned process
*/
type SpawnEnv = Record<string, string | undefined>
function getSpawnCall(): { env: SpawnEnv; argv: string[] } {
expect(mocks.spawn).toHaveBeenCalledTimes(1)
const call = mocks.spawn.mock.calls.at(0)
if (!call) {
throw new Error('spawn was not called')
}
const [opts, argv] = call
return { env: opts.env, argv }
}
function createTestDaggerEnv() {
return createDaggerEnv({
args: z.object({
push: z.string().optional(),
}),
env: z.object({
CI: z.string().optional(),
GITHUB_ACTIONS: z.string().optional(),
NODE_ENV: z.string().optional(),
}),
secrets: z.object({
API_TOKEN: z.string(),
FORWARDED_TOKEN: z.string().optional(),
}),
secretPresets: {
api: ['API_TOKEN'],
},
derivedEnvVars: {},
})
}
function createRunner(
overrides?: Partial<Pick<RunDaggerCommandConfig, 'dockerCommands' | 'additionalSecrets'>>
) {
return createDaggerCommandRunner({
projectId: 'test-project-id',
env: 'prod',
path: '/ci/test',
daggerEnv: createTestDaggerEnv(),
...overrides,
})
}
function createOnePasswordRunner(
overrides?: Partial<Pick<RunDaggerCommandConfig, 'dockerCommands' | 'additionalSecrets'>>
) {
return createDaggerCommandRunner({
opVault: 'test-vault-id',
opItem: 'test-item-id',
opSections: [{ id: 'shared-section-id', label: 'Shared' }],
daggerEnv: createTestDaggerEnv(),
...overrides,
})
}
/**
* A realistic `op item get --format json` payload with one field in the
* configured section and one field in an excluded section
*/
const testOPItem: OPItem = {
id: 'test-item-id',
title: 'Test Item',
favorite: false,
version: 1,
vault: { id: 'test-vault-id', name: 'Test Vault' },
category: 'SECURE_NOTE',
last_edited_by: 'test-user',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
additional_information: '',
sections: [
{ id: 'shared-section-id', label: 'Shared' },
{ id: 'other-section-id', label: 'Other' },
],
fields: [
{
id: 'field-1',
section: { id: 'shared-section-id', label: 'Shared' },
type: 'CONCEALED',
label: 'API_TOKEN',
value: 'test-api-token',
reference: 'op://test-vault-id/test-item-id/API_TOKEN',
},
{
id: 'field-2',
section: { id: 'other-section-id', label: 'Other' },
type: 'CONCEALED',
label: 'EXCLUDED_SECRET',
value: 'excluded-value',
reference: 'op://test-vault-id/test-item-id/EXCLUDED_SECRET',
},
],
}
describe('createDaggerCommandRunner()', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('CI', undefined)
vi.stubEnv('GITHUB_ACTIONS', undefined)
vi.stubEnv('DAGGER_CLOUD_TOKEN', undefined)
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('infisical', () => {
beforeEach(() => {
mocks.fetchJson.mockResolvedValue([
{ key: 'API_TOKEN', value: 'test-api-token', comment: '' },
{ key: 'DAGGER_CLOUD_TOKEN', value: 'test-dagger-cloud-token', comment: '' },
])
})
it('builds the expected DAGGER_OPTIONS payload from the infisical export', async () => {
const runDaggerCommand = createRunner()
await runDaggerCommand('test-cmd', {
args: { push: 'true' },
env: { NODE_ENV: 'production' },
})
expect(mocks.fetchCmd).toHaveBeenCalledWith(
'infisical export --silent --format=json --projectId test-project-id --env prod --path /ci/test'
)
const { env } = getSpawnCall()
expect(JSON.parse(env.DAGGER_OPTIONS as string)).toStrictEqual({
args: { push: 'true' },
env: { NODE_ENV: 'production' },
secrets: { API_TOKEN: 'test-api-token' },
})
expect(env.DAGGER_CLOUD_TOKEN).toBeUndefined()
})
it('passes DAGGER_CLOUD_TOKEN to the runner env (but not DAGGER_OPTIONS) in CI', async () => {
vi.stubEnv('CI', 'true')
vi.stubEnv('GITHUB_ACTIONS', 'true')
const runDaggerCommand = createRunner()
await runDaggerCommand('test-cmd')
const { env } = getSpawnCall()
const daggerOptions = JSON.parse(env.DAGGER_OPTIONS as string)
expect(daggerOptions.secrets).not.toHaveProperty('DAGGER_CLOUD_TOKEN')
expect(daggerOptions.env).toStrictEqual({ CI: 'true', GITHUB_ACTIONS: 'true' })
expect(env.DAGGER_CLOUD_TOKEN).toBe('test-dagger-cloud-token')
})
it('spawns dagger directly without an op wrapper', async () => {
const runDaggerCommand = createRunner()
await runDaggerCommand('test-cmd', { extraArgs: ['--verbose'] })
const { argv } = getSpawnCall()
expect(argv[0]).toBe('dagger')
expect(argv).not.toContain('op')
expect(argv).toStrictEqual([
'dagger',
'call',
'test-cmd',
'--verbose',
'--options=env://DAGGER_OPTIONS',
])
})
})
describe('1password', () => {
beforeEach(() => {
mocks.fetchJson.mockResolvedValue(testOPItem)
})
it('builds the expected DAGGER_OPTIONS payload from the op item sections', async () => {
const runDaggerCommand = createOnePasswordRunner()
await runDaggerCommand('test-cmd', {
args: { push: 'true' },
env: { NODE_ENV: 'production' },
})
expect(mocks.fetchCmd).toHaveBeenCalledWith(
'op item get test-item-id --vault test-vault-id --format json'
)
const { env } = getSpawnCall()
expect(JSON.parse(env.DAGGER_OPTIONS as string)).toStrictEqual({
args: { push: 'true' },
env: { NODE_ENV: 'production' },
secrets: { API_TOKEN: 'test-api-token' },
})
expect(env.DAGGER_CLOUD_TOKEN).toBeUndefined()
})
it('wraps dagger call in op run --no-masking', async () => {
const runDaggerCommand = createOnePasswordRunner()
await runDaggerCommand('test-cmd', { extraArgs: ['--verbose'] })
const { argv } = getSpawnCall()
expect(argv).toStrictEqual([
'op',
'run',
'--no-masking',
'--',
'dagger',
'call',
'test-cmd',
'--verbose',
'--options=env://DAGGER_OPTIONS',
])
})
it('passes DAGGER_CLOUD_TOKEN as an op:// reference in CI', async () => {
vi.stubEnv('CI', 'true')
vi.stubEnv('GITHUB_ACTIONS', 'true')
const runDaggerCommand = createOnePasswordRunner()
await runDaggerCommand('test-cmd')
const { env } = getSpawnCall()
const daggerOptions = JSON.parse(env.DAGGER_OPTIONS as string)
expect(daggerOptions.secrets).not.toHaveProperty('DAGGER_CLOUD_TOKEN')
expect(daggerOptions.env).toStrictEqual({ CI: 'true', GITHUB_ACTIONS: 'true' })
expect(env.DAGGER_CLOUD_TOKEN).toBe('op://test-vault-id/test-item-id/DAGGER_CLOUD_TOKEN')
})
})
describe('additionalSecrets', () => {
beforeEach(() => {
mocks.fetchJson.mockResolvedValue([
{ key: 'API_TOKEN', value: 'test-api-token', comment: '' },
])
})
it('merges additional secrets into the provider secrets', async () => {
const runDaggerCommand = createRunner({
additionalSecrets: { FORWARDED_TOKEN: 'test-forwarded-token' },
})
await runDaggerCommand('test-cmd')
const { env } = getSpawnCall()
expect(JSON.parse(env.DAGGER_OPTIONS as string).secrets).toStrictEqual({
API_TOKEN: 'test-api-token',
FORWARDED_TOKEN: 'test-forwarded-token',
})
})
it('skips additional secrets with an undefined value', async () => {
const runDaggerCommand = createRunner({
additionalSecrets: { FORWARDED_TOKEN: undefined },
})
await runDaggerCommand('test-cmd')
const { env } = getSpawnCall()
expect(JSON.parse(env.DAGGER_OPTIONS as string).secrets).toStrictEqual({
API_TOKEN: 'test-api-token',
})
})
it('takes precedence over a provider secret of the same name', async () => {
const runDaggerCommand = createRunner({
additionalSecrets: { API_TOKEN: 'test-override-token' },
})
await runDaggerCommand('test-cmd')
const { env } = getSpawnCall()
expect(JSON.parse(env.DAGGER_OPTIONS as string).secrets).toStrictEqual({
API_TOKEN: 'test-override-token',
})
})
it('merges additional secrets for the 1password provider', async () => {
mocks.fetchJson.mockResolvedValue(testOPItem)
const runDaggerCommand = createOnePasswordRunner({
additionalSecrets: { FORWARDED_TOKEN: 'test-forwarded-token' },
})
await runDaggerCommand('test-cmd')
const { env } = getSpawnCall()
expect(JSON.parse(env.DAGGER_OPTIONS as string).secrets).toStrictEqual({
API_TOKEN: 'test-api-token',
FORWARDED_TOKEN: 'test-forwarded-token',
})
})
})
describe('docker socket', () => {
beforeEach(() => {
mocks.fetchJson.mockResolvedValue([
{ key: 'API_TOKEN', value: 'test-api-token', comment: '' },
])
})
it('appends --docker-socket for dockerCommands when the socket exists', async () => {
mocks.exists.mockResolvedValue(true)
const runDaggerCommand = createRunner({ dockerCommands: ['build'] })
await runDaggerCommand('build')
const { argv } = getSpawnCall()
expect(argv).toStrictEqual([
'dagger',
'call',
'build',
'--docker-socket=/var/run/docker.sock',
'--options=env://DAGGER_OPTIONS',
])
})
it('does not append --docker-socket for non-docker commands', async () => {
mocks.exists.mockResolvedValue(true)
const runDaggerCommand = createRunner({ dockerCommands: ['build'] })
await runDaggerCommand('test-cmd')
const { argv } = getSpawnCall()
expect(argv).not.toContain('--docker-socket=/var/run/docker.sock')
expect(mocks.exists).not.toHaveBeenCalled()
})
it('does not append --docker-socket when the socket does not exist', async () => {
mocks.exists.mockResolvedValue(false)
const runDaggerCommand = createRunner({ dockerCommands: ['build'] })
await runDaggerCommand('build')
const { argv } = getSpawnCall()
expect(argv).not.toContain('--docker-socket=/var/run/docker.sock')
})
})
})