Skip to content

feat: typed execution contexts (docker, kubernetes, ssh) - #131

Open
trntv wants to merge 2 commits into
mainfrom
typed-contexts
Open

feat: typed execution contexts (docker, kubernetes, ssh)#131
trntv wants to merge 2 commits into
mainfrom
typed-contexts

Conversation

@trntv

@trntv trntv commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Overview

Contexts gain a type: discriminator — local (default), docker, kubernetes, or ssh — so tasks can run inside a container, pod, or remote host declaratively, instead of hand-assembling a docker run … / kubectl exec … / ssh … invocation via the low-level executable: {bin, args} field. Type-specific settings live in a nested block named after the type.

contexts:
  api:
    type: docker
    dir: /app                    # workdir inside the container
    env: { NODE_ENV: prod }      # forwarded into the container (-e)
    docker:
      image: node:20             # run mode  (mutually exclusive with `container`)
      options: [--network, host]
  cluster:
    type: kubernetes
    kubernetes: { pod: web-0, namespace: stage, container: app }
  box:
    type: ssh
    ssh: { host: build-01, user: deploy, port: 2222, identity_file: ~/.ssh/id_ed25519 }

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

  • Env & dir cross the boundary. For a typed context the task's declared env and dir are applied inside the target — docker via -e KEY=VAL/-w, kubernetes and ssh by inlining export KEY=VAL; cd <dir> && <command>. Only declared env is forwarded (incl. the injected TASKCTL__ARGS/TASKCTL__TASK_NAME); the host's own os.Environ is not.
  • Everything runs on-target. Not just task commands, but the task's before/after, its condition, and the context's own up/down/before/after hooks all run inside the target for a typed context.
  • Docker run vs exec. 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.
  • Backward compatible. type is optional and defaults to local; the executable: form is unchanged and remains the escape hatch. Existing configs are byte-for-byte unaffected. A typed context may not also set executable; validation requires docker=exactly one of image/container, kubernetes=pod, ssh=host.
  • The default context may itself be typed, making every context-less task run in that target.

Architecture

One structural seam does the work: ExecutionContext gains a single unexported wrapper field.

  • runner/wrapper.go (new) — a commandWrapper interface plus dockerWrapper/kubectlWrapper/sshWrapper, pure command-string builders. Exported DockerSpec/KubernetesSpec/SSHSpec + WithDocker/WithKubernetes/WithSSH options are the only additions to the public API. All injected tokens are POSIX-quoted through a single syntax.Quote choke point; env ordering is deterministic (sorted keys).
  • runner/compiler.go / runner/context.go — when wrapper != 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 — decodes type: + 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/sh interpreter 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-lint clean; every typed context also validated end-to-end via --dry-run (render + shell-parse of the wrapped command).

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>
Copilot AI review requested due to automatic review settings July 20, 2026 07:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread runner/wrapper.go
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 thread runner/wrapper.go
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)))
Comment thread runner/wrapper.go
tokens = append(tokens, "-i", shellQuote(w.spec.IdentityFile))
}

tokens = append(tokens, w.spec.Options...)
Comment thread runner/wrapper.go
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>
Copilot AI review requested due to automatic review settings July 20, 2026 07:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread runner/compiler.go
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 thread runner/wrapper.go
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 thread runner/context.go
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,
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants