Skip to content
Docs menu / Manifests and modules

Manifests and modules

konfig.ts describes a cluster with two building blocks. A manifest is a deferred description of one or more Kubernetes objects: nothing is produced until the CLI renders it. A module groups manifests into a named unit (an Argo CD Application, for example) that you can instantiate several times with different names and options. Both come from @konfig.ts/core.

The Manifest carrier

A manifest is a recipe rather than a finished object. It holds a render function; the CLI calls that function at build time with a render context and gets the Kubernetes object back:

interface Manifest<out A> {
readonly [ManifestTypeId]: Variance<A>
readonly render: (ctx: RenderContext) => Effect.Effect<A, AnyRenderError, RenderServices>
}

The type parameter is what rendering produces: a typed Kubernetes object, a tuple of them, or a raw YAML blob. Every render also has access to a small set of services (a file system, a path module, a process spawner, and a resource scope). That is why a manifest can read a file from disk or shell out to helm without you wiring anything up.

You rarely build manifests by hand. Builders in @konfig.ts/k8s such as Deployment.make or Namespace.make return them. When you do need one, Manifest.make takes a function of the render context that returns either a plain value or an Effect:

import { Manifest } from "@konfig.ts/core"
const ns = Manifest.make((ctx) => ({
apiVersion: "v1",
kind: "Namespace",
metadata: { name: `app-${ctx.env}` }
}))

A few more constructors combine, gate, or wrap manifests:

ConstructorWhat it does
Manifest.combine({ a, b })Renders two manifests concurrently and returns a manifest of the pair.
Manifest.concat(...manifests)Flattens manifests of single values or arrays into one manifest of an array. The element type itself must not be an array; the signature rejects that at the call site because one level of nesting could not be told apart from two at runtime.
Manifest.whenever({ cond, thunk })Produces the manifest only when the condition is true, otherwise undefined. The thunk is not evaluated when the condition is false.
Manifest.embedYaml({ path }) / Manifest.embedYaml({ literal })Wraps existing YAML you already have (a file or a string) as a raw YAML blob. The blob records where it came from; a file that cannot be read fails with EmbedYamlReadError.

RenderContext

Every render receives one argument: the context that says which environment is being rendered and which optional knobs the CLI was started with.

interface RenderContext {
readonly env: string
readonly cluster?: string
readonly k8sVersion?: string
readonly flags?: ReadonlyMap<string, unknown>
}

The environment name does two jobs. It picks the matching entry in konfig.json, and it names the output directory (<outDir>/<env>/, or <outDir>/<env>/<cluster>/ when a cluster is set).

The optional fields come from flags that build, validate, and diff all accept:

FlagWhere it lands
--cluster <name>ctx.cluster
--k8s-version <ver>ctx.k8sVersion
--flag k=v (repeatable)ctx.flags, as strings; read one with ctx.flags?.get("k")

All of these are part of the build cache key, so two clusters of the same env never share a cache slot. To build a context outside the CLI, use RenderContext.make(env) or RenderContext.makeFull({ ... }).

Modules

A module is a function: give it a name (and per-instance options) and it returns a handle that a target knows how to emit. The two wrappers differ only in where the namespace comes from. Module.fixedNs bakes the namespace into the wrapper; Module.dynamicNs lets each call choose one.

examples/full-stack/infra/modules/postgres.ts
export const definePostgres = Application.module({
  namespace: "app",
  annotations: Sync.wave(-1),
  build: ({ name, namespace }, opts: PostgresOpts) => {
    const ns = Namespace.make({ name: namespace })

    const release = Helm.release({
      repo: "https://charts.bitnami.com/bitnami",
      chart: "postgresql",
      releaseName: name,
      version: "16.0.0",
      digest: "sha256:483dc159c5fb377c29026d363153cc904a7f77109d524881eed64098637b9bd4",
      namespace,
      values: {
        auth: {
          database: "app",
          username: "app",
          existingSecret: "db-creds",
          secretKeys: {
            adminPasswordKey: "password",
            userPasswordKey: "password"
          }
        },
        primary: {
          persistence: {
            enabled: true,
            size: `${opts.storageGi}Gi`
          }
        }
      }
    })

    return [ns, release]
  }
})

Your build function receives two arguments. The first is { name, namespace }: the name is the string literal passed at the call site (definePostgres({ name: "postgres", ... })), and the namespace is the literal from the wrapper or from the call, depending on which wrapper you used. The second is your options object, typed however you declare the parameter. The wrapper’s call signature is derived from that: callers pass name, your options, anything the target requires (for Argo CD, a required source), and, for dynamicNs, a namespace.

build returns a list of manifests, either directly or wrapped in an Effect. Return an Effect when the module depends on something another module provides, for example yield* Dep.Secret("ghcr-pull") for a pull secret; see Dependency graph. Whatever the Effect requires becomes the module’s declared input, which is how a missing provider becomes a compile error at fromModules.

Names must be string literals, because the dependency graph keys everything by literal name. A wrapper that forwards a widened string fails with the hint _konfig_error: "Module name/namespace must be a string literal...". To fix it, make the wrapper generic over <const Name extends string> and forward the name through Module.LiteralName<Name>.

Options and provides

The smallest useful module has a fixed namespace, no options, and emits one ConfigMap. feature-flags.ts shows that shape (its options parameter is typed as an empty record):

examples/full-stack/infra/modules/feature-flags.ts
export const defineFeatureFlags = Application.module({
  namespace: "app",
  annotations: Sync.wave(-1),
  build: (ctx, opts: Record<never, never>) => [featureFlags]
})

A module can also announce that it satisfies a need for other modules by passing a layer through the wrapper’s provides field. Modules that emit nothing but exist to satisfy the graph use this. The field is declared once per wrapper, so it cannot depend on per-call options. When the provided value does depend on them (the registry and tag of an image, for example), call Application.define directly instead, as infra/modules/builds.ts does with Dep.provideImage and an empty build.

The target adapter

The wrappers do not know about Argo CD. They delegate to a target, an object with a define function plus optional extra fields at wrapper level and at call level. @konfig.ts/argocd ships Application.target:

  • Wrapper-level extras: project, syncPolicy, and annotations (for example Sync.wave(-1)), all optional.
  • Call-level extras: a required source with repoURL, targetRevision, and path.
  • The handle it returns provides the App, Application, and Namespace needs for its own name and namespace, so two modules that both declare namespace: "app" do not conflict.

@konfig.ts/core also exports a Bundle target for plain manifest bundles without Argo CD.

Next: Dependency graph.