Deploy: move the checkout's committed main into the install — name the
rollback target, build, carry the compose file, recreate the install's
container on the image, and verify it came up on it.
Usage
mix ymer_node.deployNo options and no arguments. It deploys into the
install, found through
YMER_NODE_INSTALL_DIR and defaulting to ~/Apps/ymer-node. Making the
first install is not this task's job — the README's hand steps make one,
and this task moves new code into one that already exists.
sequenceDiagram
autonumber
participant Task as mix ymer_node.deploy
participant Git as git, in the checkout
participant Docker as docker
participant Compose as compose, in the install
participant Node as the node
Task->>Docker: docker info
Task->>Git: tree state, current branch
Task->>Compose: ps -q
Note over Task,Compose: every refusal lands before anything changes
Task->>Compose: ps -a -q
Task->>Docker: the container's revision label
Task->>Docker: does the revision's tag still resolve?
Task->>Docker: point previous at that revision
Task->>Docker: build and tag
Task->>Task: copy docker-compose.yml into the install
Task->>Compose: up -d
Task->>Docker: container image id
Task->>Node: initialize
Node-->>Task: serverInfo.versionWhat it refuses, and why the order is load-bearing
A deploy stops before it changes anything when docker is missing or its
daemon is silent, when the working tree is dirty or unreadable, when the
checkout is not on main, when the install resolves inside the checkout,
when the install holds no compose file or holds one compose would read
instead of it, and when the install's container is still running — or when
compose cannot say whether it is. Each refusal names the next action.
Two of those orderings are not cosmetic. The compose-file check must
precede the running check: compose resolves its file by walking up parent
directories, so ps -q in a directory without one silently answers for an
ancestor's project — from anywhere under the checkout that is the
checkout's own project, empty, and indistinguishable from an install that
is not running. And the install-path check must precede the compose-file
check, because the checkout does hold a compose file: the file check would
pass, and up -d would create the install's container in the checkout's
project against the checkout's ./data. That comparison resolves the
install to its physical directory first — expanding ~ and .. leaves a
symlink in place, and the checkout root arrives already resolved — so an
install reached through a link into the checkout is refused too, and the
refusal then names the directory the link points at rather than the one
that was configured.
The running check reads ps -q in the install directory, so compose
resolves the project from the install's own .env and no name resolution
is re-implemented here. A container id means running and the deploy
refuses; empty means stopped, crashed, or absent, all of which up -d
recreates. A crash-looping container lists as running, which is right — it
should be stopped by hand before new code lands on it. A ps -q that fails
outright is neither: compose could not answer, which is not the same as
answering that nothing is up, so the deploy refuses there too rather than
recreating a container that may still be live. That is the one refusal a
deploy takes on a reading it could not make, and it is deliberate — the
reading it stands in for is the one that keeps a live node from being
recreated under a session.
The refusal names stop, not down. After stop the container and its
labels stay and up -d recreates it on the new image, so a deploy that
fails before up -d leaves the old node one start away, untouched. After
down the old container is gone, and a downed install reads the same as
one that was never started.
The rollback target
ymer-node:previous names the revision the install ran before the last
deploy, so going back needs no lookup and no sha typed out. The README's
"Running it" carries the two commands that use it.
It is pointed before the build, not after, and that order is the whole
reason it works. This machine's image store reclaims an image the moment
its last tag moves away, so a deploy of the revision the install is already
running would otherwise reclaim the very image being named: the tag has to
be in place before docker build moves anything. With previous holding
it, a rebuild of that same revision leaves the old image alone; without it,
the old image is gone by the time anything could name it.
The revision is read off the install's container, not off an image
record, because a container keeps its image's labels after that record has
been reclaimed — which is often the only reason the revision can still be
named at all. The container is stopped by the time this runs, the running
check having passed, so ps -a -q is what finds it; ps -q is empty.
Four states name nothing, and each is reported rather than stopping the
deploy: compose could not say what containers the install has, no container
at all (the install was taken down), a container carrying no revision label
(it was built before the label existed), and a revision whose
ymer-node:<revision> tag has since been removed. A missing rollback
target is information, not a refusal — the running check above refuses when
compose cannot answer because a wrong reading there recreates a live
container, while a wrong reading here only leaves a tag unset.
previous names code, not a particular build. Rebuilding one commit mints
a fresh image id under that same revision tag, so after a hand rebuild
between deploys the tag resolves to different bytes — of the same source,
which is what a rollback is reaching for.
What it proves before it says the node is up
Two checks, in order. The container's image id — {{.Image}}, the id, not
the reference it was created from — must equal the id ymer-node:latest
now names; a reference compare would read the same for a container that
never moved. Then initialize on the port Docker reports published must
answer a serverInfo.version equal to the version and revision just built.
The port comes from docker port rather than from any assumption here, and
a container found restarting, exited or dead fails fast with its
state and restart count rather than waiting out the budget.
The node's supervision tree brings its HTTP listener up last, so an
initialize that answers is also proof that the store opened and the
vector extension loaded.
What a deploy does not touch
The store: data/ is a bind mount the recreate keeps, and initialize is
a read. The install's .env — install-specific state, and theirs. And the
version in mix.exs, which is a release number, not a build's identity;
the revision is what names the code.
The module is wrapped in if Mix.env() in [:dev, :test], which keeps it
callable under the default :dev and out of the prod release.
Summary
Functions
Whether the checkout is on the branch a deploy ships.
A spelling split into the executable and arguments System.cmd/3 needs.
The v2 spelling is two words, one of which is a subcommand rather than a
program — printing it and running it are different jobs.
Whether the install holds a compose file. It must, and this has to be answered before anything asks compose about the install's project: compose walks up to a parent's file when a directory has none, and would then answer for the wrong project entirely.
What ps -q in the install said about its container, read from that
command's output and its exit status. Compose printing nothing on a clean
exit is an answer — the container is stopped, crashed, or was never
created — but a non-zero exit is not one: compose could not answer at all,
and reading that as "nothing is up" is how a deploy recreates a container
that is still live.
Whether a file compose would read instead of docker-compose.yml sits
beside it in the install. Compose resolves its file by name in a fixed
order — compose.yaml, then compose.yml, then docker-compose.yml — so
one of the first two in the install would be the file every compose
command here runs, while the file the deploy checks for and carries is the
third. The deploy refuses rather than pinning -f: a pin would drop the
override file compose merges on its own, and would make the commands the
refusals name run against a different file than the task did.
The Compose spelling this machine answers to: the v2 subcommand wherever the plugin is installed, the standalone binary only where the plugin is missing and the binary is present. A machine with neither gets the v2 spelling, which is the one worth installing.
Whether the container is running the image just built. Compares image ids: the reference a container was created from reads the same whether or not the recreate took, because the tag string does not move with the image it names. An id that could not be read refuses too: the proof this check carries cannot rest on two failed reads comparing equal.
Where the install is, as configured — the YMER_NODE_INSTALL_DIR value, or
the default where that is unset or blank. Left unexpanded: the caller
expands it, so the value stays printable in a refusal exactly as it was
written.
Whether the install sits outside the checkout. Inside it — the root itself,
or anything below it — the checkout's own compose file would satisfy every
later check, and up -d would create the install's container in the
checkout's project against the checkout's ./data.
The host port Docker reports published for a container, read from docker port output rather than assumed. A stopped container publishes nothing and
prints nothing, which is :error here.
The close's line for what the rollback target turned out to be — the tag it now names, or the reason nothing was named.
The rollback target a deploy can name, decided from what the install's
container says. previous is re-pointed only when all three hold: the
install has a container, that container carries a revision label, and a tag
for that revision is still in the image store. Anything else is skipped
with the reason, because failing to name a rollback target is information
rather than a reason to stop.
Whether the install's container is stopped enough to deploy onto. The
argument is compose_running_state/2's reading, and :not_running is the
only one that proceeds: a stopped, crashed or never-created container is
what up -d recreates. A crash-looping container reads :running, which
is right — it should be stopped by hand before new code lands on it — and
:unknown refuses like a running one, since the deploy cannot tell them
apart and only one of the two is safe to recreate.
The serverInfo.version in an initialize response body. The node answers
initialize as plain JSON, so nothing here unwraps an event stream.
Whether the working tree lets a deploy name what it ships. Build only warns on a dirty tree; a deploy refuses, because the revision it stamps is the only claim the running node can make about its own code.
The version string a container built from this version and revision answers
on initialize — semver build metadata, composed the same way
config/runtime.exs composes it inside the release. A build with no
revision answers the bare version.
Functions
Whether the checkout is on the branch a deploy ships.
Examples
iex> Mix.Tasks.YmerNode.Deploy.branch_refusal("main")
:ok
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.branch_refusal("topic")
iex> String.contains?(message, "Switch to main")
true
A spelling split into the executable and arguments System.cmd/3 needs.
The v2 spelling is two words, one of which is a subcommand rather than a
program — printing it and running it are different jobs.
Examples
iex> Mix.Tasks.YmerNode.Deploy.compose_argv("docker compose", ["ps", "-q"])
{"docker", ["compose", "ps", "-q"]}
iex> Mix.Tasks.YmerNode.Deploy.compose_argv("docker-compose", ["ps", "-q"])
{"docker-compose", ["ps", "-q"]}
Whether the install holds a compose file. It must, and this has to be answered before anything asks compose about the install's project: compose walks up to a parent's file when a directory has none, and would then answer for the wrong project entirely.
Examples
iex> Mix.Tasks.YmerNode.Deploy.compose_file_refusal("/opt/node", true)
:ok
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.compose_file_refusal("/opt/node", false)
iex> String.contains?(message, "/opt/node")
true
What ps -q in the install said about its container, read from that
command's output and its exit status. Compose printing nothing on a clean
exit is an answer — the container is stopped, crashed, or was never
created — but a non-zero exit is not one: compose could not answer at all,
and reading that as "nothing is up" is how a deploy recreates a container
that is still live.
Examples
iex> Mix.Tasks.YmerNode.Deploy.compose_running_state("abc123\n", 0)
:running
iex> Mix.Tasks.YmerNode.Deploy.compose_running_state(" \n", 0)
:not_running
iex> Mix.Tasks.YmerNode.Deploy.compose_running_state("no configuration file", 1)
:unknown
Whether a file compose would read instead of docker-compose.yml sits
beside it in the install. Compose resolves its file by name in a fixed
order — compose.yaml, then compose.yml, then docker-compose.yml — so
one of the first two in the install would be the file every compose
command here runs, while the file the deploy checks for and carries is the
third. The deploy refuses rather than pinning -f: a pin would drop the
override file compose merges on its own, and would make the commands the
refusals name run against a different file than the task did.
Examples
iex> Mix.Tasks.YmerNode.Deploy.compose_shadow_refusal("/opt/node", [])
:ok
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.compose_shadow_refusal("/opt/node", ["compose.yaml"])
iex> String.contains?(message, "compose.yaml")
true
The Compose spelling this machine answers to: the v2 subcommand wherever the plugin is installed, the standalone binary only where the plugin is missing and the binary is present. A machine with neither gets the v2 spelling, which is the one worth installing.
Examples
iex> Mix.Tasks.YmerNode.Deploy.compose_spelling(true, false)
"docker compose"
iex> Mix.Tasks.YmerNode.Deploy.compose_spelling(false, true)
"docker-compose"
iex> Mix.Tasks.YmerNode.Deploy.compose_spelling(false, false)
"docker compose"
Whether the container is running the image just built. Compares image ids: the reference a container was created from reads the same whether or not the recreate took, because the tag string does not move with the image it names. An id that could not be read refuses too: the proof this check carries cannot rest on two failed reads comparing equal.
Examples
iex> Mix.Tasks.YmerNode.Deploy.image_refusal({:ok, "sha256:abc"}, {:ok, "sha256:abc"})
:ok
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.image_refusal({:ok, "sha256:a"}, {:ok, "sha256:b"})
iex> String.contains?(message, "did not take")
true
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.image_refusal({:error, "the install's container"}, {:ok, "sha256:b"})
iex> String.contains?(message, "could not inspect the install's container")
true
Where the install is, as configured — the YMER_NODE_INSTALL_DIR value, or
the default where that is unset or blank. Left unexpanded: the caller
expands it, so the value stays printable in a refusal exactly as it was
written.
Examples
iex> Mix.Tasks.YmerNode.Deploy.install_dir_setting(nil)
"~/Apps/ymer-node"
iex> Mix.Tasks.YmerNode.Deploy.install_dir_setting(" ")
"~/Apps/ymer-node"
iex> Mix.Tasks.YmerNode.Deploy.install_dir_setting("/opt/ymer-node")
"/opt/ymer-node"
Whether the install sits outside the checkout. Inside it — the root itself,
or anything below it — the checkout's own compose file would satisfy every
later check, and up -d would create the install's container in the
checkout's project against the checkout's ./data.
Examples
iex> Mix.Tasks.YmerNode.Deploy.install_path_refusal("/opt/node", "/src/node")
:ok
iex> {:refuse, _} = Mix.Tasks.YmerNode.Deploy.install_path_refusal("/src/node", "/src/node")
iex> {:refuse, _} = Mix.Tasks.YmerNode.Deploy.install_path_refusal("/src/node/x", "/src/node")
iex> :matched
:matched
The host port Docker reports published for a container, read from docker port output rather than assumed. A stopped container publishes nothing and
prints nothing, which is :error here.
Examples
iex> Mix.Tasks.YmerNode.Deploy.published_port("8012/tcp -> 127.0.0.1:8012")
{:ok, 8012}
iex> Mix.Tasks.YmerNode.Deploy.published_port("")
:error
The close's line for what the rollback target turned out to be — the tag it now names, or the reason nothing was named.
Examples
iex> Mix.Tasks.YmerNode.Deploy.rollback_line({:named, "b2a7152"})
"Rollback target: ymer-node:previous now names ymer-node:b2a7152."
iex> Mix.Tasks.YmerNode.Deploy.rollback_line({:skip, "the install had no container"})
"Rollback target: none — the install had no container."
The rollback target a deploy can name, decided from what the install's
container says. previous is re-pointed only when all three hold: the
install has a container, that container carries a revision label, and a tag
for that revision is still in the image store. Anything else is skipped
with the reason, because failing to name a rollback target is information
rather than a reason to stop.
The arguments are the three readings, in the order they are taken: the
container id ps -a -q printed — empty where the install has none,
:unreadable where compose could not answer — the revision on that
container's OCI label (empty where it carries none), and whether
ymer-node:<revision> still resolves.
Examples
iex> Mix.Tasks.YmerNode.Deploy.rollback_target("abc123", "b2a7152", true)
{:tag, "b2a7152"}
iex> {:skip, reason} = Mix.Tasks.YmerNode.Deploy.rollback_target(:unreadable, "", false)
iex> String.contains?(reason, "could not read")
true
iex> {:skip, reason} = Mix.Tasks.YmerNode.Deploy.rollback_target("", "", false)
iex> String.contains?(reason, "no container")
true
iex> {:skip, reason} = Mix.Tasks.YmerNode.Deploy.rollback_target("abc123", "", false)
iex> String.contains?(reason, "no revision label")
true
iex> {:skip, reason} = Mix.Tasks.YmerNode.Deploy.rollback_target("abc", "b2a7152", false)
iex> String.contains?(reason, "ymer-node:b2a7152")
true
Whether the install's container is stopped enough to deploy onto. The
argument is compose_running_state/2's reading, and :not_running is the
only one that proceeds: a stopped, crashed or never-created container is
what up -d recreates. A crash-looping container reads :running, which
is right — it should be stopped by hand before new code lands on it — and
:unknown refuses like a running one, since the deploy cannot tell them
apart and only one of the two is safe to recreate.
Examples
iex> Mix.Tasks.YmerNode.Deploy.running_refusal("/opt/node", "dc", :not_running)
:ok
iex> refusal = Mix.Tasks.YmerNode.Deploy.running_refusal("/opt/node", "dc", :running)
iex> {:refuse, message} = refusal
iex> String.contains?(message, "`dc stop`")
true
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.running_refusal("/o", "dc", :unknown)
iex> String.contains?(message, "`dc ps -q`")
true
The serverInfo.version in an initialize response body. The node answers
initialize as plain JSON, so nothing here unwraps an event stream.
Examples
iex> body = ~s({"result":{"serverInfo":{"name":"n","version":"1.2.3+b2a7152"}}})
iex> Mix.Tasks.YmerNode.Deploy.serverinfo_version(body)
{:ok, "1.2.3+b2a7152"}
iex> Mix.Tasks.YmerNode.Deploy.serverinfo_version("not json")
:error
Whether the working tree lets a deploy name what it ships. Build only warns on a dirty tree; a deploy refuses, because the revision it stamps is the only claim the running node can make about its own code.
Examples
iex> Mix.Tasks.YmerNode.Deploy.tree_refusal(:clean)
:ok
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.tree_refusal(:dirty)
iex> String.contains?(message, "Commit, stash or remove")
true
iex> {:refuse, message} = Mix.Tasks.YmerNode.Deploy.tree_refusal(:unknown)
iex> String.contains?(message, "needs git and a work tree")
true
The version string a container built from this version and revision answers
on initialize — semver build metadata, composed the same way
config/runtime.exs composes it inside the release. A build with no
revision answers the bare version.
Examples
iex> Mix.Tasks.YmerNode.Deploy.wire_version("1.2.3", "b2a7152")
"1.2.3+b2a7152"
iex> Mix.Tasks.YmerNode.Deploy.wire_version("1.2.3", nil)
"1.2.3"