Profiles and workspaces
A workspace is what a sandbox is for: a directory, three shell strings and a user. A profile is where that is written down under a name, along with the image, the size and the identities, so that creating a sandbox is a word rather than a paragraph.
The declaration
{
"dir": "/work/omega",
"create": "git clone https://github.com/airbooksio/omega /work/omega",
"start": "devcontainer-up /work/omega",
"exec": "devcontainer exec --workspace-folder /work/omega --",
"user": "ubuntu"
}
| Field | What it is |
|---|---|
dir | Where the hooks run, and what a listing names. If it does not exist yet, hooks run in /work. |
create | Shell, run once per disk, guarded by a marker file at /work/.envend/created. |
start | Shell, run on every boot, after the sandbox's declared services are up. |
exec | A command prefix. Every command a session runs is handed to it. |
user | Who all of that runs as. Optional — most images say who they are themselves. |
Two hooks, because there are two questions with different answers. Creating is a one-time
act: a second clone over an existing checkout throws work away, so it is guarded by a marker on
the disk rather than by anything the gateway remembers — the gateway can restart, and the
evidence is on the disk anyway. Starting is not one-time: on a backend that rebuilds everything
outside /work from the image, the containers and daemons are gone after a sleep
even though the files are not, and they have to come back on every boot. A start
hook has to be idempotent, and that is the author's problem, not envend's.
Empty means nothing runs. There is no default hook, no repository
envd knows how to clone and no container it knows how to bring up. A sandbox that
should do nothing declares no workspace, or declares one with empty hooks; both are ordinary.
Both hooks run under bash -c (or sh -c on an image with no bash),
as the workspace user, with standard input closed — a hook is not attached to anybody, and a
command in it that read stdin would wait forever on a pipe nothing will write to — and in a
process group of their own, so a hook that runs out of patience can be ended along with
everything it started.
Who it runs as
Everything a person runs in a sandbox runs as the workspace user: both
hooks, every ssh shell, every exec, --host included. envd itself
stays root — it is pid 1, it installs the ssh key and it serves /work — and
nothing else does.
That is what makes a checkout usable. A repository cloned by a root shell is one the
container's own user cannot edit, that git refuses as having "dubious ownership",
and that a build cannot write its output into. Cloned by the workspace user, it belongs to
whoever has to build with it, and a file arriving over the mount from a laptop belongs to them
too.
Who they are is a fact about the image, decided in four steps and said out loud in the boot log:
- the profile's
workspace.user—"ubuntu", or"1000:1000"; - the image's own
USER, which the wrapper records before it forces root soenvdcan be pid 1 (or adev.envend.workspace-userlabel, for an image that has to run as root and says who it means instead); - the first user in the image with a home directory of its own —
ubuntuon Ubuntu,nodeonnode:22; - root, for an image with nobody else in it: alpine, distroless, scratch.
They get their supplementary groups, which is what makes docker work from a
hook in an image where the user is in the docker group, and passwordless sudo
where the image has a sudo to configure. The top of /work becomes theirs so a
create hook can make its checkout in it; nothing under /work is touched,
because that is somebody's data.
Where a command lands
exec is a command prefix, and that is the whole routing rule.
| You run | It lands |
|---|---|
env_exec(env:, cmd:) | <prefix> bash -lc "<cmd>" |
env_exec(env:, cmd:, host: true) | on the VM |
envend ssh <name> -- <cmd> | <prefix> bash -lc "<cmd>" |
envend ssh <name> | <prefix> bash -l |
envend ssh <name> --host [-- <cmd>] | on the VM |
scp, sftp, rsync, the NFS mount | the VM, always |
Nothing is probed first. envd does not check whether the thing behind the
prefix is up, and does not fall back to the VM when it is not: if the prefix fails, its
exit status and its standard error are the answer. That is what the same command typed
by hand would have given, and it is the only answer envd is in a position to give
honestly — a fallback would run your command somewhere you did not ask for and report
success.
File transfer stays on the VM because the share is the VM's /work, and
the paths under it are the same files a container sees at its own mount point. There is nothing
to route.
What a workspace says about itself
Five states, and every one comes from an exit code — envd never reads a hook's
output to decide how it went: pending, creating,
starting, ready, and failed with the reason and the last
twenty lines kept. Where to look, in increasing order of detail:
envend listandenv_listshow one line per sandbox:workspace: ready — /work/omega, orfailedwith the first line of the error. It comes from the heartbeat the machine already posts, so a listing costs no extra call.envend logs <name>andenv_logsprint the hooks' own output as they write it./work/.envend/hooks.logis everything both hooks have ever written, timestamped and appended. It is under/work, so it survives a wake and can be read straight off the mount.
A failing hook is never fatal. A sandbox whose clone failed is still a computer you can ssh
into and fix by hand; an envd that exited over it would leave nothing at all. So
envend ssh is never refused by a failed workspace — a broken checkout is the thing
you are going in to fix.
Profiles
A workspace is not a field of env_create. What to set up, and what to run
afterwards, is a property of the project, and a project's properties belong in its profile
where they can be got right once.
A profile fills in only what the call left out — a default, never an override, so a caller
who asks for size s on a profile that says xl gets s and
no argument about it. It also makes the name optional: the gateway generates
<alias>-<four characters>, random rather than counted, because several
sandboxes of one project at once is the ordinary case and a counter would be a thing to keep,
to persist and to race over.
Profiles come from two places, and the file wins: ENVEND_PROFILES, a JSON object
in the gateway's environment, and profiles.json in the gateway's data directory.
The file wins because it is the one an operator can change without a redeploy, and a setting you
can change is not much use if the deployed one keeps winning. Naming a profile the gateway does
not have lists the ones it does.
The worked example: omega
Worth reading as one, because every word about devcontainers in envend is in this JSON and none of it is anywhere else.
{
"omega": {
"image": "registry.fly.io/envgw-poc:host-v9",
"size": "xl",
"identities": { "github": { "repos": ["airbooksio/omega"] } },
"workspace": {
"dir": "/work/omega",
"create": "git clone https://github.com/airbooksio/omega /work/omega && devcontainer-up /work/omega",
"start": "devcontainer-up /work/omega",
"exec": "devcontainer exec --workspace-folder /work/omega --"
}
}
}
image is the devcontainer host image — Ubuntu with
envd, Docker and the devcontainer CLI in it, declaring
dockerd as a service so envd supervises it. It already runs
envd, so the gateway boots it as it is rather than wrapping it, and the create
answer says (as is). The devcontainer itself runs inside that VM,
unchanged, brought up by the ordinary CLI.
identities.github is what makes the clone work.
envd installs a git credential helper that mints an installation token per
operation, so the git clone in the create hook needs no key in the machine. Nothing
is pasted anywhere and nothing is long-lived, and that repos list is exactly what
the credential covers.
There is no chown in the create hook, and there is nothing for
one to do: the clone is made by the workspace user, which the host image declares as
ubuntu. A devcontainer sorts itself out from there — the CLI's
updateRemoteUserUID remaps the container's own user to the uid of whoever owns the
workspace folder on the host, so a checkout owned by ubuntu comes out editable
inside the container with no chown anywhere.
devcontainer-up is not devcontainer up. It is a
script in the host image that runs devcontainer up and works around an upstream
hang on a cold build: the CLI matches docker events on a field Docker 29 no longer
sends, so on a first boot it brings every container up and then waits forever. The script
watches for that exact shape, kills the CLI and runs it once more, which takes the warm path.
It lives in the image because the bug is the CLI's, and envd is not allowed to know
that the CLI exists.
Then, from anywhere:
envend up omega # prints the generated name, then the hooks
envend ssh omega-k4zt -- mix test # through the exec prefix
envend ssh omega-k4zt --host -- docker ps # on the VM
envend mount omega-k4zt # the checkout at ~/envend/omega-k4zt/work/omega
The first boot is minutes — a clone, an image pull and a compose build — and
envend up stays and prints it while it happens. Every boot after that is seconds,
because all of it is on /work and /work is durable.
Declared services
Related, and often confused with hooks. services declares what a sandbox should
be running — up to four long-lived processes, each with a name, a command, a
port it listens on inside the machine, and a directory:
services: [{ name: "web", cmd: "mix phx.server", port: 4000 }]
envd starts them at every boot, restarts them if they crash, and brings them
back after every sleep with nothing from you. That last part is the point: on a backend where a
wake rebuilds everything outside /work, a dev server you started by hand is gone
after a sleep while a shared URL is still pointed at it — a live link to nothing. The
port publishes nothing; it is the number you would later hand to
env_share.