Help us shape the future of Go! Take the 2026 Go Developer Survey

subprocess

package
v0.9.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package subprocess SSH transport.

When the context's Target names a host - resolved by the commands layer from --host or DOKKU_HOST - every dokku subprocess invocation is routed through an `ssh` subprocess wrapper instead of executing locally. We shell out to the user's `ssh` binary rather than using a Go SSH library so we inherit the user's `~/.ssh/config`, `ProxyJump`, agent, and known_hosts handling for free.

All invocations in a single docket run share one TCP+SSH handshake via OpenSSH ControlMaster multiplexing. The first `ssh` invocation negotiates the master connection and writes a unix-domain socket at `<tmpdir>/docket-<hash>.sock`; subsequent invocations reuse it. The socket name hashes the resolved user, host and port, the identity file and ssh options, the docket PID, and the Session the call ran under, so neither two docket processes, two sessions in one process, nor two identities for the same host collide on the socket path.

The ControlPersist option keeps the master alive 60 seconds past the last command exit. A Session records every master its calls opened and tears them down on Close, which is how the commands layer ends a run.

Error attribution. OpenSSH exits with code 255 when the transport itself fails (connect refused, auth, host-key mismatch) and forwards the remote command's exit code otherwise. We use exit 255 to classify failures: a 255 exit is wrapped as `*SSHError` so the formatter can render it with an `ssh:` prefix; any other non-zero exit is returned as the underlying error so the formatter renders it as a `dokku:` failure.

Where a command runs, whether it is sudo-wrapped, whether an unknown host key is accepted, and which identity and ssh options are used all travel on the context as a `Target`. SSH dispatch reads it the same way the local path does, so `--sudo` means "run dokku as root" on both sides - remotely as `sudo -n` inside the ssh argv, locally as `sudo -n -u root` around the child.

Index

Constants

View Source
const MaskPlaceholder = "***"

MaskPlaceholder is what every sensitive value is replaced with in user-facing output. Length is intentionally fixed at three asterisks so masked output reveals nothing about the original value (no length, prefix, or suffix).

Exported because one caller has to render the placeholder without running text through MaskString: internal/commands/apply_args.go rewrites the usage-string default of a `sensitive: true` input's flag, which `--help` prints before any value has been registered here.

Variables

View Source
var ErrSessionClosed = errors.New("ssh session is closed")

ErrSessionClosed is returned, wrapped in an *SSHError, by an SSH call made under a Session that has already been closed.

Functions

func CloseSshControlMaster

func CloseSshControlMaster(host string) error

CloseSshControlMaster sends `ssh -O exit` to the ControlMaster a call made outside any Session opened for host, so the multiplexed connection is torn down cleanly. Best-effort: errors are swallowed because the master may already have exited (ControlPersist timeout, kill -9, etc.).

It reaches only a master opened for a Target that named no IdentityFile or SSHOptions, since those are part of the socket path. Prefer a Session, which closes every master its calls opened - including ones for hosts the caller never named, such as a play's own target.

func ContextWithMasker

func ContextWithMasker(ctx context.Context, m *Masker) context.Context

ContextWithMasker returns a copy of ctx carrying m, so the layers that mask text but have no other reason to know about a run - the subprocess transports, a task registering a secret it just read back - can reach it.

func ContextWithRunner

func ContextWithRunner(ctx context.Context, fn ExecRunner) context.Context

ContextWithRunner returns a copy of ctx whose dokku commands go to fn instead of to a real process. It is the only seam there is: the executor travels on the context rather than in a package variable, so two tests can install different fakes at the same time and both can call t.Parallel().

Production code must not use it. The seam exists so a test can drive a task without a server; a caller that wants to change where commands actually run sets a Target.

func ContextWithTarget

func ContextWithTarget(ctx context.Context, target Target) context.Context

ContextWithTarget returns a copy of ctx carrying target. The commands layer calls it once per run with the resolved flags and env; a caller that wants a single command sent somewhere else derives a child context rather than setting a field on the input, which keeps "which server" a question with one answer per call.

func Probe

func Probe(ctx context.Context, input ExecCommandInput) (bool, error)

Probe runs input as a state probe and reports whether it matched (exit 0). A non-zero exit from a command that actually ran is reported as `(false, nil)`, i.e. "the probed state is absent," so callers can write idempotent probes without unwrapping errors themselves.

Any failure that means the probe never produced a real answer is propagated as `(false, err)` so the caller can short-circuit `Plan()` with `PlanResult{Error: err}` and let the formatter render `[!]`. That covers a transport-level failure (`*SSHError`), a command that could not be executed at all (the dokku binary is missing or not executable), a cancelled probe, and a `dokku` or `ssh` child killed by a signal - which exits with no status of its own, so its exit code is not an answer either. Distinguishing "ran and said no" from "could not run" relies on `ExecError.Ran`, since binary-not-found reports `ExitCode 0` and so cannot be told apart by exit code.

Use this for any plan-time probe that today reads exit code only (`apps:exists`, `network:exists`, `<service>:linked`, etc.). A probe whose command distinguishes its answers by exit code - `git:auth-status` and `registry:auth-status` both do - wants ProbeCode instead, which is the same discrimination with the code left intact. Probes that need stdout should call CallExecCommand directly and use `errors.As(err, &*SSHError)` to discriminate.

func ResolveCommandString

func ResolveCommandString(ctx context.Context, input ExecCommandInput) string

ResolveCommandString returns the masked command line ExecCommandResponse.Command would carry if input were executed under ctx now. Used by tasks' Plan() methods so PlanResult.Commands renders byte-identical to the strings apply emits via state.Commands; sharing the rendering logic keeps the two views from drifting.

The function mirrors the dispatch in defaultExecRunner, and reads the target from the same place, so plan and apply cannot disagree about where a command was going. When the target names a host and the command is `dokku`, the SSH transport's bare `cmd args` form is returned: remote sudo is wrapped server-side and never appears in the displayed command. Otherwise the local form is returned, sudo-wrapped when the target asks for it.

func ValidateHost

func ValidateHost(raw string) error

ValidateHost reports whether raw is a usable `[user@]host[:port]` target, without contacting anything. It exists so `docket validate` can reject a typo in a play's `host:` offline rather than leaving it to surface as an `ssh:` failure partway through a run.

func ValidateSSHOptions added in v0.9.1

func ValidateSSHOptions(opts []string) error

ValidateSSHOptions reports whether opts can be passed to ssh as `-o` values, without contacting anything. An option must be non-empty, must not contain a control character, and must not set one of the options docket owns. It is exported so `docket validate` can check a play's `ssh_options` offline and the commands layer can refuse a bad `--ssh-option` before connecting.

Types

type ExecCommandInput

type ExecCommandInput struct {
	// Command is the command to execute
	Command string

	// Args are the arguments to pass to the command
	Args []string

	// Stdin is the stdin of the command
	Stdin io.Reader

	// StreamStdio prints stdout and stderr directly to os.Stdout/err as
	// the command runs
	StreamStdio bool

	// StreamStdout prints stdout directly to os.Stdout as the command runs.
	StreamStdout bool

	// StreamStderr prints stderr directly to os.Stderr as the command runs.
	StreamStderr bool

	// StdoutWriter is the writer to write stdout to
	StdoutWriter io.Writer

	// StderrWriter is the writer to write stderr to
	StderrWriter io.Writer
}

ExecCommandInput is the input for the ExecCommand function

type ExecCommandResponse

type ExecCommandResponse struct {
	// Command is the resolved command line that was executed, including
	// any sudo wrapping and joined arguments. Used by callers that surface
	// the command in user-facing output (e.g. `docket apply --verbose`).
	Command string

	// Stdout is the stdout of the command
	Stdout string

	// Stderr is the stderr of the command
	Stderr string

	// ExitCode is the exit code of the command
	ExitCode int

	// Cancelled is whether the command was cancelled
	Cancelled bool
}

ExecCommandResponse is the response for the ExecCommand function

func CallExecCommand

func CallExecCommand(ctx context.Context, input ExecCommandInput) (ExecCommandResponse, error)

CallExecCommand executes a command under ctx, locally or on the remote the context's Target names.

When that target names a host and the command is `dokku`, dispatch is routed through the SSH transport so the dokku invocation runs on the remote host. Non-dokku subprocesses (echo/git/etc.) always run locally even when a host is configured, since the remote side may not have those binaries (and tests expect local execution).

The context is the caller's: cancelling it kills the child process, and a deadline on it bounds the call. Nothing in this package installs a signal handler any more - the command layer wires SIGINT to the run context via signal.NotifyContext, so one interrupt aborts the run rather than the current task.

The call is routed through a swappable executor so tests can substitute a fake; defaultExecRunner is the production path.

func CallSshCommand

func CallSshCommand(ctx context.Context, target Target, input ExecCommandInput) (ExecCommandResponse, error)

CallSshCommand executes a remote command over ssh against target under ctx. The execution pipeline mirrors CallExecCommand (same DOKKU_TRACE logging, masking, stdio wiring) so callers see identical behavior aside from the transport.

It takes the whole Target rather than a host string because the argv it builds depends on the sudo and host-key settings too, and a signature that carried only the host would have to fetch those from somewhere else - which is exactly how they ended up in the process environment.

On exit code 255 (OpenSSH's transport-failure code), the returned error is `*SSHError`. On any other non-zero exit, the returned error is the plain underlying error so the formatter renders the failure as a remote dokku error.

func ProbeCode

func ProbeCode(ctx context.Context, input ExecCommandInput) (ExecCommandResponse, error)

ProbeCode is Probe with the exit code left intact, for a probe whose command answers with more than yes or no. dokku's `*:auth-status` comparators are the motivating case: they separate "nothing is stored" from "something else is stored" from "stored, but I cannot read it", and collapsing those into one "no" loses the distinction between a create and a replacement - and, for the third, between drift and a server that will never converge.

The error discrimination is exactly Probe's, because it is the same question: a command that ran and exited non-zero produced a real answer, and anything that stopped it from running did not. On the answered path the response is returned with a nil error, so callers switch on `result.ExitCode` without unwrapping. On the unanswered path the error is propagated and the response is not meaningful.

Note that the exit code is read off the response on both paths. A non-zero exit arrives as an `*ExecError` from the real runner, but an injected test runner reports one by returning a response with a non-zero ExitCode and no error at all.

func (ExecCommandResponse) StderrBytes

func (ecr ExecCommandResponse) StderrBytes() []byte

StderrBytes returns the trimmed stderr of the command as bytes

func (ExecCommandResponse) StderrContents

func (ecr ExecCommandResponse) StderrContents() string

StderrContents returns the trimmed stderr of the command

func (ExecCommandResponse) StdoutBytes

func (ecr ExecCommandResponse) StdoutBytes() []byte

StdoutBytes returns the trimmed stdout of the command as bytes

func (ExecCommandResponse) StdoutContents

func (ecr ExecCommandResponse) StdoutContents() string

StdoutContents returns the trimmed stdout of the command

type ExecError

type ExecError struct {
	Response ExecCommandResponse
	Err      error

	// Ran is true only when the command executed to completion and
	// Response.ExitCode is its real exit status. It is false when the
	// command could not be started (binary not found, permission denied),
	// was cancelled, or was killed by a signal, in which cases
	// Response.ExitCode is not meaningful. Probe() uses this to tell a dokku-level "state absent"
	// (Ran, non-zero exit) apart from a real execution failure that must
	// be propagated.
	Ran bool
}

ExecError wraps a CallExecCommand failure with the underlying response so callers that propagate the error up the stack can later recover Stdout / Stderr / ExitCode without threading the response through every helper signature. The Error() method returns the inner error's text so existing callers that print err.Error() see the same string as before.

Callers recover the response with errors.As:

var execErr *subprocess.ExecError
if errors.As(err, &execErr) {
    // execErr.Response.Stderr is available
}

func (*ExecError) Error

func (e *ExecError) Error() string

Error returns the wrapped error's message so existing string-based comparisons (e.g. fmt.Errorf wrapping) continue to work.

func (*ExecError) Unwrap

func (e *ExecError) Unwrap() error

Unwrap exposes the wrapped error so errors.Is / errors.As traverse chains of wrapped errors correctly.

type ExecRunner

type ExecRunner func(ctx context.Context, input ExecCommandInput) (ExecCommandResponse, error)

ExecRunner is the shape of the executor CallExecCommand delegates to. A test supplies one to return canned responses without spawning a process or contacting a server.

type Masker

type Masker struct {
	// contains filtered or unexported fields
}

Masker holds the set of literal string values that must not appear in user-facing output, and replaces them with MaskPlaceholder wherever they would.

One Masker belongs to one run. That is the whole point of the type: the registry it replaces was process-wide, and the API that populated it *replaced* the set rather than adding to it, so two runs sharing a process had the first one's teardown blank the second one's secrets while it was still writing output. A Masker is never cleared - it goes out of scope with the run that owns it - so there is no teardown to get wrong.

A nil *Masker masks nothing and is safe to use. That is deliberate: a caller that registered no secrets has nothing to hide, so code paths reached with or without a masker need no branch of their own.

func MaskerFromContext

func MaskerFromContext(ctx context.Context) *Masker

MaskerFromContext returns the Masker ctx carries, or nil when it carries none. A nil Masker masks nothing, so callers need not check.

func NewMasker

func NewMasker(values ...string) *Masker

NewMasker returns a Masker holding values, in the spellings cleanSensitive derives. A Masker built with no values is still usable; more can be added.

func (*Masker) Add

func (m *Masker) Add(values ...string)

Add registers more values to mask, keeping the ones already there.

There is no Set. Replacing the set is what made the old registry fail open, and nothing needs it: a run's secrets only ever accumulate, as tasks read values back off the server that the pre-run collection could not see - a drifted property's old value, scheduler-k3s trigger metadata.

func (*Masker) String

func (m *Masker) String(s string) string

String replaces every occurrence of a registered value in s with `***`.

func (*Masker) Value

func (m *Masker) Value(v interface{}) interface{}

Value returns v with every registered value replaced by `***` in every string it contains, walking slices and maps recursively. Non-string scalars - numbers, booleans, nil - are returned unchanged: masking is substring replacement over text, and a value that carried a secret would have been rendered as a string before it got here.

Map keys are masked as well as map values. A recipe is rendered as one template before it is parsed, so a key is interpolated user data exactly as a value is. Two keys that differ only inside a secret therefore collapse into a single `***` entry - the same "two distinct values become indistinguishable" trade-off masking already makes for task names.

It exists for the values that reach a stream as interface{} - today the `loop_item` field of `--list-tasks --json`, which carries whatever the recipe's `loop:` resolved to: a scalar, a list, or a mapping. Masking the value rather than the marshalled line is deliberate: a secret containing a quote or a backslash is escaped in the serialised form, and String cannot be relied on to match it there.

func (*Masker) Values

func (m *Masker) Values() []string

Values returns a snapshot of the registered spellings, longest first.

type SSHError

type SSHError struct {
	Host    string
	Command []string
	Err     error
	Stderr  string
}

SSHError wraps a transport-level failure of the ssh subprocess (connect, auth, host-key) as opposed to a non-zero exit from the remote dokku command. The output formatter renders SSHError values with an `ssh:` prefix; all other errors render with a `dokku:` prefix.

func (*SSHError) Error

func (e *SSHError) Error() string

func (*SSHError) Unwrap

func (e *SSHError) Unwrap() error

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session owns the lifetime of the SSH connections a run opens. It hands out contexts carrying a Target and a Masker, records every ControlMaster a call under one of those contexts opens, and closes them all on Close.

Recording happens when a command is dispatched, not when a context is handed out. A context derived from a session context with ContextWithTarget - a play with its own `host:` - still belongs to the session, so its master is closed too, and a host that was never reached costs nothing.

Each session has its own control sockets, so two sessions in one process talking to the same host do not share a master, and closing one cannot cut off the other.

A Session is safe for concurrent use. Close is idempotent; call it once the calls made under its contexts have returned, since a call still in flight loses its connection.

func NewSession

func NewSession(masker *Masker) *Session

NewSession returns a session whose contexts carry masker. A nil masker leaves whatever masker the parent context carries in place.

func (*Session) Close

func (s *Session) Close() error

Close tears down every ControlMaster opened under the session. It is best-effort, like CloseSshControlMaster: a master that has already exited is skipped, and the error is always nil. Calling it again does nothing.

func (*Session) Context

func (s *Session) Context(parent context.Context, target Target) context.Context

Context returns a copy of parent carrying target, the session's masker, and the session itself, so every SSH connection a call under it opens is closed by Close.

type Target

type Target struct {
	// Host is the remote as [user@]host[:port]. When non-empty, `dokku`
	// invocations are routed through an `ssh` subprocess instead of running
	// locally. Only `dokku` is routed. Everything else stays in docket's own
	// process on this machine: the `ssh` client itself, and every file docket
	// reads - the recipe, and the runner-side file fields the task catalog
	// marks `runner_file`.
	Host string

	// Sudo wraps the dokku invocation so it runs as root: `sudo -n` on the
	// remote when Host is set, `sudo -n -u root` locally when it is not.
	// Passwordless sudo only - `-n` never prompts. Like Host, this applies to
	// `dokku` alone, so docket's own file reads - a `runner_file` field, or the
	// local certs drift probe - run as the invoking user.
	Sudo bool

	// AcceptNewHostKeys adds `-o StrictHostKeyChecking=accept-new`, so ssh
	// trusts an unknown host on first connect. Only meaningful with Host.
	AcceptNewHostKeys bool

	// IdentityFile is the private key ssh authenticates with, passed as `-i`
	// together with `-o IdentitiesOnly=yes` so ssh offers that key alone
	// rather than every key the agent holds first. It is a path on the machine
	// running docket; ssh expands a leading `~`. Empty leaves the choice to
	// ssh-agent and `~/.ssh/config`. Only meaningful with Host.
	IdentityFile string

	// SSHOptions are extra ssh options, each passed as one `-o` value in the
	// `Key=Value` or `Key Value` form ssh_config(5) describes. They come after
	// the connection options docket owns - ControlMaster, ControlPath,
	// ControlPersist and BatchMode, which ValidateSSHOptions refuses - and
	// before the ones docket adds for AcceptNewHostKeys and IdentityFile, so
	// an option stated here beats those. Only meaningful with Host.
	SSHOptions []string
}

Target describes where a dokku command runs and how it is wrapped. It is the per-invocation replacement for what used to be three pieces of process state: the `defaultHost` package variable, and the DOKKU_SUDO and DOKKU_SSH_ACCEPT_NEW_HOST_KEYS environment variables the SSH argv builder read back at dispatch time. Being per-invocation is what lets one process talk to two servers - concurrently, or from a caller embedding this package as a library.

The zero Target runs dokku locally with no elevation, which is docket's behaviour when neither --host nor DOKKU_HOST is set.

func TargetFromContext

func TargetFromContext(ctx context.Context) Target

TargetFromContext returns the target ctx carries, or the zero Target when it carries none. A nil context is treated the same way, so a caller that has not been threaded a context yet still runs locally rather than panicking.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL