Two findings, both from reading the path rather than the code. THE "RECORD IT PROPERLY" BLOCK WAS CONFINED-SHAPED. It ran install.sh - which does not run on a privileged host, deliberately - and then invoked dejima-ci-upgrade bare, which only works because install.sh had just put it there. A privileged operator following it got exactly the error that started this: the tool reporting it could not find itself. There is now a block per tier. Confined keeps install.sh. Privileged gets the three install commands bootstrap uses, because bootstrap is the only thing that installs the tool on that tier and re-running it on a built machine also redoes runner setup - a real cost for a machine that is already serving. Those three duplicate bootstrap's lines and can drift from them, so a test pins both ends to the same destinations. A tool installed to the wrong path is a tool that is not there, reported three sections later as "not installed here", which is how this whole thread started. THE UPGRADE DOCS WERE NOT LINKED FROM THE README AT ALL. Only docs/README.md mentioned them, so an operator with an existing runner had no path from the front page to the flow. There is now an "Updating an existing runner" section with the command to check a version, the instruction to walk the notes in order, and the two things a runner operator needs before starting: run from a checkout, and recording is itself a guest-side change. The runbook and the docs index point at it, and so does the intro, where someone skimming will be. `bats test/` 136 passing, 1 new.
599 lines
29 KiB
Markdown
599 lines
29 KiB
Markdown
# Provisioning a runner VM on Proxmox
|
||
|
||
The node-side half: the VM, the firewall applied to it, and its snapshots. The
|
||
in-VM half is [docs/building-a-runner.md](../docs/building-a-runner.md), the
|
||
same on any hypervisor.
|
||
|
||
## The whole job
|
||
|
||
**Already have a runner?** This page builds one. To bring an existing VM
|
||
forward, start at [Updating](../README.md#updating-an-existing-runner) — the
|
||
upgrade notes name the rules to change rather than having you re-read the table
|
||
below.
|
||
|
||
Four steps, and the two pages interleave — follow this order rather than either
|
||
page top to bottom.
|
||
|
||
| | Do this | Where | Roughly |
|
||
| --- | --- | --- | --- |
|
||
| 1 | Turn the datacenter firewall on | §1 below | one setting |
|
||
| 2 | Create the VM | §2 below | one form |
|
||
| 3 | Install Debian | [guest §1](../docs/building-a-runner.md#1-install-debian) | the usual |
|
||
| 4 | **Build the rules, apply them, verify** | §3 below | **the substance of this page** |
|
||
| 5 | Finish the guest setup | [guest §2 onward](../docs/building-a-runner.md#2-addressing-leave-it-on-dhcp) | it sends you back to §4 twice, once per snapshot |
|
||
|
||
**Everything except step 4 is short.** Step 4 is ten firewall rules entered into
|
||
a form, three policy settings, and two verification passes. If you have done it
|
||
before, [the rule table](#build-the-group-once) is the whole of it.
|
||
|
||
**Two VMs if you are following
|
||
[the tiering](../README.md#two-tiers-and-you-must-pick-one)** — one privileged,
|
||
one for the confined runners. Every step here is identical for both; the tiers
|
||
differ only in a flag, and that flag is in the guest runbook.
|
||
|
||
**The rules go on after the OS is installed, not before.** The Debian installer
|
||
takes its address by DHCP whatever you pin it to afterwards, and a lease needs a
|
||
reply back in. Apply the group first, without the DHCP pair from §3, and the
|
||
installer has no network at all — which presents as a broken mirror or a dead
|
||
link rather than as a firewall you switched on ten minutes earlier. Ticking the
|
||
Firewall checkbox in §2 is safe on its own.
|
||
|
||
## What you are building
|
||
|
||
**A machine to hand to an attacker.**
|
||
|
||
The privileged runner gives job containers the Docker socket, which is
|
||
root-equivalent on their host. So the working assumption is not *if* but *when*:
|
||
a compromised job has root on this VM.
|
||
|
||
**The firewall goes on the node, not in the guest.** Root in the VM can flush
|
||
any rule set it can see. It cannot reach one applied to its own tap device.
|
||
Everything `dejima-ci-bootstrap` installs inside the VM is defence in depth —
|
||
real against a job that has not escaped its container, worth nothing against one
|
||
that has.
|
||
|
||
---
|
||
|
||
## 1. Check the datacenter firewall is on
|
||
|
||
**Do this first.** A per-VM firewall with the datacenter switch off is read
|
||
and enforced nowhere, and nothing about that is visible from the VM — it looks
|
||
exactly like a working setup.
|
||
|
||
**Datacenter → Firewall → Options → Firewall: `Yes`**
|
||
|
||
If you have to change it, check that nothing else on the node depended on
|
||
having no firewall. The datacenter default policy is `ACCEPT` in and out, so
|
||
turning it on does not by itself filter anything — but it is worth knowing
|
||
before you flip it rather than after.
|
||
|
||
---
|
||
|
||
## 2. Create the VM
|
||
|
||
**Create VM**, and the values that matter:
|
||
|
||
| Tab | Setting | Value |
|
||
| --- | --- | --- |
|
||
| General | Name | `oubliette`, or whatever you prefer |
|
||
| OS | ISO | Debian netinst, current stable |
|
||
| System | Qemu Agent | ✓ — and see the note below, the tick alone does nothing |
|
||
| Disks | Size | 64 GB is comfortable for image builds |
|
||
| CPU | Cores | 4 |
|
||
| Memory | | 8192 MB |
|
||
| Network | Bridge | your normal bridge |
|
||
| Network | **Firewall** | **✓ — this is the checkbox everything else depends on** |
|
||
|
||
The Firewall checkbox is easy to miss and easy to uncheck later without
|
||
noticing. If it is off, the per-VM rules do not apply to the interface and the
|
||
VM is simply on your LAN.
|
||
|
||
**Qemu Agent is two halves, and this tick is one of them.** It opens a
|
||
virtio-serial channel on the host side; answering on it takes the
|
||
`qemu-guest-agent` package inside the guest, which `dejima-ci-bootstrap`
|
||
installs and which tells you if this tick is missing. Leave it on: without the
|
||
channel the agent has nothing to attach to, the Summary pane shows no address,
|
||
`qm shutdown` falls back to hoping ACPI is acted on, and a snapshot of a
|
||
running VM stays crash-consistent.
|
||
|
||
**What a moving address does *not* break.** Not the firewall. Every rule in the
|
||
next section keys on a *destination*, except the SSH rule, which keys on *your*
|
||
management address — and Proxmox applies the whole set to this VM's tap device,
|
||
identified by VMID rather than by IP. Nothing anywhere names this VM by its own
|
||
address, so a rebuilt VM that comes up on a different one is still fully
|
||
firewalled.
|
||
|
||
What it breaks is you finding the machine: `ssh <you>@<vm-ip>`, and every later
|
||
visit. That is reason enough to pin it.
|
||
|
||
---
|
||
|
||
## Now install the OS, then come back
|
||
|
||
The VM exists and has no operating system. Go to
|
||
**[docs/building-a-runner.md §1](../docs/building-a-runner.md#1-install-debian)**,
|
||
install Debian, and return here for §3.
|
||
|
||
Nothing else from the guest runbook is needed yet. Coming back with a booted VM
|
||
is what makes the rest of this page checkable rather than assumed.
|
||
|
||
---
|
||
|
||
## 3. The firewall rules
|
||
|
||
Every runner VM gets the same ten rules, whichever tier it is, so build them
|
||
once as a **security group** and apply that group to each. Two VMs with two
|
||
hand-entered copies is two chances to get the ordering wrong and one of them to
|
||
drift out of step later.
|
||
|
||
### Both tiers get these, for different reasons
|
||
|
||
Apply them to the confined tier as well as the privileged one. The rules are
|
||
identical; what differs is the job they are doing.
|
||
|
||
On the **privileged** tier this *is* the boundary. Its jobs hold the Docker
|
||
socket and run in the host's network namespace, so root on that VM is the
|
||
working assumption, and anything installed inside it can be removed by whatever
|
||
holds root. These rules are on the hypervisor, applied to the tap device, and
|
||
the guest cannot reach them.
|
||
|
||
On the **confined** tier the primary control is inside the VM: the job network,
|
||
its allowlisting resolver, and the DOCKER-USER rules. Those are real against a
|
||
job precisely because a job cannot reach them, and they decide job egress by
|
||
name and by address. The node firewall sits behind that as defence in depth. It
|
||
changes nothing the allowlist has already decided; it covers the case the
|
||
allowlist cannot, which is a job that escapes its container and reaches root on
|
||
the host. At that point what is left is this, and it is doing exactly what it
|
||
does on the other tier.
|
||
|
||
So: primary in the guest and defence in depth on the node for confined, nothing
|
||
in the guest and everything on the node for privileged.
|
||
|
||
**One thing to weigh before applying them to a confined host.** Rules 5–9 drop
|
||
every private destination, including ones a *job* might legitimately want. If a
|
||
served repository declares an internal name in `.dejima/allowed-domains` — a
|
||
package mirror, an internal registry — the allowlist will resolve it and the
|
||
node firewall will drop the connection, which reads as the allowlist not
|
||
working. An internal NTP server is the same story. Give anything in that
|
||
position its own pinhole above the DROPs, or keep it off the allowlist so the
|
||
failure is at least honest about where it comes from.
|
||
|
||
Rule 10 has the same shape and one extra wrinkle: it also drops IPv6 egress to
|
||
the *internet*, not only to your network, because the family goes whole. In
|
||
practice that changes nothing for a confined job, since `dejima-ci-firewall`
|
||
rejects IPv6 on the job bridge for the same reason. It could matter to a
|
||
privileged job that fetches from a v6-only host. If you hit that, the honest fix
|
||
is a pinhole for that destination rather than dropping the rule, because
|
||
dropping it reopens the whole LAN.
|
||
|
||
And one value that has to agree: the resolver in rules 3–4 must be the same
|
||
address as `upstream` in `/etc/dejima-ci/config`, since that is where the
|
||
confined tier's dnsmasq forwards allowed queries. Point them at different
|
||
servers and every job fails to resolve while the host itself is fine.
|
||
|
||
### Build the group once
|
||
|
||
**Datacenter → Firewall → Security Group → Create**, named
|
||
`dejima-ci-hardener`. Then **Add** these to it, in this order:
|
||
|
||
| # | Direction | Action | Protocol | Dest. port | Source / Dest | Why |
|
||
| --- | --- | --- | --- | --- | --- | --- |
|
||
| 1 | in | ACCEPT | tcp | 22 | **Source:** your management IP | The only way in |
|
||
| 2 | out | ACCEPT | tcp | 443 | **Dest:** forge IP | Fetch jobs, report results |
|
||
| 3 | out | ACCEPT | udp | 53 | **Dest:** nameserver IP | The forge's name is internal |
|
||
| 4 | out | ACCEPT | tcp | 53 | **Dest:** nameserver IP | Same, over TCP |
|
||
| 5 | out | DROP | — | — | **Dest:** `10.0.0.0/8` | |
|
||
| 6 | out | DROP | — | — | **Dest:** `172.16.0.0/12` | |
|
||
| 7 | out | DROP | — | — | **Dest:** `192.168.0.0/16` | The three that matter |
|
||
| 8 | out | DROP | — | — | **Dest:** `169.254.0.0/16` | Link-local, incl. metadata services |
|
||
| 9 | out | DROP | — | — | **Dest:** `100.64.0.0/10` | Carrier-grade NAT, where a tailnet lives |
|
||
| 10 | out | DROP | — | — | **Dest:** `::/0` | IPv6, the whole family |
|
||
|
||
**Rule 9 is for a tailnet.** Tailscale numbers its hosts out of `100.64.0.0/10`,
|
||
which is none of the three RFC 1918 ranges, so without this rule a runner on
|
||
your tailnet reaches every machine you own anywhere — a wider network than the
|
||
one rules 5 to 7 were drawn around, not a narrower one. Add it whether or not
|
||
you run Tailscale today: it costs nothing on a VM with no overlay, and the day
|
||
someone installs one is not a day anyone revisits the firewall.
|
||
|
||
#### Rule 10, and why IPv6 is dropped whole
|
||
|
||
**IPv6 has no RFC 1918**, so there is no list of private ranges to mirror — the
|
||
family goes whole instead. Worth having even on a network that routes no IPv6,
|
||
because every Linux host answers on `fe80::` regardless. The argument is in
|
||
[the threat model](../docs/threat-model.md#the-lan-boundary-is-two-boundaries)
|
||
and again above `OUT DROP -dest ::/0` in
|
||
[firewall.fw.template](firewall.fw.template).
|
||
|
||
**`dejima-ci-firewall` blocks IPv6 too, one layer in, and it is not this.** Its
|
||
rules cover a confined job leaving its bridge. They are not in front of the VM's
|
||
own traffic, and on the privileged tier not in front of the jobs either, since
|
||
those run in the host's network namespace. Rule 10 covers root on the VM — the
|
||
privileged tier's ordinary case, not its worst one.
|
||
|
||
**Do it here, not with `disable_ipv6` in the guest**, which is a setting root on
|
||
a machine this page tells you to treat as already lost can put back.
|
||
|
||
**If the console rejects `::/0`**, enter four rules in rule 10's position
|
||
instead: `fe80::/10`, `fc00::/7`, `ff00::/8`, `2000::/3`.
|
||
|
||
|
||
#### Two more, if the VM gets its address by DHCP
|
||
|
||
Skip both if you pinned the address in the guest with `dejima-ci-static-ip`.
|
||
Add both if you did not, including when the address is reserved for you on the
|
||
router: reserved or not, the guest still speaks DHCP.
|
||
|
||
| # | Direction | Action | Protocol | Dest. port | Source / Dest | Why |
|
||
| --- | --- | --- | --- | --- | --- | --- |
|
||
| 1a | in | ACCEPT | udp | 68 | — | Let the lease reply back in |
|
||
| 4a | out | ACCEPT | udp | 67 | — | Let the lease request out |
|
||
|
||
Place `1a` with the other inbound rule and `4a` above the DROPs, as the
|
||
numbering suggests. Both name a port and no host, which makes them narrower
|
||
than an exception naming your DHCP server: they add no reachable host to the
|
||
list above.
|
||
|
||
**Both or neither.** Rule `4a` alone gets the request out and the VM still
|
||
never renews, because the reply arrives from the server's address to the VM's,
|
||
which is not the tuple the request created. Connection tracking is unlikely to
|
||
match that against `Input Policy: DROP`, so `1a` is what actually lets the
|
||
lease complete.
|
||
|
||
Do not count on rebinding to save you. At T2 the client stops unicasting and
|
||
broadcasts to `255.255.255.255`, which none of the DROPs match, so the request
|
||
does leave without rule `4a` — but the reply still needs `1a`. Half a working
|
||
path renews nothing.
|
||
|
||
This failure is slow and does not look like a firewall. Nothing breaks at
|
||
reboot or at first boot. The VM simply stops renewing, and somewhere up to a
|
||
full lease time later it drops off the network.
|
||
|
||
Rule `1a` lets anything on the segment send to the client port, which is a
|
||
spoofed-reply surface. It is close to free: DHCP on a LAN is trust-on-first-use
|
||
for every host on that segment whatever the firewall does, so permitting the
|
||
reply does not create that exposure. Not using DHCP is still narrower, and
|
||
`dejima-ci-static-ip` exists for it.
|
||
|
||
### Then apply it, per VM
|
||
|
||
A security group carries **rules and nothing else**. The policies are not part
|
||
of it, so each VM still needs its own:
|
||
|
||
**VM → Firewall → Options**
|
||
|
||
| | |
|
||
| --- | --- |
|
||
| Firewall | `Yes` |
|
||
| Input Policy | `DROP` |
|
||
| Output Policy | `ACCEPT` |
|
||
|
||
Inbound denies by default; outbound accepts by default and the LAN is denied
|
||
explicitly by the group. That asymmetry is deliberate: deny-by-default earns its
|
||
keep where the protected set is enumerable, which the LAN is and the internet is
|
||
not. The argument against inverting it is above `policy_out` in
|
||
[firewall.fw.template](firewall.fw.template).
|
||
|
||
Then **VM → Firewall → Add → Security Group**, and select
|
||
`dejima-ci-hardener`. That single entry is the whole rule set, in the order the
|
||
group defines, and it is the only firewall rule the VM needs.
|
||
|
||
Adding another runner later is that one step. The ordering below was got right
|
||
once, in one place, and cannot drift between hosts.
|
||
|
||
**Order matters within the group, and the console works against you here.**
|
||
|
||
Rules are evaluated top to bottom and the first match wins. Your forge and your
|
||
resolver are *inside* the ranges rules 5–8 drop, so if either pinhole ends up
|
||
below them it is dead and the runner cannot reach the forge or resolve anything.
|
||
|
||
**The console inserts each new rule at the TOP of the list, not the bottom.** So
|
||
entering rules 1 to 10 in the order they are written above leaves you with them
|
||
in reverse: the six DROPs on top, every pinhole beneath them, and a runner that
|
||
cannot reach its forge. Following the table without watching the list is enough
|
||
to produce exactly the failure this section is warning about.
|
||
|
||
Two ways through it. **Drag each rule to the bottom as you add it**, which keeps
|
||
the list matching the table at every step and is the easier one to check. Or
|
||
enter them in reverse, from 8 up to 1, and let the insertions stack into the
|
||
right order — quicker, and you are reading the table backwards while doing it,
|
||
which is its own way to get one wrong.
|
||
|
||
Either way, **read the finished list top to bottom and compare it against the
|
||
table** before you trust it. That is the only step here that catches the
|
||
mistake, because nothing else will: a dead pinhole looks exactly like a working
|
||
firewall until something tries to reach the forge.
|
||
|
||
Rules 2–4 are the entire remaining LAN exposure, and they are why "no LAN
|
||
access" is not literally true. Both destinations are pinned to one host and
|
||
one port.
|
||
|
||
Nothing follows rule 10. The public internet is reached by the default policy.
|
||
|
||
### Values that must agree
|
||
|
||
Three pairs here are entered in two places each, and each pair is silent when
|
||
the halves disagree. Read the real value off the machine rather than recalling
|
||
it — that is the whole of the advice, and every one of these is easy to get
|
||
wrong from memory.
|
||
|
||
| This | Must equal this | Read the truth from |
|
||
| --- | --- | --- |
|
||
| Rules 3–4's destination | The VM's **nameserver** | `grep nameserver /etc/resolv.conf` on the VM |
|
||
| Rule 2's destination | The forge's address | `getent hosts <forge-hostname>` |
|
||
| Rule 1's source | The host you SSH from | wherever you are sitting |
|
||
|
||
**Rules 3–4 want the nameserver, not the gateway.** They are the same address
|
||
on plenty of networks, which is exactly what makes this worth saying: using
|
||
the gateway works for some people and, for everyone else, blocks DNS while
|
||
leaving every other check passing. `ip route` gives you the gateway;
|
||
`/etc/resolv.conf` gives you the nameserver, and it is the second one that
|
||
belongs in these rules.
|
||
|
||
There is a fourth pair inside the VM, once `install.sh` has run: the
|
||
resolver's `listen-address` and the runner's `--dns=`. Same shape, same
|
||
silence. `install.sh` derives both from one answer for that reason.
|
||
|
||
### Check it, rather than assuming it
|
||
|
||
**Two passes.** The first reads configuration, the second tests behaviour, and
|
||
only the second is proof: a rule that is loaded and a rule that does what you
|
||
meant are different claims.
|
||
|
||
#### Pass one, on the node, with the VM running
|
||
|
||
Proxmox builds these chains around the tap device, so a stopped VM has none and
|
||
their absence would tell you nothing.
|
||
|
||
```sh
|
||
# what is actually loaded right now
|
||
sudo iptables -S | grep "tap<vmid>i0"
|
||
|
||
# the same, for the other address family. Rule 10 lands here and nowhere else
|
||
sudo ip6tables -S | grep "tap<vmid>i0"
|
||
|
||
# what Proxmox would generate from the current config
|
||
sudo pve-firewall compile | grep -B2 -A20 "tap<vmid>i0"
|
||
```
|
||
|
||
**Both tables, not just the first.** An IPv6 rule compiles into `ip6tables` and
|
||
is absent from `iptables -S`, which shows rule 10 missing whether it is there or
|
||
not.
|
||
|
||
**What you are looking for.** Chains named `tap<vmid>i0-IN` and `-OUT`, holding
|
||
your rules in the order you added them. The v4 `-OUT` chain has the three
|
||
pinhole ACCEPTs, then the five LAN DROPs, then whatever Proxmox appends for the
|
||
policy. The v6 `-OUT` chain has rule 10 and no pinholes, since every pinhole
|
||
names an IPv4 address.
|
||
|
||
| What you see | What it means |
|
||
| --- | --- |
|
||
| No `tap<vmid>i0` chains at all | The firewall is not applied to this VM. The NIC's Firewall checkbox, the VM's Firewall option, or the datacenter switch — check all three, in that order |
|
||
| Chains exist but hold none of your rules | The rules are on a different VM id, or were added at datacenter or node level rather than to this VM |
|
||
| A LAN DROP above a pinhole ACCEPT | The ordering trap. The runner will not reach the forge or resolve anything; drag the pinholes above the DROPs |
|
||
| Rules present and correct, DNS still failing | The address in rules 3–4 is not the VM's nameserver. The gateway is the usual wrong answer — see "Values that must agree" |
|
||
| v4 chain right, `ip6tables -S` empty | Rule 10 was not entered, or the console rejected `::/0` and the error was missed. Nothing about the v4 half will look wrong |
|
||
|
||
`pve-firewall status` reports the datacenter switch, not these rules.
|
||
|
||
#### Pass two, from inside the VM
|
||
|
||
Three must fail and two must succeed. That is what makes the set diagnostic
|
||
rather than reassuring.
|
||
|
||
Nothing needs installing: `bash` opens TCP connections itself, `getent` is part
|
||
of libc, and `ping` ships with Debian — which matters on a machine you have not
|
||
finished setting up yet.
|
||
|
||
```sh
|
||
try() { timeout 5 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null \
|
||
&& echo " CONNECTED $1:$2" || echo " blocked $1:$2"; }
|
||
|
||
gw=$(ip -4 route show default | awk '{print $3; exit}')
|
||
dev=$(ip -4 route show default | awk '{for (i=1;i<NF;i++) if ($i=="dev") print $(i+1); exit}')
|
||
|
||
try "$gw" 443 # must be blocked - your own first hop
|
||
try <forge-ip> 443 # must connect
|
||
try deb.debian.org 80 # must connect - the public internet
|
||
getent hosts deb.debian.org && echo " resolved" || echo " DNS FAILED"
|
||
|
||
# must find nobody but this VM - rule 10
|
||
sudo ping -6 -n -c 2 -W 1 -I "$dev" ff02::1
|
||
```
|
||
|
||
**The last one asks the whole LAN who is there, over IPv6.** Any reply from an
|
||
address that is not this VM's own means rule 10 is not in force, and that the
|
||
segment is reachable over a family none of the checks above it touch.
|
||
|
||
**This one is a by-hand check and stays one.** Everything else this repository
|
||
verifies, it verifies from inside a job, in `test/ci-runner.bats` — and a job is
|
||
the wrong vantage point for this question, because rule 10 is about the VM's own
|
||
reach and a confined job's traffic is stopped by `dejima-ci-firewall` before it
|
||
ever gets to the tap. The sister project checks the equivalent automatically
|
||
because it has an in-VM audit to hang it on; this one does not, so run it when
|
||
you build the group and after any change to it.
|
||
|
||
**Both targets are chosen so that naming them writes nothing down.** The
|
||
gateway is already in the routing table and `ff02::1` is the same on every
|
||
network there has ever been, so neither describes your topology to anyone
|
||
reading a machine this page tells you to treat as already lost. Both are also
|
||
guaranteed to answer when they are reachable, which an arbitrary host is not.
|
||
|
||
Read them together, because the pattern says which thing is wrong:
|
||
|
||
- **First one connects** → the firewall is not in force at all. Check the
|
||
Network device's Firewall checkbox and the datacenter switch.
|
||
- **Second or third fails while the fourth works** → a pinhole is below the
|
||
LAN DROPs. Check the rule order.
|
||
- **Only the last one finds neighbours** → the v4 half is right and rule 10 is
|
||
missing. This is the failure that looks like success from every other angle,
|
||
which is why it has its own probe.
|
||
- **Everything fails** → the VM has no working route or DNS at all, which is a
|
||
networking problem rather than a firewall one. Note that this also makes the
|
||
first probe's "blocked" meaningless: silence proves nothing when nothing is
|
||
getting through, so re-read it only once the other three pass.
|
||
|
||
---
|
||
|
||
## The node side is done. Go back to the guest.
|
||
|
||
Everything above is what a hypervisor does and a guest cannot. The rest is
|
||
inside the VM: addressing, packages, the allowlist, the runners.
|
||
|
||
**Continue at [docs/building-a-runner.md §2](../docs/building-a-runner.md#2-addressing-leave-it-on-dhcp).**
|
||
|
||
That runbook sends you back here twice, to §4 below, once for each snapshot. It
|
||
says where each falls; this page says how to take them.
|
||
|
||
---
|
||
|
||
## 4. Snapshots and rollback
|
||
|
||
Three moments, and [docs/building-a-runner.md](../docs/building-a-runner.md)
|
||
says when each falls. The mechanics are the same for all of them: **shut the VM
|
||
down**, then **VM → Snapshots → Take Snapshot**, with *Include RAM*
|
||
**unticked**, then start it again.
|
||
|
||
| Name | When | What it is for |
|
||
| --- | --- | --- |
|
||
| `preflight` | after the OS is installed and its network verified, before anything is run on it | a known base to return to while iterating |
|
||
| `clean` | after the runner registers and the VM has been restarted once, before its first job | the state the rollback cadence returns to |
|
||
|
||
**Snapshot with it shut down, unless the guest agent is answering.** Proxmox
|
||
will snapshot a running VM, and without the agent what you get is
|
||
crash-consistent: the disk as it was at an instant, mid-writes included,
|
||
recovered by journal replay on the next boot. That is what pulling the plug
|
||
gives you, and it is a poor thing to restore unattended every week
|
||
indefinitely.
|
||
|
||
`qemu-guest-agent` removes the question — Proxmox asks it to freeze the guest's
|
||
filesystems for the duration, so a live snapshot is consistent.
|
||
`dejima-ci-bootstrap` installs it, and §2's Qemu Agent tick is what gives it a
|
||
channel to answer on. With both, snapshot the VM where it stands. With either
|
||
missing, shut it down first.
|
||
|
||
**And `clean` has an order it depends on**, which is easy to get wrong because
|
||
nothing complains at the time. It must be taken of a state that has already
|
||
booted successfully at least once *since the last change to the machine's
|
||
network configuration*. Rolling back is a cold boot - the disk is restored, the
|
||
VM is started - so every rollback re-applies whatever is in
|
||
`/etc/network/interfaces` and re-runs every unit. If that configuration is
|
||
wrong, the rollback is when it takes effect, and every rollback after it does
|
||
the same thing. Restart once and check the machine comes back before taking
|
||
`clean`, and the cadence cannot surprise you later.
|
||
|
||
**Leave *Include RAM* unticked.** A snapshot with RAM restores the guest's
|
||
clock along with everything else, so a VM rolled back to last month believes it
|
||
is last month: TLS verification fails, apt rejects Release files as not yet
|
||
valid, and every timestamp a job writes is wrong. Nothing in this VM's memory
|
||
is worth resuming, and a rollback without RAM is smaller and faster besides.
|
||
|
||
Roll back from the same tab whenever you want it clean again. The snapshot
|
||
survives being rolled back to, so `clean` stays usable indefinitely —
|
||
`qm listsnapshot <vmid>` on the node shows it still there afterwards.
|
||
|
||
Two triggers:
|
||
|
||
- **After anything suspicious.** Not optional, and the reason to keep `clean`
|
||
current enough that rolling back costs you nothing you mind losing.
|
||
- **On a cadence.** This is what bounds how long a foothold can live *without
|
||
your having noticed it* — which is the case the first trigger cannot cover,
|
||
and the reason the rest of this section exists.
|
||
|
||
### Automate the cadence
|
||
|
||
The reactive trigger depends on you noticing. The scheduled one does not, which
|
||
makes it the more valuable of the two — and the one that quietly stops
|
||
happening after the third week of remembering to do it by hand. Automate it now
|
||
or accept that it will not happen.
|
||
|
||
Proxmox schedules backups, not rollbacks, so there is no checkbox for this. It
|
||
is a cron entry on **the node** — which is also the only place it can live.
|
||
Nothing inside the VM can be relied on to revert the VM; that is the same
|
||
reason the firewall is on the node.
|
||
|
||
Create `/etc/cron.d/dejima-ci-rollback` on the node, substituting your VMID:
|
||
|
||
```
|
||
# Roll the runner VM back to its clean snapshot. Sundays, 04:00.
|
||
SHELL=/bin/sh
|
||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||
0 4 * * 0 root qm shutdown <vmid> --timeout 180 --forceStop 1 && qm rollback <vmid> clean && qm start <vmid>
|
||
```
|
||
|
||
Three things in that line are deliberate:
|
||
|
||
- **`qm shutdown` first, not `qm rollback` alone.** A snapshot without RAM
|
||
cannot be rolled back into a running VM — Proxmox will not swap the disk out
|
||
from under a live QEMU — and after the rollback the VM is left stopped, so
|
||
the `qm start` is required rather than tidy. `--forceStop 1` pulls the plug
|
||
if the guest has not gone down within the timeout, which is the correct
|
||
outcome for a machine whose state you are about to discard anyway.
|
||
- **`&&`, not `;`.** If the rollback fails, the VM stays off. That is a CI
|
||
outage, and it is the right one: the alternative is a VM that keeps taking
|
||
untrusted-code jobs from a state you had decided to throw away. Fail closed
|
||
and go look.
|
||
- **The `PATH` line.** `qm` lives in `/usr/sbin`, which is not on cron's
|
||
default path. Without it the job fails every week with `qm: not found`.
|
||
|
||
**Pick an hour when nothing is running.** A rollback mid-job kills the job,
|
||
which shows up as a failed run and is re-runnable — annoying, not harmful, but
|
||
there is no reason to schedule it into the working day.
|
||
|
||
### Check that it is actually running
|
||
|
||
Belief that this is happening is worth nothing; the whole point is that it
|
||
happens while you are not watching.
|
||
|
||
- The node's **Tasks** pane at the bottom of the web console lists a
|
||
`qmrollback` entry after each run. That is the check, and it needs no shell.
|
||
- `qm listsnapshot <vmid>` still shows `clean` — rollback does not consume it.
|
||
- If a run was interrupted the VM can be left locked, and every subsequent run
|
||
fails on the lock. `qm unlock <vmid>` clears it.
|
||
- A rollback failing with the VM still running means `qm shutdown` returned
|
||
before the guest was down. Put a `&& sleep 30` after it.
|
||
|
||
**Do not do this with a hookscript.** `qm set --hookscript` looks like the
|
||
right tool and is a trap: the pre-start and post-stop phases already hold the
|
||
VM's configuration lock, so a rollback from inside one deadlocks against
|
||
itself and times out. Cron, outside the VM's lifecycle, is the way.
|
||
|
||
### Refresh the baseline every month or two
|
||
|
||
A rollback reverts *everything* since the snapshot, including security updates.
|
||
`unattended-upgrades` catches up within a day of each boot, so the VM does not
|
||
stay unpatched — but the baseline it starts from is a week further behind every
|
||
week, and each rollback reopens whatever has been fixed since, until the timer
|
||
next runs.
|
||
|
||
So periodically: let it roll back, let it patch, reboot, confirm the runner is
|
||
*Online*, then **VM → Snapshots**, remove `clean` and take a fresh one under
|
||
the same name. That is also the moment to re-run `dejima-ci-bootstrap` if this
|
||
repository has moved on. (If a window with no snapshot at all bothers you, name
|
||
them by date instead and update the cron line — one more thing to keep in step,
|
||
which is why it is not the default suggestion here.)
|
||
|
||
Re-running `dejima-ci-bootstrap` after any rollback is safe — it is idempotent
|
||
— but should not be necessary, since the snapshot is taken after it ran. On a
|
||
`ci`-role host the allowlist is the same story from the other direction: it
|
||
reverts too, and `dejima-ci-allowlist.timer` pulls it current again on its own.
|
||
|
||
Whatever else reverts is cheap: Docker images have to be pulled again, so the
|
||
first job after a rollback is slower than the rest.
|
||
|
||
|
||
## Why there is no script for this page
|
||
|
||
A script that runs `qm create` and writes into `/etc/pve/firewall/` is exactly
|
||
the kind of artifact people paste into a root shell without reading, and the
|
||
blast radius is the whole node — including every VM on it that has nothing to do
|
||
with CI. The steps above are short enough to follow and specific enough to
|
||
check, and you can see what you are doing while you do it.
|
||
|
||
The in-VM half **is** a script:
|
||
[`bin/dejima-ci-bootstrap`](../bin/dejima-ci-bootstrap). Running that blind
|
||
costs you a VM built to be lost, which is the difference.
|
||
|
||
One step here is not console work, because it cannot be: Proxmox schedules
|
||
backups but not rollbacks, so the cadence in §4 is a three-command cron entry,
|
||
printed in full and explained part by part. |