mix ymer_node.deploy (Ymer Node v0.2.1)

Copy Markdown View Source

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.deploy

No 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.version

What 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

branch_refusal(branch)

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

compose_argv(spelling, args)

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"]}

compose_file_refusal(install_dir, bool)

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

compose_running_state(output, status)

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

compose_shadow_refusal(install_dir, shadows)

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

compose_spelling(v2_answers?, standalone_on_path?)

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"

image_refusal(arg1, arg2)

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

install_dir_setting(value)

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"

install_path_refusal(install_dir, checkout_root)

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

published_port(output)

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

rollback_line(arg)

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."

rollback_target(arg1, revision, arg3)

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

running_refusal(install_dir, spelling, atom)

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

serverinfo_version(body)

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

tree_refusal(atom)

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

wire_version(version, revision)

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"