Skip to content

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 13 Jul 19:46
· 42 commits to main since this release

Added

  • release.sh — a two-stage release process, and a barrier that enforces it.
    A stable vX.Y.Z tag is now only publishable if a vX.Y.Z-rcN tag points at the
    same commit, and the release job re-runs the entire suite against that tagged
    commit before publishing. Candidates are invisible to users — update.sh and the
    update alert both take the newest plain vX.Y.Z tag — so debugging happens across
    rc1, rc2, rc3 at nobody's expense, instead of across v0.5.0, v0.5.1, v0.5.2 at
    everybody's.

    bash release.sh 0.6.0 --rc        # candidate. Invisible to users.
    bash release.sh 0.6.0 --promote   # stable. Refused unless an rc passed HERE.
    

    The rule is enforced in tools/release_gate.py, which release.sh runs locally
    (so you fail in 200 ms) and .github/workflows/release.yml runs again where it
    cannot be bypassed (so failing locally is not optional). "The candidate passed,
    then I pushed one more little fix" is refused by name — that is precisely how
    v0.5.1 happened.

  • TrueNAS compatibility is now checked, not hoped for.
    tools/compat.py is a written-down record of everything each
    module assumes about middlewared, checked in two places:

    • CI, daily — against iXsystems' source at every release line including
      master and the current BETA/RC
      . When an unreleased TrueNAS breaks the patch
      it files a bug report automatically, so there is time to fix it before that
      version reaches anyone. It also refreshes the README's support matrix, so the
      table cannot quietly become a false promise.
    • apply.sh, at every boot — against the middleware actually installed on the
      box. A module whose assumptions no longer hold is not applied. Stock TrueNAS
      without a feature beats TrueNAS with a broken backup.

    It immediately found two real breaks: TrueNAS 26 (below), and a nested-snapshot bug
    that had been shipping for two releases (below).

  • The compatibility check now covers the middlewared methods the patch calls,
    not only the symbols it wraps — and that gap was hiding a catastrophe.

    TrueNAS 26 deletes plugins/zfs_/dataset.py and plugins/zfs_/snapshot.py
    outright
    , taking zfs.dataset.query, zfs.snapshot.query and
    zfs.snapshot.delete with them (26 uses filesystem.statfs and zfs.resource.*).
    Nothing about the five cloud_backup files reveals that, so every other check went
    green. The patch would have applied perfectly and then failed on the first
    backup
    — or, far worse, snapshotted successfully and failed to delete,
    orphaning one snapshot per descendant dataset (250 on a real pool) on every
    single run, forever.

    This is now an assumption class of its own, so a method disappearing is a BROKEN
    verdict rather than a silent time bomb.

  • Groundwork for TrueNAS 26 (async→sync and the deleted helper — see below).
    26 is still reported BROKEN and the nested module will not apply there, because
    the ZFS API rewrite above is not yet ported. Porting it needs a real 26 box to
    verify against, and shipping a port nobody has run is exactly the failure this
    project exists to avoid. On 26, TrueNAS is left stock: B2/S3 keeps working, nested
    datasets are simply not covered.

Fixed

  • A few snapshots leaked on every nested run, forever. Found on real hardware, in
    the one place it could be: a 256-snapshot backup of /mnt/Tap swept 253 cleanly and
    left 3 behind with dataset is busy.

    The cause is ZFS's own automount. Reading anything under
    <dataset>/.zfs/snapshot/<snap>/ makes ZFS automount that snapshot, and it stays
    mounted for zfs_expire_snapshot seconds (300 by default) after the last access.
    teardown() unmounts our bind mounts — but not the automount underneath — so
    zfs destroy refuses for exactly the datasets restic read most recently. Then
    cleanup_task() removed the sidecar anyway, destroying the only record that those
    snapshots existed. Nothing would ever have reclaimed them.

    Three changes, and the third is the one that makes it safe rather than merely
    unlikely:

    • release_snapdirs() unmounts ZFS's own .zfs/snapshot automounts (deepest first)
      before deleting, so the snapshots are not busy in the first place.
    • delete_snapshot_tree() retries the transient busy, and returns the
      snapshots it could not delete
      instead of swallowing them.
    • The sidecar is now removed only on a confirmed-clean sweep — including on the
      staging-failure path, which used to remove it before the caller swept. The
      asymmetry is deliberate: a sidecar left behind when the tree is already gone costs
      one no-op delete on the next run, while a sidecar removed while the tree still
      exists is unrecoverable. Survivors are reclaimed by the next run.

    Expect the occasional straggler, and expect it to clean itself up. On a
    256-snapshot tree this reliably sweeps ~255 immediately and may leave one: it is
    whatever restic read last, so its 300-second window has barely opened. That one is
    logged, its sidecar is kept, and the next run reclaims it before doing anything else.
    The leak is bounded at a single cycle rather than growing without limit — which is
    the property that actually matters. Blocking a backup job for five minutes to chase
    the last snapshot would be a worse trade, so it is not made.

  • Installing the patch permanently blocked updating it. install.sh does
    chmod +x update.sh, and git recorded update.sh as 100644 — so the chmod was a
    tracked modification, and update.sh refuses to run over a dirty tree. Install
    once and you could never update again; the error even told you to run
    git checkout -- ., which just undoes the exec bit so the next install can re-dirty
    it. A real box sat on an old version for exactly this reason.

    Fixed on both sides: the scripts install.sh chmods are now executable in git (so
    the chmod is a no-op), and update.sh's dirty check now looks at content, not
    file mode — git diff --numstat reports 0 0 for a mode-only change. A test
    asserts every script in install.sh's chmod loop is already 100755 in git.

  • Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
    along.
    SYNC_BLOCK's wrapper spelled out the stock signature and forwarded five
    arguments — but those releases declare restic_backup(middleware, job, cloud_backup, dry_run); rate_limit only arrived in 25.10. Every nested backup on
    24.10/25.04 raised TypeError: restic_backup() takes 4 positional arguments but 5 were given. The wrapper now takes *args, **kwargs and forwards whatever it is
    handed, so a trailing parameter appearing or disappearing is a non-event.

    Found by the new compatibility check, not by a user — which is the whole argument
    for having it. The check it replaced only asked whether the parameter names still
    appeared somewhere in the signature, so it happily passed a call that could never
    work.

  • The nested module is now one synchronous implementation behind two thin
    wrappers.
    TrueNAS 26 rewrites cloud_backup from async to synchronous and
    separately deletes get_dataset_recursive(), which an injected block called out
    of the host module's namespace. Either alone is a broken backup found at restore
    time: an async def wrapper hands sync.py a coroutine where it unpacks a tuple,
    and the vanished helper is a straight NameError.

    The module now talks to middlewared through call_sync, and apply.sh reads which
    flavour the installed middleware declares and injects the matching wrapper —
    TrueNAS ≤ 25.10 reaches it via await middleware.run_in_thread(...); a synchronous
    TrueNAS, already in a worker thread, calls it directly. The logic that owns the
    snapshots, the bind mounts and the failure modes exists once; an async twin
    would mean every future fix had to land twice, and the one that got missed would be
    the one that eats a backup. A middleware whose three wrapped functions disagree
    about async-ness is refused outright rather than guessed at, and
    get_dataset_recursive is carried as our own copy — removing the dependency on both
    versions instead of asserting it.

  • The patch no longer reaches into CloudSync tasks it has no business touching.
    create_snapshot is module-global in plugins/cloud/snapshot.py and is imported by
    cloud_sync.py as well as cloud_backup/sync.py — so the wrapper sat in the
    path of every rclone/Storj CloudSync task with snapshot=true, and issued a
    zfs.dataset.query before deciding it had nothing to do. That added a brand-new
    failure mode to jobs that worked fine before this patch was installed, and worse: a
    CloudSync task that ever did get staged would never be torn down, because the
    teardown is wired into cloud_backup's restic_backup and CRUD_BLOCK
    deliberately leaves CloudSync's nesting guard intact — the bind mounts would pin the
    ZFS snapshot forever. The staging path now bails out immediately unless the snapshot
    is named cloud_backup-*, before any middleware call.

  • Teardown warnings are no longer silently swallowed on TrueNAS ≤ 25.10. The async
    wrapper's finally dropped the logger= kwarg that the sync one passes, so a
    cleanup that failed to unmount a bind mount or to delete a snapshot tree logged
    nothing at all — on the only platform anyone actually runs. run_in_thread
    forwards **kwargs via functools.partial; it was a regression, not a limitation.

  • do_delete is recognised as delete. TrueNAS 24.10 and 25.04 declare
    do_delete (the CRUDService convention); 25.10 renamed it to delete. Both
    answer to zfs.snapshot.delete. Accepting only the literal name reported both older
    releases as BROKEN — a false verdict that would have switched nested snapshots off
    on boxes where they work perfectly.

  • An incompatible TrueNAS no longer sets the permanent kill switch. apply.sh
    reused a "nothing left to do" exit that touches disabled, which suppresses
    patching on every future boot and is cleared only by install.sh — never by
    update.sh. On TrueNAS 26 (providers-compatible, nested opt-out by default) that
    branch would have fired, and the very release that fixed 26 could not have
    re-enabled itself: the user would run bash update.sh, exactly as the update alert
    tells them to, and the patch would stay dead with their B2 backups off.
    Incompatibility now means "apply nothing this boot, try again next boot".
    Retirement and incompatibility are opposite situations and no longer share an exit.

  • The compatibility check itself could be fooled, in ways that each had teeth: a
    reordered, keyword-only, or newly-required parameter now reads as broken (the patch
    calls these positionally); a re-exported or conditionally-defined symbol reads
    as unknown rather than broken, so an innocent upstream refactor cannot make a
    working module decline to apply; an unreadable source (rate limit, DNS, timeout)
    is unknown rather than "iXsystems deleted this file", so a network blip cannot
    file a bug report, fail CI, and repaint the published support matrix; and native
    no longer masks BROKEN, which used to render a TrueNAS that both reworded the
    nesting guard and reshaped the functions as good news.

  • compat.py --tree no longer reads the patch's own code as native support.
    B2_BLOCK writes B2RcloneRemote.restic = True into b2.py — exactly the string
    the providers native-probe looks for — so the one command the docs recommend for
    checking a live box said "retire the providers module" on every patched machine.
    It now reads only the part of the file iXsystems wrote.

  • release.sh --promote could never succeed. It refused to run if the stable tag
    existed, and the gate refused if it did not — mutually exclusive, so the only way to
    cut a stable release was to hand-tag and bypass every gate this work exists to
    enforce. The gate now resolves the tag's commit if it exists and HEAD otherwise.
    The tests hid it by always tagging first.

Changed

  • The minimum supported TrueNAS is stated, and enforced: 24.10. TrueCloud Backup
    does not exist before it — plugins/cloud_backup/ is simply absent — so the patch
    had nothing to attach to and would have done nothing at all, silently, while the
    user believed their backups were configured. install.sh now reads
    system.version and refuses, naming the reason. A version it cannot parse is a
    warning, not a refusal: declining to install over a string we failed to read would
    be a worse failure than the one being prevented.

  • A stable release may not leave work stranded under ## Unreleased. Either it
    is finished and belongs in the release, or the release is premature. Candidates
    are exempt: an rc may legitimately have work queued behind it.

  • release.sh refuses to run on an installed box. The whole repo is cloned onto
    every box, so this file is there too; update.sh pins the checkout to a tag in
    detached HEAD, and release.sh now recognises that and says so, rather than
    emitting a confusing branch error.

  • Gitea (git.onetick.ninja/flan/truenas-truecloud-patch) is now canonical, with
    GitHub as a mirror. Both forges run the same workflows and publish the same
    releases. The update alert now derives the changelog URL from the origin
    remote
    instead of hard-coding GitHub — which matters more than it sounds: when
    the changelog cannot be read, the alert deliberately fires anyway rather than risk
    hiding a security fix, so a stale URL would not have disabled the alert, it would
    have made it nag on every release, including documentation-only ones.

Security

  • Workflow expressions are no longer interpolated into shell.
    echo "${{ steps.report.outputs.body }}" pasted the compatibility report into the
    script text, and the report is full of backticks — bash ran create-snapshot,
    def and async as commands. Since that report is built from iXsystems' source,
    anything landing in their tree would have executed on the runner. inputs.tag on
    workflow_dispatch had the same shape, and that one is attacker-chosen. Data now
    moves through files and scalars through env:; a test enforces it across every
    workflow.

Internal

  • Static-analysis annotations in patch/alert_source.py (# noqa placement). No
    runtime change.