Debian 13 runtime with restic 0.18.1 pinned outside the OS; restic assumptions re-proven; felhom-samba 1.2.0 (R-1105, R-972)
gates / gates (push) Successful in 49s

This commit is contained in:
2026-10-16 20:33:57 +02:00
parent 29a3ce8cf2
commit fbcc3cf042
47 changed files with 706 additions and 48 deletions
+28
View File
@@ -1,3 +1,31 @@
## v0.323.0 — Debian 13 with restic pinned outside the OS, re-proven; felhom-samba 1.2.0 on Alpine 3.24.2 (2026-10-16)
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task:
„the controller on Debian 13 with a pinned restic, re-proven; the Samba image rebuilt" (felhom.eu
`REPORT-debian13-restic-2026-10-16.md`).
- **R-1105 — restic is pinned by itself: the official 0.18.1 release binary, no longer Debian's package.** A new
`restic` build stage downloads `restic_0.18.1_linux_<arch>.bz2`, checks restic's `SHA256SUMS` signature with the
vendored release key (`third_party/restic/`, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no
keyserver at build time), the published sum, and the sum pinned in the Dockerfile; any mismatch fails the build. The
runtime copies only that binary. `TestR1105_ResticIsPinnedOutsideTheOS`, red-proved by four single mutations.
- **Every restic assumption re-proven on 0.18.1** against real outputs of both binaries (`internal/backup/testdata/
restic/`): the failure classes (wrong password, no repository, refused port, refused key, unreachable host, dead
rclone ssh, both lock forms, a damaged pack), the init race, the snapshot/size/progress JSON — all unchanged.
**One moved:** since restic 0.17 a `--keep-monthly` window with unused slots also keeps each group's OLDEST snapshot,
so the clean-up plan removes a SUBSET of what 0.14.0 removed (41-day history: 60 vs 62; 230-day: identical). Only
ever more kept, and old — the fake-snapshot guard's verdict holds. `TestR1105_*` (4), red-proved by six mutations.
Comment corrected: a plain `restore` DOES write a lock on both versions; the nightly proof's `--no-lock` is what
keeps it write-free.
- **Runtime `debian:13.7-slim`**, pinned by digest; the Docker apt repo's codename now comes from `/etc/os-release`
(it was hard-coded `bookworm`).
- **Removed packages the binary never runs:** `postgresql-client`, `default-mysql-client` (every dump and import runs
inside the app's database container), `sqlite3`, `fdisk`, `e2fsprogs`, `parted` (disk work is the agent's since
v0.37.0), `sshpass` (its ssh-copy-id install was replaced by the hub's key registrar in v0.289.0).
- **R-972 — felhom-samba 1.2.0:** Alpine 3.21 → 3.24.2 (digest-pinned), same packages (Samba 4.23.8). `SambaImage`
bumped. `build-samba-image.sh` now refuses a tag already in the registry, a registry it cannot ask, and a dirty or
unpushed tree — like `build.sh`; `scripts/test_samba_build_push_once.sh` (6 cases, 4 fail on the old script).
## v0.322.0 — the hub key opens no route on the box; Go 1.27.2; two Data tab leftovers; the install form's dead branches (2026-10-16)
**MinAgent: 0.158.1** — the floor's, carried. Nothing here calls the agent. No golden (`09` §3 decision 250). Task:
+54 -35
View File
@@ -40,54 +40,73 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-o /build/felhom-controller \
./cmd/controller/
# --- Runtime stage ---
# The runtime stays on Debian 12 (its current point release, 12.13, with every fixed package) ON PURPOSE: Debian 13
# ships restic 0.18.0, and the off-site engine's measured behaviour is restic 0.14.0's (a missing source path is a
# warning, the --json progress shape, the forget day-buckets, restore without a lock — offbox_*.go). Moving to
# Debian 13 is its own change with that re-proof (R-1105). The binary itself is built on Go 1.27.2 above.
FROM debian:12.13-slim@sha256:67b30a61dc87758f0caf819646104f29ecbda97d920aaf5edc834128ac8493d3
# --- restic: the official release binary, pinned by version AND checksum (R-1105, v0.323.0) ---
# Never again „whatever the OS ships": Debian 12 → 13 would have swapped restic 0.14.0 → 0.18.0 under the off-site code
# without anyone choosing it. The version below is a decision; moving it is a re-proof (07-backup-architecture.md §6.8).
# Three checks, each fails the build: restic's SHA256SUMS carries a good signature from restic's release key (vendored
# in third_party/restic/, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no keyserver at build
# time); the download matches the published sum; and it matches the sum pinned HERE, so even a re-signed release
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
ARG TARGETARCH
ARG RESTIC_VERSION=0.18.1
ARG RESTIC_SHA256_AMD64=680838f19d67151adba227e1570cdd8af12c19cf1735783ed1ba928bc41f363d
ARG RESTIC_SHA256_ARM64=87f53fddde38764095e9c058a3b31834052c37e5826d2acf34e18923c006bd45
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
RUN set -eu; \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
case "${TARGETARCH}" in \
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
esac; \
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
cd /tmp; \
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
echo "${want} ${f}" | sha256sum -c -; \
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
# Install runtime dependencies:
# - docker-cli: for "docker compose" commands
# - ca-certificates: for HTTPS (healthchecks pings, git)
# - postgresql-client: for pg_dump
# - default-mysql-client: for mysqldump
# - sqlite3: for SQLite backup
# - git: for stack sync from Gitea
# - curl: for health pings and debugging
# - util-linux: lsblk, blkid, mount (storage init)
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
# - rsync: for data migration between storage paths
# - parted: partprobe (partition table re-read after sfdisk)
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
# for the SLICE-2 offsite apply-bridge key install
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
# R-972: `apt-get upgrade` first — the Debian security fixes newer than the base image's point release (gnutls, pcre2,
# libcap2 were fixable on 12.13 on 2026-10-16). Within Debian 12 only, so restic stays 0.14.0.
# --- Runtime stage ---
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
# - docker-cli + compose plugin: every stack operation (below)
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
# - curl: the image's HEALTHCHECK, and the docker repo key below
# - gnupg: dearmors docker's repo key below
# - git: catalog sync
# - util-linux: findmnt (mount reads)
# - rsync: data migration between storage paths
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
# fed was replaced by the hub's key registrar in v0.289.0). Fewer packages, fewer known holes.
RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
git \
postgresql-client \
default-mysql-client \
sqlite3 \
util-linux \
fdisk \
e2fsprogs \
rsync \
parted \
restic \
openssh-client \
sshpass \
&& rm -rf /var/lib/apt/lists/*
# Install docker-cli (without daemon)
COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic
# Install docker-cli (without daemon). The repo's codename comes from the image's own /etc/os-release, never a
# hard-coded name (a hard-coded `bookworm` would have kept Debian 12's packages on a Debian 13 base).
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
&& . /etc/os-release \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
@@ -0,0 +1,86 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
raw, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
src := string(raw)
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
joined := strings.ReplaceAll(src, "\\\n", " ")
var stages []string
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
stages = append(stages, "FROM "+part)
}
if len(stages) < 3 {
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
}
var fetch string
for _, s := range stages {
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
fetch = s
}
}
if fetch == "" {
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
}
runtime := stages[len(stages)-1]
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
}
for _, arch := range []string{"AMD64", "ARM64"} {
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
t.Errorf("no pinned sha256 for %s", arch)
}
}
for _, want := range []string{
"COPY third_party/restic/restic-release-key.asc",
"gpgv --keyring", // SHA256SUMS is restic's, signed
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
} {
if !strings.Contains(fetch, want) {
t.Errorf("the restic stage no longer does %q", want)
}
}
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
t.Errorf("the vendored release key is missing: %v", err)
}
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
t.Error("the runtime does not take restic from the pinned stage")
}
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
for _, inst := range aptRe.FindAllString(runtime, -1) {
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
}
}
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
if !strings.Contains(from[1], "@sha256:") {
t.Errorf("base image %s is not pinned by digest", from[1])
}
}
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
}
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
}
}
+3 -1
View File
@@ -22,7 +22,9 @@
# is a Finder Settings -> Sidebar toggle). Treat sidebar discovery as an OPEN
# question, not a shipped feature.
# Evidence: felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
# 1.2.0 (R-972): Alpine 3.24.2, pinned by digest — the same base as the hub. 3.21 carried 48 fixable high findings
# (audits/security-review-2026-10-12/scanners/). Same packages; scanner before → after in the controller's v0.323.0 CHANGELOG.
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# samba = smbd + nmbd + smbpasswd/testparm (meta-package proven installable in the spike);
# wsdd = WS-Discovery daemon; tini = a proper PID1 to reap nmbd/wsdd/avahi and forward signals;
+2 -2
View File
@@ -12,8 +12,8 @@ import (
// TierOffsite capture set (recovery unit + MANDATORY userdata only) into the extra absolute paths
// appended to the app's restic snapshot, plus the Hungarian customer warnings for LOUD capture gaps.
//
// SP-3.4 is law here: restic 0.14.0 does NOT error on a missing source path — it skips with a warning,
// exits 0, and silently writes a partial snapshot. So a skipped/missing MANDATORY path is detected in
// SP-3.4 is law here: restic does NOT error on a missing source path — it skips with a warning, exits 0,
// and silently writes a partial snapshot (0.14.0, and re-measured on 0.18.1 for R-1105, 2026-10-16). So a skipped/missing MANDATORY path is detected in
// THIS function (the structural-guard Skipped list + an os.Stat filter) and surfaced in BOTH the
// English log and the Hungarian LastWarning. A restic exit code proves nothing about a missing path.
@@ -63,7 +63,8 @@ type OffboxProgress struct {
// message_types (status, summary, error, verbose_status); anything that is not "status" is ignored
// here rather than treated as garbage, because restic adds new types between versions and an unknown
// type must never break the run. Schema captured from
// restic 0.14.0 (the version in the controller image) via a live `backup --dry-run --json`:
// restic 0.14.0 via a live `backup --dry-run --json` — and the same field names on 0.18.1, the version the image pins
// since v0.323.0 (R-1105; testdata/restic/):
//
// {"message_type":"status","percent_done":0,"total_files":1,"total_bytes":112}
// {"message_type":"status","percent_done":0.558,"total_files":173,"files_done":87,
+6 -6
View File
@@ -51,9 +51,10 @@ import (
// to write. The spike established what "read-only" actually costs here, by observation rather than by
// reading the code:
//
// - `restic restore` in 0.14.0 takes NO repository lock (measured on demo-hp with a lock sampler
// that was positively controlled first: it saw a lock appear and vanish across a real `check`, and
// zero across two restores);
// - `restic restore` WITHOUT `--no-lock` DOES write a lock — corrected 2026-10-16 (R-1105): against a
// read-only repository both 0.14.0 and 0.18.1 fail on `Save(<lock/…>)`, and both restore with
// `--no-lock`. (The 2026-08 sampler on demo-hp saw zero locks across two restores — a short-lived
// lock between samples; the read-only repository cannot miss it.) The flag below is what does it;
// - `restic check` DOES take one — so the comment claiming the check never writes is wrong (R-407);
// - the PRODUCT's restore path writes anyway, because `unlockStale` runs `restic unlock` — a delete
// verb against `locks/` — before every restore (`offbox_restore.go`).
@@ -306,9 +307,8 @@ func (m *Manager) nextProofTarget(ctx context.Context) (stack, id, unitPath stri
// repository.
//
// Three differences from `RestoreOffboxScratch`, each deliberate and each named:
// - `--no-lock`, so restic does not create a lock file (it does not for `restore` in 0.14.0 anyway
// — measured — but the flag makes that a guarantee rather than an observed behaviour of one
// version);
// - `--no-lock`, so restic does not create a lock file (a plain `restore` DOES, on 0.14.0 and 0.18.1 —
// measured against a read-only repository, R-1105; with the flag both versions restore from it);
// - no `unlockStale`, so no delete verb is issued against `locks/`;
// - `m.runner()` rather than `resticStep`, so the `unlock --remove-all` escalation is unreachable
// rather than merely unlikely.
+5 -2
View File
@@ -82,7 +82,8 @@ var retentionPolicy = []string{"--group-by", "host,tags",
"--keep-daily", strconv.Itoa(keepDaily), "--keep-weekly", strconv.Itoa(keepWeekly), "--keep-monthly", strconv.Itoa(keepMonthly)}
// calendarDaysBefore: how many calendar days s lies before now, both read in s's own zone — the zone
// restic 0.14.0 buckets a snapshot's day in (the offset stored with the snapshot). Edge, recorded: in the
// restic buckets a snapshot's day in (the offset stored with the snapshot; 0.14.0, and 0.18.1 gives the same plan on a
// 502-snapshot, 230-day set made in UTC and Budapest time — R-1105). Edge, recorded: in the
// hour after local midnight on a DST change, a box whose snapshots carry two different offsets can read one
// day short; the guard then REFUSES (the safe direction) and the next week's window passes.
func calendarDaysBefore(s, now time.Time) int {
@@ -182,7 +183,9 @@ func (m *Manager) planRemovals(ctx context.Context, base, env []string) ([]guard
if err != nil {
return nil, fmt.Errorf("forget --dry-run: %w: %s", err, truncate(out))
}
// restic 0.14.0 prints the JSON array on stdout; the runner may combine stderr — take the array.
// restic prints the JSON array on stdout; the runner may combine stderr — take the array. (R-1105: on 0.18.1 the
// plan can be SMALLER than 0.14.0's — while a --keep-monthly window has unused slots it also keeps each group's
// oldest snapshot. Only ever more kept; pinned by TestR1105_TheForgetPlanOnlyEverKeepsMore.)
js := string(out)
if i := strings.Index(js, "["); i > 0 {
js = js[i:]
@@ -0,0 +1,252 @@
package backup
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// R-1105: the off-site code was measured against restic 0.14.0; v0.323.0 ships 0.18.1. The fixtures in
// testdata/restic/<version>/ are REAL outputs of both official binaries, captured 2026-10-16 on one shared scratch
// repository (made by 0.14.0, so both versions read the same snapshot set; paths rewritten to /data/x). Each
// assumption the code makes about restic's output is asserted for BOTH versions through the code's own functions —
// so a future restic that rewords one of them fails here, by name, instead of moving a failure to `unknown` on a
// customer's box (07-backup-architecture.md §6.7, §6.8).
var r1105Versions = []string{"0.14.0", "0.18.1"}
func r1105Fixture(t *testing.T, version, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "restic", version, name))
if err != nil {
t.Fatal(err)
}
return b
}
// fixtureRunner answers each restic subcommand with a captured output; err != nil when restic exited non-zero.
func r1105Runner(t *testing.T, version string, answers map[string]string) offboxRunner {
return func(_ context.Context, _ []string, args ...string) ([]byte, error) {
joined := " " + strings.Join(args, " ") + " "
for _, key := range sortedKeys(answers) {
if strings.Contains(joined, " "+key+" ") {
name := answers[key]
var err error
if strings.HasPrefix(name, "!") { // restic exited non-zero for this one
name, err = name[1:], errors.New("exit status 1")
}
return r1105Fixture(t, version, name), err
}
}
t.Fatalf("restic %s: no fixture for %q", version, joined)
return nil, nil
}
}
func sortedKeys(m map[string]string) []string {
k := make([]string, 0, len(m))
for s := range m {
k = append(k, s)
}
sort.Slice(k, func(i, j int) bool { return len(k[i]) > len(k[j]) }) // the longest (most specific) match first
return k
}
// The probe and the failure classifier: each captured failure lands in the class it has always landed in.
func TestR1105_EachResticFailureKeepsItsClass(t *testing.T) {
cases := []struct {
fixture string
probe string // classifyResticProbe on `cat config`
runClass OffsiteFailureClass // ClassifyOffsiteFailure on the error ensureOffboxRepo returns ("" = not reached)
}{
{"wrong-password.txt", "orphaned", ""},
{"no-repo.txt", "norepo", ""},
{"sftp-refused-port.txt", "other", OffsiteFailTransport},
{"sftp-refused-key.txt", "other", OffsiteFailTransport},
{"sftp-unreachable.txt", "other", OffsiteFailTransport},
{"rclone-dead-ssh.txt", "other", OffsiteFailTransport},
}
for _, v := range r1105Versions {
for _, c := range cases {
out := r1105Fixture(t, v, c.fixture)
if got := classifyResticProbe(out, errors.New("exit status 1")); got != c.probe {
t.Errorf("restic %s %s: probe class %q, want %q", v, c.fixture, got, c.probe)
}
if c.runClass == "" {
continue
}
m, _ := newOffboxManager(t)
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"cat": "!" + c.fixture}))
err := m.ensureOffboxRepo(context.Background(), []string{"-r", "x"}, nil)
if got := ClassifyOffsiteFailure(err); got != c.runClass {
t.Errorf("restic %s %s: run class %q, want %q (err: %v)", v, c.fixture, got, c.runClass, err)
}
}
// A lock left by a crashed run on another hostname (the C2 shape), both forms.
for _, f := range []string{"locked-shared.txt", "locked-exclusive.txt"} {
out := r1105Fixture(t, v, f)
if !offboxLockRe.Match(out) {
t.Errorf("restic %s %s: the lock text is no longer recognised — resticStep would not self-heal", v, f)
}
if got := ClassifyOffsiteFailure(errors.New(string(out))); got != OffsiteFailLocked {
t.Errorf("restic %s %s: class %q, want locked", v, f, got)
}
}
// The integrity check: a damaged store reads as damage, a healthy one does not.
if !looksLikeRepositoryDamage(r1105Fixture(t, v, "check-damaged.txt")) {
t.Errorf("restic %s: a damaged pack is no longer recognised as damage", v)
}
if looksLikeRepositoryDamage(r1105Fixture(t, v, "check-ok.txt")) {
t.Errorf("restic %s: a healthy check reads as damage", v)
}
}
}
// A first run that races another init: „already exists" is still read as success, so the run proceeds.
func TestR1105_InitRaceIsStillSuccess(t *testing.T) {
for _, v := range r1105Versions {
m, _ := newOffboxManager(t)
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"cat": "!no-repo.txt", "init": "!init-exists.txt"}))
if err := m.ensureOffboxRepo(context.Background(), []string{"-r", "x"}, nil); err != nil {
t.Errorf("restic %s: an init that finds the repository already made must be success, got %v", v, err)
}
}
}
// The JSON the code parses: snapshot lists, the newest snapshot of a tag, the sizes, the progress lines.
func TestR1105_TheJSONTheCodeReadsStillParses(t *testing.T) {
counts := map[string]int{}
for _, v := range r1105Versions {
m, sett := newOffboxManager(t)
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{
"snapshots": "snapshots.json", "latest": "snapshots-latest-tag.json",
"stats": "stats-id.json", "raw-data": "stats-raw.json",
}))
snaps, err := m.listGuardSnaps(context.Background(), nil, nil)
if err != nil || len(snaps) == 0 {
t.Fatalf("restic %s: snapshot list: %d, %v", v, len(snaps), err)
}
for _, s := range snaps {
if s.ID == "" || s.ShortID == "" || s.Time.IsZero() || s.Hostname == "" || len(s.Tags) == 0 {
t.Fatalf("restic %s: a snapshot lost a field the guard reads: %+v", v, s)
}
}
counts[v] = len(snaps)
id, paths, err := m.offboxLatestSnapshot(context.Background(), "a")
if err != nil || id == "" || len(paths) == 0 {
t.Errorf("restic %s: newest snapshot of a tag: %q %v %v", v, id, paths, err)
}
if size, err := m.offboxSnapshotSize(context.Background(), id); err != nil || size <= 0 {
t.Errorf("restic %s: snapshot size: %d %v", v, size, err)
}
n, ok := m.offboxRecordStats(context.Background(), nil, nil)
if !ok || n != counts[v] || sett.GetOffboxTarget().RepoSizeBytes <= 0 {
t.Errorf("restic %s: repo stats: count %d ok %v size %d", v, n, ok, sett.GetOffboxTarget().RepoSizeBytes)
}
moved := false
for _, line := range strings.Split(strings.TrimSpace(string(r1105Fixture(t, v, "backup-status.jsonl"))), "\n") {
p, ok := parseResticStatus(line)
if !ok {
t.Errorf("restic %s: a status line no longer parses: %s", v, line)
}
if p.TotalBytes > 0 && p.TotalFiles > 0 {
moved = true
}
}
if !moved {
t.Errorf("restic %s: no status line carried the totals the progress bar shows", v)
}
}
if counts["0.14.0"] != counts["0.18.1"] {
t.Errorf("the two versions list a different number of snapshots of ONE repository: %v", counts)
}
}
// The clean-up plan — the ONE assumption that MOVED. On the same snapshot set, 0.18.1 removes a SUBSET of what 0.14.0
// removes: since restic 0.17, while a --keep-monthly window has unused slots, restic also keeps the group's OLDEST
// snapshot (reason „oldest monthly snapshot"). Measured: a 41-day history → 0.14.0 removes 62, 0.18.1 removes 60 (one
// per group); a 230-day history (every monthly slot used) → identical plans (474 of 502, audits/debian13-restic-
// 2026-10-16/). It only ever KEEPS more, never removes more, and what it keeps extra is old — so the fake-snapshot
// guard (R-822/R-867), which refuses a plan that removes a YOUNG snapshot, passes the 0.18.1 plan whenever it passes
// the 0.14.0 one. Pinned here: subset, the difference is exactly the „oldest" keeps, and the guard's verdict holds.
func TestR1105_TheForgetPlanOnlyEverKeepsMore(t *testing.T) {
plans := map[string]map[string]bool{}
var all []guardSnap
for _, v := range r1105Versions {
m, _ := newOffboxManager(t)
m.SetOffboxRunner(r1105Runner(t, v, map[string]string{"forget": "forget-dryrun.json", "snapshots": "snapshots.json"}))
plan, err := m.planRemovals(context.Background(), nil, nil)
if err != nil {
t.Fatalf("restic %s: %v", v, err)
}
plans[v] = map[string]bool{}
for _, s := range plan {
plans[v][s.ID] = true
}
// Independent read of the same file: the parser drops nothing restic planned.
var groups []struct {
Remove []json.RawMessage `json:"remove"`
}
if err := json.Unmarshal(r1105Fixture(t, v, "forget-dryrun.json"), &groups); err != nil {
t.Fatal(err)
}
want := 0
for _, g := range groups {
want += len(g.Remove)
}
if len(plan) != want || want == 0 {
t.Errorf("restic %s: the parsed plan holds %d removals, restic planned %d", v, len(plan), want)
}
// The guard, at the minute the plans were captured: both plans pass it.
if all == nil {
if all, err = m.listGuardSnaps(context.Background(), nil, nil); err != nil {
t.Fatal(err)
}
}
captured := time.Date(2026, 10, 16, 18, 20, 0, 0, time.UTC)
if _, refuse := offsiteGuard(all, plan, captured, time.Time{}, -1); refuse != "" {
t.Errorf("restic %s: the guard refuses the honest plan: %s", v, refuse)
}
}
for id := range plans["0.18.1"] {
if !plans["0.14.0"][id] {
t.Errorf("0.18.1 removes snapshot %s, which 0.14.0 keeps — the new restic removes MORE", id)
}
}
// Every snapshot only 0.14.0 removes is one 0.18.1 keeps as the „oldest" of a window with unused slots.
var groups []struct {
Reasons []struct {
Snapshot struct {
ID string `json:"id"`
} `json:"snapshot"`
Matches []string `json:"matches"`
} `json:"reasons"`
}
if err := json.Unmarshal(r1105Fixture(t, "0.18.1", "forget-dryrun.json"), &groups); err != nil {
t.Fatal(err)
}
why := map[string]string{}
for _, g := range groups {
for _, r := range g.Reasons {
why[r.Snapshot.ID] = strings.Join(r.Matches, ",")
}
}
extra := 0
for id := range plans["0.14.0"] {
if plans["0.18.1"][id] {
continue
}
extra++
if !strings.Contains(why[id], "oldest") {
t.Errorf("0.18.1 keeps snapshot %s that 0.14.0 removes, for a reason other than “oldest”: %q", id, why[id])
}
}
if extra == 0 {
t.Error("the fixture no longer shows the 0.17+ “oldest” keep — it no longer tests the difference it documents")
}
}
@@ -0,0 +1,2 @@
{"message_type":"status","percent_done":0,"total_files":1,"total_bytes":41943040}
{"message_type":"status","percent_done":0.6679942601232657,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":28017724,"current_files":["/data/x/u/a/big.tar"]}
@@ -0,0 +1,14 @@
using temporary cache in /data/x/cache/restic-check-cache-279810929
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 49327794:
decrypting blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
Pack ID does not match, want fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72, got 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948
[0:00] 100.00% 123 / 123 packs
Fatal: repository contains errors
@@ -0,0 +1,8 @@
using temporary cache in /data/x/cache/restic-check-cache-1995998052
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 82 / 82 snapshots
no errors were found
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
Fatal: create repository at /data/x/repo failed: config file already exists
@@ -0,0 +1,4 @@
unable to create lock in backend: repository is already locked exclusively by PID 2773051 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:13 (1.310283004s ago)
storage ID 8cbd92f5
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,4 @@
unable to create lock in backend: repository is already locked by PID 2772696 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:11 (1.294783283s ago)
storage ID a2cb4ce8
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,3 @@
Fatal: unable to open config file: stat /data/x/nothing-here/config: no such file or directory
Is there a repository at the following location?
/data/x/nothing-here
@@ -0,0 +1,2 @@
rclone: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at rclone:x: error talking HTTP to rclone: Get "http://localhost/file-5577006791947779410": unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: u@127.0.0.1: Permission denied (publickey,password).
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 10.255.255.1 port 22: Connection timed out
Fatal: unable to open repository at sftp:u@10.255.255.1:/x: unable to start the sftp session, error: EOF
@@ -0,0 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"total_size":21,"total_file_count":12,"snapshots_count":1}
@@ -0,0 +1 @@
{"total_size":217738,"total_file_count":0,"total_blob_count":863,"snapshots_count":82}
@@ -0,0 +1 @@
Fatal: wrong password or no key found
@@ -0,0 +1,4 @@
{"message_type":"status","percent_done":0.056467624048707366,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":2368425,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":0.5571281981541595,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":23367662,"current_files":["/data/x/u/a/big.tar"]}
{"message_type":"status","percent_done":1,"total_files":3,"files_done":2,"total_bytes":41943061,"bytes_done":41943061}
{"message_type":"status","percent_done":1,"total_files":3,"files_done":3,"total_bytes":41943061,"bytes_done":41943061}
@@ -0,0 +1,19 @@
using temporary cache in /data/x/cache/restic-check-cache-3291579747
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
error for tree 49327794:
decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed
[0:00] 100.00% 82 / 82 snapshots
read all data
pack fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72 contains 2 errors: [blob 49327794eaad1ff31fb67ceb6bad31d812456993a7543d5fdf7ec8dad187e156: decrypting blob <tree/49327794> from fc6016f0 failed: ciphertext verification failed unexpected pack id 39ddedb2fc2555d1e386d3eb7c440142ec3d7c02484f3d10db46105343393948]
[0:00] 100.00% 123 / 123 packs
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
restic repair packs fc6016f0ad5e780ab7d800bd3012c8a602eb1dc9e291dedd61ea3d027befae72
restic repair snapshots --forget
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
Fatal: repository contains errors
@@ -0,0 +1,7 @@
using temporary cache in /data/x/cache/restic-check-cache-4211187088
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 82 / 82 snapshots
no errors were found
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
Fatal: create repository at /data/x/repo failed: Fatal: unable to open repository at /data/x/repo: config file already exists
@@ -0,0 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked exclusively by PID 2775667 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:26 (1.312752495s ago)
storage ID be8a3f47
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,5 @@
repo already locked, waiting up to 0s for the lock
unable to create lock in backend: repository is already locked by PID 2775299 on other-box by root (UID 0, GID 0)
lock was created at 2026-10-16 18:20:25 (1.301138628s ago)
storage ID b41afa86
the `unlock` command can be used to remove stale locks
@@ -0,0 +1,3 @@
Fatal: repository does not exist: unable to open config file: stat /data/x/nothing-here/config: no such file or directory
Is there a repository at the following location?
/data/x/nothing-here
@@ -0,0 +1,2 @@
rclone: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at rclone:x: error talking HTTP to rclone: exit status 255
@@ -0,0 +1,2 @@
subprocess ssh: u@127.0.0.1: Permission denied (publickey,password).
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 127.0.0.1 port 1: Connection refused
Fatal: unable to open repository at sftp:u@127.0.0.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1,2 @@
subprocess ssh: ssh: connect to host 10.255.255.1 port 22: Connection timed out
Fatal: unable to open repository at sftp:u@10.255.255.1:/x: unable to start the sftp session, error: error receiving version packet from server: server unexpectedly closed connection: unexpected EOF
@@ -0,0 +1 @@
[{"time":"2026-10-16T02:30:00Z","parent":"16f5cbf1aab3d6aaf201e9cf15f90b780e5db3854751f745883a6bd6e707d2dd","tree":"286f8da9b1b553de264161006b6f14be594a3eef691d3980954808cf02387f21","paths":["/data/x/u/a"],"hostname":"box","username":"root","uid":1000,"gid":1000,"tags":["felhom-offbox","a"],"id":"3e5346879326eed55c3c0ab2abcce53b9207c9f1da1a4f3ad1ee0ab831063651","short_id":"3e534687"}]
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"total_size":21,"total_file_count":12,"snapshots_count":1}
@@ -0,0 +1 @@
{"total_size":217738,"total_uncompressed_size":345762,"compression_ratio":1.587972701136228,"compression_progress":100,"compression_space_saving":37.02662525089513,"total_blob_count":863,"snapshots_count":82}
@@ -0,0 +1 @@
Fatal: wrong password or no key found
+1 -1
View File
@@ -39,7 +39,7 @@ const (
FileBrowserKeptMount = "megorzott"
// SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by
// controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest.
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.1.0"
SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.2.0"
)
// Images returns every controller-managed infra image, derived from the pins above so there is
+35
View File
@@ -18,6 +18,41 @@ ACTION="${2:-}"
REGISTRY="gitea.dooplex.hu/admin"
IMAGE="${REGISTRY}/felhom-samba"
CTX="$(cd "$(dirname "$0")/../infra-images/samba" && pwd)"
REPO_DIR="${FELHOM_REPO_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
# A VERSION NUMBER IS PUSHED ONCE (R-972, felhom-samba 1.2.0) — the same two checks build.sh makes for the
# controller (v0.316.0/v0.316.4), because the box pulls an infra image by its tag and never again once it has it:
# a second image under one number is a box that runs either and never learns which. Before any build:
# 1. the tag must be ABSENT from the registry; a registry that cannot be asked is a refusal, not a pass;
# 2. the tree must be clean and at origin/main — an image from an unpushed tree cannot be rebuilt.
# Local builds (no --push) are untouched. Tested by scripts/test_samba_build_push_once.sh (a fake `docker`).
if [[ "${ACTION}" == "--push" ]]; then
probe_rc=0
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
if [[ ${probe_rc} -eq 0 ]]; then
echo "[ERROR] ${IMAGE}:${VERSION} already exists in the registry — bump the version." >&2
exit 1
fi
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
echo "[ERROR] Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build. The registry answered: ${probe_out}" >&2
exit 1
fi
echo "[INFO] ${VERSION} is not present in the registry — proceeding."
if ! git -C "${REPO_DIR}" rev-parse --git-dir >/dev/null 2>&1; then
echo "[ERROR] ${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified." >&2
exit 1
fi
if [[ -n "$(git -C "${REPO_DIR}" status --porcelain 2>/dev/null)" ]]; then
echo "[ERROR] ${REPO_DIR} has uncommitted changes — refusing to push a build from it." >&2
exit 1
fi
if [[ "$(git -C "${REPO_DIR}" rev-parse HEAD)" != "$(git -C "${REPO_DIR}" rev-parse origin/main 2>/dev/null)" ]]; then
echo "[ERROR] ${REPO_DIR} is not at origin/main — refusing to push." >&2
exit 1
fi
echo "[INFO] Source tree is clean and at origin/main."
CTX="${REPO_DIR}/controller/infra-images/samba"
fi
echo "[INFO] building ${IMAGE}:${VERSION}"
echo "[INFO] context: ${CTX}"
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# A felhom-samba VERSION NUMBER IS PUSHED ONCE — the test for build-samba-image.sh's registry and tree checks
# (R-972, 1.2.0). Same method as test_build_push_once.sh: a fake `docker` first on PATH drives the REAL script;
# the fake answers `build`/`push` with a marker, so a run that got past a refusal is caught, and nothing is pushed.
# Run from controller/: bash scripts/test_samba_build_push_once.sh
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
SH="$HERE/build-samba-image.sh"
fails=0; ran=0
make_fake_docker() {
mkdir -p "$1"
cat > "$1/docker" <<'FAKE'
#!/bin/bash
case "$1 $2" in
"manifest inspect")
case "${FAKE_MODE}" in
present) echo '{"schemaVersion":2}'; exit 0 ;;
absent) echo "manifest unknown: manifest unknown" >&2; exit 1 ;;
down) echo "dial tcp: connection refused" >&2; exit 1 ;;
esac ;;
"build "*|"push "*) echo "FAKE-DOCKER-REACHED: $*"; exit 0 ;;
esac
exit 0
FAKE
chmod +x "$1/docker"
}
run() { # $1 mode, $2 repo dir ("" = default), rest = args
local mode="$1" repo="$2"; shift 2
local tmp; tmp="$(mktemp -d)"; make_fake_docker "$tmp/bin"
if [[ -n "$repo" ]]; then FAKE_MODE="$mode" FELHOM_REPO_DIR="$repo" PATH="$tmp/bin:$PATH" bash "$SH" "$@" >"$tmp/out" 2>&1
else FAKE_MODE="$mode" PATH="$tmp/bin:$PATH" bash "$SH" "$@" >"$tmp/out" 2>&1; fi
rc=$?; out="$(cat "$tmp/out")"; rm -rf "$tmp"
}
check() { # name, want_rc, want_text, forbid_text
ran=$((ran+1))
if [[ "$rc" != "$2" ]] || { [[ -n "$3" ]] && ! grep -qi -- "$3" <<<"$out"; } || { [[ -n "${4:-}" ]] && grep -qi -- "$4" <<<"$out"; }; then
fails=$((fails+1)); echo " FAIL $1 (exit $rc)"; echo "$out" | tail -4 | sed 's/^/ /'; return
fi
echo " ok $1"
}
scratch_repo() {
local d; d="$(mktemp -d)"; git -C "$d" init -q; git -C "$d" config user.email t@t; git -C "$d" config user.name t
mkdir -p "$d/controller/infra-images/samba"; echo x > "$d/controller/infra-images/samba/Dockerfile"
git -C "$d" add -A >/dev/null; git -C "$d" commit -qm one >/dev/null; git -C "$d" update-ref refs/remotes/origin/main HEAD; echo "$d"
}
echo "build-samba-image.sh — a version number is pushed once, from a clean tree"
R="$(scratch_repo)"
run present "$R" 9.9.9 --push; check "tag present -> refuses" 1 "already exists in the registry" "FAKE-DOCKER-REACHED"
run down "$R" 9.9.9 --push; check "registry unreachable -> refuses" 1 "Could not establish whether" "FAKE-DOCKER-REACHED"
run absent "$R" 9.9.9 --push; check "tag absent, clean tree -> builds and pushes" 0 "FAKE-DOCKER-REACHED: push" "refusing"
echo dirt > "$R/controller/infra-images/samba/extra"
run absent "$R" 9.9.9 --push; check "dirty tree -> refuses" 1 "uncommitted changes" "FAKE-DOCKER-REACHED"
rm -f "$R/controller/infra-images/samba/extra"; echo y > "$R/controller/f2"; git -C "$R" add -A >/dev/null; git -C "$R" commit -qm two >/dev/null
run absent "$R" 9.9.9 --push; check "ahead of origin/main -> refuses" 1 "not at origin/main" "FAKE-DOCKER-REACHED"
rm -rf "$R"
run present "" 9.9.9; check "local build, tag present -> not refused" 0 "FAKE-DOCKER-REACHED: build" "already exists"
echo
[[ $fails -eq 0 ]] && { echo "all $ran cases behaved"; exit 0; }
echo "$fails of $ran cases FAILED"; exit 1
+63
View File
@@ -0,0 +1,63 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
JPO/wKqyIDcKxEZq978lxWDmCwGc
=SIvp
-----END PGP PUBLIC KEY BLOCK-----