Architecture
Package layout
Everything under pkg/ is exported and importable. There is no internal/
directory: the server is a library that also ships a binary, not a binary with
some code hidden inside it.
flowchart LR
ROOT["ota-updater/"] --> CMD["cmd/<br/><i>thin wrappers</i>"]
ROOT --> PKG["pkg/<br/><i>all exported</i>"]
CMD --> EA["edge-agent<br/><i>~190 lines</i>"]
CMD --> US["update-server"]
PKG --> AGENT["agent<br/><i>Updater, SlotManager,<br/>Watchdog, Downloader</i>"]
PKG --> SERVER["server<br/><i>Store, Registry, Manifester,<br/>handlers, Retention</i>"]
PKG --> PROTO["protocol<br/><i>wire types, ArtifactKey</i>"]
PKG --> CRYPTO["crypto<br/><i>Ed25519 + PEM</i>"]
PKG --> DELTA["delta<br/><i>bsdiff + zstd</i>"]
PKG --> COMP["compression<br/><i>zstd</i>"]
PKG --> AIO["atomicio<br/><i>durable writes</i>"]
classDef dir fill:#6b76872e,stroke:#6b7687
classDef entry fill:#fbbf242e,stroke:#fbbf24
classDef lib fill:#38bdf82e,stroke:#38bdf8
class ROOT,CMD,PKG dir
class EA,US entry
class AGENT,SERVER,PROTO,CRYPTO,DELTA,COMP,AIO libThe dependency direction is one-way and shallow: each cmd/ binary imports
its own package, agent and server both import all five of the remaining
ones, and nothing in that shared set imports anything above it. delta is the
only one with an internal dependency โ it compresses its output through
compression.
protocol is the only package both sides depend on for wire compatibility.
Its structs carry dual JSON and CBOR tags, so one type serializes over
HTTP (JSON) and CoAP (CBOR) with no duplication and no risk of the two
representations drifting apart.
The store is content-addressed
This is the load-bearing decision. Nothing in the store is keyed by version, name, or artifact โ only by SHA-256 of content.
flowchart LR
subgraph DISK["on disk"]
direction TB
B1["binaries/<br/><b>{sha256}.bin</b>"]
D1["deltas/<br/><b>{from}_{to}.delta.zst</b><br/><b>{hash}.full.zst</b>"]
end
subgraph RAM["in RAM โ byte-budgeted LRUs"]
direction TB
TC["target cache<br/><i>uncompressed targets,<br/>shared by all artifacts</i>"]
HC["hot cache<br/><i>transfer bytes:<br/>deltas + full binaries</i>"]
end
subgraph NEVER["never in RAM"]
SRC["source binaries<br/><i>kernel page cache<br/>does the LRU</i>"]
end
B1 --> TC
D1 --> HC
B1 -.-> SRC
classDef disk fill:#6b76872e,stroke:#6b7687
classDef ram fill:#4ade802e,stroke:#4ade80
classDef never fill:#f871712e,stroke:#f87171
class B1,D1 disk
class TC,HC ram
class SRC neverThree consequences follow directly:
- Two artifacts shipping identical bytes share one file. Publishing the
same build on
agent/linux/arm64andagent/linux/amd64costs one.bin, not two. - A delta is identified by its
(from, to)pair alone, regardless of which artifact asked for it. The delta endpoint therefore needed no artifact segment when multi-artifact support was added. - Deduplication is free and automatic. Re-publishing an unchanged build is a no-op: same hash, same file, no cache invalidation, and no device sees a spurious update.
Bytes vs bookkeeping
classDiagram
class Store {
<<content-addressed>>
+RegisterBinary(data) hash
+HasBinary(hash) bool
+LoadBinary(hash) bytes
+EnsureDelta(from, to) path
+GetDeltaBytes(from, to) bytes
+GetBinaryBytes(hash) bytes
}
note for Store "Knows nothing about versions,\nnames or artifacts. Addressed\npurely by SHA-256."
class Registry {
<<publication state>>
+PublishBytes(key, version, data)
+PublishFile(key, version, path)
+Resolve(name) Artifact
+LiveHashes() set
+CurrentTargets() set
}
class Artifact {
+ArtifactKey Key
+string Version
+string TargetHash
+int64 TargetSize
+string Source
+[]string History
}
class ArtifactKey {
+string Name
+string OS
+string Arch
+String() "name/os/arch"
}
Registry "1" o-- "N" Artifact
Artifact *-- ArtifactKey
Registry ..> Store : publishes bytes intoThe Registry is the only component that knows what “current” means. It
persists to a JSON state file, because a server that forgets which version is
current after a restart is not a source of truth for a fleet โ it is an
outage.
History exists for retention: a superseded target is still a plausible delta
source for a device that has not checked in yet, so the sweeper must not
collect it.
Request path for a transfer
Concurrent requests for the same uncached artifact collapse through
singleflight, so a campaign burst โ thousands of devices asking for the same
delta within seconds of each other โ becomes one disk read or one
bsdiff run, not thousands.
flowchart TD
REQ["GET /delta/{from}/{to}"] --> VALID{"both segments<br/>valid SHA-256 hex?"}
VALID -->|no| NF["404"]
VALID -->|yes| CUR{"is {to} a current<br/>target of some artifact?"}
CUR -->|no| NF
CUR -->|yes| HOT{"in hot cache?"}
HOT -->|hit| SERVE["serve from RAM<br/><i>zero I/O</i>"]
HOT -->|miss| DISK{"on disk?"}
DISK -->|yes| SF["singleflight:<br/>read file ONCE"]
SF --> POP["populate hot cache"] --> SERVE
DISK -->|no| GEN["dispatch async bsdiff<br/><i>bounded by delta_concurrency</i>"]
GEN --> R404["404 โ agent retries<br/>after RetryAfter"]
classDef good fill:#4ade802e,stroke:#4ade80
classDef bad fill:#f871712e,stroke:#f87171
class SERVE good
class NF,R404 badThe “is {to} a current target” check is not cosmetic. Without it, anyone able
to name two known hashes could ask an unauthenticated endpoint to run an
arbitrary bsdiff โ the most expensive operation the process performs, at
roughly 20ร the binary size in peak RAM. Restricting the destination bounds
what a request can make the server do.
Durability
Every write that must survive power loss goes through pkg/atomicio, which
guarantees the same three steps in order:
flowchart LR
W["write to<br/>.tmp-XXXX"] --> S["f.Sync()<br/><i>content durable</i>"]
S --> R["rename()<br/><i>atomic swap</i>"]
R --> D["fsync(parent dir)<br/><i>dirent durable</i>"]
classDef step fill:#38bdf82e,stroke:#38bdf8
class W,S,R,D stepThe final fsync of the parent directory is the step most implementations
skip. Without it, a rename can be lost across a power cut even though the file
contents were synced โ the classic “the file is there but empty, or not there
at all” failure on unclean shutdown.
Callers unified behind it: the store’s binaries and deltas, the agent’s slot writes and symlink swap, the pending-update marker, the boot counter, the download resume state, and the registry state file.
A crash between create and rename leaves a .tmp-* file behind; both the
store and the agent sweep stragglers older than 24h at startup.