go — source-map
Detail page of the go recipe card.
Unified YAML loader (LoadUnified)
Section titled “Unified YAML loader (LoadUnified)”The unified format’s entry point is LoadUnified(dir) at charly/unified.go. It reads <dir>/charly.yml, recursively resolves the import: statement (max depth 8, cycle-safe via visited set), and parses every file as a YAML multi-document stream (so bundle files with --- separators work). Every document is unified node-form (name-first): mergeUnifiedDocs runs each document through the shared routing core — classifyDoc (top-level-key inspection) → the closed #NodeDoc CUE gate → normalizeNodeInto (the reserved-word-driven node decomposer in reserved_registry.go). #NodeDoc (schema/node.cue) is the SOLE load-time gate for every loaded document, the root charly.yml and discovered manifests alike. classifyDoc does NOT route a legacy kind-keyed / root-shape document — it HARD-REJECTS it with a charly migrate hint (the legacy mergeKindDoc / firstKindKey / kindKeyedDoc / VmDoc routing was deleted in the #NodeDoc-sole-gate cutover). The legacy-shape detector is rootShapeKeySet (CUE-derived: spec.DocDirectives + every spec.KindWords entry, plus the legacyKindAliases deploy/check) — a DETECTOR only, no routing reads it. F9 BOOTSTRAP PHASE: before the early gateSchemaVersion schema gate, LoadUnified runs runBootstrapPhase(rootData) (bootstrap_phase.go) — it enumerates providerRegistry.providersInPhase(sdk.PhaseBootstrap) and invokes each one’s Invoke(OpBootstrap, {config}), threading the returned (possibly transformed) bytes, so a bootstrap-phase plugin can rewrite the raw root bytes BEFORE validation rejects them. LoadUnified seeds the transformed root into loadUnifiedInto via the fileOverrides map (keyed on the root’s abs path), so the rewrite reaches the actual PARSE + the post-merge gate — not just the early version gate. Bootstrap plugins are compiled-in (in-proc), so this never re-enters the validated-config load. Today only the no-op candy/plugin-example-bootstrap registers in this phase — migrate is NOT a bootstrap-phase transform (it is the compiled-in candy/plugin-migrate command:migrate plugin, invoked explicitly by charly migrate and by the refs.go remote-cache auto-migration Invoke — never as an in-loader byte-rewrite; the load gate keeps the Run: charly migrate reject for a stale config, because migration is whole-project file-based + host-coupled and cannot run on root bytes inside LoadUnified). Phases are an sdk.Phase* set declared per-capability via ProvidedCapability.phase (proto field 9, lifted in buildUnit/buildUnitInProc onto the phaseCarrier; phaseOfProvider defaults to runtime). A no-op bootstrap plugin (candy/plugin-example-bootstrap) returns the bytes unchanged.
UnifiedFile.ApplyDiscover(rootDir) walks the flat generic discover: list after initial merge. discover: is DiscoverConfig = []ScanSpec ({path, recursive, manifest}) — no kind dimension. For each spec, findEntityDirs finds directories containing the spec’s manifest (default UnifiedFileName — charly.yml, the ONE filename the code knows; a missing discover path is a no-op, not an error), and applyDiscoveredManifest validates each discovered document through the SAME classifyDoc → #NodeDoc gate: a candy: node registers a lazy From: directory reference (scanCandy parses + validates it later), every other node decomposes + merges via normalizeNodeInto. Explicit map entries always win over discovered entries. ApplyDiscover runs in the loader’s main path (loadUnifiedInto depth-0 boundary, for the root AND every namespace), so discovered image nodes (candy: nodes carrying base:/from:, the former box:) reach ProjectConfig — not just the layer-loading path. The authoring kind vocabulary is CUE-derived — spec.KindWords projected to the kindWordSet membership set in reserved_registry.go; the former hand kindKeys/kindKeysSet/entityKind lists were deleted.
Projections to today’s concrete types: ProjectConfig() → *Config, ProjectDistroConfig() → *DistroConfig, etc. Existing LoadConfig / LoadBuildConfigForBox / LoadBundleConfig continue to work unchanged — migration to the unified entry point is incremental.
Binary-embedded default config (charly/embed_defaults.go). The loader has ONE document-interpretation path (mergeUnifiedDocs). The binary-embedded default config is plain node-form YAML at charly/charly.yml (//go:embed charly.yml, embed_defaults.go), parsed by the SAME unified loader as any project charly.yml — there is no CUE-source front-end and no compile step: embeddedDefaults feeds the embedded bytes straight through the UNCHANGED mergeUnifiedDocs, then applyEmbeddedDefaults merges the vocabulary in as the lowest-priority base (project-wins). The embedded vocabulary is schema-validated against the sdk schema (sdk/schema — #Distro/#Builder/#Init/#Resource/#Sidecar) at LOAD time via the #NodeDoc gate (validateNodeDocCUE) — guarded by TestEmbeddedDefaults_SchemaConformance, with TestEmbeddedDefaults_SameLoaderPath proving the embed flows through the identical loader core. (Corrected: the former claim that this ran through a shared validateVocabularyCollections helper “also used by charly box validate for project files” was stale — that call site was already cut in c9befd83 when the legacy root-shape collection format it validated became unreachable [HARD-REJECTED at load before validation], and validateVocabularyCollections/its sibling validateEntityCUE were themselves deleted as dead code in the dead-code-radical-removal batch. RDD-verified live: a project’s own vocabulary override — e.g. an unknown key in a builder: entity — is rejected at LOAD time via the per-kind plugin gate, plugin kind:builder: plugin_input fails #BuilderInput, not a separate charly box validate-time pass.)
Schema Driven Design (SDD)
Section titled “Schema Driven Design (SDD)”The operationalization of the project rulebook “Schema Driven Design (SDD)” pillar (AGENTS.md / CLAUDE.md) — the mandate lives there, the how lives here: the configuration schema comes BEFORE the code, and as much code as possible is GENERATED from the schema. The full pipeline map, source → generator → artifact:
| Source (authored) | Generator | Generated artifact |
|---|---|---|
sdk/schema/*.cue (the base ingress schema) |
task cue:gen — cue exp gengotypes + sdk/internal/schemagen (concat/retag/vocab/version), both over the shared sdk/schemaconcat |
sdk/spec/cue_types_gen.go, spec/vocab_gen.go, spec/version_gen.go |
each plugin’s own schema/*.cue |
the same pipeline (the superproject task cue:gen per-plugin params loop, -pkg=params) |
candy/plugin-*/params/cue_types_gen.go |
charly/charly.yml compiled_plugins: |
pluginsgen (charly/internal/pluginsgen, run by task build:binary) |
charly/plugins_generated.go + the repo-root go.work |
sdk/protocol/schema/*.cue |
task wire:gen (wiregen + pinned protoc plugins) |
sdk/proto/plugin.proto, plugin.pb.go, plugin_grpc.pb.go |
Validation at every boundary derives from the SAME schema: ingress — sharedCueSchema (charly/cue_schema.go, the #NodeDoc load gate) + validateKindValueCUE; plugin inputs — registerPluginUnitSchema + validateAuthoredPluginInput (/charly-internals:plugin); migrations — the declarative table candy/plugin-migrate/migrations.cue (/charly-build:migrate); egress — the files charly WRITES (/charly-internals:egress).
Reproducibility gates — regeneration on a clean tree is a NO-OP; drift is an R1 incident: TestGenReproducible (sdk/spec/gen_repro_test.go), TestPluginsGenReproducible (charly/internal/pluginsgen/main_test.go), and TestGeneratedProtocolReproducible (sdk/internal/wiregen).
A high-risk schema shape is spiked first (RDD — /charly-internals:strict-policy “The spike”): prove the def compiles, the generated type round-trips, and the gate accepts/rejects as intended on a throwaway run BEFORE coding against it. The def-level @go(CharlyName) breakage documented in the next section was caught exactly that way.
Generation coverage — the wire-type mandate + the spike-verified exceptions.
WIRE TYPES ARE CUE-SOURCED WITHOUT EXCEPTION (the project rulebook SDD): every
sdk/spec/*_wire.go host↔plugin / render-context data-carrier struct AND every
plugin’s params is a CUE def in sdk/schema/*.cue generated by task cue:gen —
hand-writing a wire struct is FORBIDDEN. A wire type is a plain or DISCRIMINATED
struct, which cue exp gengotypes generates faithfully — RDD-proven live (a
{kind!: "a"|"b", a?: …, b?: …} def generates a real Go struct with the
discriminator + per-variant optional fields), so a wire type NEVER needs a
disjunction. No @go(-) / hand-written type is EVER added without a full RCA +
a live cue exp gengotypes spike proving CUE genuinely cannot express it — an
unverified @go(-) is a mandate violation, not an “exception”. The ONLY
spike-proven cases are four NON-wire categories (each @go(-)’d / documented +
kept in lockstep with its def):
sdk/spec/union_types.go— faithful union/shorthand types for AUTHORED-CONFIG CUE disjunctions (the user’s either/or authoring surface, e.g.VmSourcecloud_image⊻bootc). RDD spike:#X: {cloud_image!: string} | {bootc!: string}→type X map[string]any—gengotypesgenuinely degrades a disjunction (Go has no sum type). The matching CUE def is@go(-)’d. A wire type that would carry a union is a DISCRIMINATED struct instead (aKind/discriminator field + per-variant optional fields —deploy.cue’s#ReverseOptagged-union pattern, RDD-proven to generate) and REFERENCES a disjunction type by name; it never redefines one.sdk/spec/hand_state_types.go— open-tailed struct+map authoring/state shapes (PortSpec, the open-tailedVmDeployState). RDD spike:#X: {known?: string, {[string]: _}}→type X map[string]any— the known fields collapse. Mirrored against@go(-)’d defs. A new wire type uses an EXPLICIT map field instead of an open tail so it generates.sdk/spec/charly_names.go— charly-name Go type aliases (def-level@go(CharlyName)is broken in cue v0.16.1; see the next section). Not a wire struct.- The host↔plugin & render-context wire structs are fully CUE-SOURCED — the
sdk/spec/*_wire.gohand-written-file class is EXTINCT (the last 13 — deploy/init/gpu/doctor/enc/feature/k8sgen/resource/settings/substrate_template/agent/distro/vm — converted in the SDD-conversion batch;arbiter_wire.golanded earlier, FLOOR-SLIM Unit-8B).buildctx.cue(InstallContext/BuildStageContext),clean.cue(RetentionRequest/RetentionReply),arbiter.cue(+ the handspec/arbiter_consts.gostring-enum residue), and nowagent.cue/distro.cue/init.cue/vm.cue/resource.cue/sidecar.cue(appended) plussettings.cue/feature.cue/doctor.cue/gpu.cue/enc.cue/k8sgen.cue/substrate_template.cue/buildwire.cue(new) anddeploy.cue/seam.cue(appended, the deploy-IR/build-time/lifecycle wire) are ALL LANDED — no hand-written wire-struct file remains anywhere insdk/spec. Any NEW wire type is authored the same way: a CUE def insdk/schema/*.cue, generated bytask cue:gen(a plain/discriminated struct always generates, referencing a disjunction/state type by name where one is genuinely needed) — hand-writing one is a mandate violation from the first line, never a “conversion-in-progress” grace period. The handful of NAMED ENUM TYPES with Go behavior gengotypes cannot generate (Scope+ itsString()/ScopeFromName, mirroringStatusinstatus_result.go) and the per-domain STRING-CONSTANT groups (ReverseOpKind,EncMethod*,GpuMode*/HostDriver*,ArbiterAction*) stay hand-written in smallspec/*_consts.go/spec/*_methods.gofiles beside their CUE-generated struct siblings — never a type CUE could express, always a documented, spike-justified exception per the taxonomy above. sdk/protocol/schema/*.cue— the gRPC transport model, including services and streaming flags. The project-ownedsdk/internal/wiregengenerator closes the former CUE→proto tooling gap;sdk/proto/plugin.protoand all language stubs are generated and must never be edited directly.json:"-"fields (keep-in-Go, drop-from-wire) —gengotypeshas NO construct for a field that exists in memory but is excluded from marshaling (it emits a realjson:"<name>,omitempty"tag instead). RDD spike (2026-07-12, cue v0.16.x, the P12 check seam):kit.CheckResult.DeadlineExceeded bool json:"-"(an engine-internal retry signal that must never cross the wire) is genuinely inexpressible — but the spike ALSO confirmed the REST ofCheckResult(Op/Verb/Status/Message/Elapsed/Attempts/TotalElapsed/CapturedValue) generates faithfully. FLOOR-SLIM Unit 4 acted on that:sdk/schema/checkresult.cue→spec.CheckResultis CUE-sourced (the base), andkit.CheckResultis nowstruct { spec.CheckResult; DeadlineExceeded bool json:"-" }— an EMBEDDING WRAPPER adding back ONLY the one field, not a hand-duplicated whole type. The legal exception this bullet documents is narrower than it once was: a hand-written field on an otherwise CUE-sourced type is the LEGAL documented shape, not a hand-written type in its entirety — apply the same embed-wrapper pattern to any other single-fieldjson:"-"exception before assuming the whole containing type must stay hand-written.Op.Kind()(sdk/spec/charly_methods.go) — the exactly-one-discriminator cross-field rule, kept in Go because CUE cannot express it as a generable annotation. A method, not a type.
Spike-proven CAN/CANNOT quick reference (reuse these results — do NOT re-spike settled shapes; DO spike any new shape class):
| Shape | gengotypes result |
|---|---|
Plain / DISCRIMINATED struct (kind!: + per-variant optionals) |
✅ faithful struct — the wire-type workhorse |
Reference to another generated def (Op?: #Op → *Op) |
✅ typed pointer — a def-having embed crosses the wire TYPED |
time.Duration / custom scalar via @go(,type=…) override |
✅ (T-P14a SubstrateKind, T-P12 Elapsed spikes) |
| Untagged-PascalCase-no-omitempty JSON | ✅ via required (!) fields + PascalCase CUE names |
Self-recursive struct (nested?: [...#Self]) |
✅ pointer slice []*Self — JSON byte-identical to a value slice |
[string]: T map |
✅ map[string]T |
Disjunction {a!:…} | {b!:…} |
❌ map[string]any → hand-written union (authored-config only; a wire type uses a discriminated struct) |
Open tail {known?: string, {[string]: _}} |
❌ known fields collapse → hand_state_types |
Int-keyed map [int]: string |
❌ degrades to an empty struct → re-shape to map[string]string or spike-justified @go(-) |
json:"-" keep-in-Go field |
❌ no construct → documented hand-written exception |
The member-by-member detail of the sdk/spec package is the next section; the step-by-step recipe is “How to change the charly.yml schema (CUE is the single source of truth)” under Common Workflows — neither is restated here.
CUE is the single source of truth — the sdk/spec package
Section titled “CUE is the single source of truth — the sdk/spec package”The charly.yml ingress schema has ONE author-of-record: the CUE definitions in sdk/schema/*.cue — in the sdk contract module (github.com/opencharly/sdk, mounted as the sdk/ git submodule). The Go param structs, the reserved-word vocabulary, and the kind/verb wiring are GENERATED or DERIVED from that source — there are no hand-maintained parallel copies. charly/go.mod requires github.com/opencharly/sdk with replace github.com/opencharly/sdk => ../sdk (in-tree resolution; the require version matters for out-of-tree consumers), and the repo-root go.work carries use ./sdk (generated by pluginsgen, which guards a missing sdk submodule with a clear git submodule update --init sdk error). The pieces:
sdk/spec— the generated param structs (import pathgithub.com/opencharly/sdk/spec; charly core reaches most of these types viaspec.Xdirectly — a shrinking residual subset still routes through charly’s OWN transitionalcharly/*_aliases.gofiles, K4/K5 migration inventory being DISSOLVED per the ZERO-ALIASES v2 target; see “The alias surface” below — this skill does not enumerate their current per-symbol contents, since that membership changes every wave).task cue:gen(the sdk repo’s task; the superprojecttask cue:genchains it first, then the per-plugin params loop) regenerates this package fromsdk/schema/*.cue. Its members:spec/cue_types_gen.go— generated bycue exp gengotypes(then yaml-tag retagged). Carries theCode generated … DO NOT EDITbanner; NEVER hand-edit. Every authored param type lives here (Box,Candy,Vm,Op,Deploy, …).spec/vocab_gen.go— generated by the companionsdk/internal/schemagen(-mode=vocab). The CUE-derived reserved-word slices:KindWords,ResourceKinds,DocDirectives,StepKeywords,ContextWords,OpFields,OpVerbs,AuthoringVerbs.spec/union_types.go— hand-written faithful union / shorthand types.cue exp gengotypesdegrades every CUE disjunction toany/map[string]any/an empty struct, so the matching CUE def is annotated@go(-)(suppressing the lossy generated type) and the precise Go type is hand-written here in the SAME package, referenced by the generated structs by name.spec/charly_names.go— hand-written charly-name aliases (type BoxConfig = Box,type VmSpec = Vm, …). Def-level@go(CharlyName)is BROKEN in cue v0.16.1 (it dangles the fields that reference the renamed def, producing uncompilable Go — RDD-verified on a live spike), so the charly NAME is exposed as a Go type alias here instead of via a def-level attribute. The per-FIELD@go(GoName,…)attributes (which DO work) carry the name/pointer/type overrides insdk/schema/*.cue.spec/gen_repro_test.go—TestGenReproducible, the reproducibility gate: re-runs the sametask cue:gentools into a temp dir and diffs against the committedcue_types_gen.go+vocab_gen.go, failing on any drift (skips gracefully when the pinnedcueCLI is absent).spec/scalar_aliases.go,spec/hand_state_types.go,spec/charly_methods.go— hand-written supporting scalar aliases, runtime state types, and the pure methods (Op.Kind(), …) that moved into packagespecalongside the types they operate on.
sdk/internal/schemagen(main.go) — the companion generator. Four modes:-mode=concat(concatenateschema/*.cueinto oneschema_spec.cuecompilation unit headedpackage spec),-mode=vocab(compile that and emitspec/vocab_gen.go),-mode=version(emit theSchemaVersion/SchemaFloorconsts →spec/version_gen.go),-mode=retag(the principled Go yaml-tag transform on thegengotypesoutput →spec/cue_types_gen.go). Compiled and documented — neversedon generated Go.sdk/schemaconcat(schemaconcat.go) — the shared PUBLIC concat contract (R3,github.com/opencharly/sdk/schemaconcat). The ONEConcatSchemaboth the RUNTIME (charly/cue_schema.go’ssharedCueSchema, over thesdk/schemaembed FS) and the dev-time generator call to fold every package-lessschema/*.cuefile into one compilation unit, so the schema the runtime validates against and the Go typesgengotypesproduces can never drift. A leaf package depending only on the stdlibio/fsabstraction (runtime passes thesdk/schema//go:embedFS, the generator passesos.DirFS).- The alias surface (v2 migration INVENTORY — every
charly/*_aliases.goexits at K3/K4/K5 as its call sites move into plugins; the compile-time parity gate below is a TRANSITIONAL convenience, NOT a permanent design virtue — see the project rulebook “Core is a PLUGIN HOST” + the ZERO-ALIASES standing rule). Most authored param types are referenced FULLY-QUALIFIED asspec.Xdirectly (spec.BoxConfig,spec.Op,spec.CandyYAML,spec.ServiceEntry, …) — package main defines NO bare alias for these at all, and the hand struct DEFINITIONS were deleted; this is the state AFTER the wave-2vmshared→specenvelope-route repoint (#128). A SHRINKING RESIDUAL SUBSET of package-main types still route through charly’s OWN transitionalcharly/*_aliases.gofiles (vmshared_aliases.go,kit_aliases.go) — this is K4/K5 migration inventory being DISSOLVED, so this skill deliberately does NOT enumerate which symbol currently lives in which file, or how many remain: that membership and count change every wave, and asserting it here is the exact fragile, error-prone claim class that produced FIVE separate factual errors in one validation round (three in thecheckspec.gorow’sOp/Matcher/MatcherListaside, one in this bullet’s own prior wording, one in thecheckvars.gorow). Before citing which alias file holds a given symbol,grep -n '<Symbol>\s*='the actual file — never cite this skill for it. Because every reference — a directspec.Xor a residual charly-core alias — compiles against the real spec/vmshared types, the Go compiler IS the field-parity check: a renamed or removed spec field (or wire-key/type change) fails the build at this surface. Name collisions where the package-main type is a different concept stay hand-written (CalVer,CandyRef, the runtimeCandy— the authored-config type isspec.CandyYAML, itself an intra-spec-package alias toCandyatsdk/spec/charly_names.go, a STABLE sdk-side fact, not a charly-core alias). charly/reserved_registry.go(package main) — the CUE-derived reserved-word membership sets + the startup VERB bijection gate. Hosts the membership sets that are the loader’s SINGLE reserved-word classification source:kindWordSet,resourceKindSet,stepKeywordSet,authoredOpFieldSet,docDirectiveSet(each a set view of aspec.*slice regenerated bytask cue:gen; no hand-maintained parallel list).VerbCatalogdispatches the generic install verbs;normalizeNodeInto(the reserved-word-driven node decomposer) also lives here. Theinit()runs the VERB bijection check fail-fast at process start (mirrored asTestReservedWordRegistry_*):checkVerbBijection(VerbCatalog, spec.OpVerbs, spec.AuthoringVerbs)— a verb can never be added to the schema without a handler, nor a handler kept after its word is dropped. The KIND bijection ischeckKindProviderBijection(spec.KindWords)incharly/provider_kind.go(panics fromregistry_bootstrap.go): it gates ClassKind PROVIDERS againstspec.KindWords, which is now EMPTY — every authoring kind is plugin-served, so there is no in-corereservedKindHandlersmap (a kind is decoded by its provider viarunPluginKind, not a core handler).charly/uniform_api_gate_test.go(package main) — the F11 uniform-API gate (TestNoSinglePluginAPISurface), the externalization capstone. Asserts the “generic over ad-hoc” invariant STRUCTURALLY: no provider WORD appears in the plugin↔kernel API SURFACE — not as ansdk.Op*selector value, asdk.ProvidedCapability/StepContractfield name, a reverse-channel RPC method name (ExecutorService/CheckContextService/Provider/PluginMeta), or ahostBuilderskey. The forbidden-word universe is the union of the CUE-derived generic config vocabulary (spec.OpVerbs ∪ spec.KindWords ∪ spec.OpFields ∪ spec.AuthoringVerbs) plus every compiled-in non-command provider’sReserved()word — which is where the externalized check verbs cdp/vnc/mcp/kube/libvirt/spice/adb/appium enter, since they are plugin-served and NOT#Op/spec.OpFieldsfields — minusgenericConceptCollisions(venue— the genericExecutorService.VenueRPC coincides with the#Opvenue field). The invariant is structural, NOT a user-count (each capability flag has exactly ONEcandy/plugin-example-*user — “≥1 by construction”). Per-plugin data rides the opaqueSubstrate json.RawMessage; a host MAY call the genericconnectPluginByWord(class, word)with a specific word ARGUMENT (the word is data, never API shape — those call-sites are not scanned). Sibling of the startup bijection gates; has a teeth arm (a re-introduced provider word must trip it) + fixed RPC-method allowlists (a new reverse RPC is a conscious, reviewed addition).
import-namespace loader (UnifiedFile.Import + Namespaces)
Section titled “import-namespace loader (UnifiedFile.Import + Namespaces)”UnifiedFile.Import (type ImportList, YAML tag import) is the single composition statement. Its custom UnmarshalYAML accepts a mixed-shape sequence: a scalar item → ImportEntry{Ref: …} (flat, Namespace == ""); a single-key mapping item → ImportEntry{Namespace: alias, Ref: …} (namespaced). A matching MarshalYAML round-trips both shapes (migrators rely on it). validateNamespaceAlias enforces a bare lowercase-hyphenated alias (no dots).
loadUnifiedInto processes the queue:
- Flat entries are loaded and root-merged into the importing
UnifiedFile(same root-wins merge that drives same-repo file splits + an imported build-vocabulary override). - Namespaced entries call
loadNamespaceCached(ref, base, nsCache, loadingRepos), which loads the target as a fully-resolved, isolatedUnifiedFile(its own flat imports + its own namespaced imports, with a FRESHvisitedset for its file-cycle detection) and mounts it undermerged.Namespaces[alias]. These entries are NOT flat-merged into the root maps — they are referenced qualified.
UnifiedFile.Namespaces (map[string]*UnifiedFile, YAML tag -, never authored directly) holds the mounted children; projectConfigCached projects it to Config.Namespaces (map[string]*Config, pointer-keyed cache → self-references project safely).
Cycle-break by REPO IDENTITY (ns_identity.go), not pinned version. Two maps cooperate in loadNamespaceCached: nsCache is the version-keyed (canonicalRef: repo@version/subpath) diamond memo — it dedups identical refs across a load; loadingRepos is the ancestor/cycle set, keyed by REPO IDENTITY (nsRepoIdentity: a remote ref’s RepoPath, or a local path’s git remote origin). BEFORE any fetch, if the ref’s repo identity is already in loadingRepos (an ancestor still on the load stack), the loader returns that in-progress node — so an import cycle between two projects that import each other (or a transitive back-import of an ancestor still being loaded) terminates even when the loop’s pins diverge: a back-reference to a DIFFERENT pinned version of an in-progress repo resolves to the in-progress node instead of fetching (and recursing into) a divergent — possibly stale-schema — snapshot. LoadUnified seeds loadingRepos[rootIdentity] = merged (the root’s identity comes from its optional repo: field, else git remote origin), so any transitive import of the root’s OWN repo resolves to the local working tree — the importing project’s namespace pins win. loadingRepos entries are pushed before recursing and popped after (stack-scoped — two SIBLING imports of the same repo at different versions still each load); the root seed is never popped. A whole-repo ref with an empty sub-path resolves to that repo’s charly.yml. Covered by TestImportNamespace_DivergentVersionMutualCycle + TestNsRepoIdentity in ns_identity_test.go.
Namespace resolver (charly/namespace.go)
Section titled “Namespace resolver (charly/namespace.go)”The resolver implements Go-package-member semantics over Config.Namespaces:
splitNamespaceRef(ref)— splits a qualified ref on its FIRST.into(ns, rest); a bare ref returnsok=false; the remainder may itself be qualified (a.b.c→"a","b.c").resolveBoxRef(ref)/resolveLocalRef(ref)— bare names resolve in the currentConfig;ns.namedescends intoc.Namespaces[ns]recursively, returning the entry plus theConfig(namespace context) it lives in.resolveNamespacedBases(out, …)— after the local image set resolves, pulls every namespace-qualifiedbase:(and qualifiedbuilder:ref, but only for images that actually have layers to build) intoout, keyed by fully-qualified name, iterating to a fixpoint (a pulled-in image may reference a deeper namespaced base).pullNamespacedBox(from, ref, keyPrefix, …)— descends the namespace chain to the leaf, re-keys the entry’s own internal base to the fully-qualified ancestor so the build graph references it correctly, and recurses to pull that ancestor.
The inheritance rule lives here: distro:/build: are VALUES → inherited across a namespace boundary; builder: is a map of namespace-relative REFS → NOT inherited (the consumer declares its own). See the file header comment for the rationale (avoid leaking a base-namespace-relative ref into a consumer where that namespace doesn’t exist). leafName(ref) strips every namespace prefix to the final member name (arch.arch-builder → arch-builder); paired with resolveBoxRef’s returned namespace Config it keys the resolved entity in that Config.Box map (used by the reachability walk below).
Remote-layer resolver (charly/refs.go + charly/layers.go) — per-entity version + reachability-scoped collection
Section titled “Remote-layer resolver (charly/refs.go + charly/layers.go) — per-entity version + reachability-scoped collection”@github layer refs resolve in TWO phases: the :vTAG git tag is only the FETCH coordinate (which commit to clone); the layer’s own version: field — read AFTER fetch — is the authoritative identity that drives dedup + warn-and-newest-wins.
CandyRef(charly/refs.go) — the single representation of arequire:/candy:ref. It stores the ORIGINAL ref string (Raw, with any@repoprefix and:versionsuffix);.Bare()(the map-key form),.Version()(the pinned git tag — the FETCH coordinate, NOT the identity), and.IsRemote()are DERIVED. Aresolvedslot carries the qualified sibling key set byqualifyRemoteSiblingDepsafter a remote layer is fetched, so ONE list serves both the graph (keys on.Bare()) and the transitive fetch (keys on the immutable.Raw).Candy.Require/Candy.IncludedCandyare[]CandyRef— there are no parallel bare/raw arrays.- Two-phase per-entity-version resolution —
CollectRemoteRefsOpts(charly/refs.go) collects EVERY distinct(repo, git-tag)a bare ref is referenced at — it does NOT collapse to one winning tag and does NOT warn (the git tag is just where to clone from). TheScanAllCandyWithConfigOptsfix-point (charly/layers.go) fetches each(repo, git-tag)(tracking scanned(repo,git-tag,ref)triples), reads each materialization’s per-entityversion:, accumulates candidates per bare ref, thenpickCandyVersionarbitrates: same per-entity version across different git tags → NO warning, the newest git tag wins for freshness (compareSemver); different per-entity versions → warn once (naming both per-entity versions + sources) and the newest per-entity version wins (compareCalVer). Exactly one materialization per bare ref reaches the layer map, so the graph + intermediates are unchanged. A fetched layer with NOversion:is a HARD ERROR (no fallback — first-party remotes are backfilled by remote-cache auto-migration,EnsureRepoDownloaded→RunProjectMigrations).pickCandyVersionis the SOLE arbiter for direct AND transitive refs, so a transitive dep can never silently pull a different version of an already-resolved layer. This is why a repo re-tag of an UNCHANGED layer no longer warns — the old resolver compared the repo git tag, which advances on every landing. - Reachability-scoped collection (
CollectRemoteRefsOpts.collectBox) — collection walks ONLY the enabled root images + the namespaced images reachable via theirbase:/builder:edges (resolveBoxRef+leafName), plus local layers’ transitive deps. It does NOT scan every image andkind:localtemplate of every imported namespace (that over-collection pulled unrelated layers pinned at a different tag — e.g. a namespace’scharly-cachyosworkstation template’schrome— and tripped the version policy). Builder edges ARE followed when an image builds (a namespacedfedora.fedora-builderis built as an intermediate and needs itsrpmfusion/yaylayers); dropping them under-collects (“unknown layer”). - One unified populator (
populateCandyFromYAML,charly/unified.go) — bothscanCandy(discovered-layer-dir path) andsynthesizeInlineCandy(charly.yml inline path) call it, so they can’t drift. TheHas*predicates (HasEnv/HasPorts/HasVolumes/…) are derived methods; only the filesystem-probe caches (HasPixiToml/HasSrcDir/…) stay fields.
charly box reconcile (see /charly-build:reconcile) is the operator tool that aligns the on-disk git-tag pins so every reference of a repo fetches one commit, clearing any residual per-entity-version warning.
Capabilities — BoxMetadata alias + label completeness check
Section titled “Capabilities — BoxMetadata alias + label completeness check”Capabilities = BoxMetadata (type alias in charly/capabilities.go). CapabilityLabelMap lists every field with its OCI label home; TestCapabilityLabelCompleteness fails the build if an BoxMetadata field lacks a mapping. This invariant keeps charly bundle from-box reliable: every field deploy code might consult is readable from a pushed image’s labels alone, independent of charly.yml.
Kubernetes substrate (EXTERNAL — deploy:k8s, candy/plugin-kube)
Section titled “Kubernetes substrate (EXTERNAL — deploy:k8s, candy/plugin-kube)”target: k8s is an EXTERNAL deploy substrate (F1): there is no in-proc k8s DeployTarget — it resolves to pluginDeployTarget (charly/unified_targets.go, S3b), dispatched via candy/plugin-bundle’s Invoke(OpDeployDispatch) to the ACTUAL substrate provider, served out-of-process by candy/plugin-kube’s deploy:k8s provider (beside its kube: verb). The Kustomize GENERATOR is the COMPILED-IN candy/plugin-k8sgen (M13, verb:k8sgen serving OpEmit; the workload-kind heuristic selectWorkloadKind maps the generic kind: enum to Deployment/StatefulSet/DaemonSet/Job/CronJob/Pod) — kept SEPARATE from the heavy external plugin-kube because it has no client-go dependency and must resolve in the project-less from-box path. charly/k8s_generate.go is now a thin in-core SHIM: GenerateK8sKustomize lifts the 3 caps scalars (Port/UID/GID) + spec.Deploy + spec.K8s into a spec.K8sGenInput, Invokes the candy (OpEmit → spec.K8sGenReply manifest docs), validates each doc HOST-SIDE via the M16 egress shim (ValidateEgressValue), then writes the base/+overlays/ tree. The PLUGIN-side deploy:k8s preresolver (candy/plugin-kube/preresolve.go, F6/FINAL-K5-unit-6a, reaching this shim via the host_build_k8s_generate.go “k8s-generate-kustomize” HostBuild seam) + charly bundle from-box --target k8s (calling it directly, unmoved) both call this shim (unchanged signature). The plugin runs kubectl --context <ctx> apply -k. See /charly-internals:install-plan + /charly-kubernetes:kubernetes.
VM target (external substrate)
Section titled “VM target (external substrate)”target: vm is an EXTERNAL deploy substrate, exactly like local/android/k8s: there is no in-proc VM DeployTarget. It resolves to pluginDeployTarget (S3b), dispatched via candy/plugin-bundle’s Invoke(OpDeployDispatch) to the ACTUAL substrate, served out-of-process by candy/plugin-deploy-vm’s deploy:vm provider. UNLIKE k8s, the vm substrate DOES consume the InstallPlan IR — the plugin walks the plan via the SAME shared sdk/kit.WalkPlans the local deploy uses, but the executor the reverse channel serves is the guest SSHExecutor (sdk/kit/deploy_executor_ssh.go), so the same walk runs INSIDE the guest (bash bodies via ssh guest 'sudo bash -s'). The DeployExecutor interface (sdk/kit/deploy_executor.go) decouples “how shell commands run” from the walk — ShellExecutor + SSHExecutor are the two implementations.
The VM venue lifecycle (boot the domain, build the guest SSH executor, nested pod-in-guest, teardown, the charly vm Start/Stop/Status/Logs/Shell/Rebuild) is IMPLEMENTED IN THE PLUGIN candy/plugin-deploy-vm/lifecycle.go (Lifecycle:true) over generic seams — sdk/kit (ssh-config stanza, guest waits, charly delivery), HostBuild("cli") (the charly vm/charly box build family), the “deploy-entity-resolve” HostBuild seam (the plugin resolves its OWN spec.LifecyclePrepareInput end-to-end — the former host-side lifecyclePrepareHook DATA-seam is GONE, FINAL/K5 unit 6a), and the served guest executor reverse channel (nested pod-in-guest). Core keeps ONLY the residual cleanup the plugin cannot do: the F12 vmAttachResolver + the vm lifecyclePostTeardownHook (charly/vm_lifecycle_preresolve.go). The lifecycle Ops are reached through pluginDeployTarget → candy/plugin-bundle’s Invoke(OpDeployDispatch) → the plugin’s own sdk.Executor.InvokeProvider call into the vm substrate provider (S3b, replacing the former dedicated grpcSubstrateLifecycle proxy + substrateLifecycle interface, both DELETED) — and persists the returned VmDeployState. Both pod and vm own a real venue lifecycle, each externalized to its plugin.
charly bundle add vm:<name> dispatches through bundle_add_cmd.go::dispatchNode → ResolveTarget → pluginDeployTarget (no per-kind dispatch function); bundle_add_cmd_vm.go carries the host-side VM-only helpers that REMAIN (vmNameFromDeployName, sshReverseRunner, resolveVmSshUser / resolveVmSshPort, saveVmDeployState, removeVmDeployEntry). Full architecture + preflight flow lives in /charly-internals:vm-deploy-target.
YAML surface ↔ Go identifier convention
Section titled “YAML surface ↔ Go identifier convention”The codebase keeps wire format (YAML keys) and internal names (Go fields/types) in strict symmetry — plural YAML keys get plural Go identifiers, singular get singular. The singular builder: / distro: / init: top-level keys in the embedded build vocabulary (charly/charly.yml) and project charly.yml carry singular Go identifiers: BuilderMap, BoxConfig.Builder, BuilderConfig.Builder, DistroConfig.Distro, InitConfig.Init. The rule: if you change a YAML tag, also rename the Go identifier. Tests enforce this indirectly — struct literals won’t compile if they disagree. Note: the OCI label key is grouped under platform.* / builder.* sub-namespaces — see LabelPlatformDistro, LabelPlatformFormat, LabelBuilderUse, LabelBuilderProvide in labels.go. Label wire-names are decoupled from YAML/Go identifiers by design.
Kong default:"withargs" for parent+leaf commands
Section titled “Kong default:"withargs" for parent+leaf commands”Kong normally treats a struct as either a branch (has child cmd:"" subcommands) OR a leaf (accepts arg:"" positionals and has a Run() method) — not both. When you want both shapes on the same parent command (e.g., charly config <image> runs setup AND charly config mount|status|… dispatch to subcommands), tag the default child with default:"withargs". Kong then dispatches to that child when the first token doesn’t match a subcommand name, passing positional args/flags through.
One use in the codebase:
charly/config_image.go—BoxConfigCmd.Setupis the default (default:"withargs");charly config <image>routes throughBoxConfigSetupCmdwhilecharly config mount|status|…dispatch explicitly.
charly check — now the compiled-in command:check plugin (candy/plugin-check) — needs no such pattern: every live-container verb (wl/cdp/vnc/dbus/… and libvirt) is an out-of-process declarative verb, NOT a charly check subcommand, so no subcommand name can shadow the charly check live <image> positional.
Mode purity: LoadConfig must NOT read charly.yml
Section titled “Mode purity: LoadConfig must NOT read charly.yml”OCI labels are written exclusively from charly.yml at charly box build / charly box generate time. charly.yml is deploy-mode state and must never bleed into the baked image. The key guarantee lives in charly/config.go:LoadConfig — it calls LoadConfigRaw only, with no MergeDeployOverlay.
The rule: every build-mode command (anything under charly box …) calls LoadConfig. If you ever re-introduce MergeDeployOverlay inside LoadConfig, you will silently contaminate OCI labels with whatever is in the user’s local charly.yml — exactly the bug that made images bake ports: ["5900:5900","9250:9222"] from a stale charly.yml entry instead of the charly.yml-declared ["5900:5900","9222:9222","9224:9224"].
Deploy-mode commands (charly config, charly start, charly stop, charly update, charly bundle add, charly bundle del, charly shell, charly cmd, charly service, charly vm create, …) read labels via ExtractMetadata and then apply the deploy overlay explicitly via MergeDeployOntoMetadata(meta, dc, instance). This split is load-bearing — never collapse it.
Host-deploy specifics: charly bundle add host is deploy mode (reads both charly.yml and charly.yml), not build mode — despite looking like “install on host, not into an image”. The compiler (BuildDeployPlan in install_build.go) is pure and shared with build mode, but the invocation path reads charly.yml for add_candy: and install_opts: like every other deploy-mode command.
InstallPlan IR — the shared intermediate representation
Section titled “InstallPlan IR — the shared intermediate representation”The DEPLOY paths (pod/vm + external [local/k8s/android]) route through a shared IR; build-mode Containerfile emission is a SEPARATE generator (WriteCandySteps → EmitTasks in sdk/deploykit, relocated from charly/generate.go in #67, driven by candy/plugin-build over the envelope + HostBuild("render-seam"); emitTasks in charly/tasks.go is a thin shim to deploykit.Generator.EmitTasks that stays for the pod-overlay), NOT the IR. The k8s substrate is EXTERNAL and does NOT consume the IR — it generates a Kustomize tree host-side (see “Kubernetes substrate” above). Flow:
Layer + ResolvedBox + HostContext → BuildDeployPlan (install_build.go) [pure; deploy-path only, NOT charly box build] → InstallPlan (install_plan.go) → DeployTarget.Emit (NO in-proc DeployTargets remain — the former in-proc overlay walker + the pod overlay target were DELETED in P11c; the pod overlay render now lives in the candy `plugin-deploy-pod/overlay.go`, via `deploykit.OCITarget` + `deploykit.NewRenderGeneratorFromProject`) / UnifiedDeployTarget lifecycle └── pluginDeployTarget (charly/unified_targets.go, S3b) → candy/plugin-bundle's Invoke(OpDeployDispatch) → the ACTUAL substrate provider's own InvokeProvider (S1) over the OpExecute reverse channel (deploy:local — candy/plugin-deploy-local walks the IR via kit.WalkPlans, host-engine steps via RunHostStep; deploy:vm — candy/plugin-deploy-vm runs the SAME walk INSIDE the guest over the guest SSHExecutor, with the vm venue lifecycle plugin-implemented, reached through the SAME OpDeployDispatch generic dispatch; deploy:k8s — plugin-side preresolver generates the Kustomize tree, plugin runs kubectl apply -k; deploy:android)deploykit.OCITarget is constructed only by the candy plugin-deploy-pod’s buildOverlay; charly box build/generate emit via the WriteCandySteps → EmitTasks generator in sdk/deploykit (deploykit.Generator, relocated from charly/generate.go in #67; emitTasks in charly/tasks.go is a thin shim to deploykit.Generator.EmitTasks that stays for the pod-overlay), sharing the package-cascade / shell-snippet / localpkg compiler helpers with the IR. Full reference lives in /charly-internals:install-plan — go there before touching any of those files. Supporting Go files (ledger, builder_run, shell_profile, reverse_ops, service_render, deploy_ref, hostdistro, migrate_services_tool) are covered in /charly-internals:local-infra.
VM-path architecture
Section titled “VM-path architecture”The VM path spans the following module topology:
| File | Role |
|---|---|
sdk/spec/cue_types_gen.go (generated) |
VmSpec (= Vm) + VmSource discriminated union (cloud_image / bootc) + VmChecksum + VmNetwork + VmSSH + VmKeyInjection |
sdk/spec/cue_types_gen.go (generated) |
VmCloudInit + VmCloudInitUser/File/Network/Mirrors + VmCharlyInstall (auto/scp/url/skip state machine) |
sdk/vmshared/libvirt_yaml.go |
LibvirtDomain + 30+ sub-types (features, CPU, clock, memory backing, numatune, cputune, devices, seclabel, launch security, resource, sysinfo) — the opencharly YAML-facing shape of the libvirt: stanza |
candy/plugin-vm/libvirt_yaml_bridge.go |
RenderDomainXML/BuildLibvirtDomainXML pure functions (build a libvirtxml.Domain tree, marshal to XML) + buildDomainDevices device emission (passt backend, portForward attribute order, virtio-gpu default, SMBIOS credentials, XMLPassthrough merge) |
sdk/vmshared/qemu_render.go |
RenderQemuArgv for direct-QEMU backend |
sdk/vmshared/cloud_init_render.go + cloud_init_iso.go |
RenderCloudInit + ResolveKeyInjectionChannels + composeUsers (adopt-merge) + WriteSeedISO via xorriso/genisoimage/mkisofs |
charly/vm_cloud_image.go + sdk/kit/http_fetch.go |
BuildCloudImage pipeline: fetch URL (FetchQcow2, in sdk/kit) + sha256 sidecar + resize + seed ISO render |
spec/exec/charly_install.go |
exec.EnsureCharlyInDeployVenue — the GENERIC “copy charly into a running venue” mechanism over spec.DeployExecutor (container podman cp / VM-SSH scp / host install, all via DeployExecutor.PutFile): returns the charly invocation command, copying the host os.Executable() to a non-$PATH /tmp/charly-<hostVer>-<digest8> on absence/older (idempotent, never shadows a packaged charly). Used by nested from-image delegation (charly/plugin_grpc.go) + the --host SSH re-exec (sdk/kit/reexec_ssh.go), so an image need not bake the charly layer. exec.EnsureCharlyInGuest is the VM-deploy PrepareVenue strategy wrapper (auto/scp/skip) layered on top — host-surface ssh/scp against the managed alias, before the reverse channel serves a guest executor. Both re-exported as kit.EnsureCharlyInDeployVenue/kit.EnsureCharlyInGuest in sdk/kit/exec_aliases.go |
sdk/vmshared/ovmf_paths.go |
ResolveOvmfPaths (per-distro OVMF_CODE/VARS paths) + EnsurePerVmNvram + ResolveOvmfForSpec (bios-sentinel returning empty strings) |
sdk/schema/vm.cue + cue_kind_vm.go |
#Vm — the closed CUE schema validating VmSpec + the #LibvirtDomain/#VmCloudInit subtrees (the Go VM/libvirt validators were deleted; CUE owns it via the per-kind registry) |
sdk/kit/deploy_executor*.go |
DeployExecutor interface + ShellExecutor + SSHExecutor with WaitForSSH + WaitForCloudInit |
charly/unified_targets.go + charly/deploy_target_dispatch.go + charly/arbiter_bracket.go |
S3b: pluginDeployTarget — the thin, data-only adapter EVERY external substrate (local/vm/pod/k8s/android) routes through, dispatching via dispatchDeployTarget to candy/plugin-bundle’s Invoke(OpDeployDispatch); arbiter_bracket.go is the ONE piece that stays core-resident, wrapping (not living inside) the dispatch call. Replaces the DELETED charly/deploy_target_external.go (externalDeployTarget), charly/substrate_lifecycle_grpc.go (grpcSubstrateLifecycle), charly/deploy_preresolve.go (wireDeployPreresolver), and charly/deploy_substrate_lifecycle.go (the substrateLifecycle interface) |
candy/plugin-bundle/deploy_target.go |
S3b: the ORCHESTRATION bulk ported from the deleted core files above, behind runDeployDispatch (sdk.OpDeployDispatch, discriminated by an Op field: add/update/del/start/stop/status/logs/shell/attach/rebuild); reaches the ACTUAL substrate provider via its own sdk.Executor.InvokeProvider (S1) |
charly/vm_lifecycle_preresolve.go |
FINAL/K5 unit 6a: the vm lifecyclePrepareHook DATA-seam is GONE — the plugin resolves its own spec.LifecyclePrepareInput via the “deploy-entity-resolve” HostBuild seam. This file keeps only the F12 vmAttachResolver + the vm lifecyclePostTeardownHook (residual ephemeral cleanup the plugin can’t do), reached through pluginDeployTarget → candy/plugin-bundle’s generic OpDeployDispatch; the vm venue lifecycle itself lives in candy/plugin-deploy-vm/lifecycle.go |
candy/plugin-deploy-vm/ |
the out-of-process deploy:vm plugin — the plan WALK (kit.WalkPlans over the guest SSHExecutor) + the venue lifecycle (lifecycle.go, over kit + HostBuild("cli") + the served guest executor) |
charly/bundle_add_cmd_vm.go |
host-side VM-only deploy helpers that REMAIN (vmNameFromDeployName, sshReverseRunner, resolveVmSshUser / resolveVmSshPort, saveVmDeployState, removeVmDeployEntry); charly bundle add vm:<name> itself dispatches through dispatchNode → ResolveTarget → pluginDeployTarget |
candy/plugin-vm/vm_create_spec.go + candy/plugin-vm/vm_build_resolve.go |
charly vm create CLI wiring (in the command:vm plugin) + the VM-disk build ENGINE (plugin-side — the former charly/vm_build.go HostBuild(“vm-build”) host-builder is DELETED; the resolve moved into the plugin) reading kind: vm entities |
sdk/vmshared/libvirt_helpers.go + libvirt_yaml_listen.go |
helpers shared by the libvirt YAML bridge + qemu_render argv emitter (VmRuntimeParams); structured <listen> support for LibvirtGraphics |
unified.go VM support (C2-substrate): "vm" is NO LONGER a spec.KindWords kind — the 5 substrate kinds (pod/vm/k8s/local/android) were externalized to the compiled-in candy/plugin-substrate (kind:pod/vm/k8s/local/android, Structural:true), so vm LEFT spec.KindWords + the #Node disjunction (no #VmArm) but STAYS in spec.ResourceKinds (member nesting) and its #VmValue def is KEPT for the host-side value gate. A vm: node resolves via recognizedKind (the compiled-in provider) → runPluginKind → foldSubstrateKind, which host-decodes the CANONICAL node via the core loader (buildBundleNode for a deploy shape → uf.Bundle; for a bare template, decodeStandaloneTemplateJSON — the C2-substrate TEMPLATE fold arm), validates its value against the KEPT #VmValue def (validateKindValueCUE), threads it to plugin-substrate’s OpLoad via op.Env (spec.StructuralKindLoadEnv.Standalone), and folds the plugin’s ECHO into uf.VM via foldStandaloneTemplateReply → foldOpaqueTemplateReply, which stores the reply JSON VERBATIM into uf.VM map[string]json.RawMessage — an OPAQUE map, not a typed map[string]*VmSpec: the kernel keeps the template body opaque and candy/plugin-substrate resolves it on read (the same de-typed pattern as local/android). The type VmSpec, however package main currently reaches it (today via a residual charly/vmshared_aliases.go binding — K4/K5 dissolving inventory, grep-confirm before citing it), resolves through the stable vmshared.VmSpec (sdk/vmshared/spec_aliases.go) to spec.ResolvedVm — a wire-envelope type mirroring spec.Vm’s fields (CUE-sourced at sdk/schema/vm.cue, generated into spec/cue_types_gen.go), NOT spec.Vm itself. #NodeDoc is the sole STRUCTURE gate; a residual legacy vm:-keyed (or vms:-plural) document is hard-rejected by classifyDoc with a charly migrate hint (rootShapeKeySet unions spec.ResourceKinds so the substrate words stay legacy-detectable).
Full subsystem references: /charly-internals:vm-spec, /charly-internals:libvirt-renderer, /charly-internals:cloud-init-renderer, /charly-internals:vm-deploy-target, /charly-internals:ovmf, /charly-internals:cutover-policy.
Self-exec coordination: host → container AND host → host
Section titled “Self-exec coordination: host → container AND host → host”The charly binary self-execs in two distinct directions.
Host → container — the host charly delegates to a container-baked (or copied-in) charly via exec … charly <subcommand>. The surviving site is nested from-image delegation: a from-image plan re-invokes charly inside the venue, and exec.EnsureCharlyInDeployVenue (spec/exec/charly_install.go, re-exported as kit.EnsureCharlyInDeployVenue) copies the host binary in on demand when the venue lacks it. The best-effort desktop notification in charly/notify.go (sendVenueNotification) is NOT a self-exec site — it drives the venue’s session bus with gdbus directly, no in-container charly.
Host → host (none) — there is NO host→host self-exec for check verbs. Every live-container verb (wl/cdp/vnc/dbus/mcp/record/kube/adb/appium/spice/libvirt) dispatches OUT-OF-PROCESS through the provider registry to its plugin candy (EXEC-based verbs drive the venue over the DeployExecutor reverse channel; endpoint verbs dial a host-pre-resolved address) — never by spawning a charly subprocess. charly’s core carries no in-proc live-verb dispatch machinery.
The rule: whenever you rename a subcommand path crossed by the surviving host→container self-exec site (nested from-image delegation), edit the host-side invocation strings AND plan a coordinated rebuild of every image that bakes the charly layer (affected images: grep charly.yml for - charly$).
Source Code Map
Section titled “Source Code Map”Core Generation
Section titled “Core Generation”| File | Purpose |
|---|---|
main.go |
CLI entry point (Kong framework). CLI struct carries two global path fields: Dir (-C / --dir / env CHARLY_PROJECT_DIR) and Repo (--repo / env CHARLY_PROJECT_REPO). When Repo is set, main() resolves it via ResolveProjectRepo and assigns the cache path back into Dir; when Dir is non-empty (after that resolution), main() calls os.Chdir(Dir) before ctx.Run() — one-line intervention that propagates to every os.Getwd() call site throughout build-mode commands without requiring per-command plumbing. --repo and --dir are mutually exclusive (fast-fail). Covered by TestCharlyDir_FlagChdir, TestCharlyDir_Errors, TestCharlyRepo_FlagChdir, TestCharlyRepo_DirConflict, TestCharlyRepo_DefaultExpansion in main_dir_test.go + main_repo_test.go. Load-bearing for charly mcp serve inside a container where cwd resolves to /workspace (the charly-mcp layer default) — either bind-mounted with the project, or empty in which case the externalized MCP server’s managed --repo default child prefix falls back to the upstream repo (computeProjectPrefix in candy/plugin-mcp/serve.go). |
main_repo.go |
--repo resolver. DefaultProjectRepo = "github.com/opencharly/charly". normalizeRepoSpec(spec) handles four spec shapes: "default" literal, bare owner/repo (auto-prefix github.com/ when first segment has no dot), bare owner/repo@ref, host-qualified host.tld/owner/repo[@ref]. ResolveProjectRepo(spec) reuses EnsureRepoDownloaded from refs.go so the project-repo cache shares ~/.cache/charly/repos/ (override CHARLY_REPO_CACHE) with the existing remote-layer cache. Empty version triggers GitDefaultBranch resolution. |
config.go |
charly.yml parsing, inheritance resolution. BuildFormats type. Distro field. ResolvedBox.Tags (union). SupportsTag(), SupportsBuild() methods |
format_config.go |
DistroConfig (with per-distro Formats), BuilderConfig types. BuildFile loader struct matches the three top-level build-vocabulary sections (distro:, builder:, init:). LoadBuildConfigForBox reads the project charly.yml (via LoadUnified, with the embedded build vocabulary merged in as the project-wins base) and splits it into DistroConfig / BuilderConfig / InitConfig views. Per-image config resolution with remote ref support |
format_template.go |
Go text/template rendering engine. Template helpers: cacheMounts, cacheMountsOwned, quote, default, splitFirst, replace, join. InstallContext, BuildStageContext types |
layers.go |
Layer scanning, file detection. spec.CandyYAML (an intra-spec-package alias to spec.Candy, the real generated struct — no hand struct; the alias lives at sdk/spec/charly_names.go, NOT a charly-core file), CUE-decoded via cue_loader.go; load-time top-level typo-detection via the rejectUnknownCandyTopLevelKeys guard — no custom UnmarshalYAML. Task struct + Kind() method (exactly-one-verb). derivePackageSectionsFromCalamares is the SOLE package-surface populator: every distro: key (bare / versioned / compound) → a per-distro tagSections entry (NOT a shared format section — that collapse caused the non-deterministic deb-repo bug); top-level package: → topPackages (folded at resolve time); arch aur: keeps its aur format section. compileSystemPackageSteps (install_build.go) cascades these — see /charly-internals:install-plan. The runtime Candy.ExternalBuilder field (the reserved word of an EXTERNAL builder plugin a candy selects; from the candy manifest external_builder:) lives here, populated by populateCandyFromYAML (unified.go) and resolved at build via OpResolve (sdk/deploykit EmitExternalBuilderStages, relocated in #67). |
tasks.go |
All task emission logic — per-verb emitters (emitMkdirBatch, emitCopy, emitWrite, emitLinkBatch, emitDownload, emitSetcapBatch, emitCmd, emitBuild), emitTasks orchestrator, stageInlineContent (content-addressed), resolveUserSpec, taskSubstPath, taskUnresolvedRefs. Adjacent-coalescing (taskCoalescesWith). Shell-quoting helpers: shellSingleQuote(s) for standard '...' escaping (used by LABEL values + emitDownload env entries) and shellAnsiQuote(s) for bash ANSI-C $'...' quoting (used by emitCmd so multi-line script bodies survive podman’s line-oriented Dockerfile parser). emitDownload env rule: uses export VAR=val; (semicolon-terminated) not VAR=val cmd, because bash expands ${VAR} in URL arguments before the cmd-prefix environment is assembled. ~430 lines, single home for install-task codegen. plugin: verb case + emitPluginFragment: a run: plugin step emits placement-agnostically — a builtin ProvisionActor renders an act shell RUN in-proc, any other resolved provider renders via emitPluginFragment → Invoke(OpEmit) → spec.EmitReply.Fragment spliced verbatim (in-proc for a builtin, go-plugin gRPC for an external). See /charly-internals:plugin + /charly-build:generate. |
generate.go |
The build-mode Containerfile render DRIVE (Generate / generateContainerfile / WriteCandySteps / WriteLabels / writeJSONLabel / WriteBootstrap / EmitBuilderStages / EmitBuilderArtifacts / EmitExternalBuilderStages / EmitExternalBuilderArtifacts / GenerateTraefikRoutes / EmitTraefikRouteStage / EmitInitFragmentStages / EmitInitAssembly) moved to sdk/deploykit (deploykit.Generator) in #67, driven by candy/plugin-build over the resolved-project envelope + HostBuild("render-seam"); the charly/generate.go top-level Generate/writeCandySteps orchestrator is DELETED. emitBakedPlugins likewise moved to sdk/deploykit (deploykit.EmitBakedPlugins, wired directly by NewRenderGeneratorFromProject — the former HostBuild("bake-plugins") round-trip + host_build_bake_plugins.go are DELETED). What STAYS in generate.go: NewGenerator (connects the project’s external plugin candies via loadProjectPlugins — the build-time plugin connect seam); the builder OpResolve resolve helpers resolveBuilderStage / resolveExternalBuilder (reached via HostBuild("render-seam") + the pod-overlay); generateInitFragments (a shim to toDeploykit().GenerateInitFragments, for the pod-overlay); writeContextIgnore; the status helpers (resolveStatus/candyStatus/worstStatus); the host-fs helpers (createRemoteCandyCopies/materializeBuildConfigAsset/rewriteHeaderCopyForRemote/candyByName/candyCopySource); buildStageContext/collectBuilderRuntimeEnv/globalOrderForBox/resolveUserContext. The builder stage emission is deploykit.Generator.EmitBuilderStages / EmitBuilderArtifacts / EmitExternalBuilderStages / EmitExternalBuilderArtifacts (relocated in #67); the builder OpResolve resolve helpers (resolveBuilderStage / resolveExternalBuilder in generate.go) STAY. WriteCandySteps (deploykit) orchestrates per-layer: packages → EmitTasks (deploykit; emitTasks in charly/tasks.go is a thin shim to deploykit.Generator.EmitTasks that stays for the pod-overlay) → builders → USER reset. Package resolution goes through the SAME resolveCascadePackages (install_build.go) the deploy compiler uses — ONE distro-specificity cascade for build AND deploy (folds the top-level package: base + unions distro tag sections most-specific-first), then renders the primary format’s install template; non-primary build formats (aur) emit from their own format section. Config-driven format install and bootstrap from the build vocabulary (distro: + builder: sections — the embedded default lives in charly/charly.yml); builder STAGE templates moved out to the plugins’ kit.BuilderResolve (C10 — the builder: section retains detection + cache mounts + the deploy host phase + pixi’s context inputs). WriteLabels (deploykit) is called at the END of the final stage (after the final USER directive) — the volatile LabelDescription value would otherwise invalidate every downstream RUN/COPY on a baked-plan edit; with LABELs-at-end, only the LABEL steps themselves re-emit (cache preserves all install work). writeJSONLabel (deploykit) routes every JSON label value through shellSingleQuote so embedded ' chars in test commands (awk '{print $1}') don’t break podman’s key=value LABEL parser. |
validate.go |
The host-natural validation checks that need the raw loader — the ONLY validation left in core: CUE-conformance (validateCandyCUESchemas / validateProjectCUESchemas), validateBuildAndDistro, validateBuildTunables, validateMergeConfig, validateBuilderRefs, validateBoxBaseFrom, validateRemoteCandies, boxEntityWireYAML, isNodeFormFile. Format/builder validation against config definitions (not hardcoded maps). The per-kind/op/candy/graph rule ENGINE (validateCandyTasks / validateCandyContents / validateAliases / validateVolume / validateCandyReferences / the box+candy DAGs / validateOps / the candy plugin: block checks) moved to the compiled-in command:box plugin (candy/plugin-box/{validate.go, validate_rules.go, validate_graph.go, validate_check.go}), reading the resolved-project envelope. (validateVocabularyCollections + its sibling validateEntityCUE, and core’s validatePluginCandy, were dead-code-radical-removal-batch deletions — the modern per-kind LOAD-time plugin gate and candy/plugin-box’s own plugin:-block checks supersede them; see the batch’s CHANGELOG entry for the coverage-comparison evidence.) |
version.go |
CalVer computation |
scaffold.go |
new layer scaffolding (single-layer dir creation with stub charly.yml) |
box_fetch_reentry.go |
The hidden charly __box-fetch / __box-refresh core reentry points behind the COMPILED-IN candy/plugin-authoring command:fetch / command:refresh verbs (P14b). The repo resolver (ResolveProjectRepo → EnsureRepoDownloaded: CHARLY_REPO_OVERRIDE + the refs-backend dispatch + the command:migrate auto-migration) is host-coupled, so the authoring plugin re-runs these hidden commands over HostBuild("cli") (the SAME seam candy/plugin-box’s pkg verb uses for __box-pkg). The box set/add-candy/rm-candy/write/cat authoring verbs + the AddCandyToBox/RemoveCandyFromBox yaml.Node helpers + resolveProjectFile (the path-traversal guard for box write / box cat) MOVED to the compiled-in command:authoring plugin (candy/plugin-authoring, P14b) — they run PURE on sdk/kit (kit.SetByDotPath / kit.MappingChild / kit.SaveYAMLNodeFile) + stdlib, zero core reentry. The create-side box new project/box/candy ENGINE is kit.ScaffoldProject / kit.AddBox / kit.ScaffoldCandy (shared with candy/plugin-box’s command:new). Tested in scaffold_project_test.go (the kit-scaffolder tests) + candy/plugin-authoring/authoring_edit_test.go (the moved helpers). |
sdk/kit/yaml.go (SDK kit, not charly/) |
kit.SetByDotPath(path, dotpath, valueYAML) + kit.MappingChild(m, key) — the generic comment-preserving YAML node utilities used by charly box set and the command:candy plugin. Walk *yaml.Node trees; create intermediate mappings on demand; reject descent into scalars. Tested in sdk/kit/yaml_test.go (TestSetByDotPath_ScalarReplacement + list-value, intermediate-mapping, scalar-descent-error cases). |
Plugins, external deploy & build-time emit
Section titled “Plugins, external deploy & build-time emit”Provider/registry/SDK internals are owned by /charly-internals:plugin; the external-deploy lifecycle + wire types by /charly-internals:install-plan. The file map:
| File | Purpose |
|---|---|
unified_targets.go + deploy_target_dispatch.go + arbiter_bracket.go |
S3b: pluginDeployTarget — the thin, data-only UnifiedDeployTarget adapter EVERY out-of-process deploy substrate routes through, dispatching via candy/plugin-bundle’s Invoke(OpDeployDispatch); records teardown ops to the ledger keyed on computeDeployID. Replaces the DELETED deploy_target_external.go (externalDeployTarget) + substrate_lifecycle_grpc.go (grpcSubstrateLifecycle) |
plugin_step_external.go |
externalPluginStepProvider — the StepProvider for StepKindExternalPlugin (a run: plugin: <verb> step served by an OUT-OF-PROCESS plugin). EmitOCI→Invoke(OpEmit) fragment (reusing emitPluginFragment, R3) is the only in-proc Emit. At DEPLOY time the step is executed via the shared invokeExternalStep dispatch (plugin_executor_reverse.go, S4/R3)→Invoke(OpExecute) over the PLUGIN↔PLUGIN InvokeProvider leg — reached as a host-engine step over RunHostStep during the external local/vm deploy walk — recording the reply’s dynamic ReverseOps to the CandyRecord. The executorInvoker interface (InvokeWithExecutor, satisfied SOLELY by *grpcProvider) is the discriminator. The IR kind StepKindExternalPlugin + deploykit.ExternalPluginStep struct (sdk/deploykit/steps.go); compileActOp (install_build.go) routes an external (executorInvoker) plugin verb to it; allStepKinds + the bijection gate (provider_step.go) register it. Owned by /charly-internals:install-plan. |
plugin_prescan.go |
Byte-gated, additive parse pre-scan: prescanPluginManifest registers an external deploy SUBSTRATE word (ClassDeployTarget, consumed by unified.go’s loader path before the provider connects), an external COMMAND word (ClassCommand, registered via registerDeclaredExternalCommand, snapshot via declaredExternalCommandWords, consumed by prescanProjectCommandWords in main.go before kong.Parse), AND (F4) an external KIND word (ClassKind → registerDeclaredKind; recognizedKind = connected-OR-prescanned). F4 kind connect: unlike a deploy substrate (which defers to the bundle builder) or a command (lazy-connect on invocation), a kind: <plugin-word> entity must DECODE its body during load (runPluginKind), so connectDeclaredKindPlugins (called at the depth-0 loader hook right after the prescan) host-builds + connects the declared kind plugins BEFORE mergeUnifiedDocs. It is re-entrancy-GUARDED (inKindConnectPass): the connect re-loads the project (LoadConfig/ScanAllCandyWithConfigOpts → LoadUnified → the SAME root that contains the kind node), and the nested load skips the pre-pass while normalizeNodeInto DEFERS (skips, no error) the not-yet-connected kind node — so the nested scan succeeds and the OUTER pass then has the provider registered + decodes. A declared kind whose provider never connects is WARN-SKIPPED in normalizeNodeInto — a loud stderr warning + the node dropped (never a silent drop, never a hard load error), so read-only commands (box list, validate) still work when a plugin can’t build/connect in a degraded environment (a minimal container with no Go toolchain); a command that actually USES the kind fails loudly at that point. Example: candy/plugin-example-kind (out-of-process-only). F5 flat-vs-structural decode (runPluginKind, provider_kind_invoke.go): a FLAT kind lands its OpLoad body opaquely in uf.PluginKinds[disc][name] (F4); a STRUCTURAL kind (capability Structural=true, carried by the structuralKindCarrier on the grpc/inproc provider) returns a spec.Deploy (BundleNode) member tree runPluginKind json-unmarshals + folds into uf.Bundle[name] — the SAME map buildBundleNodeInto populates for a builtin pod, so the folded member goes through the SAME validateDeploy. This is the channel that externalizes the structural kind decoders: group is DONE (C2-group — the COMPILED-IN candy/plugin-group serves kind:group, Structural:true) and the 5 deploy-substrate kinds pod/vm/k8s/local/android are DONE (C2-substrate — the COMPILED-IN candy/plugin-substrate serves all 5, Structural:true; the shared builtin standaloneKind + cue_kind_*.go-arm removal, all left spec.KindWords + the #Node disjunction but STAY in spec.ResourceKinds so the loader still nests their members). Unlike group (a scalar #GroupInput value decoded in the plugin from op.Params), a substrate value is RICH + core-referencing, so the host uses the F5 Standalone channel: foldSubstrateKind (provider_kind_invoke.go) host-decodes the CANONICAL node via the core loader (buildBundleNode deploy → uf.Bundle, decodeStandaloneTemplateJSON template → uf.Pod/uf.VM/… — the TEMPLATE-map fold arm extending F5’s deploy-only fold), validates the value host-side against the KEPT #<Kind>Value def (validateKindValueCUE, replacing the removed #Node arm’s closedness — a self-contained plugin schema can’t carry the rich value), threads it via op.Env (spec.StandaloneLoad), and folds the plugin’s pure ECHO. candy is DONE too (C2-candy — the LAST structural kind; candy/plugin-candy-kind, COMPILED-IN): foldCandyKind host-decodes via the bootstrap-critical core candyIsImage + buildCandy (which STAY core — the discovered-candy pre-check calls them directly, so the compiled-in plugin has no bootstrap cycle), validates against the KEPT #CandyValue (validateKindValueCUE), threads spec.Box/spec.Candy via the SAME StandaloneLoad channel (candy-image/candy-layer shapes), and folds the echo into uf.Box/uf.Candy. candy is Structural:false (it nests no deploy members) and routes via an explicit gn.disc=="candy" host branch. So the #Node disjunction now has ZERO built-in arms (#Node: {...} — a structural gate only; per-kind value closedness is host-side) and spec.KindWords is EMPTY — every authoring kind is plugin-served. Authored-member INPUT-threading: the node’s AUTHORED resource-member children cannot ride op.Params (closed #<Kind>Input), so runPluginKind PRE-DECODES them host-side via the SAME core recursion the builtin path uses (buildResourceMemberChildren, node_bundle.go — the ONE member-decode source, called by buildBundleNode too, R3) and threads the decoded subtree to OpLoad via op.Env (spec.StructuralKindLoadEnv); the plugin attaches them to its reply, so the reconstructed Bundle is byte-equivalent to the former builtin group (proven by TestExternalStructKind_StructuralDecode + the check-group / check-structkind runtime beds). A FLAT kind carrying members is a hard error (no silent drop). The parser gate admits sub-entity children under a recognized external STRUCTURAL kind (externalKindMayNestMembers/recognizedStructuralKind); core non-resource kinds stay guarded. Example: candy/plugin-group (compiled-in structural kind); candy/plugin-example-structkind (out-of-process-only witness). |
plugin_command_prescan.go |
The EARLY (pre-kong.Parse) external-COMMAND-word prescan: prescanProjectCommandWords resolves the project dir pre-parse (projectDirPreParse: CHARLY_PROJECT_DIR → scanDirFlag over os.Args → cwd) and registers each declared command word so charly <word> PARSES; connectCommandPlugin is the LAZY connect (LoadConfig → ScanAllCandyWithConfigOpts → loadProjectPlugins scoped to the one word → resolve(ClassCommand, word)), paid only on an actual charly <word> invocation |
provider_command_external.go |
OUT-OF-PROCESS command dispatch: collectExternalCommandPlugins builds a Kong grammar holder per prescanned word with the provider UNconnected (prov nil) so the CLI parses; dispatchExternalCommand lazy-connects on invocation (connectCommandPlugin) and forwards the pass-through args via Invoke(OpRun, {"args":[…]}); NestedCommandProvider nests an external command under a parent (e.g. charly check kube). The BUILTIN command path is provider_command.go (CommandProvider.KongCommand() + Go Run; builtinCommandBase.Invoke is in-proc-only). F8 command compile-in: dispatchCommand (the dispatch entry, called from main) routes a parsed dynamic command by PLACEMENT — a COMPILED-IN command candy (registered in-proc as an inprocProvider, not a *grpcProvider) dispatches IN-PROC via dispatchInProcCommand → Invoke(OpRun, {"args":[…]}), so the candy’s handler runs in charly’s own process (native stdio); an out-of-process one keeps dispatchExternalCommand/syscall.Exec. The dynamic Kong grammar (externalCommandHolder) is identical for both placements — only the dispatch transport differs (the command half of placement-invisibility). Example: candy/plugin-example-command (dual-placement, compiled-in) |
check_venue.go |
checkLocalTarget routes an external deploy host-side (the SAME path target: local takes) for charly check live / charly check <verb>, R3 |
sdk/schema/deploy.cue + sdk/schema/buildwire.cue + sdk/schema/seam.cue (appended) |
Deploy IR wire types shared with the plugin SDK, CUE-sourced (SDD conversion; the former hand-written sdk/spec/deploy_wire.go is deleted): #ReverseOp (+ the hand Scope/ReverseOpKind named-enum types + ReverseOpPluginScript const, spec/deploy_consts.go), #InstallPlanView, #DeployVenue, #DeployReply (deploy.cue); the build-time #BuildEnv / #EmitReply for OpEmit, and #BuilderResolveInput + #BuilderResolveReply ({Stage, CopyArtifacts, CopyBinary, InlineFragment}) for the builder OpResolve leg (buildwire.cue); the M4 substrate-lifecycle #LifecycleOpts/#HostEnv/#LifecyclePrepareInput/#PrepareVenueReply/#PostTeardownReply/#CliRequest/#CliReply (seam.cue, appended) |
tasks.go:emitPluginFragment |
Renders a plugin verb’s BUILD-context Containerfile fragment via Invoke(OpEmit) → spec.EmitReply.Fragment (placement-agnostic above the registry) |
deploykit: EmitBuilderStages / EmitBuilderArtifacts + generate.go: resolveBuilderStage |
The DETECTION-builder BUILDER leg (C10, relocated to sdk/deploykit in #67): for each candy a builder DETECTS, connects the plugin (ensureBuildersConnected) + Invoke(OpResolve) via the shared resolveBuilderStage (charly/generate.go, STAYS) → spec.BuilderResolveReply (Stage pre-main-FROM, CopyArtifacts+CopyBinary post-main-FROM; cargo’s InlineFragment splices in WriteCandySteps (deploykit)). Renders via the plugins’ kit.BuilderResolve, NOT an in-core vocabulary. Detection stays host-side (candyNeedsBuilder) |
deploykit: EmitExternalBuilderStages / EmitExternalBuilderArtifacts + generate.go: resolveExternalBuilder |
The external_builder: BUILDER leg (relocated in #67): emit an out-of-tree ClassBuilder candy’s multi-stage via the SAME resolveBuilderStage/Invoke(OpResolve) (minimal input — candy name only); selected by a candy’s external_builder: field, requires a non-empty Stage. resolveExternalBuilder STAYS in charly/generate.go |
build_emit_test.go |
TestEmitPluginFragment_BuildTimeOpEmit — the build-time-plugin-execution gate (a non-ProvisionActor provider’s fragment is spliced via Invoke(OpEmit)) |
provider_bench_test.go |
The E3 perf go/no-go gate: TestPerfGate_BuiltinVerbsSkipEnvelope, BenchmarkVerbTypedDispatchFork (0-alloc) vs BenchmarkVerbEnvelopeMarshal — builtins skip the JSON Invoke envelope; it is paid ONLY out-of-process |
Dependency & Graph
Section titled “Dependency & Graph”| File | Purpose |
|---|---|
graph_shim.go |
Thin package-main wrappers (ResolveBoxOrder(), BoxNeedsBuilder(), ExpandCandy()) delegating to the relocated topological sort in sdk/deploykit/graph.go; shrinks as callers move to deploykit (the old core graph.go is gone) |
intermediates.go |
Auto-intermediate image computation (trie analysis). createIntermediate() inherits Distro and BuildFormats from the parent image first, falling back to cfg.Defaults.* only when the parent is external or empty. Inverting this (defaults winning over the explicit parent) mis-tags every arch-rooted intermediate as build: [rpm], so every layer section keyed on pac: emits an empty RUN step (symptom: arch-ssh-client ships without direnv / gnupg / openssh). Regression guard: TestComputeIntermediates_InheritDistroFromParent uses defaults.Build=[rpm] but expects arch-rooted intermediates to come out [pac]. |
Build & Runtime
Section titled “Build & Runtime”| File | Purpose |
|---|---|
build.go |
build command (sequential image building, retry logic) |
merge.go |
merge command (post-build layer merging) |
shell.go |
shell command (execs engine run) |
start.go |
start/stop commands |
candy/plugin-status/command.go |
status command (the Kong grammar + dispatch; structured table/detail view, live tool probing, --json) — relocated from core, reached via the status-substrate HostBuild seam |
candy/plugin-substrate/status_flat.go |
the charly status collection ENGINE (K6, whole-file relocated from charly/status_collector.go — the “stays core, registry-boundary blocker” verdict was reopened and reversed): flatCollector.collectFlat (substrate fan-out, a direct in-package call to statusCollect) / flatCollector.collectSingle / enrichOne (deploy enrichment). charly/status_substrate_host.go is now a thin generic HostBuild forward with zero status-specific logic |
commands.go |
enable/disable/logs/update/remove |
service.go |
service command (init system service management inside containers) |
data.go |
Volume data seeding (provisionData, seedKind, SeederHelperImage) for bind-backed + named-volume targets, driven by charly config --seed/--force-seed |
hooks.go |
Lifecycle hooks (post_enable, pre_remove) collection and execution |
remote_image.go |
Remote image ref resolution, pull-or-build |
candy/plugin-vm/vm.go |
VM lifecycle: create, start, stop, destroy, list, console, ssh (the compiled-in command:vm plugin) |
vm_build.go |
VM disk image builds (qcow2, raw via bootc install) |
candy/plugin-vm/vm_libvirt.go |
Libvirt backend: VM operations via session-level libvirt |
candy/plugin-vm/vm_qemu.go |
QEMU backend: direct VM operations via qemu-system |
sdk/vmshared/smbios_credentials.go |
SSH key injection via SMBIOS/systemd credentials at VM boot |
libvirt.go |
Libvirt XML snippet collection and injection |
check_endpoint_resolve.go |
The GENERIC host-endpoint reverse-legs the check-verb dispatch serves back to an out-of-process verb over CheckContextService (the Uniform API Invariant — class-generic, never a per-verb RPC): resolveVerbEndpoint(port) (venue → host-reachable addr — cdp’s 9222, vnc’s container 5900), resolveVerbGraphics(kind) (a VM’s `<graphics type=‘vnc’ |
candy/plugin-substrate/status_probes.go (plugin, not charly/) |
The live tool probes (cdp/vnc/supervisord/dbus/charly/wl/sway HostProbe + GuestProbe) + the devToolsTab CDP-tab decode struct, P14a — moved from core (charly/status_probes.go + charly/cdp_preresolve.go). The cdp endpoint resolution is the cc.ResolveEndpoint reverse-leg; the cdp: verb (open/list/close/text/html/url/screenshot/click/type/eval/wait/coords/raw + spa-*) + its CDP WebSocket client live out-of-process in candy/plugin-cdp (the core’s former minimal CDP client browser_cdp.go was DELETED when wl externalized, so golang.org/x/net is an INDIRECT dependency). |
vnc_helpers.go |
The host-side VNC support the cc.ResolveGraphicsEndpoint reverse-leg needs but the out-of-process plugin cannot reach: resolveVNCPassword (the VNC credential store) — now the ONLY thing this file holds. The UNIX-socket→TCP bridge for the TCP-only RFB client is pure host-side networking with zero core state; it moved to sdk/kit/vnc_bridge.go (kit.UnixToTCPBridge, P12a follow-up) — ssh.go and check_endpoint_resolve.go call it there directly. The RFB verb (screenshot/click/type/key/mouse/status/passwd/rfb) + its RFC 6143 client live out-of-process in candy/plugin-vnc; the venue-aware dual pod/vm resolution (a pod’s 5900, or a VM’s libvirt VNC display bridged/tunneled) is resolveVerbGraphics("vnc") in check_endpoint_resolve.go. The separate charly ssh tunnel vnc (ssh.go) stays in core. |
Infrastructure
Section titled “Infrastructure”| File | Purpose |
|---|---|
engine.go |
Docker/Podman abstraction, ResolveBoxEngineForDeploy() |
registry.go |
Remote image inspection (go-containerregistry) |
transfer.go |
Cross-engine image transfer |
runtime_config.go |
~/.config/charly/config.yml, secret_backend key, credential maps |
network.go |
Shared “charly” container network management |
candy/plugin-vm/machine.go |
Podman machine management (rootful VM builds; in the command:vm plugin) |
Configuration
Section titled “Configuration”Key types — user_policy + exclude_distros architecture:
| Type / Field | File | Purpose |
|---|---|---|
DistroDef.BaseUser *BaseUserDef |
format_config.go |
Pointer to a declared pre-existing uid-1000 account in the upstream base image. Nil when not declared (fedora/arch/debian); set for ubuntu ({ubuntu, 1000, 1000, /home/ubuntu}). Inherited via resolveInherits so a child distro with no base_user: inherits the parent’s |
BaseUserDef |
format_config.go |
Four required fields: Name, UID, GID, Home. Parsed from the embedded build vocabulary’s distro.<name>.base_user: |
BoxConfig.UserPolicy string |
config.go:130 |
YAML field user_policy. Values: auto (default) / adopt / create. Drives the reconciliation switch in ResolveBox |
ResolvedBox.UserAdopted bool |
config.go:194 |
True when the policy reconciliation adopted a distro’s BaseUser (User/UID/GID/Home overwritten). Consumed by WriteBootstrap in sdk/deploykit (relocated in #67) to skip the useradd step |
Op.ExcludeDistros []string |
checkspec.go |
Per-test filter — test runner in checkrun.go:runOne skips the check when any of the image’s distro tags intersects with this list. Reason reported as excluded on distro "<tag>" |
TagPkgConfig.Raw map[string]any |
layers.go |
Captures the full YAML map for a tag section (e.g. debian:13:), not just package:. Enables repos:, keys:, options: inside tag sections. Read by the generator’s install-template emission path |
Policy reconciliation flow (charly/config.go:ResolveBox, after distroDef loaded):
policy := img.UserPolicyif policy == "" { policy = c.Defaults.UserPolicy }if policy == "" { policy = "auto" }baseUser := (*BaseUserDef)(nil)if resolved.DistroDef != nil { baseUser = resolved.DistroDef.BaseUser }userExplicitlySet := img.User != "" || c.Defaults.User != ""
switch policy {case "adopt": if baseUser == nil { return nil, fmt.Errorf(...) } // overwrite User/UID/GID/Home resolved.UserAdopted = truecase "auto": if baseUser != nil && !userExplicitlySet { // overwrite User/UID/GID/Home resolved.UserAdopted = true }case "create": // no-op}See /charly-image:image “user_policy” for the user-facing decision matrix, /charly-build:build “base_user:” for the declarative side, and /charly-build:generate “writeBootstrap” for the consumer side.
Existing configuration files
Section titled “Existing configuration files”| File | Purpose |
|---|---|
env.go |
ENV merging, path expansion |
envfile.go |
.env file parsing (ParseEnvFile, ParseEnvBytes), runtime env var resolution/merging |
security.go |
Container security config collection, CLI args generation. Merges Mounts from layer security configs |
labels.go |
OCI label constants. LabelDescription (ai.opencharly.description) carries the LabelDescriptionSet — each LabeledDescription (a Description string) plus its Plan []Step list; BoxMetadata’s *LabelDescriptionSet field is populated by ExtractMetadata when present |
egress.go |
Egress validation SHIM (M16) — ValidateEgress/ValidateEgressValue/validateTextEgress keep their signatures but resolve verb:egress + Invoke(OpValidate, {kind,label,mode,data}) (plain host→plugin dispatch). The validation logic + the egress CUE schemas (package-less defs + the vendored cloud_config) moved to the compiled-in candy/plugin-egress (egress-schemas/), which holds them internally + serves only a trivial Describe schema. The former in-core egressDef/registerVendoredEgressKind/egressKindDefs + the 7 cue_*egress*.go registrars were deleted. The core-side ValidateXMLEgress entry point was itself deleted in the 2026-07-22 dead-code-radical-removal batch (zero production callers) — the sole surviving ValidateXMLEgress is candy/plugin-vm’s own copy (vm_egress_shim.go), which Invokes verb:egress directly over its own reverse channel for the libvirt-domain-XML path. See /charly-internals:egress. |
volumes.go |
Named volume collection/mounting |
alias.go |
Command aliases (wrapper scripts) |
deploy.go |
Per-deployment config overlay, DeployVolumeConfig, ResolveVolumeBacking(), saveDeployState(), cleanDeployEntry() (instance-aware provides cleanup) |
provides.go |
Env/MCP provides injection, removeBySource(), removeByExactSource() (instance-specific cleanup), podAwareMCPProvides() |
enc.go |
Encrypted-volume in-core SHIM + deploy-model (C16a). Keeps ResolvedBindMount, the config loader (loadEncryptedVolume), the path/probe helpers (encryptedPlainDir/isEncryptedMounted/isEncryptedInitialized/cipherPopulatedPlainEmpty — consumed synchronously by the mandatorily-core ResolveVolumeBacking + verifyBindMounts), encStatus (pure probe+print), and the credential passphrase resolution (resolveEncPassphrase*/awaitKeyringUnlockViaPlugin). encMount/encUnmount/encPasswd/ensureEncryptedMounts are thin shims that HOST-PRELIFT the per-volume plan (encPlanFor: resolved cipher/plain dirs + init/mounted flags + scope-unit) + the passphrase, then encExecViaPlugin resolves verb:enc and Invokes OpExecute. The gocryptfs / systemd-run --scope --unit=charly-enc-<dir>-<volume> / fusermount3 / extpass SHELLING lives in candy/plugin-enc now, NOT core (-allow_other for rootless keep-id, stale-scope retry, all there). The encMount all-mounted fast-path (skip passphrase when every volume is already mounted) stays in the shim |
devices.go |
The KEPT core GPU/device surface after the GPU/VFIO host-DETECTION externalized to candy/plugin-gpu (C11). Holds the embedded detection DATA tables (devicePatterns/gpuRenderVendors/pciClassLabels, kept in core because the “hostprobe” HostBuild seam (host_build_hostprobe.go) reads devicePatterns on charly doctor’s behalf — threaded to the plugin via the shims, R3) + the pure host-INDEPENDENT appendEnvUnique + LogDetectedDevices + AutoDetectFlags. The dead-code-radical-removal batch (2026-07-22) deleted the charly-core copies of appendAutoDetectedEnv()/appendGroupsForAMDGPU (the DRINODE/HSA_OVERRIDE_GFX_VERSION/keep-groups env+group injection) and memlockUnlimited as unreached residue: the real callers had already migrated to candy/plugin-deploy-pod/config_setup_helpers.go (the first two) and candy/plugin-doctor/command.go + candy/plugin-vm/vm_util_copies.go (each keeping its own memlockUnlimited copy) — devices.go itself never called them, only its now-deleted test file did. The sysfs/exec detection PRIMITIVES moved out separately (see gpu_shim.go) |
gpu_shim.go |
The in-core SHIMS for GPU/VFIO host detection (C11): DetectGPU/DetectAMDGPU/DetectVFIO/DetectHostDevices (package vars, testability) + EnsureCDI/MemlockLimitBytes/VfioGroupAccessible/detectAMDGFXVersion resolve verb:gpu and Invoke the COMPILED-IN candy/plugin-gpu (OpRun, action-multiplexed spec.GpuProbeInput) — the k8sgen/egress resolve+Invoke pattern. The detection RESULT types alias package spec: type VFIOReport = spec.VFIOReport (+ VFIOGpu/VFIOPCIDevice/DetectedDevices), so the ~10 consumers (config_image/start/shell CDI-env sites, charly doctor, charly vm gpu, charly vm create, gpu_allocate.go) compile unchanged. In-proc placement keeps MemlockLimitBytes reading charly’s OWN RLIMIT_MEMLOCK. Wire types: CUE-sourced at sdk/schema/gpu.cue (SDD conversion; the former hand-written sdk/spec/gpu_wire.go is deleted) — #VFIOPCIDevice/#VFIOGpu/#VFIOReport/#DetectedDevices/#GpuProbeInput/#GpuProbeReply; #VFIOGpu is now FLATTENED (CUE unification has no Go-embedding concept, and Go’s own JSON-promotion of an anonymous embedded field already made the wire shape identical either way) — construct one via the hand spec.NewVFIOGpu(d) constructor (spec/gpu_methods.go), never a VFIOGpu{VFIOPCIDevice: d} literal. The DRIVER-SWITCH (C9, 1B) now ALSO resolves+Invokes verb:gpu — the gpu_shim.go shims switchGPUDriverMode/gpuSwitchModeTolerant/groupInMode/currentGPUMode/gpuDisplayDriver/gpuWedgeDetected/ensureCDIRoot/gpuSwitchPlan dispatch the OpRun DRIVER-SWITCH actions (spec.GpuSwitchInput/GpuSwitchReply); the logic moved into candy/plugin-gpu (switch.go), the mode/driver consts + wedge sentinel + pure SelectGPUByVendor/NormalizePCIVendor into spec. Auto-allocation (gpu_allocate.go) stays core for now (GPU host-seam dropped, revisitable on hardware — the operator-deferred GPU exception, not a K-wave inventory item); the config-coupled GPU-consumer helpers moved to gpu_imply.go (still core) |
preempt.go |
The HOST side of the resource arbiter after cutover C9: the arbiter LOGIC (ResourceArbiter) moved into the COMPILED-IN candy/plugin-preempt (verb:arbiter). Core keeps (a) the in-core PROXY — newResourceArbiter() returns *arbiterProxy whose ReleaseClaimant/clearPoison/resourcePoisoned + the Lease + acquireResourceForClaimant/acquireExclusiveForClaimant/acquireSharedForClaimant/releaseResourceClaim shims resolve+Invoke verb:arbiter (the generic core→verb registry bridge the core LEASE-LIFECYCLE consumers compile through, R3 — the externalized command:preempt CLI reaches verb:arbiter via InvokeProvider instead, owning the lease-table formatting itself), and (b) the 7 arbiter HOST-SEAM impls (gatherPreemptibleHolders/holderRunning/holderStop/holderStart/gatherResources/holderAddrFor/lookupVMClaimant/waitStoppedHost) the arbiter calls back over the ALWAYS-SERVED ExecutorService.HostArbiter reverse leg (stateless, so inert on every non-arbiter channel). Persisted + seam wire types are CUE-sourced at sdk/schema/arbiter.cue (the hand-written sdk/spec/arbiter_wire.go was deleted in FLOOR-SLIM Unit-8B; a small hand spec/arbiter_consts.go keeps only the plain string-enum constants); the ledger I/O + poison + liveness + mode-math live IN the plugin (arbiter.go/arbiter_support.go) |
arbiter_host.go |
The HOST handler for the C9 ExecutorService.HostArbiter reverse channel: arbiterHostServer.dispatch runs the 7 arbiter host-seams, projecting gatherPreemptibleHolders → []spec.HolderDescriptor + gatherResources → token→vendor, folding the stop seam’s wait (holderStop+waitStoppedHost), and routing switchMode/ensureCDI to the gpu shims |
tunnel.go |
The RESOLUTION half of the tunnel subsystem (C16b externalization): the wire types TunnelConfig/TunnelPort, the pure helpers schemeTarget/tailscaleFlag/isTCPFamily/ValidPublicPorts (shared with the quadlet emitter), the config-path helpers tunnelConfigDir/tunnelConfigPath (referenced by quadlet.go’s generateTunnelUnit), and the resolution ResolveTunnelConfig/TunnelConfigFromMetadata/parseHostPorts/buildPortMapping/resolveProto. The EXECUTION leg (tailscale serve/funnel + the cloudflared lifecycle) externalized to candy/plugin-tunnel — see tunnel_plugin.go |
tunnel_plugin.go |
The CORE adapter for the EXTERNALIZED tunnel execution leg (C16b, the welded-verb pattern mirror of credential_plugin.go): the TunnelStart/TunnelStop/cloudflareTunnelSetup seams forward a resolved TunnelConfig to verb:tunnel as a {method, config} plugin_input envelope over the Invoke registry (tunnelProvider is registry-first so the compiled-in provider resolves project-lessly, then falls back to connectPluginByWord for baked/source). The tailscale serve/funnel + cloudflared lifecycle live in candy/plugin-tunnel/ (compiled into charly via compiled_plugins:, or out-of-process); verb:tunnel also serves a creds-free plan dry-run returning the argv it WOULD run (box/fedora’s check-tunnel-pod bed R10) |
quadlet.go |
Quadlet .container file generation, Secret= directives |
credential_plugin.go |
The CORE adapter for the EXTERNALIZED credential store (C2 dep-shed removed go-keyring; the godbus dep-shed removed godbus too — charly/go.mod links neither): CredentialStore interface, ResolveCredential(), DefaultCredentialStore() (→ pluginCredentialStore), resolveSecretBackend(), credentialHealth(), pluginCredentialStore.awaitUnlock + the credentialAwaiter seam (enc.go’s source=locked keyring wait — RPCs verb:credential await-unlock, the godbus PropertiesChanged subscription running IN the plugin), the setDefaultCredentialStoreForTest seam. Every method forwards to verb:credential (served out-of-process by candy/plugin-secrets, or the baked /usr/lib/charly/plugins binary). The store backends + the keyring-unlock waiter + the charly secrets CLI + GPG .secrets surface live in candy/plugin-secrets/ now, NOT core. Generic host-adapter seam (F7/C7): callCtx connects via connectPluginByWord(ClassVerb, "credential") — the ONE on-demand connect for a verb word that appears in NO plan step. vm_plugin_client.go (invokeVmPluginEnv → verb:libvirt) and k8s_plugin.go (invokeKubePluginWithBroker → verb:kube) now route through the SAME seam (connectPluginByWordRef adds an optional canonical-ref fallback for a project whose closure references the plugin candy nowhere, e.g. a box/<distro> VM bed) — the bespoke ensureVmPluginConnected sync.Once + kube’s bare ResolveVerb were deleted (R3) |
secrets.go |
Container secret collection from labels, Podman secret provisioning, SecretArgs(), generateAndStoreSecret, the interactive promptPassword (a deploy-time operator prompt) |
Remote Layer Refs
Section titled “Remote Layer Refs”| File | Purpose |
|---|---|
refs.go |
Remote ref types, parsing, cache management. CHARLY_REPO_OVERRIDE (RepoOverrideEnv) Go-replace-style local-tree override (repoOverrideDir). selfSuperprojectOverridePair(dir) derives the bed project’s OWN superproject override (git rev-parse --show-superproject-working-tree → rootRepoIdentity); mergeRepoOverrides appends it after operator entries (operator wins). The check-bed setup op (in candy/plugin-check/bed_run.go, over the check-bed host seam) auto-applies it so a box/<distro> bed tests LOCAL parent-repo candies, never the pinned remote — the candy-ref analogue of auto --dev-local-pkg. Tests: repo_override_test.go. |
refs_git.go |
Git operations: clone, resolve ref, tag resolution |
Declarative Testing
Section titled “Declarative Testing”Implements the check gathering, validation, and host seams behind charly check live / charly check box (whose CLI now lives in the command:check plugin
candy/plugin-check) and the ai.opencharly.description OCI label. User-facing
authoring, verb catalog,
runtime variables, and charly.yml overlay rules live in /charly-check:check — this
section is the Go-implementation map.
| File | Purpose |
|---|---|
checkspec.go |
VerbCatalog, VerbSpec, DoMode (act/assert/instruct) + the do-mode resolvers (opActsInBuildDeploy, opEffectiveDo, opEffectiveContexts, opInContext) — the unified verb vocabulary’s per-verb legality + lowering, the former Task + Check merged into one; Op.Kind() (a package-spec method) enforces exactly-one verb. Built-in verbs (file/port/command/http/package/service/process/dns/user/group/interface/kernel-param/mount/addr/matching) plus the live-container verbs — ALL out-of-process now (wl/cdp/vnc/dbus/kube/adb/appium/spice/mcp/record/libvirt), dispatched via invokeVerbProvider to their plugin candies (all still verb words on core #Op; wl was the LAST compiled-in live verb). Status on the http verb is a plain int — not a MatcherList. One expected code per test; no [200, 302] list shorthand. Op itself is used fully-qualified as spec.Op throughout charly/*.go (including this file’s own opActsInBuildDeploy/opEffectiveDo/opEffectiveContexts/opInContext) — charly core defines NO Op alias at all; the only Op = spec.Op binding is sdk/vmshared/spec_aliases.go’s own internal alias for the vmshared package’s use, unrelated to (and easily confused with) the similarly-named charly/vmshared_aliases.go. Matcher + MatcherList (custom YAML and JSON unmarshalers for scalar/list/map shorthand — symmetry between charly.yml authoring and hand-crafted OCI labels) are sdk/spec/union_types.go defs; Matcher alone is aliased package-wide via sdk/matchers.go (type Matcher = spec.Matcher, grep-confirmed), while MatcherList is aliased only at sdk/vmshared/spec_aliases.go (sdk/matchers.go mentions the name once, in a comment explaining why MatchAll takes []Matcher rather than MatcherList — it declares no MatcherList type or alias). The plan step types travel in LabelDescriptionSet (the ai.opencharly.description label carries a Plan []Step field per LabeledDescription). The extended ${NAME[:arg]} matcher grammar — ExpandTestVars, TestVarRefs, IsRuntimeOnlyVar, RuntimeOnlyVarPrefixes, ExpandOpVars (formerly Op.ExpandVars) — lives ONCE in sdk/kit/checkvars_expand.go so a plugin candy running a plan expands ${VAR}s identically; charly/kit_aliases.go is dissolving K4/K5 residue that MAY bind a subset of these symbols into package main at any given time — do NOT cite this skill for which ones; grep -n '<Symbol>\s*=' the actual file before asserting a specific binding. No bash-style defaults: ${VAR:-fallback} is unsupported; only ${IDENT}. |
sdk/kit/checkvars.go (sdk, not charly/, P12a) |
ContainerInspection + siblings, CheckVarResolver, ResolveCheckVarsBuild / ResolveCheckVarsRuntime — moved from core (charly/checkvars.go): every dependency was already portable (spec.BoxMetadata, vmshared.BundleNode, a plain {engine} inspect shell-out). IsHostNetworked() is a method on kit.ContainerInspection; ContainerInspection (renamed from the former package-level swappable InspectContainer var) maps podman inspect output into HOST_PORT:<N>, VOLUME_PATH:<name>, VOLUME_CONTAINER_PATH:<name>, CONTAINER_IP, CONTAINER_NAME, ENV_<NAME>. (Package main may reach a subset of these via a residual charly/kit_aliases.go binding — K4/K5 dissolving inventory; grep the actual file rather than citing this skill for its current contents.) |
checkrun.go |
The package-main RunMode binding onto spec.CheckRunMode (RunModeLive/RunModeBox) + the check-verdict result constructors (passf/failf/skipf over spec.CheckResult). Spec-only glue, zero sdk/kit import. The check-engine driver itself is kit.Runner (sdk/kit); the host-coupled dispatch surface (hostVerbResolver/hostCheckCarrier/hostPlanGrammar) stays core in planrun_adapter.go, produced for the check reverse channel by plugin_dispatch_reverse.go from the wire spec.CheckEnv snapshot. The IN-PROC plan-drive construction (the former newCheckRunner/carrierFromRunner/resolverEnv) is GONE from production: the deploy-scope check DRIVE moved PLUGIN-SIDE (command:check OpVerifyChecks, #55 CHECK-ENGINE cone Unit 2 — candy/plugin-check’s newPluginCheckRunner, dispatched from core via check_cmd.go’s dispatchVerifyChecks), so core no longer builds a kit.Runner itself; the former constructor survives only as a test helper (checkrun_helpers_test.go). |
checkrun_verbs.go |
Dispatch for the remaining verbs: package (rpm/dpkg/pacman), service (supervisorctl + systemctl), process (pgrep), dns (host-side net.LookupIP or in-container getent), user/group (getent passwd/group), interface (ip -o addr show + MTU), kernel-param (sysctl -n), mount (findmnt), addr (host-side net.DialTimeout or in-container nc -z), matching (pure in-process value matching). resolvePackageName(c, distros) implements the distro-aware package-map: when Check.PackageMap is non-empty, the first entry in Runner.Distros that matches a key wins; otherwise Check.Package is used as-is. Covered by TestResolvePackageName (6 sub-cases including empty-map fallback, first-matching-tag-wins priority, and empty-string-map-value fall-through). Runner.Distros is populated from meta.Distro wherever a Runner is built — the check_cmd.go live-gather engine and the command:check plugin’s box/feature/harness runners. |
bundle_members.go |
Sibling-member lifecycle — venue-from-position members (shared by check + deploy — R3). foldMembers registers each tree-position BundleNode.Members entry as a top-level addressable Bundle entry (MemberOf set, disposability inherited); validateMembers enforces dot-free + valid-target member keys. bringUpMembers / tearDownMembers shell out (via the package-var runCharlySubcommand) to charly config+charly start (pod members) / charly bundle add+del (other), invoked by BundleAddCmd/BundleDelCmd (operator) AND the bed runner — candy/plugin-check/bed_run.go drives bringUpMembers/tearDownMembers (kept core) over the check-bed seam’s members-up/members-down ops (pod + VM paths). Members are excluded from bedCheckLiveRefs (instruments, never check-live’d). |
checkrun_charly_verbs.go |
Shared host-side helpers for the EXTERNAL live-container verbs: resolveCheckApk (anchors an adb/appium apk: fixture to its authoring candy’s source tree, host-side, before the marshalled Op crosses to the plugin) + the noVmDisplayDeviceErr skip sentinel (the spice/vnc VM-display N/A skip). Every live-container verb (wl/cdp/vnc/dbus/kube/adb/appium/spice/mcp/record/libvirt) is an EXTERNAL out-of-process plugin dispatched via invokeVerbProvider with the full Op (wl/record/dbus are EXEC-based: the provider drives the venue over the DeployExecutor reverse channel; cdp/vnc dial a host-pre-resolved endpoint). The EXEC-based verbs’ (wl/record/dbus) shared boilerplate lives in the SDK/kit, ONE copy each (R3): sdk.RunArtifactValidators (post-run artifact validators) + sdk.MatchAll (the matcher pipeline) + sdk.ResultJSON (the {status,message} reply) + sdk.CheckRequiredModifiers (the required-modifier check) + the *sdk.Executor venue methods VenueCapture/VenueHasTool/VenueRunSilent, with kit.ShellQuote/kit.TrimPreview the pure quoter/preview; each plugin keeps only its per-verb requiredModifiers map + modifierZero. The nested-CLI argv contract a plugin imports (kit.MethodSpec + the kit.Pos* builders) lives in sdk/kit/methodspec.go. |
candy/plugin-mcp (mcp resolution) |
The mcp: verb resolves its OWN context via the reverse-legs (resolve.go): cc.ResolveImageLabel("ai.opencharly.mcp_provide") for the declared servers, then {{.ContainerName}} substitution + spec.PodAwareMCPProvides localhost rewrite + pick + cc.ResolveEndpoint to map the container port → host address. The MCP CLIENT (the go-sdk dial + the 7 methods ping/servers/list-tools/list-resources/list-prompts/call/read) lives here too, beside the SERVER (serve.go, command:mcp); charly’s core links NO MCP SDK — its host half is the charly __cli-model seam (cli_model_cmd.go). |
cli_model_cmd.go |
charly __cli-model (hidden machinery) — emits charly’s ASSEMBLED Kong command tree (the core CLI struct + the builtin command-provider grammar) as an sdk.CLIModel JSON document on stdout: the host half of the EXTERNALIZED MCP server. candy/plugin-mcp (command:mcp — the externalized charly mcp … CLI; serve.go) fork/execs it at startup (fetchCLIModel, deliberately with NO project prefix — the model needs no project), registers one MCP tool per model leaf (cliLeafToTool/argToSchema, additionalProperties: false), annotates/filters mutating tools via its mcpDestructivePaths allowlist (--read-only skips registering them), and executes each tool call as a charly <path> <args…> SUBPROCESS (makeToolHandler → argvFromJSON → forkCharly) carrying a managed project prefix (computeProjectPrefix: charly.yml in cwd → none; --no-default-repo → none, project tools error at call time; else --repo default) with childCharlyEnv stripping CHARLY_PROJECT_DIR/CHARLY_PROJECT_REPO so the prefix stays authoritative. Core links NO MCP SDK — the fork/exec design replaced the former in-process server wholesale. Test coverage: cli_model_cmd_test.go. Full reference: /charly-build:charly-mcp-cmd. |
main_dir_test.go |
Integration tests for the -C / --dir / CHARLY_PROJECT_DIR global: spawns a freshly-compiled charly binary from /tmp with a scratch project, verifies all three flag forms make charly box list boxes resolve the scratch charly.yml. Error cases: missing dir, file-not-dir. |
sdk/kit/local_image.go (sdk, not charly/, P12a) |
ResolveLocalImageRef(engine, input) (renamed from resolveLocalImageRef) — moved from core (charly/local_image.go); test-mode-only image resolution that never reads charly.yml. Full refs pass through with a LocalImageExists check; short names match against ListLocalImages() output using label-preferred matching (ai.opencharly.box=<name>) with a repo-name trailing-component fallback. Returns ErrImageNotLocal on no-match so FormatCLIError renders the “charly box pull / charly box build” recommendation. Used to keep charly check box purely OCI-labels-driven — short names resolve against local podman storage, never charly.yml. Also carries ListLocalImages, LocalImageInfo, ParseLocalImagesJSON / ExtractCalVerTag (exported — candy/plugin-clean/retention.go calls them now, since the retention engine relocated there from core in the K1-alpha core-minimization wave), ResolveNewestLocalCalVer, LooksLikeFullRef (4 other core call sites repointed to kit.LooksLikeFullRef, R3 single-source). |
description_collect.go |
CollectDescriptions(cfg, layers, imageName) *LabelDescriptionSet walks the base-image chain — mirror of CollectHooks in hooks.go:18-68 — with a visited-image guard so pathological cycles reported by validateBoxDAG can’t hang the collector. Bucketizes plan steps into candy/box/deploy by source + context, stamps Origin for reporting. MergeDeployDescriptions(baked, local) implements id-based replace, append, and {id: X, skip: true} disable semantics. |
check_cmd.go |
The DEPLOY-SCOPE tree + plugin-context helpers the deploy verbs + the local --verify path share: resolveMergedDeployTree / resolveCheckRunnerContext / resolveDeployNodeByPath / deployNodePluginContext / candyDirsFromScan / checkLocalDeployScope / runLocalDeployScopePlan (consumed by unified_targets.go, bundle_add_cmd.go, local_spec.go, deploy_tree.go, check_venue_resolve.go). The former CLI-free live-check GATHER engine (checkLiveGather / checkLiveVM / checkLivePod / checkLiveLocal / checkLiveGroup + the CheckLiveCmd struct) RELOCATED to the compiled-in command:check plugin candy/plugin-check (live_gather.go / run_box.go / score*.go), which drives its OWN kit.Runner via newPluginCheckRunner; host_build_check_run.go is now PREFLIGHT-ONLY. The charly check CLI — its command tree, the box/live/run/feature verbs, the check-run management subcommands, and the charly check box disposable-container flow — lives in that same candy/plugin-check plugin. |
check_runner_cmd.go |
Now holds ONLY scorePodTargetEntry — the pod-target-disposability read consulted by the check-config seam (host_build_check_config.go). The check-run management Cmds and the orchestrator preflight (runWithPhaseResync — the harness-sandbox restart + credential sync + charly check run-local dispatch) moved to the command:check plugin candy/plugin-check. |
check_runner_live.go |
RunCheckLive — the “score” mode body reached via the host_build_check_run.go “score” seam, invoked at iter end by the command:check plugin’s harness scorer. Buckets check: steps by pod:, resolves chains via ResolveDeployChain for dotted paths, dispatches to the right DeployExecutor. The plugin’s self-evaluate path (the AI-side mid-iter sanity check) drives the same seam. |
check_image_preflight.go |
ensureScoreImages — the “preflight” mode body (reached via the host_build_check_run.go seam): walk the plan’s per-step-venue images + the target image, dedup, ensure each present in local podman before a host-target harness run. |
host_build_check_run.go |
The “check-run” host-builder the compiled-in command:check plugin drives — spec.CheckRunRequest{mode} ∈ box / live / feature-box / feature-live / score (→ RunCheckLive) / preflight (→ ensureScoreImages). The gathering + registry verb dispatch + venue construction stay host-side; the plugin owns CLI-parse + formatting + the exit code via sdk.ExitCodeError. |
host_build_check_bed.go |
The op-discriminated “check-bed” host SESSION seam (setup / members-up / members-down / wait-ready / teardown) — the bed’s lock / preempt-lease / repo-override + deploy-config env lifecycle the compiled-in bed runner cannot hold across a process boundary. setup returns the BedDescriptor (spec.CheckBedReply, incl. BedDomain #33, group Members, LocalChildKeys) the kind-blind plugin drives the R10 sequence from. Transitional (K5). |
host_build_check_config.go |
The “check-config” projection seam — the check-project reads the harness makes (bed-vs-iterate classify, sandbox class, pod-target disposability, resolved iterate config, include-expanded plan, kind:agent catalog) that a plugin (a separate module) cannot LoadUnified for. Transitional (K1). |
check_bed_run.go |
The KEPT bed-seam helpers the check-bed session drives: bedVmDomains/acquireVmDomainLock (per-deploy domain locks, #33), persistBedDeployOverrides, deployNestedLocalChildren, bedCheckLevel, waitForVmSshReady/waitForContainerReady — shared with bundle_add_cmd. The bed-run ORCHESTRATION itself moved to candy/plugin-check/bed_run.go (driven over this seam + HostBuild("cli")). |
Related skill: /charly-check:check is the authoring-facing reference.