Custom Resources
Implement your own resources with ResourceDefinition.
Ignition's resource system is extensible, but the custom-resource API is currently internal to the repository. The package root does not currently re-export ResourceDefinition, executeResource, requireCapability, or ResourceRegistry.
This page documents the current in-repo pattern implemented in:
src/core/types.tssrc/core/resource.tssrc/core/registry.ts
The ResourceDefinition interface
interface ResourceDefinition<TInput, TOutput> {
readonly type: string
readonly schema?: ResourceSchema
formatName(input: TInput): string
check(ctx: ExecutionContext, input: TInput): Promise<CheckResult<TOutput>>
apply(ctx: ExecutionContext, input: TInput): Promise<TOutput>
}| Method | Purpose |
|---|---|
type | Unique lowercase identifier (e.g. "user") |
formatName | Returns a human-readable name for display. Must be pure (no I/O). |
check | Inspects current state and reports whether changes are needed. Keep it side-effect-free where possible. |
apply | Mutating. Converges to the desired state and returns the output. |
Implementing check()
check() should be side-effect-free wherever possible. It inspects the remote host and returns a
CheckResult:
async check(
ctx: ExecutionContext,
input: UserInput,
): Promise<CheckResult<UserOutput>> {
const result = await ctx.connection.exec(`id -u ${shellQuote(input.name)}`)
const exists = result.exitCode === 0
if (input.state === "absent") {
return exists
? { inDesiredState: false, current: { exists: true }, desired: { state: "absent" } }
: { inDesiredState: true, current: { exists: false }, desired: { state: "absent" }, output: { name: input.name, changed: false } }
}
if (!exists) {
return { inDesiredState: false, current: { exists: false }, desired: { state: "present" } }
}
return {
inDesiredState: true,
current: { exists: true },
desired: { state: "present" },
output: { name: input.name, changed: false },
}
}Key rules:
- Return
inDesiredState: truewithoutputwhen no changes are needed - Return
inDesiredState: falsewithoutoutputwhen changes are needed - Include
currentanddesiredfor diff display - Keep mutating preconditions out of
check()soignition run --checkstays trustworthy
Implementing apply()
apply() makes changes to converge the host to the desired state:
async apply(ctx: ExecutionContext, input: UserInput): Promise<UserOutput> {
if (input.state === "absent") {
await ctx.connection.exec(`sudo userdel ${shellQuote(input.name)}`)
return { name: input.name, changed: true }
}
await ctx.connection.exec(
`sudo useradd -m -s /bin/bash ${shellQuote(input.name)}`,
)
return { name: input.name, changed: true }
}Shell quoting
Always quote values interpolated into shell commands:
function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}Capability checks
Use the internal requireCapability() helper from src/core/resource.ts to assert the transport supports what you need:
async check(ctx: ExecutionContext, input: UserInput) {
requireCapability(ctx, "exec", "user")
// ...
}This throws a CapabilityError if the transport doesn't support the required capability.
Complete example
A user resource that ensures a system user exists:
// Imports omitted intentionally.
// In the current repo, these helpers live in src/core/types.ts and src/core/resource.ts.
interface UserInput {
readonly name: string
readonly shell?: string
readonly state?: "present" | "absent"
}
interface UserOutput {
readonly name: string
readonly changed: boolean
}
function shellQuote(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
const userDefinition: ResourceDefinition<UserInput, UserOutput> = {
type: "user",
formatName(input: UserInput): string {
return input.name
},
async check(ctx: ExecutionContext, input: UserInput): Promise<CheckResult<UserOutput>> {
requireCapability(ctx, "exec", "user")
const state = input.state ?? "present"
const result = await ctx.connection.exec(`id -u ${shellQuote(input.name)}`)
const exists = result.exitCode === 0
if (state === "absent") {
return exists
? { inDesiredState: false, current: { exists: true }, desired: { state: "absent" } }
: {
inDesiredState: true,
current: { exists: false },
desired: { state: "absent" },
output: { name: input.name, changed: false },
}
}
if (!exists) {
return {
inDesiredState: false,
current: { exists: false },
desired: { state: "present", shell: input.shell },
}
}
return {
inDesiredState: true,
current: { exists: true },
desired: { state: "present" },
output: { name: input.name, changed: false },
}
},
async apply(ctx: ExecutionContext, input: UserInput): Promise<UserOutput> {
requireCapability(ctx, "exec", "user")
const state = input.state ?? "present"
if (state === "absent") {
await ctx.connection.exec(`sudo userdel -r ${shellQuote(input.name)}`)
return { name: input.name, changed: true }
}
const shell = input.shell ?? "/bin/bash"
await ctx.connection.exec(`sudo useradd -m -s ${shellQuote(shell)} ${shellQuote(input.name)}`)
return { name: input.name, changed: true }
},
}
export function createUser(ctx: ExecutionContext) {
return (input: UserInput, meta?: ResourceCallMeta) =>
executeResource(ctx, userDefinition, input, ctx.resourcePolicy, meta)
}Use it in a recipe:
import { createUser } from "./resources/user.ts"
export default async function (ctx: ExecutionContext) {
const user = createUser(ctx)
await user({ name: "deploy", shell: "/bin/bash" })
}Registering with ResourceRegistry
Within the repository, ResourceRegistry and defaultRegistry live in src/core/registry.ts. Use them for schema generation and agent discoverability:
const registry = new ResourceRegistry()
// Copy built-in resources
for (const def of defaultRegistry.definitions()) {
registry.register(def)
}
// Add custom resource
registry.register(userDefinition)