Add an application to DeployFlow¶
An application is one directory under apps/. Nothing in common/ and
nothing in playbooks/site.yml changes when you add one. If your change touches
either, stop — you are about to put application knowledge in the framework, which
is the one thing this layout exists to prevent.
Any change to app.yaml or inventory/ ends with ./scripts/sync_state.sh
(step 11). Both files are gitignored here; the private deployflow-state
repository is what makes the record survive this machine.
The two roots¶
| Root | Knows about | Contains |
|---|---|---|
common/ |
guests, never applications | lxc/ (create), python/ (contract) |
apps/ |
its own application, never guests | example-app/, and whatever you add |
roles_path = common:apps in ansible.cfg. An application may not require
anything from another application.
The contract: one variable¶
common/python/defaults/main.yml derives everything from app_name:
app_user, app_home, app_dir, app_venv, app_state_dir,
app_service_name. You set app_name and every path follows.
Two values have no derivation and you must set them: app_port and anything else
unique to your app.
Process¶
1. Scaffold¶
2. defaults/main.yml — the whole interface¶
---
app_name: myapp # the ONLY required value
app_port: 9000 # no derivation exists for this
app_service_name: myapp # defaults to app_name; set only if they differ
3. meta/main.yml — declare the dependency, not the playbook¶
Ansible runs a role's dependencies before its own tasks, so common/python
prepares the guest first. This is what lets a new app avoid editing any
playbook. Verified: including an application runs all nine common/python tasks with
python named nowhere in the playbook.
4. Build artifacts¶
Only for kind: repo. For kind: package the wheel already exists on PyPI and
appctl build myapp says so and exits non-zero rather than failing silently.
appctl build myapp # wheel + pinned lockfile + BUILDINFO
appctl build myapp --check # verify committed artifacts, build nothing
defaults.python_version in app.yaml must match the interpreter the lockfile
was compiled for. appctl build refuses any lock entry without a sha256, which
is the same condition --require-hashes enforces at install time — caught at
build time it names the package; caught at install time pip names nothing.
What is committed: the lockfile only. The wheel and BUILDINFO are gitignored, because the wheel changes on every commit and BUILDINFO records the sha256 the role checks before installing anything.
5. Configuration¶
If the application has settings, write them as YAML. No code.
# app.yaml
config:
target: "{{ app_state_dir }}/config.json"
files: [apps/myapp/config.yml]
vars: { port: 9000 }
appctl render turns that into JSON under tmp/rendered/<app>/, and
common/python copies it to target. Set app_config_target in the role's
defaults/main.yml so the framework knows where to put it.
Expect the file to be partial. Applications merge their own defaults into whatever they are given — many applications do it themselves — so only what differs from a default belongs in the YAML.
Never write a script that runs on the guest to produce configuration. That was tried here: a script merged settings into config.json on every play. The application already does that merge itself, so the script was a second implementation of the application's behaviour, shipped to every guest, free to drift from it. A two-key config.json boots the service.
Use {{ name }} for values appctl substitutes on the controller, and ${NAME}
for secrets the application resolves at runtime from its EnvironmentFile. The
second form is what keeps a secret out of the repository, the rendered file and
the logs.
6. tasks/main.yml¶
Copy the closest existing role. apps/example-app/ is the reference: a wheel
install, a systemd unit, a config merge, secrets, and the postconditions that
prove the service answers.
Keep every postcondition — see below. They are the point of the role, not boilerplate.
Two mistakes that cost a run each, and that the reference role now avoids:
pip runs on the guest, so the lockfile must be copied there first. Pointing
pip at {{ role_path }}/files/requirements.lock — a path on the controller —
fails with "Could not open requirements file". That message names a missing
file rather than a file that is present, on a different machine, and it looks
like a broken lockfile rather than a path problem. Copy the file to
{{ app_dir }}/requirements.lock and point pip at that.
The import check needs a module name, not the application name. An
application name may contain a hyphen, and import example-app is a syntax
error, not a missing module — so the check fails for a reason that has
nothing to do with the install, and the real problem is never reached. Name the
module separately (app_module), and point the unit's PYTHONPATH at the
install root where it lives.
End the role by reporting what it proved. A debug task named
Report the checks that passed, whose msg is a list of one line per check,
becomes the check: lines in the operator's summary. Without it the summary
reports a task count, which is not evidence:
- name: Report the checks that passed
ansible.builtin.debug:
msg:
- "{{ app_name }} is installed and answering on port {{ app_port }}"
- "import {{ app_module }}: ok"
- "endpoint /: HTTP {{ app_endpoint.status }}"
7. Name the target¶
Every application says where it runs:
# app.yaml
applications:
myapp:
target:
kind: proxmox_lxc # the only implemented target
options:
hostname: myapp
vmid: 121
bind_host: 127.0.0.1 # 0.0.0.0 exposes the service to the network
kind must be a registered target. vm and docker_vm are planned and fail
before anything is built, so an application that names one cannot be deployed
yet. Run appctl targets to see the current list.
The manifest's pin and the role's pin must agree. source.package in
app.yaml is what the operator reviews; the role pins the same thing in its
own defaults/main.yml, because a role has to be able to say what it installs
without reading the manifest. Those are two files, and they drift.
appctl passes the manifest's pin into the play as appctl_source_package and
appctl_source_version. A role should refuse to run when they disagree:
- name: Refuse to install a package the manifest does not name
ansible.builtin.assert:
that:
- appctl_source_package == app_package_name
- appctl_source_version == app_package_version
fail_msg: >-
The manifest pins {{ appctl_source_package }}=={{ appctl_source_version }},
but this role installs {{ app_package_name }}=={{ app_package_version }}.
quiet: true
The application's name is not its package name. example-app is what the
guest calls the service; bottle is what gets installed to serve it. Keep the
two in separate variables, or the comparison above compares a name with
itself.
options belongs to the target. The shared schema does not read inside it, so
a misspelled key there is caught by the target, not by the schema.
8. Wire it up and deploy¶
Configuring the application in app.yaml does not deploy it. It is deployed
when its name is in some guest's app_roles — that is what appctl list shows
in its DEPLOYED column.
Add <name> to the guest's app_roles:
The guest itself is one block in inventory/hosts.yml under lxc, plus a group
that holds it. Name the group without hyphens, and do not let a group and a
host share a name — Ansible rejects both with a warning that is easy to miss
among a long inventory. ex and example-app is the pair to copy.
The guest block carries ansible_user, lxc_vmid and lxc_hostname. Leave the
address out: Proxmox leases one and common/lxc reads it back.
If inventory/ does not exist yet¶
inventory/ is gitignored. It names the operator's own Proxmox host, the
guest IDs and the addresses of machines only they can reach — no credential in
it, but not public either. A fresh clone has no inventory, and that is the
expected state, not a broken checkout.
The committed templates are in inventory.example/. Create the real inventory
from them:
mkdir -p inventory/group_vars
cp inventory.example/hosts.yml inventory/hosts.yml
cp -r inventory.example/group_vars/. inventory/group_vars/
Then edit the two files the quickstart names:
| File | What to change |
|---|---|
inventory/hosts.yml |
ansible_host and ansible_port for the proxmox group; the guest blocks under lxc |
inventory/group_vars/all/main.yml |
lxc_ssh_public_key — the operator's own public SSH key |
The template's hosts.yml already carries one example guest and one example
group, so the shape is visible before anything is edited.
Do not commit inventory/. It is in .gitignore; git add inventory/
would need -f, and the reason it is ignored is that it is the operator's own
topology. If a file from it ever does land in a commit, treat it as a leak:
the host names and guest IDs are not secret, but they are not public either.
The shape of inventory/hosts.yml¶
all:
children:
proxmox:
hosts:
pve: # the Proxmox node itself
ansible_host: ...
ansible_port: 6022
ansible_user: root
lxc:
hosts:
<guest>: # each guest the repository provisions
ansible_user: root
lxc_vmid: 121
lxc_hostname: <guest>
<group>: # a group per guest, holding its app_roles
hosts:
<guest>:
Two rules that cost an afternoon to learn:
- The group name and the host name must differ. Ansible rejects a group
that shares its name with a host, and the warning is easy to miss in a long
inventory.
ex/example-appis the pair to copy;mygrp/my-appis the other. - The group name carries no hyphen. Ansible rejects a hyphen in a group name outright.
appctl reads app_roles out of inventory/group_vars/ with a shallow scan,
so the group does not have to be listed in all.children for appctl list to
report the application as deployed — but Ansible needs it there for the play
to reach the host, so put it in both.
9. Secrets¶
Declare the names in app.yaml under the application, then:
appctl secrets init # writes .env (0600) and .env.example
appctl secrets set myapp_api_key '...' # or: appctl secrets set NAME --generate
appctl secrets check # exit 1 while any is unset
appctl install refuses to start while a required secret is absent, and names
the missing keys. Never put a secret in app.yaml, in group_vars, or in a
role default.
10. Prove it¶
appctl install myapp # first run: expect several changed
appctl install myapp # second run: MUST be changed=0
appctl list # DEPLOYED must show myapp, not "no guest"
The second run reaching changed=0 is the acceptance test. A role that
reports changed every run reinstalls the tree each time and tells you nothing
about its own state. See "Sentinels" below — a broken sentinel is the most
common reason this test fails.
Then inject drift and confirm one re-run repairs it:
rm -f /opt/myapp/app/venv/bin/myapp # deleted entrypoint
systemctl stop myapp && systemctl disable myapp
rm -rf /opt/myapp/state # mutable state must survive
appctl install myapp
For a shipped-tree application the entrypoint lives in the tree instead of the
venv — delete a file the controller still has and check that rsync puts it back.
Then run the play once more and require changed=0 again: drift repair that
leaves the next run reporting changes has fixed the symptom and not the
sentinel.
11. Sync the installation record¶
app.yaml and inventory/ are gitignored here, so the record of what you just
installed lives only on this machine until it is copied into the private
deployflow-state repository. This step is what makes it survive.
- It runs last, after step 10 passed. The record should describe an install that worked, not one that was still being debugged.
- It exits 0 when there was nothing to record. A non-zero exit means the copy or the push failed — read it, do not ignore it.
- It refuses
.env, key material and PEM blocks. If it refuses, remove the file; the guard is not the thing to work around.
Any later edit to app.yaml or inventory/ — a new guest, a changed pin, a
resized container — ends the same way:
./scripts/sync_state.sh --check # exit 1 if the record has drifted
./scripts/sync_state.sh # record it
An application that is not a Python wheel¶
Every step above assumes a wheel. An application that ships a JavaScript tree —
or any prebuilt artifact with no pyproject.toml — needs the role to do the
installing. apps/deepseek-harness/ is the worked example, and it is the second
kind of application this repository has.
appctl build will fail on it and that is expected. build exports a git
tree, optionally builds a webui bundle, then runs uv build --wheel. There is
no path to "copy this tree". The app.yaml entry still needs a build block,
because the schema requires one for kind: repo and appctl list prints it as
the SOURCE column — say in a comment that it is inert, so the next reader does
not go looking for a wheel. appctl install reads none of it: install runs the
play and nothing else.
The build stays an operator act on the controller. pnpm install and
pnpm run build are run by a person, not by the role, because a role that
rebuilds on every play cannot tell "no changes" from "rebuilt". The role's job is
to refuse to ship a tree whose install or build is older than its sources:
- name: Read the newest source file in the checkout
# git ls-files, not a find: lib/, dist/ and node_modules are gitignored, so
# this is exactly the set that can invalidate the build, with no build output
# counted as an edit.
ansible.builtin.shell:
cmd: git -C {{ dsh_local_source | quote }} ls-files -z | xargs -0 -r stat -c %Y -- | sort -n | tail -1
delegate_to: localhost
become: false
Fail with the exact command to run. Comparing ages is all that is available —
there is no build receipt to check against — so use stat's mtime, not
mtime_epoch (see Gotchas).
Shipping the tree is one ansible.posix.synchronize from the controller:
rsync_opts: >-
--links --times --owner --group --chown={{ app_user }}:{{ app_user }}
--exclude=.git --exclude=**/tests
delegate_to: localhost
Each flag is load-bearing and each failure is silent — see Gotchas for what
happens without them. Exclude tests and fixtures wholesale: they are the largest
block of source and nothing at run time resolves through them. Use delete:
true so the guest cannot keep a file the controller dropped.
The runtime goes beside the tree, not inside it. app_dir is the install
root and common/python's venv already lives in it; put the shipped tree under
it (app_dir/src) so the two never collide. app_state_dir stays where the
framework put it, and nothing in the shipped tree is written at run time.
A runtime the template does not ship goes in as a pinned, checksummed
release that the role verifies and unpacks — get_url with
checksum: "sha256:…", then unarchive. Both halves of that have a gotcha
below. Read the installed version back and assert it, rather than trusting a
stamp file beside it.
Guest shape cannot be set per application. app.yaml's guest: block
accepts only hostname, vmid, ip_optional and bind_host; anything else is
rejected as an unknown key. Cores, memory and rootfs come from app.yaml
defaults or from the guest's own group_vars, which is the only per-guest
override.
Never run two installs against one guest. They share apt and dpkg, and the
second fails on Could not get lock /var/lib/dpkg/lock-frontend, which names
nothing about what actually collided.
An application built from a source checkout¶
The second kind of shipped tree is one the controller builds rather than
installs: a fork, a development branch, or any tree with no published
artifact. apps/omniroute/ is the worked example — OmniRoute from main,
built on a build host and served from a guest.
This is the shape the rest of this section assumes, with three differences.
The version is a commit, not a tag. A published release pins
omniroute@3.8.51; a source build pins the sha the controller built from.
Never pin a branch name: main moves, and a play that builds main twice
reports two different artifacts. Read the sha out of the checkout and write it
into the build receipt, so the guest can be asked what it is actually running.
The build must produce something the guest can run, and the role must prove
it. A source tree usually has a dist/ that is gitignored, so a checkout
alone is not deployable — the build is part of the artifact. The receipt
therefore records the sha and the artifact it produced, and the role
refuses to ship a tree whose receipt is missing, stale, or does not match the
sha it was asked for.
The dependency tree is part of the artifact, not of the install. A published
npm release brings its own dependencies. A source build has to npm ci before
it can run at all, and the node_modules that results is what gets shipped.
That tree is large, symlink-based, and must be transferred with the flags
ansible.posix.synchronize needs (--links, --times) or it arrives broken
or is re-transferred forever.
The role's job is to refuse, not to build. Rebuilding on every play cannot tell "no changes" from "rebuilt", so the build is an operator act on the controller and the role's job is to refuse to ship a tree that is older than its sources. Fail with the exact command to run.
The receipt is what makes the second run reach changed=0. Without it the role
has nothing to compare and either reinstalls every time or trusts a mtime.
- name: Read the build receipt
ansible.builtin.stat:
path: "{{ om_build_receipt }}"
delegate_to: localhost
become: false
register: om_receipt_stat
- name: Refuse to ship a tree that was not built from the requested commit
ansible.builtin.fail:
msg: >-
{{ om_build_receipt }} is missing or does not name
{{ om_source_ref }}. Build it on the controller:
cd {{ om_local_source }} && git checkout {{ om_source_ref }} &&
npm ci && npm run build:release
when: >-
not om_receipt_stat.stat.exists or
(om_receipt_stat.stat.content | from_json).commit != om_source_sha
An application whose purpose is to build another application¶
The third kind of application is a toolchain: a guest that installs no
service, publishes no port, and exists so other applications can be compiled
on it. apps/builder/ is the worked example.
It is worth writing because the alternative is to describe that guest as one
of the two kinds that exist for shipped applications, and both are lies. A
package source claims something comes from a registry — nothing does. A
repo source claims there is a checkout to build — there is not one, and the
schema demands a build: block that would do nothing.
So there is a third source kind, and it says exactly what it is:
builder:
description: the guest other applications are built on
target:
kind: proxmox_lxc
options:
hostname: builder
vmid: 123
bind_host: 127.0.0.1
source:
kind: toolchain
secrets: []
The role must not depend on common/python. That dependency is what
asserts app_port is defined, and a toolchain serves nothing. A role with
no dependency still runs — play 2 loops over each host's app_roles and
include_roles it — so no playbook changes. Write dependencies: [] and say
why in a comment, because an empty list in a role that otherwise has one
looks like an oversight.
The postconditions are the whole role. A toolchain's product is a machine that can build something, so every assertion is about the machine rather than about an install:
| Assertion | What it catches |
|---|---|
uname -m is x86_64 |
a builder running under emulation, where esbuild's Go runtime faults |
| the runtime reports the pinned version | a runtime the tree was not built against |
| ≥ 24 GB of RAM | a guest that thrashes during the build |
| ≥ 40 GB free on the workspace | a build that fails on "No space left on device" |
| the package registry is reachable | a build that fails at npm ci |
Assert the minimums, not the current values. A toolchain that is too small
does not fail at install — it fails hours later, during a build, with an error
that names a memory limit rather than the guest. Measured: a 12 GB builder
ran a build to 11.5 GB RSS, stopped answering SSH entirely, and left
pct exec failing with "Cannot allocate memory - Failed to clone attached
process". The play that launched it was green.
Publish the address; do not name the guest. Guest hostnames do not resolve
from the controller — Proxmox's resolver is not the controller's — so a script
that needs to reach the guest has to read the lease from somewhere. The
lxc role already discovers it with pct exec <vmid> -- hostname -I; the
toolchain role writes it to a file under tmp/, which is gitignored because
the address is a DHCP lease and changes when the guest is rebuilt.
Put the user in the file, not in the script. A build script that reads
host and leaves the user out gets $USER from ssh, which is not root on
the controller, and the connection fails with "Too many authentication
failures" — an error that looks like a credential problem and is not one.
A toolchain's runtime is pinned in one place. If the build script also
installs the runtime, there are two copies of the version and they drift. The
role installs it, checksummed, and the build script asserts it is there and
fails with a named error. That is the difference between an environment that
is provisioned and one that is whatever apt-get returned that day.
Postconditions: non-negotiable¶
Every role must assert the result, not the exit code. These four caught real bugs that a green play hid:
| Assert | Guards against |
|---|---|
the CLI runs (myapp --version) |
a deleted entrypoint with the package intact |
the package imports (python -c "import myapp") |
a half-installed tree that still has a console script |
| the app's own data exists (a row, a file) | a running server with nothing to log in to |
ss -ltn shows the requested address |
the process listening somewhere you did not ask for |
Assert the authority the process reports, not the one you asked for. The strongest form of the last row: read the listener's own URL from its log, and fail unless it carries the address the role configured. Rebuilding the URL from a variable asserts what the role already believes and proves nothing about what is running. Do that read inside the guest, in one shell step, when the value is a credential — the launch token of an authenticated service is a live credential for the life of the process, and a task that interpolates it puts it in the controller's log.
Wait for the service before asserting on it. systemd returns from start
when it accepts the job, which is seconds before a composed application binds.
wait_for on the port, and on the state file, so the assertions that follow read
a running service. An empty ss reading during boot is indistinguishable from
address drift, and a drift check that cannot tell the difference restarts a
service that was starting correctly.
Do not use --force on an app's init command. A failed init leaves a
half-written state directory and refuses to run again; --force destroys the
database on top of that. Recovery means deleting the state directory.
Pass secrets through the environment, never argv. ps is world-readable
on the guest.
Sentinels: the four ways to break changed=0¶
Every one of these shipped green. All were found by running the play twice and
asking why the second run still reported changes. Read this section before
writing the checks in tasks/main.yml.
A sentinel that can never succeed is worse than no sentinel. It looks like a guard, it passes review, and it silently keeps a gate permanently open so the role reinstalls itself forever.
| Trap | Why it is always wrong |
|---|---|
'changed' in stdout |
matches "unchanged". Use == 'changed' after a trim. |
| an import of a name that does not exist | the class may live in a different module. Verify the import on the guest before trusting it. |
a glob consumed by next() twice |
next(g, None) and (next(g) / 'f') takes the only match on the first call and raises StopIteration on the second. Materialise with list(). |
a changed_when test that is true when nothing changed |
always compare the whole value, not a substring |
Greedy regexes and block scalars¶
Both of these cost real time and neither raises an error.
# WRONG: greedy, matches up to the LAST colon, so '127.0.0.1:18790' -> '18790'
| map('regex_replace', '^.*:', '')
# WRONG: a YAML block scalar is literal, so Jinja reads \d as an escaped
# backslash and the regex matches a literal backslash. Strips nothing.
| map('regex_replace', ':\\d+$', '')
# RIGHT: strip by the numeric port, then trim
| map('trim')
| map('regex_replace', ':\d+$', '')
A trailing newline survives unless you trim, and a trimmed value still fails
to equal an untrimmed expectation. Check both sides.
Gotchas, all measured on this host¶
- The framework must have no default for
app_nameorapp_port. A role'smetadependency has its defaults applied after the including role's own, so a default incommon/pythonsilently overrides the application's and the guest gets the wrong paths. It stayed hidden while there was one application, because its name happened to equal the default. Do not add one back. .j2files belong intemplates/, notfiles/.ansible.builtin.templateresolvessrcfromtemplates/; infiles/it fails with "Could not find or access". The.j2suffix is the tell.playbook_dirisplaybooks/, not the repo root. Uselxc_repo_rootfromgroup_vars/allfor controller paths. A role default will not do: it does not exist onlocalhost, which is wheredelegate_to: localhostputs the lookup.app_namemust be a valid path segment. It is interpolated intoapp_home,app_dirandapp_venvwithout sanitising.virtualenv_commandis a command line, not a path. Use"{{ app_venv }}/bin/python -m venv". Hardcoding/usr/bin/python3lets the interpreter and the venv drift apart silently.- The pip module takes
requirements:, nevername:for a lockfile path.nameparses its argument as a requirement and rejects a path. --no-depsis required alongside--require-hashes. With hashes present pip cannot resolve, so every package must already be named in the file.- Gate the install on the lockfile changing or the package being absent.
The pip module reports
changedevery time it runs, which is what stops a settled play from reinstalling 200 wheels on each run. - Playwright's cache is
chromium-1243, notchromium_headless_shell-*. A sentinel using the headless-shell name never matches and re-downloads 115 MB every run. Assert on a versioned directory, not on the cache root. playwright install-depstakes no--with-depsand reportschangedon every invocation. Gate it on a library it must install, e.g.dpkg-query -W -f=${Status} libnss3.- Pin
PLAYWRIGHT_BROWSERS_PATHto the account the service runs as, or the browser lands in root's cache where the service cannot see it. - A venv left by an older template is a trap. It installs wheels for the
interpreter it was built with and fails at the first compiled import.
common/pythonasserts the version and fails loudly; keep that check. unarchivedefaults to the controller, not the guest. Fetching a tarball withget_urland then unpacking it needsremote_src: true, or the module looks for the path on the controller and fails with "Could not find or access … on the Ansible Controller" while the file sits on the guest.remote_srcthen makes it refuse adestthat does not exist, so create the directory first — the two errors name the wrong thing.synchronizeskips symlinks and resets mtimes. Both are fatal for a JavaScript tree and neither is reported. Without--links, a pnpmnode_modulesarrives with its packages but an emptynode_modules/@deepseek-ai/, and every entry point failsERR_MODULE_NOT_FOUNDfor a package that is present. Without--times,synchronize's own--delay-updatesstages each file under a temporary name and renames it at the end of the run, which gives every file a fresh mtime — so the next run's quick check sees the whole tree as different and transfers all of it again, reportingchangedforever. The play never settles.stathas nomtime_epochon ansible-core 2.16. It returns float seconds inmtime. Asking for the removed key fails the conditional with "'dict object' has no attribute 'mtime_epoch'", which reads like a missing file rather than a missing field.- The stock Ubuntu template ships no
curl. A shell assertion that probes the service with it retries to its timeout against "command not found" and reports a broken service that is serving perfectly well. Install it in the role, or useansible.builtin.uri. - A runtime installed under a version directory is not on
PATH, and its own bin shims then fail. Everybin/*entry of such a runtime is#!/usr/bin/env node, so running{{ dsh_node_dir }}/bin/corepackdies withenv: 'node': No such file or directorybefore the tool starts. Invoke the interpreter by absolute path on the tool's JS entry instead. The same applies to anypackage.jsonbina guest should expose: a symlink to it runs whatevernodePATHfinds, which on a stock guest is nothing at all — write a wrapper that names the pinned interpreter,DSH_HOMEand the state the tool expects. - Read a version from the artifact that was built, not from a second copy of
it. A package manager version belongs in the project's own
packageManagerfield; a guest that hard-codes it can end up holding a tool the tree was never built with, and the mismatch surfaces at use, not at install. - A host with no fixed address cannot be asserted by name. Assert the address the process reports it bound, not a configured one, or the assertion tests the inventory rather than the service.
df --output=avail -B Mprints a unit suffix, and| inton55579Mis 0. A disk assertion that reads 0 MB free fails for the wrong reason, and the message says "too small" when the disk is full of room. Use-B1and divide, or strip the suffix.df --outputprints a header row above the value. The value is the last line of stdout, not the whole of it.- The
commandmodule never runs a shell.df … | tail -1arrives as two arguments named|andtailand fails with a non-zero exit. Pipe in Jinja, or make the command print one value and nothing else. - A regex inside a YAML string inside a Jinja expression needs four layers
of escaping, and it fails as
NoneType has no attribute grouprather than as a syntax error. When a value has to be parsed, make the command print a bare number:awk '/^MemTotal:/ {print $2}' /proc/meminfobeats aregex_searchongrep MemTotal /proc/meminfo. get_urlis only idempotent if the file stays. A task that downloads a tarball, unpacks it and then deletes the tarball re-downloads 31 MB on every run and reportschangedforever. Keep the download in a cache directory.- A
set_factwhose value is a plain integer string fails the play with "Conditional result (True) was derived from value of type 'str'". Pipe it through| boolor compare against a typed value.
Adding a guest, not an app¶
Different task. One block in inventory/hosts.yml, no address — Proxmox leases
one and common/lxc reads it back:
Plus a group that holds it, and a group_vars/<group>/main.yml that says what
the guest runs. Remember the group name and the host name must differ, and the
group name carries no hyphen.
inventory/ is gitignored — it holds the operator's own topology, not the
project's. If it does not exist, create it from inventory.example/ first:
mkdir -p inventory/group_vars
cp inventory.example/hosts.yml inventory/hosts.yml
cp -r inventory.example/group_vars/. inventory/group_vars/
Do not commit it. See "If inventory/ does not exist yet" above for the full
procedure.
Read docs/PLAN-proxmox-lxc-ansible.md before changing anything in
common/. Section 8 is a list of failures that passed review and only showed up
under test; several of them produce a green play and a broken service.