kclplugin

package
v1.0.15 Latest Latest
Warning

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

Go to latest
Published: Nov 4, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

Documentation

Overview

Package kclplugin registers forge's in-process KCL plugin namespace (kcl_plugin.forge.*), letting KCL pull host-runtime values during evaluation instead of forge having to pre-enumerate and inject them.

It provides two port primitives, plus derive_jwk:

  • resolve_port(name, preferred) — a pure-Go free-port allocator. KCL declares `port = forge.resolve_port("reliant-web", 3000)` inline, by any name it likes, and binds it to a variable other declarations reference (the frontend's port, env-var URLs, CORS origins). One declaration, referenced everywhere — forge owns the allocation, KCL owns the plumbing. The allocation is AVAILABILITY-CHECKED (it steps off busy ports), so it suits ports that may float.

  • allocate_port(base, key) — a DETERMINISTIC, memoized keyed allocator for parallel dev stacks: returns base + block(key)*100, where forge assigns a stable small block per key (the index is internal, never surfaced in KCL) and persists it under a file lock so up and deploy agree. key "" ⇒ base unchanged. Unlike resolve_port it does NOT step off busy ports — it must match externally-fixed ports (a k3d pre-mapped host port; the host reliant's listen port). The block engine lives in internal/devstack; this package holds a settable hook (UseBlockAllocator) so the CLI wires the lock-guarded registry in.

WHAT USED TO LIVE HERE. An earlier resolve_oidc_client primitive applied resolve_port's shape to an OIDC client_id — a value the IdP generates at registration and that therefore cannot be declared up front, resolved from INSIDE a render by dialing the IdP mid-evaluation. That solved a problem that does not exist: a render never actually needs to LEARN a generated value, because a workload can reference it BY NAME instead. The generated identity is now converged by `forge env up` (pkg/identity, the library the control plane runs too), which publishes it to the env's secret store; every render lowers forge.IdentityRef to those keys. See the `auth/dev-loop` skill.

The plugin bridge that lets KCL call back into Go is forge's own purego callback (bridge.go) — no cgo, so every forge binary on every OS has it.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DeriveJWK added in v0.1.45

func DeriveJWK(privateKeyPEM, kid, alg string) (map[string]any, error)

DeriveJWK derives the PUBLIC JWK document from a private-key PEM, so a forge.TestJWKS can publish the public half of the exact key its signer uses — the JWKS and the signer can never drift.

Two key types, because two signing algorithms matter:

  • ES256 (P-256 EC) — what Supabase-style end-user tokens use.
  • RS256 (RSA) — what the common OIDC providers (Zitadel, Auth0, Okta, Keycloak) sign with. A verifier configured for one of them PINS RS256, so a test IdP limited to ES256 cannot stand in for any of them.

alg is a CLAIM about the key and is checked against it: a JWKS advertising RS256 over an EC key makes every verifier reject every token, and that surfaces as a 401 far from the declaration that caused it. An empty alg is inferred from the key. A symmetric alg (HS*) is refused — it has no public half to publish.

Returns a map[string]any (KCL-plugin-friendly) shaped exactly like one entry in a JWKS `keys` array, carrying ONLY public members.

func Probe added in v0.1.45

func Probe() error

Probe evaluates a one-line KCL program that calls kcl_plugin.forge.probe and checks the value comes back. It answers "can this process render an environment?" by doing the smallest possible render, so it fails for every reason a real render would fail before reaching project code:

  • libkcl (kcl.dll / libkcl.so / libkcl.dylib) could not be extracted into the cache dir or loaded — antivirus quarantine, a read-only TEMP, a KCL_LIB_HOME pointing nowhere;
  • the plugin callback is not installed, so kcl_plugin.forge is "not found";
  • the callback is installed but the round trip (args in, result out) is broken.

It goes through Serialized, the same entry every real evaluation uses, so it exercises the same native client and the same callback — a probe with its own code path would prove nothing about renders. A runtime that cannot load arrives here as Serialized's error (see install), not a panic.

func Ready added in v0.1.45

func Ready() error

Ready installs the bridge if needed and reports whether the native KCL runtime is usable. A non-nil error is permanent for this process.

func Register

func Register()

Register installs the kcl_plugin.forge namespace and wires forge's purego plugin bridge into the native KCL client. Idempotent, cheap after the first call, and safe to call before every render.

ORDERING IS LOAD-BEARING: the native client is a process-wide singleton whose plugin agent is fixed by whoever initializes it first, and kcl.Run / kcl.ListOptions / kpm all initialize it implicitly with no agent (a plugin-less runtime where `import kcl_plugin.forge` fails). So Register must run before ANY of those. Rather than an init() — which would load libkcl in every forge command, including ones that never render — every forge entry into KCL calls Register itself: Serialized (the choke point for every native evaluation) and the kpm-client call sites in kclrender and kcloptions, which can initialize the client outside Serialized.

func ResetDefaultResolverForTest added in v0.1.45

func ResetDefaultResolverForTest()

ResetDefaultResolverForTest returns the process-global resolver to its unbound, in-memory default. Tests only: the resolver is process-global so ports stay stable across the several renders one command performs, which means one test's arming would otherwise leak into the next.

func Serialized added in v0.1.45

func Serialized[T any](fn func() (T, error)) (T, error)

Serialized runs fn — one call into the native KCL runtime — while holding the process-wide KCL evaluation lock. Every native evaluation forge makes (internal/kclrender.Run, internal/kcloptions' option discovery) goes through here, so no two ever overlap.

Why evaluations cannot overlap

The KCL runtime reports a refusal — an `assert`, a schema `check:` — by panicking, and captures the panic through a PROCESS-GLOBAL panic hook that each evaluation swaps in for its own duration, recording into a THREAD-LOCAL slot that is never cleared (kcl crates/runner/src/runner.rs, FastRunner::run: take_hook → set_hook(record) → evaluate → set_hook(prev)). Two overlapping evaluations interleave those swaps: the one that finishes first restores whatever hook it found — possibly the default one — while the other is still running. The other's refusal then panics through the wrong hook, is printed to stderr as a bare Rust panic, and is never recorded. What that evaluation reports depends on the OS thread it ran on:

  • no record on the thread: an empty refusal, or SUCCESS — a refused render comes back as `{}`, a policy check that failed reads "passed";
  • a stale record: ANOTHER evaluation's message, from a different file.

All three were seen in CI as TestKCLModule_NegativeChecks failing a different fixture each run. `forge env status` renders every env concurrently, and build/up fan out too, so users were exposed to the same corruption. The kcl-go client is a single process-wide handle, so there is no per-evaluation instance to isolate instead; the lock is the fix until the runtime stops mutating global state per evaluation.

Reentrancy

The lock is not reentrant, and kcl_plugin.forge callbacks (resolve_port, allocate_port, write_file, …) run INSIDE an evaluation. A callback must therefore never start another KCL evaluation — none does, and none may.

func SuppressedWrites added in v0.1.45

func SuppressedWrites() []string

SuppressedWrites returns, and clears, the paths write_file declined to write because no writer was armed. A read-only command that wants to say what it did NOT do (`forge env render`) reads this after rendering.

func UseBlockAllocator

func UseBlockAllocator(fn func(base int, key string) (int, error))

UseBlockAllocator arms allocate_port with fn for this process. Call once before rendering, on the up/deploy path. Both `forge env up` AND `forge deploy` arm the SAME (lock-guarded, persistent) allocator, so the two commands resolve identical ports for a given key — the up-vs-deploy fix.

func UseDevStacks added in v0.1.45

func UseDevStacks(fn func() ([]string, error))

UseDevStacks arms dev_stacks with fn for this process.

func UseFileWriter added in v0.1.45

func UseFileWriter(projectDir string, sharedRoot ...string)

UseFileWriter arms write_file for this process, rooted at projectDir. Pass "" to disarm. Call only on a render whose job is to materialize the env on this machine.

sharedRoot is where `shared=True` writes land — the primary checkout's counterpart of projectDir (devstack.SharedProjectDir). Omitted or "", it is projectDir: on the primary checkout the two are the same directory.

func UsePortStore

func UsePortStore(path string) (restore func())

UsePortStore swaps the global resolver for one that persists assignments to path (cross-run port stability), making that file the SINGLE SOURCE OF TRUTH for resolve_port: once allocated (availability-checked), a (role) -> port mapping is read back identically on every subsequent render. Both `forge env up` AND `forge env deploy` call this with the same instance-scoped path, so the two commands resolve identical ports — the fix for the up-vs-deploy port drift.

Call once before rendering, only on the dev-launch / deploy path — not for read-only renders like `forge ci`, which shouldn't write a ports file. Safe to call repeatedly.

It returns a restore func that reverts the store file to its bytes at call time (or removes it if it didn't exist). A render shifts + persists a port when the preferred one is busy; a caller whose render is then REJECTED (e.g. up's already-running guard) calls restore so the rejected attempt can't drift the stable assignments. Callers that commit the render ignore it.

func UsePortStoreReadOnly added in v0.1.45

func UsePortStoreReadOnly(path string)

UsePortStoreReadOnly points the resolver at path for READING only, unless a writable store is already armed — in which case it does nothing, so a launch path that armed the real store keeps it.

This is what closes the gap between what the scaffolded KCL documents and what forge did. `resolve_port` is documented as remembering its answer in .forge/ports-<env>.json "so it is stable from then on", and the writable store delivered exactly that — on `env up` and `env deploy`, the only two paths that armed it. Every OTHER render (env config, env render, db reset's DSN reconciliation, status, doctor, build) ran with an UNBOUND resolver, so it ignored the recorded answer and re-probed from zero.

That is worse than not persisting at all, because the re-probe's result depends on whether the stack is running: the live postgres ANSWERS on its port, portFree says busy, and forge hands out the next port — stepping off the database it was asked to describe. `forge env config` then printed one DSN while `forge env up` had launched on another, and `forge db reset` was handed a port nothing listens on.

Reading is always safe and always correct: the stored value IS the answer a previous resolution already committed to.

func UseStackPortStore added in v0.1.46

func UseStackPortStore(projectDir, env string, readOnly bool)

UseStackPortStore coordinates all checkout/env reservations at the repository anchor. Scope uses the exact project directory (not a basename or branch). No rollback: a failed launch may reserve a port but must never erase another process's concurrent allocation. Reservations are stable across retries.

func UseStackPortStoreReadOnly added in v0.1.46

func UseStackPortStoreReadOnly(projectDir, env string)

UseStackPortStoreReadOnly arms ordinary renders without replacing a writable launch context. The shared store is also the read side's source of truth.

Types

type PortResolver

type PortResolver struct {
	// contains filtered or unexported fields
}

PortResolver hands out host ports, one per logical name, stable for the life of the resolver: repeated Resolve("x", …) calls — including across the several KCL renders a single forge command performs — return the same port, so a component's bound port and the URLs composed from it never drift.

Allocation order for a never-seen name:

  1. the port it got on a previous run (when a store is configured and that port is still free) — stable dev ports across runs;
  2. the requested `preferred` port, then a short scan upward from it (3000 → 3001 → …) so dev ports stay human-friendly and parity-stable;
  3. an OS-assigned free port as the last resort.

It never hands the same port to two names. Availability means BOTH bindable and unanswered — see portFree, where a docker-published port that a bind probe alone reports free is the case that forced the second check. Like any probe-then-bind scheme (cf. cloud-dev.sh's _pick_port) there is an inherent TOCTOU window between resolving a port and the launched process binding it; acceptable for the dev loop this serves.

func NewPersistentPortResolver

func NewPersistentPortResolver(path string) *PortResolver

NewPersistentPortResolver remembers assignments in a JSON file at path, so a name reuses the same port across forge runs. Best-effort: a read/write error never fails a render — it degrades to fresh allocation.

func NewPortResolver

func NewPortResolver() *PortResolver

NewPortResolver returns an in-memory PortResolver that allocates ports for the current run only, without persisting assignments across runs.

func NewReadOnlyPortResolver added in v0.1.45

func NewReadOnlyPortResolver(path string) *PortResolver

NewReadOnlyPortResolver READS an existing store but never writes one. It is what every render that is not a launch gets, and it is the half that was missing: the store's whole purpose is that a port allocated once stays put, and a reporting command that re-probed from scratch reported a DIFFERENT port than the stack it was describing is actually on.

It does not write, because a read-only command must not create machine-local state — and more importantly because the first resolution has to happen while the port is still free to be meaningful. `forge env config` run against a RUNNING stack would otherwise record the port the live service just made forge step off, pinning the wrong answer permanently.

func (*PortResolver) Bound added in v0.1.45

func (r *PortResolver) Bound() bool

Bound reports whether this resolver has been pointed at a store file. The render seam uses it to arm the READ half by default without clobbering a writable store a launch path already armed.

func (*PortResolver) Resolve

func (r *PortResolver) Resolve(name string, preferred int) (int, error)

Resolve returns the port for name (see allocation order on PortResolver).

Jump to

Keyboard shortcuts

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