Skip to content

Latest commit

 

History

History
192 lines (150 loc) · 9.96 KB

remote-execution.md

File metadata and controls

192 lines (150 loc) · 9.96 KB

Remote execution

By default docket runs dokku commands on the local machine, so you would run it on the Dokku server itself. Often you would rather drive a remote server from your laptop or a CI runner without installing docket there. Set DOKKU_HOST (or pass --host) and docket routes every dokku invocation through ssh instead:

# Apply against a remote server.
DOKKU_HOST=deploy@dokku.example.com docket apply

# Same, via the flag (which overrides the env var).
docket apply --host deploy@dokku.example.com:2222

The host is [user@]host[:port]. All invocations in one run share a single SSH connection through OpenSSH ControlMaster multiplexing, so you pay the connection cost once. The connection is closed when the run ends, including the ones opened for a play's own host:. A program embedding docket closes them the same way, or has them close after each command with sdk.WithControlPersist.

Because docket shells out to your own ssh binary, everything ssh already knows works without extra configuration: your ~/.ssh/config, ProxyJump, ssh-agent, and known_hosts all apply. You do not need to teach docket about any of it.

Flag Effect
--host <user@host:port> The remote host to ssh into. Overrides DOKKU_HOST.
--sudo Run dokku as root, with passwordless sudo only. Equivalent to DOKKU_SUDO=1.
--accept-new-host-keys Pass -o StrictHostKeyChecking=accept-new so SSH trusts an unknown host on first connect. Equivalent to DOKKU_SSH_ACCEPT_NEW_HOST_KEYS=1.
--ssh-identity-file <path> Authenticate with this private key only, as -i <path> -o IdentitiesOnly=yes. Equivalent to DOKKU_SSH_IDENTITY_FILE=<path>.
--ssh-option <option> Pass an extra option to ssh as -o <option>. Repeatable.

--sudo works on both sides of the transport: with --host it wraps the remote invocation in sudo -n, and without one it runs the local dokku under sudo -n -u root. Either way it covers dokku alone - files docket reads itself, such as a runner-side file, are read as the user running docket. -n never prompts, so the account has to have passwordless sudo already.

--accept-new-host-keys is convenient in CI, where seeding known_hosts ahead of time is awkward, but it gives up man-in-the-middle protection on the first connection. When you can, prefer seeding the key yourself:

ssh-keyscan dokku.example.com >> ~/.ssh/known_hosts

Choosing a key and ssh options

By default ssh picks the key itself, from ssh-agent or ~/.ssh/config. Where neither is set up - a CI runner holding a deploy key as a file, say - name the key with --ssh-identity-file:

docket apply --host deploy@dokku.example.com --ssh-identity-file ~/.ssh/dokku_deploy

docket passes it as -i <path> -o IdentitiesOnly=yes, so ssh offers that key alone rather than trying every key the agent holds first, which can exhaust the server's MaxAuthTries before the right one is reached. The path is read on the machine running docket, and ssh expands a leading ~. docket takes a path only: when a tool holds the key material itself, it writes the key to a file readable only by its owner (mode 0600) and passes that path.

--ssh-option passes any other ssh option, in either Key=Value or Key Value form, and can be repeated:

docket apply --host deploy@dokku.example.com \
  --ssh-option ConnectTimeout=10 --ssh-option "ProxyJump bastion.example.com"

ssh keeps the first value it sees for an option. Your options come after the ones docket uses to share one connection - ControlMaster, ControlPath, ControlPersist, and BatchMode - so those four are refused rather than silently ignored. They come before the options docket adds for --accept-new-host-keys and --ssh-identity-file, so an explicit IdentitiesOnly=no or StrictHostKeyChecking value of your own wins over docket's. docket closes its connections itself when the run ends, so there is no ControlPersist to tune from the command line; a program embedding docket sets it with sdk.WithControlPersist.

The identity and the options are part of what decides which connection a command shares. Two plays on the same host with different keys or options each open their own, and both are closed at the end of the run.

A saved plan records the target it was planned against. docket apply --plan runs against that target and ignores DOKKU_HOST, DOKKU_SUDO, DOKKU_SSH_ACCEPT_NEW_HOST_KEYS, and DOKKU_SSH_IDENTITY_FILE, and it refuses --host, --sudo, --accept-new-host-keys, --ssh-identity-file, and --ssh-option. A plan made against one server cannot be pointed at another.

A recipe that spans hosts

The flags above set one target for the whole run. A play can name its own instead, which is how a migration or a fan-out deploy is written as a single recipe:

---
- name: drain the old server
  host: deploy@old.example.com
  tasks:
    - dokku_maintenance: { app: api }

- name: install on the new one
  host: deploy@new.example.com
  tasks:
    - dokku_app: { app: api }

Each play opens its own multiplexed SSH connection and docket closes all of them at the end of the run. A play that names no host: uses the run-wide target, and sudo, accept_new_host_keys, ssh_identity_file, and ssh_options carry over to a play that redirects unless it overrides them. See recipes for the full rules.

Argument quoting

OpenSSH joins the words of a remote command into a single string that the remote login shell re-parses, so docket shell-quotes each dokku argument before sending it. Values containing spaces or shell metacharacters - a start-cmd like npm run start, an nginx access-log-format with $remote_addr, or a backup schedule like 0 3 * * * - reach the remote dokku verbatim, exactly as they would when running locally. An argument that cannot be represented for a POSIX shell (one containing a tab, newline, or null byte) is rejected with an ssh: error rather than sent in a corrupted form.

Environment variables stay local

Only the command line crosses to the server. Variables you set around docket - FOO=bar docket apply --host ..., or anything exported in your shell - decorate the local ssh process, and docket configures no SendEnv, so they reach the remote dokku only if your own ssh config forwards them and the server opts in with AcceptEnv. Do not rely on that.

Values meant for the server belong in the recipe, where they become arguments docket sends explicitly: dokku_config for an app's config, and dokku_service_create's custom_env for the environment a service container starts with.

Reading errors

Errors are categorized so you can tell which side failed. SSH-level failures (refused connection, auth, host-key mismatch) carry an ssh: prefix; remote dokku failures carry a dokku: prefix:

[error]   create app
          ! ssh: ssh deploy@dokku.example.com: Permission denied (publickey).
[error]   add buildpack
          ! dokku: app foo does not exist

What the runner needs

Only dokku runs on the server. The machine running docket needs two things of its own:

  • The OpenSSH client on PATH. docket execs ssh rather than speaking the protocol itself, and shares one connection per server through ControlMaster multiplexing, so it needs OpenSSH specifically. Without it, the first dokku command fails with ssh binary not found in PATH.
  • Any runner-side file a recipe names. A few fields name a file docket reads on the machine it runs on and streams to dokku, rather than a path it hands to dokku. dokku_maintenance_custom_page's tarball is one: the archive must exist on the runner, whatever server docket is driving. These fields carry "runner_file": true in docket schema, and their task page lists them under Runner requirements, so a wrapper can warn about or refuse a recipe whose files it cannot supply.

File paths are remote

Unless a field is a runner-side file, a file path a task references - for example the cert and key fields on dokku_certs - is handed to dokku and interpreted on the remote host, not your local machine. docket does not upload such files, so any referenced file must already exist on the server. Place it there before the run.

Some tasks offer an inline alternative that sidesteps this constraint. dokku_certs, for instance, accepts cert_content and key_content strings; docket streams the PEM material to dokku as a tarball over stdin, so the bytes never have to live as files on the remote.

The same constraint narrows what plan can tell you. dokku_certs compares the certificate a recipe pins against the one the server holds, so a renewal shows up as drift - but over SSH the cert path names a file docket cannot read, leaving nothing to compare it against, and the task reports in sync as long as some certificate is installed. The inline form carries its own material, so it is compared on every transport.

That comparison is a fingerprint, not a copy. docket reads the SHA-256 digest the scope's report carries - certs:report for an app, global-cert:report --global for the global certificate - and settles it against a digest taken locally, so the certificate itself never crosses the connection at plan time. It falls back to reading the PEM back with certs:show or global-cert:show only where a digest cannot answer: a recipe pinning a certificate chain, where dokku digests the leaf alone, and a report that carries no usable fingerprint.

See also