dagger-env
Version:
A type-safe, reusable environment configuration abstraction for Dagger modules.
224 lines (197 loc) • 6.77 kB
text/typescript
import { z } from 'zod/v4'
import { $, fs } from 'zx'
import { OPItem, OPSection } from './op.js'
import type { DaggerEnv, DaggerEnvConfig } from './dagger-env.js'
/**
* A single secret returned by `infisical export --format=json`
*/
export type InfisicalSecret = z.infer<typeof InfisicalSecret>
export const InfisicalSecret = z.object({
key: z.string(),
value: z.string(),
})
/**
* Infisical secrets provider configuration
*/
export interface InfisicalProviderConfig {
/** Infisical project ID */
projectId: string
/** Infisical environment slug (e.g. `prod`) */
env: string
/** Infisical folder path to fetch secrets from (e.g. `/ci/my-repo`) */
path: string
}
/**
* 1Password secrets provider configuration
*/
export interface OnePasswordProviderConfig {
/** 1Password vault ID */
opVault: string
/** 1Password item ID */
opItem: string
/** 1Password sections to include for secrets */
opSections: Array<{ label: string; id: string }>
}
/**
* Configuration shared by all secrets providers
*/
export interface RunDaggerCommandBaseConfig<T extends DaggerEnvConfig = DaggerEnvConfig> {
/** Commands that should include Docker socket if available */
dockerCommands?: string[]
/** Hook to run before executing the command (e.g., vendor file setup) */
beforeCommand?: () => Promise<void>
/** DaggerEnv instance for schema validation and type safety */
daggerEnv: DaggerEnv<T>
/**
* Secrets to pass to Dagger alongside the ones fetched from the provider,
* taking precedence on conflict. Entries with an undefined value are
* skipped, so short-lived credentials that only exist in some environments
* (e.g. a CI-issued token) can be forwarded without a separate config path.
*/
additionalSecrets?: Record<string, string | undefined>
}
/**
* Configuration for running Dagger commands with Infisical or 1Password integration
*/
export type RunDaggerCommandConfig<T extends DaggerEnvConfig = DaggerEnvConfig> =
RunDaggerCommandBaseConfig<T> & (InfisicalProviderConfig | OnePasswordProviderConfig)
/**
* Options for individual command execution
*/
export interface RunDaggerCommandOptions {
/** Arguments to pass to the Dagger command */
args?: Record<string, any>
/** Additional environment variables */
env?: Record<string, string>
/** Additional command-line arguments */
extraArgs?: string[]
}
/**
* Fetches secrets from Infisical via `infisical export`
*/
async function fetchInfisicalSecrets(
config: InfisicalProviderConfig
): Promise<Record<string, string>> {
const exportedSecrets = InfisicalSecret.array().parse(
await $`infisical export --silent --format=json --projectId ${config.projectId} --env ${config.env} --path ${config.path}`.json()
)
return exportedSecrets.reduce(
(acc, s) => {
acc[s.key] = s.value
return acc
},
{} as Record<string, string>
)
}
/**
* Fetches secrets from a 1Password item, filtered to the configured sections
*/
async function fetchOnePasswordSecrets(
config: OnePasswordProviderConfig
): Promise<Record<string, string>> {
const opItem = OPItem.parse(
await $`op item get ${config.opItem} --vault ${config.opVault} --format json`.json()
)
const sectionsToInclude = OPSection.array().parse(config.opSections)
return opItem.fields
.filter((f) => sectionsToInclude.some((s) => s.id === f.section?.id))
.reduce(
(acc, f) => {
acc[f.label] = f.value
return acc
},
{} as Record<string, string>
)
}
/**
* Creates a function to run Dagger commands with Infisical or 1Password integration
* @param config Configuration for the command runner
* @returns Function to execute Dagger commands
*/
export function createDaggerCommandRunner<T extends DaggerEnvConfig>(
config: RunDaggerCommandBaseConfig<T> & InfisicalProviderConfig
): (commandName: string, options?: RunDaggerCommandOptions) => Promise<void>
export function createDaggerCommandRunner<T extends DaggerEnvConfig>(
config: RunDaggerCommandBaseConfig<T> & OnePasswordProviderConfig
): (commandName: string, options?: RunDaggerCommandOptions) => Promise<void>
export function createDaggerCommandRunner<T extends DaggerEnvConfig>(
config: RunDaggerCommandConfig<T>
) {
return async function runDaggerCommand(
commandName: string,
options?: RunDaggerCommandOptions
): Promise<void> {
const { args = {}, env = {}, extraArgs = [] } = options ?? {}
// Run any pre-command setup
if (config.beforeCommand) {
await config.beforeCommand()
}
// Environment variables to pass to the spawned command
const envVars: Record<string, string> = {}
const commandArgs: string[] = [...extraArgs]
// Add Docker socket for specific commands if available
if (config.dockerCommands?.includes(commandName)) {
try {
if (await fs.exists('/var/run/docker.sock')) {
commandArgs.push('--docker-socket=/var/run/docker.sock')
}
} catch {
// Ignore if fs is not available or docker socket doesn't exist
}
}
// Fetch secrets from the configured provider
let secrets: Record<string, string>
if ('opVault' in config) {
secrets = await fetchOnePasswordSecrets(config)
// Pass dagger cloud token in CI because we don't have user auth.
// The op:// reference is resolved by the `op run` wrapper below.
if (process.env.CI !== undefined) {
envVars.DAGGER_CLOUD_TOKEN = `op://${config.opVault}/${config.opItem}/DAGGER_CLOUD_TOKEN`
}
} else {
secrets = await fetchInfisicalSecrets(config)
// Pass dagger cloud token in CI because we don't have user auth
if (process.env.CI !== undefined && secrets.DAGGER_CLOUD_TOKEN !== undefined) {
envVars.DAGGER_CLOUD_TOKEN = secrets.DAGGER_CLOUD_TOKEN
}
}
for (const [name, value] of Object.entries(config.additionalSecrets ?? {})) {
if (value !== undefined) {
secrets[name] = value
}
}
// Build environment variables for Dagger
const daggerEnv: Record<string, string> = { ...env }
if (process.env.CI !== undefined) {
daggerEnv.CI = process.env.CI
}
if (process.env.GITHUB_ACTIONS !== undefined) {
daggerEnv.GITHUB_ACTIONS = process.env.GITHUB_ACTIONS
}
// Validate and serialize dagger options
const daggerOptions = config.daggerEnv.getOptionsSchema().parse({
args,
env: daggerEnv,
secrets,
})
envVars.DAGGER_OPTIONS = JSON.stringify(daggerOptions)
// Construct the command. 1Password wraps `dagger call` in `op run`
// so that op:// references in the environment are resolved.
const cmd: string[] = [
...('opVault' in config ? ['op', 'run', '--no-masking', '--'] : []),
'dagger',
'call',
commandName,
...commandArgs,
'--options=env://DAGGER_OPTIONS',
]
// Execute the command
await $({
env: {
...process.env,
...envVars,
},
stdio: 'inherit',
})`${cmd}`
}
}