install-plan — emit-opts-and-testing
Detail page of the install-plan recipe card.
Op selectors at build vs deploy
Section titled “Op selectors at build vs deploy”A provider’s Invoke carries an op.Op selector (charly/provider.go). Three drive THIS subsystem (the build/deploy IR), placement-agnostically (in-proc for a builtin, go-plugin gRPC for an external); a fourth, OpRun, is the runtime/CLI selector OUTSIDE the install-plan IR (listed here for selector completeness):
OpEmitis INVOKED at IMAGE-BUILD time — arun:plugin verb / plugin step returns aspec.EmitReply.Fragment(with aspec.BuildEnvdescriptor inop.Env) that the generator splices verbatim into the Containerfile (egress-validated). This is a BUILD-mode concern, dispatched fromemitTasks(charly/tasks.go), NOT the IR/deploykit.OCITarget— EXCEPT the pod-overlay render, whereExternalPluginStep.EmitOCI’sclass:verbdispatch AND the authoredexternal:<word>class:stepstep (declaringStepContract.Emits=true, F-STEP-EMIT) both route throughcandy/plugin-installstep’s"oci-dispatch"word (K5-A item 2), which InvokesOpEmiton the resolved target viaInvokeProvider— the plugin-side mirror of the SAMEinvokeOpEmitFragmentshape (R3)emitPluginFragmentuses host-side. See/charly-build:generate+/charly-internals:generate-source.OpResolveis ALSO INVOKED at IMAGE-BUILD time — the BUILDER leg, serving BOTH the four DETECTION-builders (pixi/npm/aur/cargo, C10) and theexternal_builder:-selected out-of-tree builders. AClassBuilderprovider returns aspec.BuilderResolveReply({Stage, CopyArtifacts, CopyBinary, InlineFragment}, with aspec.BuildEnvinop.Env+ aspec.BuilderResolveInputrender context inop.Params):Stage(aFROM <ref> AS <name>block) splices PRE-main-FROM,CopyArtifacts+CopyBinary(COPY --from=<stage> …) POST-main-FROM, and an INLINE builder’s (cargo)InlineFragmentsplices in-candy. The detection builders are dispatched fromemitBuilderStages/emitBuilderArtifacts(the host computes the full render context; the stage template lives in the plugins’sdk/kit.BuilderResolve); theexternal_builder:ones fromemitExternalBuilderStages/emitExternalBuilderArtifacts— both share theresolveBuilderStageInvoke helper. The multi-stage counterpart of the verb/stepOpEmitleg. See/charly-build:generate+/charly-internals:generate-source.OpExecutedrives EXTERNAL deploy-context execution over the executor reverse channel — the ACTUAL substrate provider’sOpExecute(reached bycandy/plugin-bundle’shandleDeployApply/handleLifecycleSimple/… via its ownsdk.Executor.InvokeProvider— S1, S3b), AND anExternalPluginStep(ordeploykit.ExternalStep) on alocal:/vm:deploy walk, which the host executes via the sharedinvokeExternalStepdispatch (charly/plugin_executor_reverse.go, S4/R3 —Invoke(OpExecute)over the SAME PLUGIN↔PLUGINInvokeProviderleg, driven overRunHostStepas the walk reaches the step) — both decoding thespec.DeployReplyteardown record (above).OpExecuteis the deploy-context counterpart of the build-contextOpEmit: the SAME external verb-step bakes a fragment at build (OpEmit) and executes its effect on a live target at deploy (OpExecute), picked by venue.OpRunis the RUNTIME / CLI selector, OUTSIDE the install-plan IR (it neither builds an image nor applies a deploy plan): it runs a check verb / live-container probe (provider_checkenv.goinvokeVerbProvider), AND it dispatches an EXTERNAL COMMAND plugin’scharly <word>subcommand —provider_command_external.godispatchExternalCommandforwards the pass-through CLI tokens asop.Params = {"args":[…]}(marshalled directly, noplugin_inputenvelope) on anInvoke(OpRun)to the lazy-connected out-of-process command provider. Owned by/charly-internals:plugin(the command class) +/charly-check:check(the verb probes).
StepBatch batching
Section titled “StepBatch batching”InstallPlan.StepsByVenue() partitions Steps into contiguous same-(Scope, Venue) runs. The local:/vm: deploy walk uses this to emit one shell heredoc per batch:
| Batch | Emission form |
|---|---|
{ScopeSystem, VenueHostNative} |
sudo bash <<'CHARLY_ROOT' … CHARLY_ROOT |
{ScopeUser, VenueHostNative} |
bash <<'CHARLY_USER' … CHARLY_USER |
{_, VenueContainerBuilder} |
podman run <builder> bash -s < script |
{_, VenueSkip} |
Logged, no exec |
deploykit.OCITarget doesn’t batch — it emits each step in order as Containerfile directives; adjacent same-USER steps collapse naturally via the existing USER switching logic in emitTasks.
Deferred home resolution ({{.Home}} + InstallPlan.ResolveHome)
Section titled “Deferred home resolution ({{.Home}} + InstallPlan.ResolveHome)”Home-bearing step fields carry the deferred HomeToken ({{.Home}}) rather
than a home expanded at compile time. compileShellHookStep always emits the
token; compileShellSnippetSteps emits it for deploy targets (hostCtx.Target
== host/vm) and keeps img.Home for the container build. Each
DeployTarget calls plan.ResolveHome(home) once at emit with the home of the
actual destination:
| Target | Home used |
|---|---|
deploykit.OCITarget (pod-overlay image build) |
img.Home (the image’s runtime home — the candy reads it from the envelope) |
external local: deploy |
host home (ShellExecutor.ResolveHome) |
external vm: deploy |
GUEST home (SSHExecutor.ResolveHome) |
ResolveHome substitutes the token in ShellHookStep (EnvVars + PathAdd),
ShellSnippetStep (Snippet + Destination + PathAppend), and FileStep.Dest;
it deliberately skips OpStep command/content bodies (~/$HOME there
shell-expand at runtime on the destination as the deploy user) and
BuilderStep (home resolved separately by deploykit.RenderBuilderScript,
sdk/deploykit/localpkg.go). It is
idempotent. Baking img.Home at compile time was the VM $HOME bug: the
synthetic plan’s Home was the host operator’s, so a guest deploy wrote env.d
pointing at /home/<operator> and user-scope installs (npm -g, cargo) landed
in a root-owned path the guest user couldn’t write. See
/charly-internals:vm-deploy-target “Guest-home resolution”.
ReverseOp catalogue
Section titled “ReverseOp catalogue”See /charly-local:local-deploy for the user-facing reverse-op table. The Go-level source of truth is ReverseOpKind in install_plan.go; each step’s Reverse() method emits ops tagged with kind + targets + scope. Execution lives in sdk/kit/reverse_ops.go (moved from charly/reverse_ops.go) — one handler per kind, all routed through runReverseOps(ops, executor) in LIFO order.
Adding a new reverse kind requires:
- Add the
ReverseOpKindconstant ininstall_plan.go. - Emit it from the appropriate step’s
Reverse()method. - Add a handler function in
reverse_ops.goand register it inrunReverseOp’s dispatch switch.
EmitOpts cross-cutting flags
Section titled “EmitOpts cross-cutting flags”type EmitOpts struct { DryRun bool FormatJSON bool AllowRepoChanges bool AllowRootTasks bool WithServices bool SkipIncompatible bool AssumeYes bool Verify bool Pull bool BuilderImageOverride string}CLI flags on BundleAddCmd / BundleDelCmd populate this struct; each target reads what it needs. AssumeYes enables all three opt-in gates (via GateEnabled).
Testing
Section titled “Testing”install_plan_test.go— 13 unit tests over step-kind derivations (scope/venue/gate/reverse).install_build_test.go— 8 integration tests that load realcandy/viaScanAllCandyWithConfig;testHostContextWithDistrohelper.install_build.go:200comment — canonical fixture docs.
When you add a step kind, add:
- A scope/venue/gate/reverse unit test in
install_plan_test.go. - An integration test in
install_build_test.goexercising the compiler path. - Target-specific tests in
build_target_oci_test.go, the shared out-of-process walk insdk/kit/walk_test.go, and the host-engine channel inplugin_executor_hoststep_test.go.