feat: typed execution contexts (docker, kubernetes, ssh) - #131
Open
trntv wants to merge 2 commits into
Open
Conversation
Add a `type:` discriminator to contexts so docker/kubernetes/ssh targets
can be declared with high-level, per-type fields instead of hand-writing
the `executable: {bin, args}` invocation. Type-specific settings live in a
nested block (`docker:`/`kubernetes:`/`ssh:`).
A typed context wraps every command — task commands, the task's
before/after and condition, and the context's own up/down/before/after
hooks — so they run inside the target. The task's declared env and dir are
forwarded into the target: docker via `-e`/`-w`, kubernetes and ssh by
inlining `export …; cd … && <cmd>` (POSIX-quoted). The low-level
`executable:` form is unchanged and remains the local escape hatch; `type`
is optional and defaults to `local`, so existing configs are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces typed execution contexts (local, docker, kubernetes, ssh) so tasks (and all lifecycle hooks) can run inside common targets declaratively, while keeping the existing executable: escape hatch for arbitrary wrappers.
Changes:
- Added wrapper builders (
docker,kubectl,ssh) and wired them into task compilation and context lifecycle execution. - Extended config decoding/validation to support
type:plus nested type-specific blocks and reject invalid combinations. - Updated docs/examples to describe and demonstrate typed contexts alongside the escape hatch.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| runner/wrapper.go | Introduces wrapper command-string builders and quoting helpers for typed contexts. |
| runner/wrapper_test.go | Adds unit tests asserting wrapper output for docker/kubectl/ssh. |
| runner/context.go | Adds a wrapper to ExecutionContext and functional options to enable typed wrappers. |
| runner/context_test.go | Adds tests verifying WithDocker/WithKubernetes/WithSSH set and exercise the wrapper. |
| runner/compiler.go | Wraps compiled task commands when a typed wrapper is present; forwards env/dir into the target. |
| runner/compiler_test.go | Adds coverage for typed command compilation vs. existing escape-hatch behavior. |
| internal/config/context.go | Decodes type + nested blocks, validates combinations, and constructs wrapper options. |
| internal/config/context_test.go | Adds config-level tests for typed contexts and validation failures. |
| internal/config/testdata/typed_contexts.yaml | Provides a fixture config covering the supported typed context cases. |
| README.md | Documents typed contexts, behavior/validation rules, and keeps escape hatch guidance. |
| docs/example.yaml | Updates the example config to showcase typed contexts and clarifies the escape-hatch context. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+99
to
+108
| tokens = append(tokens, w.spec.Options...) | ||
|
|
||
| if w.spec.Image != "" { | ||
| tokens = append(tokens, shellQuote(w.spec.Image)) | ||
| } else { | ||
| tokens = append(tokens, shellQuote(w.spec.Container)) | ||
| } | ||
|
|
||
| tokens = append(tokens, w.spec.Shell...) | ||
| tokens = append(tokens, shellQuote(command)) |
Comment on lines
+125
to
+135
| tokens = append(tokens, "exec") | ||
| tokens = append(tokens, w.spec.Options...) | ||
| tokens = append(tokens, shellQuote(w.spec.Pod)) | ||
|
|
||
| if w.spec.Container != "" { | ||
| tokens = append(tokens, "-c", shellQuote(w.spec.Container)) | ||
| } | ||
|
|
||
| tokens = append(tokens, "--") | ||
| tokens = append(tokens, w.spec.Shell...) | ||
| tokens = append(tokens, shellQuote(buildScript(env, dir, command))) |
| tokens = append(tokens, "-i", shellQuote(w.spec.IdentityFile)) | ||
| } | ||
|
|
||
| tokens = append(tokens, w.spec.Options...) |
Comment on lines
+37
to
+41
| Port int | ||
| IdentityFile string | ||
| Options []string | ||
| Shell []string | ||
| } |
Comment on lines
+103
to
+106
| case "", "local": | ||
| if err := checkForeignBlocks(def, def.Type); err != nil { | ||
| return nil, nil, err | ||
| } |
| return fmt.Errorf("context type %q does not accept a kubernetes block", typ) | ||
| } | ||
| if typ != "ssh" && def.SSH != nil { | ||
| return fmt.Errorf("context type %q does not accept a ssh block", typ) |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
internal/config/context.go:190
- Grammar in the validation error: "a ssh block" should be "an ssh block".
if typ != "ssh" && def.SSH != nil {
return fmt.Errorf("context type %q does not accept a ssh block", typ)
}
Comment on lines
+106
to
+109
| if executionCtx.wrapper != nil { | ||
| j.Command = executionCtx.wrapper.wrap(command, envutil.ConvertToMapOfStrings(env.Map()), renderedDir) | ||
| j.Env = variables.NewVariables() | ||
| j.Dir = "" |
Comment on lines
+179
to
+183
| b.WriteString(" ") | ||
| } | ||
| b.WriteString(k) | ||
| b.WriteString("=") | ||
| b.WriteString(shellQuote(env[k])) |
Comment on lines
+103
to
+108
| case "", "local": | ||
| if err := checkForeignBlocks(def, def.Type); err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| return &def.Executable, nil, nil |
Comment on lines
+67
to
+69
| if local.Executable == nil { | ||
| t.Error("local_ctx: expected non-nil Executable") | ||
| } |
Comment on lines
+132
to
+136
| // Env/dir are forwarded into the target by the wrapper itself, so the | ||
| // local launcher job must not also apply them. | ||
| job = &executor.Job{ | ||
| Command: c.wrapper.wrap(command, envutil.ConvertToMapOfStrings(c.Env.Map()), c.Dir), | ||
| Vars: c.Variables, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Contexts gain a
type:discriminator —local(default),docker,kubernetes, orssh— so tasks can run inside a container, pod, or remote host declaratively, instead of hand-assembling adocker run …/kubectl exec …/ssh …invocation via the low-levelexecutable: {bin, args}field. Type-specific settings live in a nested block named after the type.Goal
The generic
executable:escape hatch works but is verbose, unvalidated, and easy to get wrong (exact flag order, quoting). Typed contexts give the common docker/kubernetes/ssh targets first-class, validated fields while keeping the same underlying execution model.Decisions
dirare applied inside the target — docker via-e KEY=VAL/-w, kubernetes and ssh by inliningexport KEY=VAL; cd <dir> && <command>. Only declared env is forwarded (incl. the injectedTASKCTL__ARGS/TASKCTL__TASK_NAME); the host's ownos.Environis not.before/after, itscondition, and the context's ownup/down/before/afterhooks all run inside the target for a typed context.image:→docker run --rm(fresh container per command);container:→docker exec(a container you started yourself). Exactly one is required. Run mode is ephemeral, so state does not persist between commands — documented; use exec/kubernetes/ssh for stateful setup.typeis optional and defaults tolocal; theexecutable:form is unchanged and remains the escape hatch. Existing configs are byte-for-byte unaffected. A typed context may not also setexecutable; validation requires docker=exactly one of image/container, kubernetes=pod, ssh=host.defaultcontext may itself be typed, making every context-less task run in that target.Architecture
One structural seam does the work:
ExecutionContextgains a single unexportedwrapperfield.runner/wrapper.go(new) — acommandWrapperinterface plusdockerWrapper/kubectlWrapper/sshWrapper, pure command-string builders. ExportedDockerSpec/KubernetesSpec/SSHSpec+WithDocker/WithKubernetes/WithSSHoptions are the only additions to the public API. All injected tokens are POSIX-quoted through a singlesyntax.Quotechoke point; env ordering is deterministic (sorted keys).runner/compiler.go/runner/context.go— whenwrapper != nil, task commands and lifecycle hooks are wrapped and env/dir are emptied on the local launcher (forwarded into the target instead). When nil (local/escape-hatch), the existing code paths are untouched.internal/config/context.go— decodestype:+ the nested blocks and compiles them into the wrapper options, with validation.Command execution itself is unchanged: the wrapped string is still parsed and run by the embedded
mvdan.cc/shinterpreter locally — the wrapper just prefixes the transport (docker/kubectl/ssh).Tests
Exhaustive white-box wrapper output tests (
runner/wrapper_test.go) are the correctness net since real docker/ssh can't run in CI; plus compiler, context-wiring, and config decode/validation tests. Full suite:go test -race ./...→ 212 passing across 18 packages;golangci-lintclean; every typed context also validated end-to-end via--dry-run(render + shell-parse of the wrapped command).