kcl

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 6 Imported by: 0

README

forge — KCL module

The typed authoring surface and the lowering that forge projects import (ADR 0002, docs/adr/0002-one-workload-model.md). A workload is declared ONCE and each env binds it to a runtime:

import forge
import forge.workloads as fw

# deploy/kcl/workloads.k — WHAT runs, complete on its own
api = fw.Workload {
    name = "api"
    build = forge.GoBuild {cmd = "./cmd/myapp", output_name = "myapp"}
    args = ["api"]                     # the subcommand, on every runtime
    ports = [fw.Port {name = "http", port = 8080, expose = True}]
    env = {
        LOG_LEVEL = "info"
        DATABASE_URL = forge.SecretRef {name = "myapp-secrets", key = "database_url"}
        WEB_URL = forge.WorkloadURL {workload = "web"}
    }
}

# deploy/kcl/dev/main.k — WHERE it runs, in this env
_k3d = forge.ClusterTarget {cluster = "k3d-myapp", namespace = "myapp-dev", registry = "localhost:5050"}

output = forge.render(forge.Bundle {
    project = "myapp"
    workloads = [
        wl.api | {runtime = forge.OnHost {runner = "air"}}
        wl.migrate | {runtime = forge.OnHost {}}
        wl.search | {runtime = forge.OnCluster {target = _k3d}}    # one workload elsewhere
    ]
    frontends = [forge.Frontend {name = "web", type = "vite", path = "frontends/web"}]
})

output = forge.render(bundle) is the ONE entrypoint; there is no other public top-level var.

What ships here

Declaration What it is
fw.Workload Everything that runs: kind = service / worker / job / cron / operator / tool. Field names and types are the generated v1alpha1.WorkloadSpec's (camelCase), plus name, build, runtime, config_secrets, serves.
forge.OnHost / OnCompose / OnCluster / OnHosted / BuildOnly The runtime a workload binds to. Every workload in an env binds its own; there is no env default.
forge.GoBuild / DockerBuild / ShellBuild / RemoteBuild How forge produces a workload's artifact. Unset = forge builds nothing. ShellBuild.built_images builds one image FROM another: forge builds the named workload first and hands the command its pushed <repository>@sha256:<digest>.
forge.SecretRef / ConfigMapRef / FieldRef / ManagedSecret / DatabaseRef / WorkloadURL The non-literal values of fw.Workload.env (a map, name -> value).
forge.Bundle One environment: workloads, infra (forge.HostInfra), frontends, databases, gateways/routes, secrets, clusters, manifests (forge.Manifests, raw objects), network_policy (opt-in).
forge.Frontend A dev server or a static build forge publishes, bound per env to forge.OnHost / OnHosted / OnBucket / OnFirebase / BuildOnly (required, no default). The build facts — public_dir, base_path, bundle, cache_control — are on the frontend. A containerized frontend is a workload.
forge.ManagedDatabase A Postgres on a cluster (CloudNativePG) or on the control plane.
What each runtime does with a workload
Runtime forge does spec.image
OnHost {runner} launches a process; the argv is derived from build + args (go run <cmd> <args>, air, ./bin/<out> <args>, dlv) ""
OnCompose {service} docker compose up of the compose file's service; literal env feeds the compose process env. shared = True runs it from the repo's primary checkout, whichever worktree deploys ""
OnCluster {target} a forge.dev/v1alpha1 Workload record in output.manifests, expanded by pkg/deploy.RenderWorkloads (Full profile) <registry>/<image>:<tag> or @digest
OnHosted {} publishes the spec to the control plane, which renders it (Restricted profile) the artifact / third-party image
BuildOnly {build_variants} builds and ships, never runs ""

A workload whose runtime cannot honour a field is REFUSED at render, naming the workload, the field and the reason. That covers the hosted runtime's Restricted profile (generated from v1alpha1.FieldProfiles into tiers/tiers_gen.k) and forge's own host/compose/build-only rules (render.k _mask_violations).

A forge-built service gets explicit /readyz + /healthz probes on its http port in the spec (ADR 0002 §5). A forge.WorkloadURL is resolved at render for host and cluster referrers (a cluster pod reaches a host process through its target's host_gateway, default host.k3d.internal) and kept as a reference for hosted ones. See kcl/lib/workload_url.k.

What each runtime does with a frontend

A frontend binds a runtime exactly as a workload does (ADR 0002 §6). OnHost, OnHosted and BuildOnly are the workload schemas of the same name; the workload-only fields on them (runner, listen_ports, build_variants, ...) are refused on a frontend, naming the field. output.frontends[].runtime is {type, ...}, and the Go dispatch keys on type:

Runtime forge env deploy does
OnHost {} nothing — it is the dev server (<dev_runner> dev on port); forge env up runs it
OnHosted {} forge env build <env> --push pushes the site as an OCI release; the deploy publishes a StaticSite CR (needs control_plane)
OnBucket {bucket, cdn?, keep_releases} builds, assembles, uploads releases/<digest>/, syncs live/, invalidates the CDN
OnFirebase {project, site, target?, spa_fallback?, rewrites, keep_asset_releases} builds, assembles, carries the last keep_asset_releases releases' hashed assets forward, firebase deploy with a route-only SPA fallback and immutable/no-cache headers
BuildOnly {} builds it, so a sibling frontend's bundle can assemble its output; ships nothing

cache_control is honoured on OnBucket (object headers) and OnFirebase (firebase.json headers, replacing its default of an immutable asset_dir and no-cache everywhere else), and refused elsewhere; bundle is refused on OnHost. On OnFirebase a catch-all ** -> /index.html rewrite is refused: it answers a missing hashed asset with the page and a 200, so declare spa_fallback = "/index.html" instead. OnHosted has no bucket field at all, so "a hosted site that names a bucket" does not compile.

Cross-repo sources

A Frontend declares its code EITHER as a path (a directory in this repo) OR as a source = forge.GitSource { repo, ref, subdir? } — never both. The source form exists because a filesystem path to a sibling checkout has two failure modes: it does not exist in CI, and where it does exist it silently ships whatever happened to be checked out, so identical commits produce different artifacts on different machines.

ref is required — forge does not default to a repository's default branch. Fetches are cached per repo+ref, and a machine-local .forge/source-overrides.yaml (gitignored, so it can never un-pin CI) maps a repo to a working copy for local iteration.

See docs/cross-repo-sources.md for the full model.

Extending a workload — schema MyService(fw.Workload)

A project can use KCL-native inheritance to add its OWN typed/required fields to a workload while forge renders the result EXACTLY like the base:

import forge.workloads as fw

schema BillingService(fw.Workload):
    region: str               # extra REQUIRED field — enforced at parse time
    tier: "free" | "pro" = "free"

    check:
        region, "BillingService.region is required"

_svc = BillingService {
    name = "billing-api", region = "us-east-1"
    ports = [fw.Port {name = "http", port = 8080}]
}

This works because the lowering is typed on the BASE schema, which accepts any subtype: the subtype renders the same output a plain fw.Workload would. The app's extra fields ride on the typed value but are NOT part of the rendered contract (they're yours, for your own KCL logic). An extra field with no default — or a check: the value violates — fails at KCL load, so your domain invariants are enforced the same way forge's are.

Declared external prerequisites — required_secrets / required_dns

A deploy often depends on out-of-band facts forge does NOT (and must not) create: the cert-manager cloudflare-api-token Secret, per-host DNS A-records, the load-bearing *.workspaces wildcard. Left in a docstring, forge env deploy renders green and THEN ACME / DNS hangs silently. Declare them as first-class prerequisites on the Bundle so they're MODELED:

_bundle = forge.Bundle {
    project = "acme"
    # ... workloads / gateways / ...
    required_secrets = [
        forge.ExternalSecret {
            name = "cloudflare-api-token"
            namespace = "cert-manager"      # often NOT the deploy namespace
            keys = ["api-token"]
            reason = "cert-manager DNS-01 Cloudflare API token"
        }
    ]
    required_dns = [
        forge.DNSRecord {
            host = "*.workspaces.example.com"
            reason = "DNS-01 wildcard cert + workspace-proxy traffic"
        }
    ]
}

What this buys (beyond a comment):

  • Render-time checklist — forge env deploy prints the prerequisites every run; forge project audit surfaces them as the prerequisites category.
  • Deploy preflight BLOCK — a declared ExternalSecret that's absent (or missing a declared key) on the live target FAILS the deploy before the first apply, in its OWN declared namespace, reusing the same SecretGetter the secretKeyRef preflight uses. DNS can't be verified authoritatively, so required_dns is a checklist note, not a block.
  • Cross-secret byte-match — when ONE logical value is projected to N refs (the same token under two names), give each ExternalSecret a shared value_group. KCL rejects a group whose members declare different key sets at load; the preflight byte-compares the live values and BLOCKS on a divergence (a half-rotated credential).

forge never creates these resources — the declaration drives the checklist + preflight only; nothing leaks into the rendered manifests.

How projects consume this

Project's deploy/kcl/kcl.mod:

[package]
name = "myapp"
edition = "v0.11.0"
version = "0.0.1"

[dependencies]

There is no forge dependency to declare, and no tag, registry or project-local copy to pin. Every forge command that evaluates KCL supplies this module — the copy embedded in the forge binary doing the evaluation — as an external package, materialized once into a content-addressed user cache (<UserCacheDir>/forge/kcl/<hash>/, overridable with FORGE_KCL_MODULE_CACHE). A project pinned to a released forge therefore renders against exactly that release's module wherever it runs: CI installs the pinned forge, and the binary IS the module. No network, no git, nothing to commit. Because kpm does not know about the module, the stock kcl CLI cannot render a project on its own; render through forge (forge env render <env>).

Project's deploy/kcl/dev/main.k ends with the one entrypoint:

import forge
import ..workloads as wl

output = forge.render(forge.Bundle {
    project = "myapp"
    workloads = [
        wl.api | {runtime = forge.OnHost {runner = "air"}}
        wl.migrate | {runtime = forge.OnHost {}}
    ]
    frontends = [forge.Frontend {name = "admin-web", path = "frontends/admin-web"}]
})

Then forge env render dev prints it, and every forge command reads it.

Standard -D render options

The forge CLI drives every render with a standard set of top-level KCL bindings (kcl run -D <key>=<value>). They carry per-invocation facts into your main.k. Read them through the typed forge accessors (each wraps option(...) with a default + doc) rather than raw option() so the whole set is discoverable from the forge surface:

-D key Accessor Always passed? What it is
env forge.env(default) yes environment name (dev/staging/prod/…)
image_tag forge.image_tag(env) yes resolved image tag (override > per-env default > latest); Bundle.image_tag defaults to it
namespace forge.namespace(default) yes k8s namespace to deploy into
image_digests forge.image_digests() when deploying JSON name→digest map (pins each image to its digest)

The image registry is not a render option, and not an env field either: an environment does not have a registry. A workload does, as part of its image (image = "ghcr.io/acme/api" in deploy/kcl/workloads.k). forge contributes only the tag and, after a push, the digest, composing them onto the reference you wrote. forge env build <env> --push, forge registry login <env> and forge env deploy <env> all follow the images — so two workloads in one env can ride two different registries.

Per-env config is NOT passed via -D: it lives in the typed AppConfig instance in deploy/kcl/<env>/config.k and is projected into each workload's env by config_gen.appConfigEnvMap — read config there, never raw option().

Per-env conditional manifests

Use forge.env() to conditionally include manifests — e.g. skip in-cluster infra on dev-host envs where docker-compose already provides those services:

_is_dev_host = forge.env() == "dev-host"

_bundle = forge.Bundle {
    project = "acme"
    workloads = [...]
    manifests = [] if _is_dev_host else [forge.Manifests {objects = [
        # in-cluster NATS, Temporal, LiteLLM, etc.
    ]}]
}

Labels forge stamps — and forge.dev/env

Every k8s object the render emits carries the k8s-recommended set (app.kubernetes.io/name, .../managed-by=forge, .../part-of) plus a forge-owned ownership tag:

Label Value What it answers
forge.dev/env the environment name which env rendered this object
forge.dev/cluster a kubectl context (k3d-…) which cluster this manifest is pinned to

forge.dev/env exists because two envs can legitimately resolve to the SAME namespace (a dev and a dev-k8s both rendering into myapp-dev). Deploying one then silently replaces the other's workloads, and without the tag nothing on the surviving objects says whose render won. With it:

kubectl get all -n myapp-dev -l 'forge.dev/env!=dev'   # what dev does NOT own

It is automatic. The value defaults to the -D env=<name> binding the CLI passes on every render, so a project gets it without editing any KCL.

Override it with a KCL drill-in — a schema field, not a CLI flag:

_bundle = forge.Bundle {
    project = "acme"
    env = "dev-k8s"        # this render's ownership tag
    cluster_target = _target
    workloads = [...]
}

A single object can also opt out by naming the label itself — the stamp merges UNDER labels you already set, so an explicit metadata.labels."forge.dev/env" always wins.

The stamp writes label maps only: object metadata.labels, and the pod template's labels for the workload kinds (so pods are selectable too). It never touches a selector — spec.selector.matchLabels is immutable in Kubernetes, and every forge builder derives its selector from a separate one-key app.kubernetes.io/name literal. Rendering with no -D env= binding and no drill-in emits no label at all, so a plain kcl run is byte-identical to the pre-stamp output. See lib/labels.k.

Raw objects go in Bundle.manifests (forge.Manifests), which the same gate stamps. Cluster workloads' Kubernetes objects are rendered by Go from their Workload records and carry the record's labels.

Secrets — the ConfigSecretRef config-contract override

For a SENSITIVE config field (sensitive: true in the config proto), forge's config codegen types the field as a ConfigSecretRef on the generated AppConfig schema, defaulting to the <project>-secrets Secret and a <env_var lowercased> key; config_gen.appConfigEnvMap(cfg, config_secrets) projects it to a forge.SecretRef entry of the workload's env map (a secretRef on a cluster or host workload, a managedSecret of the same store key on a hosted one). To bind a field to a DIFFERENT existing cluster Secret/key, set its ConfigSecretRef in the per-env deploy/kcl/<env>/config.k:

# deploy/kcl/prod/config.k
app_config: config_gen.AppConfig = {
    internal_service_secret = config_gen.ConfigSecretRef {
        name = "control-plane-internal", key = "secret"
    }
}

Use a kebab-case key for cluster secrets whose keys don't match forge's lowercase-env-var default (e.g. key = "database-url"). The Secret itself is provisioned out-of-band (ESO / sealed-secrets / kubectl create secret). A hand-written credential is env = {X = forge.SecretRef {name = ..., key = ...}}.

Versioning

The module version IS the forge version doing the render — there is nothing separate to pin, refresh or go stale. Pin forge (go.mod's github.com/reliant-labs/forge vX.Y.Z, and the matching go install in CI) and the module is pinned with it.

When a release changes this module's schemas in a breaking way, forge ships a migration skill for that release; forge project upgrade list surfaces the ones your project still needs.

See docs/adr/0003-kcl-module-from-the-binary.md for why the binary is the only source (and docs/adr/0001-always-vendor-forge-kcl.md for the vendored copy it replaced).

Layout

kcl/
  kcl.mod              # module declaration
  README.md            # you are here
  workload.k           # fw.Workload, env references, the runtimes
  schema.k             # the Bundle and every env-level declaration
  render.k             # the lowering: Bundle -> output (forge.render)
  core.k               # env-map helpers for [EnvVar] lists (frontends)
  base.k               # -D option accessors, env groups, helpers
  workloads/schema.k   # `forge.workloads` (fw): re-exports for authors
  tiers/tiers_gen.k    # GENERATED from pkg/deploy/v1alpha1 (+ the hosted mask)
  lib/
    capabilities.k     # kind x capability matrix
    images.k           # cluster image reference resolution
    workload_url.k     # forge.WorkloadURL resolution
    gateway.k          # Gateway API builders
    labels.k           # label sets and the forge.dev/env stamp
    crd.k, jwks.k, annotations.k
  example/dev/main.k   # one env using every runtime
  tests/               # KCL-level invariant tests (go test ./internal/templates -run TestKCLModule)

Documentation

Overview

Package kcl embeds the forge KCL module — the typed schemas + manifest render layer that generated projects' deploy/kcl env files import (`import forge`, `import forge.workloads`).

Why the module rides inside the binary: dev builds of forge have no published `kcl-vX.Y.Z` git tag to resolve the scaffold's kcl.mod dependency against, exactly like a local or dirty build has no forge version any Go module proxy can serve. The Go-side answer is to pin nothing and demand a source bridge: generate's forge-compat handshake (internal/cli/generate_pkg_compat.go) refuses to generate against a published pin until a gitignored go.work `use`s a forge checkout (`forge project new --link-forge` writes one). The KCL side cannot ask for that — a daemon or `go install`'d binary may have no forge checkout on disk at all — so its answer is supplying THIS embedded copy to every KCL evaluation as an external package, from a content-addressed user cache (internal/kclvendor). For a released binary that copy IS the release's module.

The embed deliberately covers only what a consuming project needs to resolve and render: kcl.mod plus the schema/render sources (root *.k, workloads/, lib/). tests/ and example/ are module-development artifacts and are excluded; kcl.mod.lock is derived state.

Index

Constants

This section is empty.

Variables

View Source
var Module embed.FS

Module is the embedded forge KCL module, rooted at this directory (paths like "kcl.mod", "schema.k", "workloads/expand.k").

Functions

func DeployTargetsFor

func DeployTargetsFor(workloadSchema string) ([]string, error)

DeployTargetsFor returns the names of the targets valid on the named workload schema (e.g. "Frontend"), sorted. Empty when that schema declares no deploy union.

This is what the deploy warning's hint text is built from, so the hint names exactly the targets the schema actually accepts for that workload kind — never a literal, and never the other union's members.

Types

type DeployTarget

type DeployTarget struct {
	// Name is the schema name, e.g. "OnBucket".
	Name string
	// File and Line locate the schema declaration inside the module
	// (e.g. "schema.k", 1851), so a reader's next step is a targeted read.
	File string
	Line int
	// Doc is the first line of the schema's """docstring""", empty when it
	// has none.
	Doc string
	// Fields are the schema's own declared fields, in source order.
	Fields []SchemaField
	// Workloads are the schemas whose `runtime` union names this target,
	// sorted. This is the load-bearing distinction between the unions: a
	// Frontend and a Workload accept different runtime sets.
	Workloads []string
}

DeployTarget is one schema a `runtime` field may be set to, resolved from the embedded module.

func DeployTargets

func DeployTargets() ([]DeployTarget, error)

DeployTargets returns every deploy target declared in the embedded module, sorted by name. See the package-level note above for why the set is derived structurally rather than listed.

type SchemaField

type SchemaField struct {
	Name string
	// Type is the declared type verbatim, including a union
	// ("GoBuild | DockerBuild | ShellBuild") or a container
	// ("[FirebaseBundleDir]", "[{str: any}]").
	Type string
	// Optional is true for `name?: T` — the field may be omitted.
	Optional bool
	// Default is the literal after `=`, empty when the field has none.
	// A field with a default is also omittable, but differently: it takes
	// the default rather than staying unset.
	Default string
}

SchemaField is one field of a KCL schema declaration.

func (SchemaField) Required

func (f SchemaField) Required() bool

Required reports whether the field must be supplied — neither optional nor defaulted.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL