You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Wire the per-mode helpers from #215–#218 into the top-level controller, and add the CLI arg parser, env handling, and dashed startup banner so the binary is functionally a drop-in replacement for the Python entrypoint. Combines what would otherwise be two PRs since the CLI is just ~150 LOC of mechanical wiring on top of the controller and adds a serial round-trip if split.
After this PR the agent works end-to-end against a real config — only the Docker image (#220) and the operator-agent parity tests (#221) remain before the cutover (#222).
This is the orchestration layer plus its public entry point. The pieces all exist after #213–#218; this PR is the glue that:
Decides which helper to call for a given Mode.
Manages SIGTERM-driven graceful shutdown so in-flight steps can stop cleanly when the agent pod is terminated.
Parses os.Args in three legacy + one new positional form (different operator versions in production may use different forms — backward compatibility per the AGENTS.md "CLI must remain backward-compatible" rule).
Prints the dashed banner that operators and e2e tests grep for.
Splitting controller and CLI would mean #219 lands code that has no entry point and #220 lands an entry point with no functionality to gate. Better to land them together as "the binary now works".
Feature description
A new internal/controller package with the orchestration logic, plus a complete cmd/agent/main.go that parses args, sets env, prints the banner, and dispatches to controller.Run.
Validate mode against the known set; if unknown, log warn and return failed=false (matches Python).
If the data dir hasn't been copied to {rootMount}{copyDir}, recursively copy SKYHOOK_DATA_DIR (default /skyhook-package). Also copy /etc/nvidia-bootstrap/node-files/ if present (legacy mode).
Plumb ctx through every step iteration, every runner.RunStep call, and every interrupts.Run call. The runner from #217 already accepts ctx. When ctx.Err() != nil mid-loop, return failed=true with no error and let Run log the graceful shutdown.
Match Python's log lines: Received SIGTERM signal - initiating graceful shutdown and Gracefully shutting down due to SIGTERM.
6. CLI arg parsing
Match Python's cli() (lines 653–718) verbatim. Four supported forms:
Legacy 2-arg: agent {mode} {copy_dir} with root_mount = "/root".
Add // why: legacy operator versions invoke the 2- and 3-arg forms; we must accept all four for backward compatibility (see AGENTS.md CLI compat rule).
7. Env handling
COPY_RESOLV (default "true"): if truthy, copy /etc/resolv.conf into {root_mount}/etc/resolv.conf. Match Python's shutil.copyfile semantics.
OVERLAY_ALWAYS_RUN_STEP (default "false"): pass to controller.Run's alwaysRunStep arg.
Read SKYHOOK_RESOURCE_ID, SKYHOOK_DATA_DIR, SKYHOOK_ROOT_DIR, SKYHOOK_LOG_DIR, SKYHOOK_AGENT_BUFFER_LIMIT, SKYHOOK_AGENT_WRITE_LOGS for the banner.
8. Startup banner
Print exactly the layout from Python print lines 687–711, with the same field order, same dashes, same colon spacing, same str.center(s, 20, "-") quirk on section headers (which produces e.g. --CLI CONFIGURATION- because the string is 17 chars and center adds the extra dash on the right). The directory section uses MakeConfigDataFromResourceID() (the helper #218 added).
The banner is a public contract — comment it as such.
9. Subcommand routing
Detect os.Args[1] == "chroot-exec" and route to the #216 subcommand handler before the positional-arg parser.
SIGTERM during step loop → returns failed=true, doesn't run subsequent steps.
CLI tests:
Each of the four arg forms parses to the expected (mode, rootMount, copyDir, interruptData) tuple.
COPY_RESOLV=false skips the resolv.conf copy.
OVERLAY_ALWAYS_RUN_STEP=true is forwarded.
Banner output matches a golden text fixture (compare line-by-line).
Exit code 1 on failed=true.
Exit code 0 on failed=false.
chroot-exec subcommand still routes correctly.
For step execution use a mock runner.RunStep injected through an interface seam. Use a runMain(args []string, env map[string]string, stdout, stderr io.Writer) int seam to make the CLI testable without spawning a subprocess.
Operator-side changes — the operator already invokes the agent in one of these forms; no contract change.
Acceptance criteria
All ported orchestration and CLI tests pass.
SIGTERM stops mid-loop within one step iteration (test-asserted).
START flag is always written before any step runs.
check_results file format matches Python (path True|False with capitalized booleans).
_ALL_CHECKED flag is conditional on no failures.
History is written exactly when Python writes it (only on the three *-check modes, only on success).
Uninstall-check removes flags exactly once.
All four arg forms route correctly with table-driven tests.
Banner golden test matches Python output line-for-line.
COPY_RESOLV and OVERLAY_ALWAYS_RUN_STEP defaults match Python (true and false).
chroot-exec subcommand still works.
Exit code is 1 on either err != nil or failed=true.
Open questions
Drain on SIGTERM: the Python implementation just sets a flag and lets the in-flight step finish naturally (subprocess gets the signal too via process group). We should do the same — passing ctx to exec.CommandContext already gives this behavior. Confirm in a test that the in-flight subprocess gets SIGTERM and we wait for it.
Should we add a --help flag? Python doesn't. Recommend yes (hidden / non-breaking) — useful for operators debugging on a node, no contract impact.
Reproduce the dashed banner verbatim or normalize? Recommend verbatim — operator e2e logs grep for the dashed strings, and "fixing" them is a behavior change.
Use controller-runtime patterns (Reconcile, RequeueAfter). Rejected — the agent is not a controller, it's a one-shot CLI invoked per pod. Borrowing the vocabulary would mislead readers.
Use a flag-based CLI (--mode, --root-mount, --copy-dir). Rejected — would break the operator's existing invocation. Could be added later as an alternative form.
Keep controller and CLI as separate PRs. Rejected — CLI is small wiring on top, splitting just adds a serial round-trip.
Summary
Wire the per-mode helpers from #215–#218 into the top-level controller, and add the CLI arg parser, env handling, and dashed startup banner so the binary is functionally a drop-in replacement for the Python entrypoint. Combines what would otherwise be two PRs since the CLI is just ~150 LOC of mechanical wiring on top of the controller and adds a serial round-trip if split.
After this PR the agent works end-to-end against a real config — only the Docker image (#220) and the operator-agent parity tests (#221) remain before the cutover (#222).
Depends on #213–#218.
Motivation
This is the orchestration layer plus its public entry point. The pieces all exist after #213–#218; this PR is the glue that:
Mode.os.Argsin three legacy + one new positional form (different operator versions in production may use different forms — backward compatibility per the AGENTS.md "CLI must remain backward-compatible" rule).Splitting controller and CLI would mean #219 lands code that has no entry point and #220 lands an entry point with no functionality to gate. Better to land them together as "the binary now works".
Feature description
A new
internal/controllerpackage with the orchestration logic, plus a completecmd/agent/main.gothat parses args, sets env, prints the banner, and dispatches tocontroller.Run.Proposed direction
1.
controller.RunRun(ctx, mode step.Mode, rootMount, copyDir, interruptData string, alwaysRunStep bool) (failed bool, err error):modeagainst the known set; if unknown, log warn and returnfailed=false(matches Python).{rootMount}{copyDir}, recursively copySKYHOOK_DATA_DIR(default/skyhook-package). Also copy/etc/nvidia-bootstrap/node-files/if present (legacy mode).mode == step.Interrupt, delegate tointerrupts.Run(...)([FEA]:do_interruptwith NodeRestart-15special case #218) and return.{rootMount}/{copyDir}/config.jsonand callconfig.Load(...)([FEA]: Config loader with embedded JSON schemas +Steps.validate#214).modeis notUninstall/UninstallCheck:{rootMount}/{copyDir}/root_direxists, recursively copy ontorootMount(lets packages drop files into the host filesystem).cfg.ExpectedConfigFilesentry is present under{rootMount}/{copyDir}/configmaps/. Missing →SkyhookValidationError.agentRun(ctx, mode, rootMount, copyDir, cfg, interruptData, alwaysRunStep).agentRunreturns an error ANDctx.Err() != nil(SIGTERM), log graceful-shutdown and returnfailed=trueinstead of the error.2.
agentRunper-step loopLines ~590–636 of agent/skyhook-agent/src/skyhook_agent/controller.py:
{flagDir}/STARTflag ([FEA]: Filesystem helpers (paths, flags, log paths) + history file #215'sSetFlag).cfg.Modes[mode]is empty, logThere are no {mode} steps defined. This will be ran as a no-op.and skip the loop.ctx.Err() != nil.flags.MakeFlagPath([FEA]: Filesystem helpers (paths, flags, log paths) + history file #215).Mode.Upgrade/Mode.UpgradeCheck, callhistory.Read(...)([FEA]: Filesystem helpers (paths, flags, log paths) + history file #215) soCURRENT_VERSION/PREVIOUS_VERSIONenv (and UpgradeStep args) are populated.flags.CheckFlagFile(...)returns true, continue (already done).{mode} {step.Path} {step.Arguments} {step.Returncodes} {step.Idempotence} {step.OnHost}exactly (operator e2e log grep target).runner.RunStep(...)([FEA]:teestreaming andrun_step#217). If failed, returnfailed=true.last_run: {ISO time}\nstep_always_runs: {bool}body.runner.RunStep(...)result to aresults []boolslice. (Don't return early on check failure — letSummarizeCheckResultsdecide.)modeis a check mode and there are steps, callSummarizeCheckResults(...). Returns true →failed=true.modeisApplyCheck,UpgradeCheck, orUninstallCheckand we haven't returnedfailed=true, callhistory.Write(...)([FEA]: Filesystem helpers (paths, flags, log paths) + history file #215).mode == UninstallCheck, also callRemoveFlags(...).3.
SummarizeCheckResultsPort lines 440–463 verbatim:
len(results) != len(steps)→ print "It does not look like you have successfully run all check steps yet." and return true.{flagDir}/check_resultscontaining one line per step in the form{step.Path} {True|False}(Python literal, capitalized).{flagDir}/{mode}_ALL_CHECKEDempty flag and return false.4.
RemoveFlagsPort lines 537–541. Walk every step in every mode, build the flag path,
os.Removeif it exists. Ignore not-exist errors.5. SIGTERM handling
Replace Python's global
received_sigtermflag with a context:Plumb
ctxthrough every step iteration, everyrunner.RunStepcall, and everyinterrupts.Runcall. The runner from #217 already acceptsctx. Whenctx.Err() != nilmid-loop, returnfailed=truewith no error and letRunlog the graceful shutdown.Match Python's log lines:
Received SIGTERM signal - initiating graceful shutdownandGracefully shutting down due to SIGTERM.6. CLI arg parsing
Match Python's
cli()(lines 653–718) verbatim. Four supported forms:agent {mode} {copy_dir}withroot_mount = "/root".agent interrupt {copy_dir} {interrupt_data}withroot_mount = "/root".agent {mode} {root_mount} {copy_dir}.agent {mode} {root_mount} {copy_dir} {interrupt_data}.Add
// why: legacy operator versions invoke the 2- and 3-arg forms; we must accept all four for backward compatibility (see AGENTS.md CLI compat rule).7. Env handling
COPY_RESOLV(default"true"): if truthy, copy/etc/resolv.confinto{root_mount}/etc/resolv.conf. Match Python'sshutil.copyfilesemantics.OVERLAY_ALWAYS_RUN_STEP(default"false"): pass tocontroller.Run'salwaysRunSteparg.SKYHOOK_RESOURCE_ID,SKYHOOK_DATA_DIR,SKYHOOK_ROOT_DIR,SKYHOOK_LOG_DIR,SKYHOOK_AGENT_BUFFER_LIMIT,SKYHOOK_AGENT_WRITE_LOGSfor the banner.8. Startup banner
Print exactly the layout from Python
printlines 687–711, with the same field order, same dashes, same colon spacing, samestr.center(s, 20, "-")quirk on section headers (which produces e.g.--CLI CONFIGURATION-because the string is 17 chars andcenteradds the extra dash on the right). The directory section usesMakeConfigDataFromResourceID()(the helper #218 added).The banner is a public contract — comment it as such.
9. Subcommand routing
Detect
os.Args[1] == "chroot-exec"and route to the #216 subcommand handler before the positional-arg parser.10. Exit code
controller.Runreturns(failed bool, err error). CLI rules:err != nil→ print error to stderr, exit 1.failed→ exit 1.11. Tests
Port the orchestration-level cases from agent/skyhook-agent/tests/test_controller.py:
failed=false.interrupts.Run.STARTflag written, step's flag written, success returned.check_resultsfile written,_ALL_CHECKEDflag conditional.expected_config_files→SkyhookValidationError.failed=true, doesn't run subsequent steps.CLI tests:
(mode, rootMount, copyDir, interruptData)tuple.COPY_RESOLV=falseskips the resolv.conf copy.OVERLAY_ALWAYS_RUN_STEP=trueis forwarded.failed=true.failed=false.chroot-execsubcommand still routes correctly.For step execution use a mock
runner.RunStepinjected through an interface seam. Use arunMain(args []string, env map[string]string, stdout, stderr io.Writer) intseam to make the CLI testable without spawning a subprocess.Scope boundaries
In scope:
Run,agentRun, mode dispatch, SIGTERM, summarize/remove helpers.chroot-exec.Out of scope:
agent-goDockerfile and container CI #220).Acceptance criteria
STARTflag is always written before any step runs.check_resultsfile format matches Python (path True|Falsewith capitalized booleans)._ALL_CHECKEDflag is conditional on no failures.*-checkmodes, only on success).COPY_RESOLVandOVERLAY_ALWAYS_RUN_STEPdefaults match Python (trueandfalse).chroot-execsubcommand still works.err != nilorfailed=true.Open questions
ctxtoexec.CommandContextalready gives this behavior. Confirm in a test that the in-flight subprocess gets SIGTERM and we wait for it.--helpflag? Python doesn't. Recommend yes (hidden / non-breaking) — useful for operators debugging on a node, no contract impact.Runtake a*log/slog.Loggeror use the package-level logger? Use whatever [FEA]: Bootstrapagent/go/module + port pure-data types #213 standardized on.References (codebase)
Alternatives considered
Reconcile,RequeueAfter). Rejected — the agent is not a controller, it's a one-shot CLI invoked per pod. Borrowing the vocabulary would mislead readers.--mode,--root-mount,--copy-dir). Rejected — would break the operator's existing invocation. Could be added later as an alternative form.Code of Conduct