Alchemy Integration
TypeKro integrates with Alchemy to deploy your TypeKro resources through Alchemy's declarative, stateful runtime — so they get per-resource state, dependency-ordered deployment, idempotent reconcile, and reverse-topological teardown alongside the rest of your Alchemy-managed infrastructure.
Alchemy v2. This integration targets Alchemy v2 (the Effect-based
2.0.0-betaline). It is declarative: TypeKro emits resource declarations, and your Alchemy runtime materializes them as Alchemy resources. The older v1 imperative model (graph.deployWithAlchemy(...), thealchemyScopefactory option, the globalalchemy(...)scope-driven deploy) has been removed.
What is Alchemy?
Alchemy is an infrastructure-as-TypeScript tool with a stateful runtime: it tracks every resource it manages in a state store, deploys them in dependency order, reconciles them idempotently, and tears them down in reverse-topological order. TypeKro's integration represents each TypeKro KRO resource as an Alchemy resource, so a TypeKro deployment becomes a first-class part of an Alchemy stack.
The v2 model
TypeKro exports a declarative Alchemy v2 integration from typekro/alchemy:
KroResource— a declarative Alchemy v2Resourcerepresenting one TypeKro KRO resource. That single resource can be an RGD (ResourceGraphDefinition), a CR instance, or a direct-mode Kubernetes resource.kroProvider— the AlchemyProvider(an EffectLayer) that backsKroResource. Merge it into your Alchemy runtime's providers.materializeAlchemyResources(KroResource, declarations)— a helper that returns an Effect. Run it inside an AlchemyStackbody to instantiate a list of declarations asKroResources. It wires each declaration'sdependsOninto AlchemyOutputdependencies, so resources deploy in dependency order and direct-mode cross-resource references resolve against their dependencies' live state.AlchemyResourceDeclaration—{ id: string; props; dependsOn: string[] }. This is whattoAlchemyResourcesreturns.
Both DirectResourceFactory and KroResourceFactory expose:
toAlchemyResources(spec, opts?): Promise<AlchemyResourceDeclaration[]>It emits the resource(s) as declarations:
- KRO mode → a declaration for each discovered singleton owner (its own RGD + CR instance), then the composition's RGD, then its CR instance. The instance
dependsOnthe RGD and any singleton instances, so a deployment with no singletons is just two declarations (RGD + instance), and one that depends on shared singletons emits those first. - Direct mode → one declaration per resolved Kubernetes resource, topologically ordered, with
dependsOntaken from the resource dependency graph.
The result is the same per-resource state granularity as the old v1 integration — one Alchemy state entry per resource, reverse-topological teardown, idempotent reconcile — but expressed declaratively.
Canonical Usage
This is the verified pattern (see test/integration/alchemy/direct-fan-out-e2e.test.ts):
import { Cel, simple, toResourceGraph } from 'typekro';
import { KroResource, kroProvider, materializeAlchemyResources } from 'typekro/alchemy';
// + your Alchemy v2 runtime — its `providers` must include `kroProvider`, plus a state backend.
// 1. Build the factory as usual.
const factory = await graph.factory('direct', { namespace: 'apps', waitForReady: true });
// 2. Emit per-resource declarations (topologically ordered, dependsOn wired).
const decls = await factory.toAlchemyResources(spec);
// 3. Inside an Alchemy Stack body (an Effect generator), with kroProvider in the runtime:
const outputs = yield* materializeAlchemyResources(KroResource, decls);Once deployed, each TypeKro resource is a per-resource entry in Alchemy's state: Alchemy reconciles them idempotently and tears them down in reverse-topological order.
Cross-provider artifact outputs
An experimental semantic plan may reference an output from a non-Kubernetes Alchemy resource with artifactOutput(requirementId, output). toAlchemyResources() keeps that value symbolic and records the exact requirement and output uses on each declaration. Supply the external resource handle and its output expressions to materializeAlchemyResources():
const build = yield* ContainerBuild('build', buildProps);
const declarations = await factory.toAlchemyResources(spec);
const outputs = yield* materializeAlchemyResources(KroResource, declarations, {
artifacts: {
build: {
resource: build,
outputs: { image: build.image },
},
},
});The external handle becomes a real Alchemy dependency edge for every consuming TypeKro declaration. Missing requirements or output names fail before provider reconciliation. Outputs used inside a sensitive value are converted to Effect Redacted values before they enter TypeKro provider props, remain redacted through Alchemy state rehydration, and are unwrapped only while materializing the Kubernetes apply operation. This works for both direct and KRO factories. KRO bindings currently accept string outputs because the generated root CRD must declare a stable SimpleSchema type; direct-mode bindings may carry any JSON-compatible Kubernetes field value. In KRO mode the resolved string is projected through a reserved field in the root custom resource spec. Alchemy state remains redacted, but the Kubernetes API object is not a secret store. For credential material, have the external provider create or identify a Kubernetes Secret and bind a non-sensitive Secret reference instead of placing raw credential bytes in an artifact output.
The planning DTOs and artifactOutput() are experimental. Keep them behind a version-pinned internal adapter; do not persist or expose DesiredStatePlan or artifact DTOs as an application API.
The Alchemy runtime itself — how you construct the runtime, which providers and state backend you supply — is part of your own Alchemy v2 setup and is not provided by TypeKro. The only TypeKro requirement is that kroProvider is merged into the runtime's providers, and that a state backend is configured. Everything above the toAlchemyResources / materializeAlchemyResources calls is TypeKro-side and is what this page documents.
Direct mode: per-resource fan-out
In direct mode, toAlchemyResources returns one declaration per resolved Kubernetes resource, ordered so that dependencies come first:
import { Cel, simple, toResourceGraph } from 'typekro';
import { KroResource, kroProvider, materializeAlchemyResources } from 'typekro/alchemy';
import { type } from 'arktype';
const graph = toResourceGraph(
{
name: 'fanoutapp',
apiVersion: 'v1alpha1',
kind: 'FanoutApp',
spec: type({ name: 'string', image: 'string', replicas: 'number%1' }),
status: type({ readyReplicas: 'number%1' }),
},
(schema) => {
const deployment = simple.Deployment({
name: schema.spec.name,
image: schema.spec.image,
replicas: schema.spec.replicas,
id: 'appDeployment',
});
return {
deployment,
// Reads the Deployment's LIVE status → a genuine cross-resource dependency.
config: simple.ConfigMap({
name: Cel.template('%s-cfg', schema.spec.name),
data: { readyReplicas: Cel.template('%s', deployment.status.readyReplicas) },
id: 'appConfig',
}),
};
},
(_schema, resources) => ({ readyReplicas: resources.deployment?.status.readyReplicas })
);
const factory = await graph.factory('direct', { namespace: 'apps', waitForReady: true });
// One declaration per resource; the ConfigMap dependsOn the Deployment.
const decls = await factory.toAlchemyResources({ name: 'fanapp', image: 'nginx', replicas: 1 });
// In the Stack body, with kroProvider in the runtime's providers:
const outputs = yield* materializeAlchemyResources(KroResource, decls);Because the ConfigMap reads the Deployment's live status.readyReplicas, its declaration dependsOn the Deployment. Alchemy therefore deploys the Deployment first, captures its live status, and only then deploys the ConfigMap — resolving the cross-resource reference against real cluster state.
Kro mode: RGD + instance (+ singleton owners)
In KRO mode, toAlchemyResources returns the composition's RGD and a CR instance that dependsOn it — preceded by a declaration for each singleton owner the composition depends on (each its own RGD + instance). A composition with no singletons therefore yields exactly two declarations:
const factory = await graph.factory('kro', { namespace: 'apps' });
const decls = await factory.toAlchemyResources({ name: 'web', image: 'nginx', replicas: 3 });
// (any singleton owners' RGD + instance come first, deps-first)
// decls[-2] → the composition's RGD
// decls[-1] → its CR instance (dependsOn the RGD + any singleton instances)
const outputs = yield* materializeAlchemyResources(KroResource, decls);Alchemy applies singleton owners and the RGD first, then the instance, and the Kro controller reconciles the rest at runtime — each piece tracked as its own state entry. Singleton owners use deterministic ids, so a singleton shared across compositions is deduplicated to one state entry. Singleton spec-drift protection is enforced at reconcile time: deploying a singleton identity whose live spec fingerprint differs from the one being applied fails rather than silently clobbering the shared owner.
Compositions that own their workload namespace (auto-detected)
A composition that creates and owns its workload Namespace as a graph child (typically a bootstrap that installs an operator/Helm release into a namespace it also creates) can't leave that Namespace a graph child while the instance CR lives inside it: KRO deletes graph children — including the Namespace — before clearing the owner CR's finalizer, so a self-owned instance namespace would deadlock on delete.
TypeKro handles this automatically — no flag, and without moving the instance. When it detects that a composition owns the Namespace its instance would land in, it hoists that Namespace out of the RGD graph and emits it as a sibling resource created deps-first (outside the graph). The instance CR is placed in the factory namespace (never in the owned workload namespace, and spec.namespace is not consulted for CR placement). Because the workload namespace is no longer a graph child, deleting the instance can never garbage-collect it, so the finalizer is never stranded. So toAlchemyResources returns an extra leading declaration: the hoisted workload Namespace. A composition with no singletons therefore yields three declarations instead of two:
const factory = await bootstrap.factory('kro', { namespace: 'platform' }); // owns its workload namespace
const decls = await factory.toAlchemyResources({ name: 'demo', namespace: 'workloads' });
// decls[0] → the hoisted workload Namespace `workloads` (owned; empty-gated teardown; see below)
// decls[-2] → the composition's RGD (no longer owns the Namespace as a child)
// decls[-1] → its CR instance, in the FACTORY namespace `platform` (dependsOn the RGD + namespace)Three properties matter:
- The instance stays in the factory namespace; its alchemy id is the namespace-agnostic kind+name. The CR is placed in the factory namespace, and its alchemy id is derived from kind+name only (reverting the earlier namespace-hashed id, which would have changed every existing instance's id and let alchemy tear the live CR down on upgrade). ⚠️ A different k8s factory namespace does NOT automatically give a different alchemy scope. Within one alchemy stack, two instances collide iff they share this kind+name id — regardless of their k8s namespace. So
analytics/devandanalytics/prodfactories expose the same alchemy id, and if you materialize both in the same alchemy stack the second clobbers the first. To keep same-named instances isolated, you must put them in separate alchemy stacks/scopes (one app/stage per environment) — the factory does not do this for you. - One Alchemy stack per installation is an invariant, not a naming suggestion. The application layer must map its complete installation identity (for example application, connection, control-plane namespace, instance, and profile) to one distinct Alchemy stack/scope. TypeKro validates declaration identities within one materialization call, but it cannot inspect or allocate the caller's Alchemy stack and therefore cannot enforce isolation across separate calls. Application adapters should reject startup when two installation identities resolve to the same stack key.
- A deduped, name-keyed singleton. The hoisted Namespace's declaration id is keyed to the namespace name (not the RGD), so N factories/stacks targeting the same workload namespace converge on one state entry rather than N fighting copies.
- Ownership-scoped, empty-gated teardown (create-first ownership). Ownership is decided create-first: at deploy the hoisted Namespace is
CREATEd with atypekro.io/created-by-rgd=<rgd>stamp — a201means TypeKro created it (owned); a409 AlreadyExistsmeans it pre-existed, so TypeKro adopts it and strips the stamp before applying (an adopted namespace is never stamped, so it is never deleted). Alchemy's reverse-topological teardown deletes the instance CR and the RGD first (bothdependsOnthis declaration), then runs the Namespace's delete last — an empty-gated delete that removes the namespace only if it is both owned by this RGD (carries the stamp) and empty, and retains it if another stack/user still has resources inside it. Because each hoisted Namespace is its own Alchemy state entry, a delete that fails or a namespace that is retained is reconciled independently on the next run — teardown does not depend on the CR record surviving. The emitted Namespace also carries GitOps prune-protection: bothkustomize.toolkit.fluxcd.io/prune: disabled(Flux) andargocd.argoproj.io/sync-options: Prune=false,Delete=false(Argo CD;Delete=falsesurvives an Argo Application deletion, not merely a sync-prune). For any other GitOps tool, pre-create the workload namespace out-of-band. - The generated CRD is LEFT Active for reuse or out-of-band GC. Both Alchemy and imperative
deleteInstanceretain the composition's generated CRD on normal teardown — the CRD is cluster-scoped, KRO defaults toallowCRDDeletion=false, and initiating deletion can leave its apiextensions cleanup finalizer stalled for minutes (kro#1171). ATerminatingdefinition blocks later deployment of the same RGD/kind, whereas an Active definition with zero instances is safely reusable. Garbage-collect a retired kind explicitly only after proving that no RGD or custom resources need it (kubectl delete crd <plural>.<group>).
Every Namespace is hoisted OUT of the RGD graph — the hoist is unconditional (TypeKro never emits a Namespace into RGD YAML; selection is trivially kind === 'Namespace'), regardless of whether the composition owns it. Ownership does not decide whether a Namespace is hoisted; it only decides teardown: an owned (created-by-this-RGD) empty Namespace is deleted, while an adopted/occupied one is retained. The self-owned-namespace safety guard remains the real protection for the cases hoisting cannot cover: an owned Namespace whose name can't be proven safe (fails closed), and explicitly pinning instanceNamespace to a namespace the composition owns — both still throw UNSAFE_KRO_NAMESPACE_OWNERSHIP.
Security: kubeconfig in Alchemy state
toAlchemyResources persists enough kubeconfig information for a later state-driven reconcile or delete to reconnect. TypeKro rejects detected static credential bytes in that durable configuration. Ambient factories re-read the default kubeconfig in the operation host, a configured file source persists only its path, and exec/authProvider configuration can be carried through state when it contains no detected secret-bearing fields, environment values, or common credential arguments.
An explicit KubeConfig containing static fields such as user.token, user.certData, or user.keyData fails before declaration creation unless each field has a named host binding:
const factory = graph.factory('direct', {
namespace: 'default',
kubeConfig,
alchemyKubeConfig: {
credentialBindings: {
'/user/token': { kind: 'environment', name: 'TYPEKRO_KUBE_TOKEN' },
},
},
});Alchemy persists the JSON-pointer path and environment-variable name, not the resolved value. The provider operation resolves TYPEKRO_KUBE_TOKEN immediately before constructing the Kubernetes client and fails closed if it is unavailable. To re-read a known source instead, use alchemyKubeConfig: { source: { kind: 'default' } } or { source: { kind: 'file', path: '/run/secrets/kubeconfig' } }.
Use a secured Alchemy state backend regardless: state still contains non-secret cluster connection details and the rest of the deployment plan.
Upgrade note: Alchemy state written by older TypeKro releases may already contain inline static kubeconfig credentials. The current provider rejects that legacy state rather than silently continuing to persist or consume it. Before upgrading, reconcile affected declarations with a default/file source or named bindings while the previous release is still available, or destroy them with the previous release. A state-driven delete that has only rejected legacy credentials fails closed; TypeKro will not guess a cluster or copy those bytes into the new contract.
Without Alchemy
If you don't need Alchemy's state and lifecycle management, TypeKro deploys standalone — just call the factory directly:
const factory = await graph.factory('direct', { namespace: 'default' });
await factory.deploy({ name: 'app', image: 'nginx' });Next Steps
- Deployment Modes - Direct vs Kro deployment
- Custom Integrations - Create custom factories
- Examples - See more patterns