[1.3.3] - 2026-10-04
Changed
- One upstream fetch per Docker layer, however many clients ask for it (#1003) — a cold blob was fetched from upstream once per request: every concurrent pull, every client retry and every other node asking while the first fetch was still running opened its own upstream download and spooled its own copy, so a CI fleet or a node pool rolling one image multiplied upstream traffic by the number of clients (one 1.27 GB layer was fetched 56 times in four days). Now the first request leads the fill and every request for the same blob that arrives before it is stored joins it, streaming the same spool as it grows — including a
Rangeresume, answered206from the shared fill. A joiner still passes its own authentication, curation, internal-namespace and quarantine checks before it joins, and only joins a fill from the upstream its own request resolved to. A fill that fails, is poisoned or panics releases the blob at once: clients already following it see the body abort (never a truncated success), and the next request starts a fresh fetch. The existing single-flight for npm metadata is unchanged;nora_proxy_coalesced_total{registry="docker"}counts the joins. - Package file modes no longer depend on the machine that built them (#1031) —
dist/nora.nfpm.yamlleft the unit file, the shippednora.envand the copyright at whatever mode the build tree had, so a builder withumask 002published them group-writable (0664). The modes are now explicit: 0644 for the unit and the copyright, and 0640 root:nora for/etc/nora/nora.env, which holds tokens and S3 credentials and is read by systemd as anEnvironmentFilebefore the service drops to thenorauser — it never needed to be world-readable. The packages are also installed and upgraded in CI now, in a container with systemd as PID 1, which is how the mode drift was noticed in the first place. - A
rawupload answers200, not201(#1039) — aPUTthat created a new object answered201 Created, and object-storage clients read anything but200as a failure:lake cache putaborts witherror: failed to upload artifact, error 201even though NORA has stored the object, which is the worst shape of a failure.rawis the surface such clients reach for — S3's ownPutObjectanswers200— so every successfulPUT(a create, anIf-Matchoverwrite, or a re-PUTof identical bytes) now answers200and the OpenAPI document lists one success code instead of two. A client that treats any2xxas success sees no difference; one that asserted exactly201has to accept200.
Fixed
- A large Docker layer pulls through a slow or flaky upstream link (#1015) —
proxy_timeoutwas applied as a total deadline on the upstream blob request, so any layer that took longer thanproxy_timeoutto transfer was cut mid-body even while bytes kept flowing (a 3.5 GB layer withproxy_timeout = 120failed every attempt), and a body that broke off was discarded and fetched again from byte zero.proxy_timeoutnow bounds only the wait for upstream response headers; the body is paced by the per-chunkread_timeout. A body that breaks off (connection reset, stalled chunk, short body) is resumed from upstream withRange: bytes=<written>-into the same spool and the same digest check, up to five attempts in a row without progress; an upstream that ignoresRangeand resends the whole blob has the bytes already on disk skipped. A clientRangeon a cache miss — how containerd and dockerd resume a broken pull — is now answered206from the spool (or416past the end) instead of200from byte zero, which made the client read and discard everything up to its offset (failed to discard to offset). An upstream that refuses a resume (4xx, or a token it no longer accepts) is recorded as alive, not failed, by the circuit breaker, as on the initial request. - Graceful shutdown drains for a bounded time (#1016) — on SIGTERM/SIGINT NORA stopped accepting connections and then waited for every open one with no limit, so a client that stopped reading a response (dockerd after a failed layer, a stalled proxy) held the process until the orchestrator killed it, and the steps after serving — the audit log drain and the token
last_usedflush — never ran. In-flight requests now getserver.shutdown_timeoutseconds (NORA_SHUTDOWN_TIMEOUT, default 15, which leaves room for the scheduler wait inside Kubernetes' default 30 s grace period); connections still open then are closed and shutdown continues. - Concurrent cross-device writes of one blob no longer collide (#1019) — when a proxied blob's spool and local storage sit on different filesystems,
put_from_pathcopies through a temp file, and that temp file was always<blob>.tmp. Two writes of the same blob at once (two clients pulling one cold layer) truncated each other's copy, a reader could briefly open the blob shorter than its content, and the second write failed withNotFoundbecause its temp file had been renamed away. Each write now stages its own copy under a unique name, the same wayput()already did, so every concurrent write succeeds and only a complete file is ever published. - The systemd unit ships under
/usr/lib, not the aliased/lib(#1023) — Debian policy has forbidden/lib/systemd/systemsince the/usrmerge, and that is where the packaged unit went: fouraliased-locationlintian errors in every release, and an install path that is a symlink into/usron any current system. Both the.deband the.rpmnow place it at/usr/lib/systemd/system/nora.service, where systemd looks for it, anddpkg -Llists only that path. Fixed by @nicholas-rees. - A fresh package install starts instead of crash-looping (#1025) — the
.deb/.rpmandinstall.shdefaults setNORA_HOST=0.0.0.0with noNORA_PUBLIC_URL, which validation rejects, so the service panicked (SIGABRT) and systemd restarted it every 5 s forever. The shipped defaults are nowNORA_HOST=::(IPv4 and IPv6, falling back to IPv4 where IPv6 is unavailable) withNORA_PUBLIC_URL=http://localhost:4000, the same as the Docker image; set your real address for clients on other hosts. A configuration NORA cannot start with now exits with status 78 (EX_CONFIG) and a clear message instead of panicking, and the unit files carryRestartPreventExitStatus=78, so an existing install whosenora.envstill has the old defaults stops once with the reason in the journal rather than looping. - An upgrade stopped the registry and never started it again (#1035) —
apt upgrade noraanddnf upgrade norareported success while leaving the service down until somebody ransystemctl start nora. Both maintainer scripts ignored the argument that separates an upgrade from a removal:prermstopped the unit and dropped its enablement on every upgrade, andpostinstre-enabled it but started nothing. An upgrade that aborted beforepostinst— a non-interactivedpkgstopping at an editednora.envconffile prompt — left the unit both stopped and disabled, so not even a reboot brought it back, and underunattended-upgradesthe registry went down at night with client failures as the first signal.prermnow stops and disables only on a real removal (dpkg'sremove, rpm's last instance),postinstrunssystemctl try-restarton an upgrade so a unit the administrator had stopped stays stopped, and both tolerate a system without systemd instead of failing the install. The behaviour is covered byscripts/test-package-scriptlets.shin CI, which runs the real scripts against a stubsystemctland asserts which verbs they invoke. rawrejected a retried upload of unchanged bytes (#1036) — aPUTwith no conditional headers to a key that already held identical content answered409 File already exists, the same as a genuine conflict. Object-storage clients that retry an upload after a partial transfer expect a retry of the same bytes to succeed, not fail. The unconditionalPUTpath now compares the uploaded content's sha-256 against the stored object's pin (the same lookup already used forIf-Match) and treats a match as success instead of a conflict. Different bytes under an existing key are unaffected — immutability still holds, and still answers409.- A false
INTEGRITY VIOLATIONon the local backend (#1041) — the body of an object and its hash pin are written as two separate steps (arename, then an append to the NDJSON pin sidecar), so two writers of one key could interleave into "body from one writer, last pin from the other". The next read then failed hash-pin verification and refused to serve bytes nobody had tampered with:INTEGRITY VIOLATION: refusing to serve tampered artifact. It surfaced on a Docker pull-through cache, where a manifest's.meta.jsonis written both by the pull counter and by the proxy-cache fill — one key out of 4039 failed anora migrate, and the same read path serves client pulls, so that manifest's metadata was unreadable until the key was rewritten. Both durable steps now happen inside a per-key critical section in the backend itself, so no writer can produce the split, whether or not it takes a publish lock. The object-store backends were never affected: there the pin travels in the object's own metadata, written in the same request as the body. A crash between the two steps still leaves a stale pin behind, which an operator repairs by rewriting the key — the body is intact.
Install
# x86_64
curl -LO https://github.com/getnora-io/nora/releases/download/v1.3.3/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora
# ARM64 (Apple Silicon, Graviton, Ampere)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.3.3/nora-linux-arm64
chmod +x nora-linux-arm64
sudo mv nora-linux-arm64 /usr/local/bin/noraDocker
docker pull getnora/nora:1.3.3| Variant | Image | Platforms |
|---|---|---|
| Alpine (default) | getnora/nora:1.3.3
| amd64, arm64 |
| RED OS | getnora/nora:1.3.3-redos
| amd64 |
| Astra Linux SE | getnora/nora:1.3.3-astra
| amd64 |
| GHCR | ghcr.io/getnora-io/nora:1.3.3
| amd64, arm64 |
DEB / RPM
# Debian / Ubuntu / Astra Linux (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.3.3/nora-amd64.deb
sudo dpkg -i nora-amd64.deb
# RHEL / Fedora / RED OS (amd64)
curl -LO https://github.com/getnora-io/nora/releases/download/v1.3.3/nora-amd64.rpm
sudo rpm -i nora-amd64.rpmChangelog
See CHANGELOG.md