devc

module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Jul 24, 2026 License: Apache-2.0

README

devc - a minimal devcontainer runner

devc reads a devcontainer.json, brings the container(s) up with podman or docker, and gives you an SSH host an editor's Remote-SSH can connect to (VSCodium/VS Code). Think devpod, without the provider abstraction, features, or cloud. It is a standalone tool.

cd ~/src/myproject
devc up      # build/start, then write ~/.config/devc/ssh.config
             # now: ssh devc.myproject  (or point Remote-SSH at devc.myproject)
devc down    # remove the container(s)

Install

With Go (installs a binary named devc):

CGO_ENABLED=0 go install github.com/terrakuh/devc/cmd/devc@latest

CGO_ENABLED=0 is required: the binary injects itself into arbitrary container images, so it must be static (see How SSH works). Use @latest or pin a tag like @v1.0.0. The version is read from the build info, so a tagged install (or a clean release-tag build) reports that tag with no extra flags.

Or build from source:

CGO_ENABLED=0 go build -o devc ./cmd/devc

# smaller, reproducible binary:
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o devc ./cmd/devc

The host binary and the container must share a CPU architecture (there is no cross-build); devc doctor reports a mismatch. Prebuilt static linux binaries for amd64 and arm64 are attached to each GitHub release.


How SSH works

There is no sshd in the image and no published port. The devc binary injects itself into the container as an SSH server and talks the SSH protocol over a podman exec pipe. You are already allowed to reach the container (you can run the container runtime), so no network auth is needed. The SSH crypto still runs because a real editor is a real SSH client, but the keys are generated per workspace, stay on your host, and never leave the machine.

ssh devc.myproject
  -> ProxyCommand: devc ssh --stdio --start --config <abs>/devcontainer.json --runtime /usr/bin/podman myproject
       -> podman exec -i <ctr> /.devc/agent __serve   <-- SSH -->  in-container agent
                                                                     - session (shell/exec/pty/sftp)
                                                                     - direct-tcpip  (ssh -L)
                                                                     - tcpip-forward (ssh -R)

Because the binary runs inside an arbitrary image, it must be built static (CGO_ENABLED=0) so it carries no dynamic libc dependency. See Install.


Commands

Command What it does
devc up build/start, inject the agent, run hooks, write ssh config. Flags: --recreate, --rebuild, --skip-hooks, --rerun-hooks
devc down remove the container(s). --volumes (compose), --purge (also drop keys, ssh block, control dir), --auto (honor shutdownAction)
devc stop stop without removing
devc status [--json] this workspace: kind, runtime, state, ports, config drift
devc list [--json] every devc workspace on the host (found by label)
devc logs [--follow] [service] container / compose logs
devc exec [-T] -- <cmd> run as the remote user in the workspace folder, with remoteEnv
devc ssh [<name>] spawn ssh devc.<name> (shares the ControlMaster)
devc ssh --stdio [--start] ProxyCommand transport (what the generated config runs)
devc ssh-config [--print] regenerate (or preview) the workspace's ssh config block
devc keys [--rotate] show or rotate the workspace's SSH keys
devc doctor [--json] preflight: runtime, container, tar/curl, libc, $HOME, disk, agent
devc config [--raw] print the resolved Spec (or the post-substitution raw doc)

Global flags: --path, --config, --runtime, --compose-cmd, --platform, --selinux, --userns, -q.


Config support

Both forms of devcontainer.json are supported (JSONC: comments and trailing commas allowed).

  • Compose: dockerComposeFile (string or array, applied in order), service, runServices, workspaceFolder, forwardPorts.
  • Single container: image or build.{dockerfile,context,args,target,...}, workspaceMount, mounts, containerEnv, containerUser, runArgs, init, privileged, capAdd, securityOpt, appPort.

Shared by both: name, remoteUser, remoteEnv, overrideCommand, shutdownAction, userEnvProbe, waitFor, and all lifecycle hooks (initializeCommand on the host; onCreate/updateContent/postCreate once per container identity; postStart every start; postAttach). Variable substitution covers ${localWorkspaceFolder}, ${localEnv:VAR:default}, ${containerWorkspaceFolder}, ${devcontainerId}, and deferred ${containerEnv:VAR}.

Not supported (by design): features, hostRequirements, and updateRemoteUserUID are rejected with a clear error. customizations and portsAttributes are parsed and ignored. On the compose path devc never generates an override file, so single-container-only keys used alongside dockerComposeFile are rejected instead of silently dropped; the compose file itself must keep the service alive (command: sleep infinity).

forwardPorts become LocalForward lines in the ssh config, so the ports work the moment you connect, with no publishing and no host-port collisions.


Fedora / rootless podman notes

  • SELinux: bind mounts get :z automatically when selinuxenabled is true. Override with --selinux=auto|z|Z|none.
  • User mapping: devc does not default to --userns=keep-id. Under plain rootless podman, container UID 0 already maps to your host user, so files land as yours while root stays available for /.devc setup and the agent's privilege drop. Pass --userns=keep-id only for a fixed non-root container user.
  • The agent installs and runs as --user 0, then drops to the session user itself (the same model as sshd).

State and credentials

Per workspace, under ~/.local/share/devc/<id>/ (0700):

File Purpose
id_ed25519(.pub) client key; the public half is the container's authorized key
host_key(.pub) the agent's host key (the only private half copied into the container)
known_hosts pinned, so StrictHostKeyChecking yes is honest
state.json container identity, hook records, probed env, agent version

The SSH ControlMaster socket lives under /tmp/devc-<uid>/<id>/. This short path matters: the state dir is too deep for the ~104-char Unix socket limit, which would silently break connection multiplexing. ~/.ssh/config gets one idempotent Include ~/.config/devc/ssh.config line (with a timestamped backup on first write).

The workspace id is <slug>-<sha256(abs folder)[:8]>: stable across rebuilds and unique across two checkouts of the same repo.


Troubleshooting the editor connection

The generated ProxyCommand bakes in an absolute --config and --runtime so it resolves correctly whatever working directory or PATH the editor spawns it with (GUI editors give it neither of yours). If Remote-SSH fails with a generic "connection lost before handshake" / "premature close", the editor has thrown away the ProxyCommand's stderr, so devc logs every step of the --stdio transport to a file:

cat /tmp/devc-$(id -u)/ssh-proxy.log

That shows how far it got: which runtime was resolved, the exact podman exec line, and the exit error. Then devc doctor checks the in-container side (tar, curl/wget, libc, $HOME, disk, agent version) that the editor's server installer needs.

After upgrading devc, reconnect with a fresh container (devc up --recreate), and clear a stale mux socket with ssh -O exit devc.<name> if needed.


Testing

Everything except the container runtime is unit-tested with no daemon present: config parsing (jsonc, substitution, resolution), argv construction (runtime.FakeRunner), ssh config rendering, hooks, and the agent over a real x/crypto/ssh client (publickey accept/reject, exec exit codes, PTY, direct-tcpip, tcpip-forward, SFTP round-trip).

go test ./...                               # unit tests, no daemon needed
go test -tags devc_integration ./cmd/devc/  # end-to-end, needs podman

Verify the editor itself manually, once; it is not part of the automated suite.

Directories

Path Synopsis
Package agent implements the in-container SSH server that devc injects and runs over `podman exec -i ...
Package agent implements the in-container SSH server that devc injects and runs over `podman exec -i ...
cmd
devc command
Command devc is a minimal devcontainer runner: it reads a devcontainer.json (compose or single-container form, no features/customizations), brings the container(s) up with podman or docker-compose, and exposes an SSH endpoint a local editor (VSCodium Remote-SSH) can connect to.
Command devc is a minimal devcontainer runner: it reads a devcontainer.json (compose or single-container form, no features/customizations), brings the container(s) up with podman or docker-compose, and exposes an SSH endpoint a local editor (VSCodium Remote-SSH) can connect to.
Package config loads and resolves devcontainer.json into a semantic Spec.
Package config loads and resolves devcontainer.json into a semantic Spec.
Package container turns a resolved config.Spec into container-runtime operations: build, create, start, reuse, exec, and inspection.
Package container turns a resolved config.Spec into container-runtime operations: build, create, start, reuse, exec, and inspection.
Package hooks runs devcontainer lifecycle commands.
Package hooks runs devcontainer lifecycle commands.
Package jsonc converts JSONC (JSON with comments and trailing commas, the dialect VS Code uses for devcontainer.json) into plain JSON that encoding/json accepts.
Package jsonc converts JSONC (JSON with comments and trailing commas, the dialect VS Code uses for devcontainer.json) into plain JSON that encoding/json accepts.
Package keys manages the per-workspace SSH credentials devc uses to secure the agent transport.
Package keys manages the per-workspace SSH credentials devc uses to secure the agent transport.
Package runtime abstracts the container CLI (podman or docker) behind a small Runner interface.
Package runtime abstracts the container CLI (podman or docker) behind a small Runner interface.
Package sshconf generates the OpenSSH client configuration that points an editor (VSCodium Remote-SSH) or the ssh CLI at a devc workspace.
Package sshconf generates the OpenSSH client configuration that points an editor (VSCodium Remote-SSH) or the ssh CLI at a devc workspace.
Package state manages devc's per-workspace host-side directories and the state.json that records facts we must not keep inside the container: which container identity a workspace last used, which lifecycle hooks have run, the cached environment probe, and the injected agent version.
Package state manages devc's per-workspace host-side directories and the state.json that records facts we must not keep inside the container: which container identity a workspace last used, which lifecycle hooks have run, the cached environment probe, and the injected agent version.

Jump to

Keyboard shortcuts

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