From fb8341e1dd4d3669afa2652e3dc62a02d77cc1a9 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 7 Sep 2026 22:46:12 -0300 Subject: [PATCH 1/2] =?UTF-8?q?Promote=20qcow=20out=20of=20internal/:=20th?= =?UTF-8?q?e=20consumer=20ADR-0021=20=C2=A74=20waited=20for=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0021 §4 fixed the shape of the host-side surface — "a self-contained type the agent loop *uses*, never a method on the loop, so spin's runner can take the same type without the loop, its heartbeat, or its Control Plane client" — and left the name to settle when a consumer existed. It does: spinbox is dropping containerd, and what replaces its snapshotter is this package. The move is the whole package and nothing else. `internal/qcow` is now `qcow`; every exported symbol was already exported, and everything under internal/ stays free to change. Why this package and not a new abstraction beside it: the four properties spinbox wants are already here and are not about qcow2. A host is told what it should be serving and converges, with no attach and no detach. The contract with whoever launches the VM is two paths derived from a data directory and a volume id. Layers are immutable, named by their own id, and live in one directory for the whole host, so a hundred chains descending from one layer share one file — a snapshotter whose sharing happens in the qcow2 backing header, read by the guest kernel, with no union filesystem and no daemon. And the file is the truth: QEMU's answer outranks the pointer, and chain shape is read back with qemu-img rather than tracked. The package doc is rewritten for a reader who is not in this repository. It named ActiveImage, which does not exist — the function is ActivePointer, and the distinction is the point of it — and it left the launcher's one prohibition buried next to the measurement that found it: file locking must stay on, or a live volume reads as an idle one and nothing here can detect it. Verified: task ci, including the six demos against real QEMU. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019GHRRFvbnZi5q3KSTvyW5a --- cmd/volume-agent/main.go | 4 +-- hack/comment-rot-allow.txt | 28 +++++++++---------- hack/comment-rot.sh | 2 +- hack/deadcode-allow.txt | 4 +-- hack/refs-allow.txt | 1 - integration/e2e/compaction_test.go | 4 +-- integration/e2e/datadir_test.go | 2 +- internal/agent/agent.go | 2 +- internal/dst/checkers_reconcile.go | 2 +- internal/dst/planted_bug_test.go | 2 +- internal/dst/scenarios_reconcile.go | 6 ++-- internal/dst/scenarios_sweep.go | 4 +-- internal/lifecycle/lifecycle.go | 2 +- internal/publisher/metrics_test.go | 2 +- internal/publisher/publisher.go | 6 ++-- internal/publisher/publisher_test.go | 2 +- internal/qmp/qmp.go | 2 +- .../recovery/adversary_blank-disk_test.go | 2 +- .../recovery/adversary_corrupt-layer_test.go | 2 +- internal/recovery/adversary_formats_test.go | 2 +- internal/recovery/adversary_rebuild_test.go | 2 +- internal/recovery/depth_cost_test.go | 2 +- internal/recovery/lineage_guard_test.go | 2 +- internal/recovery/metrics_test.go | 2 +- internal/recovery/recovery.go | 2 +- internal/recovery/recovery_test.go | 2 +- internal/simio/real/host.go | 2 +- .../adversary_blank-disk_test.go | 2 +- .../adversary_catalog-down_test.go | 2 +- .../adversary_crash-points_test.go | 2 +- .../qcow => qcow}/adversary_disk-full_test.go | 2 +- .../qcow => qcow}/adversary_fencing_test.go | 2 +- .../qcow => qcow}/adversary_formats_test.go | 2 +- .../adversary_host-reboot_test.go | 2 +- .../adversary_live-corruption_test.go | 2 +- .../adversary_qcow-chain_test.go | 2 +- .../adversary_qcow-restart_test.go | 2 +- .../adversary_qcow-twohosts_test.go | 2 +- .../qcow => qcow}/adversary_reconcile_test.go | 2 +- .../adversary_snapshot-kill_test.go | 2 +- .../adversary_store-down_test.go | 2 +- {internal/qcow => qcow}/chain.go | 25 +++++++++++++---- {internal/qcow => qcow}/chain_test.go | 2 +- {internal/qcow => qcow}/compact.go | 0 {internal/qcow => qcow}/compact_test.go | 2 +- {internal/qcow => qcow}/diskbytes_test.go | 2 +- {internal/qcow => qcow}/export_test.go | 0 {internal/qcow => qcow}/lineage_test.go | 2 +- {internal/qcow => qcow}/manager.go | 0 {internal/qcow => qcow}/manager_test.go | 2 +- {internal/qcow => qcow}/reclaim.go | 0 {internal/qcow => qcow}/reclaim_test.go | 2 +- {internal/qcow => qcow}/state.go | 0 {internal/qcow => qcow}/state_derive_test.go | 0 {internal/qcow => qcow}/state_test.go | 2 +- {internal/qcow => qcow}/sweep.go | 0 {internal/qcow => qcow}/sweep_test.go | 2 +- 57 files changed, 88 insertions(+), 76 deletions(-) rename {internal/qcow => qcow}/adversary_blank-disk_test.go (99%) rename {internal/qcow => qcow}/adversary_catalog-down_test.go (99%) rename {internal/qcow => qcow}/adversary_crash-points_test.go (99%) rename {internal/qcow => qcow}/adversary_disk-full_test.go (99%) rename {internal/qcow => qcow}/adversary_fencing_test.go (99%) rename {internal/qcow => qcow}/adversary_formats_test.go (97%) rename {internal/qcow => qcow}/adversary_host-reboot_test.go (99%) rename {internal/qcow => qcow}/adversary_live-corruption_test.go (98%) rename {internal/qcow => qcow}/adversary_qcow-chain_test.go (99%) rename {internal/qcow => qcow}/adversary_qcow-restart_test.go (99%) rename {internal/qcow => qcow}/adversary_qcow-twohosts_test.go (99%) rename {internal/qcow => qcow}/adversary_reconcile_test.go (99%) rename {internal/qcow => qcow}/adversary_snapshot-kill_test.go (99%) rename {internal/qcow => qcow}/adversary_store-down_test.go (99%) rename {internal/qcow => qcow}/chain.go (97%) rename {internal/qcow => qcow}/chain_test.go (99%) rename {internal/qcow => qcow}/compact.go (100%) rename {internal/qcow => qcow}/compact_test.go (99%) rename {internal/qcow => qcow}/diskbytes_test.go (97%) rename {internal/qcow => qcow}/export_test.go (100%) rename {internal/qcow => qcow}/lineage_test.go (98%) rename {internal/qcow => qcow}/manager.go (100%) rename {internal/qcow => qcow}/manager_test.go (99%) rename {internal/qcow => qcow}/reclaim.go (100%) rename {internal/qcow => qcow}/reclaim_test.go (99%) rename {internal/qcow => qcow}/state.go (100%) rename {internal/qcow => qcow}/state_derive_test.go (100%) rename {internal/qcow => qcow}/state_test.go (99%) rename {internal/qcow => qcow}/sweep.go (100%) rename {internal/qcow => qcow}/sweep_test.go (99%) diff --git a/cmd/volume-agent/main.go b/cmd/volume-agent/main.go index 5128777..22ffa21 100644 --- a/cmd/volume-agent/main.go +++ b/cmd/volume-agent/main.go @@ -8,7 +8,7 @@ // // The data path is QEMU's. This process prepares each volume's local qcow2 chain, hands the // paths to whoever launches the VM, and speaks QMP to the QEMU that ends up there — it does -// not start one; internal/qcow carries the two-path contract. So: claim the data directory, +// not start one; qcow carries the two-path contract. So: claim the data directory, // read the key, register, heartbeat, hold a lease, learn which volumes to serve, prepare a // chain for each, and report what it saw. package main @@ -32,10 +32,10 @@ import ( "github.com/spin-stack/storage/internal/descriptor" "github.com/spin-stack/storage/internal/obs" "github.com/spin-stack/storage/internal/publisher" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" "github.com/spin-stack/storage/internal/simio/real" "github.com/spin-stack/storage/internal/storecfg" + "github.com/spin-stack/storage/qcow" ) // version is the build identity the Agent reports. Overridden at link time with diff --git a/hack/comment-rot-allow.txt b/hack/comment-rot-allow.txt index 78d7117..365bed6 100644 --- a/hack/comment-rot-allow.txt +++ b/hack/comment-rot-allow.txt @@ -59,19 +59,19 @@ internal/agent/loop.go:Loop.giveUpWhatIsNoLongerOurs:lease-valid # "not 'the lea internal/metadata/metadata.go:Store.Now:lease-valid # about the Control Plane granting an epoch "while the old writer's monotonic lease is still valid" — mutual exclusion, the one part of fencing V1 keeps # --- naming a withdrawn mechanism to say it is gone, and two forward references -------- -internal/qcow/chain.go:overlay:compaction # "§19 already owns flattening, as compaction, outside the recovery path" — §21's chain compaction, which internal/qcow/compact.go performs. The comment is why a recovery does NOT flatten. -internal/qcow/compact.go:MaxLayers:compaction # the ceiling's reason: §19's compaction is what keeps a chain an order of magnitude below it, and it runs — DefaultCompaction is what a binary uses -internal/qcow/manager.go:DefaultRotateAtBytes:remote # "a slow remote one at F = 200 ms" is a point on the backend curve the threshold is derived over, not the withdrawn remote WAL +qcow/chain.go:overlay:compaction # "§19 already owns flattening, as compaction, outside the recovery path" — §21's chain compaction, which qcow/compact.go performs. The comment is why a recovery does NOT flatten. +qcow/compact.go:MaxLayers:compaction # the ceiling's reason: §19's compaction is what keeps a chain an order of magnitude below it, and it runs — DefaultCompaction is what a binary uses +qcow/manager.go:DefaultRotateAtBytes:remote # "a slow remote one at F = 200 ms" is a point on the backend curve the threshold is derived over, not the withdrawn remote WAL internal/controlplane/clone.go:MaxChainDepth:compaction # the ceiling's cost is per commit of the ancestor, and §21's compaction is what bounds an ancestor's growth — the same subject, on the Control Plane's side. internal/controlplane/clone.go:Clone:compaction # two constraints on §19's compaction: what it must leave true about a clone's depth, and that a clone mints a fresh DEK or breaks crypto-shred. -internal/qcow/compact.go:CompactionPolicy.AtLayers:compaction # §21's chain compaction, which this file performs. The withdrawn "compaction" the checker knows is v5's objectization of WAL extents, a different subject that shares a word. -internal/qcow/compact.go:CompactionPolicy.Validate:compaction # same subject: the thresholds §21 says must be measured rather than chosen. -internal/qcow/compact.go:Manager.compact:gc # same subject: what a collapse deliberately does not delete, and whose job that is instead. -internal/qcow/compact.go:Manager.recordCollapsed:compaction # same subject: why a collapse leaves the RPO anchor where it is — it publishes nothing the guest wrote. -internal/qcow/compact.go:compaction:compaction # same subject: the package comment naming what this file is for. -internal/qcow/chain.go:Paths.Rename:compaction # same subject: the one caller of Rename is the flattened root a collapse builds under a temporary name. -internal/qcow/chain.go:checkNotStale:compaction # same subject: a HEAD this host's own collapse published is not a chain another host moved past. -internal/qcow/sweep.go:sweep:compaction # same subject: the half-written image a killed convert leaves, which is the one non-layer file the sweep takes. -internal/qcow/manager.go:Config.Compaction:compaction # same subject: the policy an operator sets, zero being "collapse nothing until somebody has measured". -internal/qcow/manager.go:Manager.reconcile:compaction # same subject: where a collapse runs in the cycle, and why it runs for an unattached volume too. -internal/qcow/sweep.go:LiveImage:compaction # §21's collapse, which is live: the sentence is about the `.compacting` file a killed `qemu-img convert` leaves, and why the sweep does take that one +qcow/compact.go:CompactionPolicy.AtLayers:compaction # §21's chain compaction, which this file performs. The withdrawn "compaction" the checker knows is v5's objectization of WAL extents, a different subject that shares a word. +qcow/compact.go:CompactionPolicy.Validate:compaction # same subject: the thresholds §21 says must be measured rather than chosen. +qcow/compact.go:Manager.compact:gc # same subject: what a collapse deliberately does not delete, and whose job that is instead. +qcow/compact.go:Manager.recordCollapsed:compaction # same subject: why a collapse leaves the RPO anchor where it is — it publishes nothing the guest wrote. +qcow/compact.go:compaction:compaction # same subject: the package comment naming what this file is for. +qcow/chain.go:Paths.Rename:compaction # same subject: the one caller of Rename is the flattened root a collapse builds under a temporary name. +qcow/chain.go:checkNotStale:compaction # same subject: a HEAD this host's own collapse published is not a chain another host moved past. +qcow/sweep.go:sweep:compaction # same subject: the half-written image a killed convert leaves, which is the one non-layer file the sweep takes. +qcow/manager.go:Config.Compaction:compaction # same subject: the policy an operator sets, zero being "collapse nothing until somebody has measured". +qcow/manager.go:Manager.reconcile:compaction # same subject: where a collapse runs in the cycle, and why it runs for an unattached volume too. +qcow/sweep.go:LiveImage:compaction # §21's collapse, which is live: the sentence is about the `.compacting` file a killed `qemu-img convert` leaves, and why the sweep does take that one diff --git a/hack/comment-rot.sh b/hack/comment-rot.sh index 543156c..5de49ec 100755 --- a/hack/comment-rot.sh +++ b/hack/comment-rot.sh @@ -2,7 +2,7 @@ # Which production comments still describe a mechanism this tree withdrew — computed, # not remembered. # -# A finding is a (symbol, term) pair — internal/qcow/chain.go:Open:checkpoint. Rejected +# A finding is a (symbol, term) pair — qcow/chain.go:Open:checkpoint. Rejected # keys: file:line (moves on any edit above it), the whole file (collapses a file's eight # mentions into one decision), the line's text hashed (a reword fails the gate with a # message about a hash). diff --git a/hack/deadcode-allow.txt b/hack/deadcode-allow.txt index 5c93c58..afda201 100644 --- a/hack/deadcode-allow.txt +++ b/hack/deadcode-allow.txt @@ -48,7 +48,7 @@ internal/lifecycle.RefusalNames # compared with volumes.refusal's CHECK list # --- the observability test provider ---------------------------------------------------- # obs.NewTestProvider is why every metric in this repository is proven by reading the # collected value rather than by checking that a recorder was called. It cannot live in a -# _test.go file: internal/agent, internal/qcow and internal/obs's own tests all import it. +# _test.go file: internal/agent, qcow and internal/obs's own tests all import it. internal/obs.NewTestProvider # the in-memory meter provider tests collect from; production wires the OTLP one internal/obs.Provider.CollectedMetrics # reading it is how a test asserts on what left the process internal/obs.Provider.GaugeValues # same, for gauges @@ -82,4 +82,4 @@ package internal/simio/network # harness-only: the DST partition scenario drive # it agreeing with the scenario and with nothing else, which is the shape a checker cannot # catch — so the production function is exported and a binary reaches it through # Manager.Apply. -internal/qcow.Sweep # driven by internal/dst; production reaches the same code through Manager.Apply +qcow.Sweep # driven by internal/dst; production reaches the same code through Manager.Apply diff --git a/hack/refs-allow.txt b/hack/refs-allow.txt index c930411..0893304 100644 --- a/hack/refs-allow.txt +++ b/hack/refs-allow.txt @@ -36,7 +36,6 @@ hack/analyzers/simulable/simulable.go internal/testinfradriver # deliberately h docs/plan/DECISIONS/ADR-0026-v1-accepts-session-rpo.md internal/wal # an ADR records the decision as it was taken; the package it names is what the decision was about # --- other repositories ----------------------------------------------------------------- -internal/qcow/chain.go cmd/runner # spin's runner, which launches the VMs (ADR-0021). Not ours to resolve. docs/plan/DECISIONS/ADR-0021-integration-with-spin.md cmd/cli # spin's operator CLI, the reason storage ships no volctl # --- a regex that reads as a path -------------------------------------------------------- diff --git a/integration/e2e/compaction_test.go b/integration/e2e/compaction_test.go index 9b24b2b..4865ff7 100644 --- a/integration/e2e/compaction_test.go +++ b/integration/e2e/compaction_test.go @@ -15,10 +15,10 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/real" "github.com/spin-stack/storage/internal/simio/sim" "github.com/spin-stack/storage/internal/testinfra" + "github.com/spin-stack/storage/qcow" ) // The volume this lane collapses. Small, because every layer of it is written in full. @@ -104,7 +104,7 @@ func TestACompactedRootReadsBackAsTheChainItReplaced(t *testing.T) { Disk: sim.NewDisk(), // No VM: the QMP socket does not exist, so the volume is unattached and the // collapse can finish in one cycle. A rebase under a running guest is what v6 §5 - // forbids, and internal/qcow's own tests hold that end. + // forbids, and qcow's own tests hold that end. Runner: real.NewRunner(), Paths: files, Dialer: real.NewUnixDialer(), Recovery: noHistory{}, Publisher: pub, }) diff --git a/integration/e2e/datadir_test.go b/integration/e2e/datadir_test.go index a438cd3..9419496 100644 --- a/integration/e2e/datadir_test.go +++ b/integration/e2e/datadir_test.go @@ -26,7 +26,7 @@ import ( // // The lock has moved twice and this test is why the moves were safe: into `main` when the // volume manager that used to take it was withdrawn, and back into the volume manager -// (internal/qcow) when Stage 1 gave it a directory layout to own. What it asserts is +// (qcow) when Stage 1 gave it a directory layout to own. What it asserts is // about the *process*, not about which type inside it holds the descriptor — which is // exactly the property that let the second move happen without a second argument. // diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e31ee9a..5ed3a38 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -8,7 +8,7 @@ // device, and the set of volumes this host is serving. cmd/volume-agent is the only // place the real implementations are constructed. // -// The volume manager behind the VolumeSource is internal/qcow: QEMU owns the local +// The volume manager behind the VolumeSource is qcow: QEMU owns the local // copy-on-write format, and what is left in this package is the half that talks to the // Control Plane. VolumeReconciler is the seam, qcow.Manager plugs into it, and VolumeSet // is what the loop's own tests drive. diff --git a/internal/dst/checkers_reconcile.go b/internal/dst/checkers_reconcile.go index 378ec9b..c9febc0 100644 --- a/internal/dst/checkers_reconcile.go +++ b/internal/dst/checkers_reconcile.go @@ -22,7 +22,7 @@ func reconcileCheckers() []Checker { // A publish naming a layer no seal event named is not this checker's subject and is // skipped: `two-hosts-cannot-both-publish` publishes layers that were never rotated into // being, and publishing a layer that is *not* sealed — the tip, with a guest still -// writing into it — is asserted through the Manager in internal/qcow's reconcile +// writing into it — is asserted through the Manager in qcow's reconcile // adversary lane, where the publisher can see which file it was handed. type SealOrderChecker struct { sealed []string diff --git a/internal/dst/planted_bug_test.go b/internal/dst/planted_bug_test.go index 90e6215..9c14202 100644 --- a/internal/dst/planted_bug_test.go +++ b/internal/dst/planted_bug_test.go @@ -292,7 +292,7 @@ func TestSingleWriterCheckerCatchesAdvisoryPreconditions(t *testing.T) { // // Each was written, run, and watched go red before the line that closes it existed. The // second is the sharp one and it is still checkable in one edit: delete the -// `keep[LayerIDOfImage(open.Path)]` line in internal/qcow/sweep.go and +// `keep[LayerIDOfImage(open.Path)]` line in qcow/sweep.go and // `sweepScenario(true)` removes the layer a guest is writing into, which is how that line // came to be there. // diff --git a/internal/dst/scenarios_reconcile.go b/internal/dst/scenarios_reconcile.go index 82ac8c1..ae77564 100644 --- a/internal/dst/scenarios_reconcile.go +++ b/internal/dst/scenarios_reconcile.go @@ -9,8 +9,8 @@ import ( "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/crypto" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/objectstore" + "github.com/spin-stack/storage/qcow" ) // reconcileScenarios is the half of the reconciler a simulation can reach. @@ -19,7 +19,7 @@ import ( // chain, ask a live QEMU which file it has open, snapshot a new overlay over the tip — // is another process's work, and a fake for it here would be a second implementation of // qemu-img: a model of a program, agreeing with the scenario and with nothing else. That -// half is tested against the real binary, in internal/qcow's adversary lane +// half is tested against the real binary, in qcow's adversary lane // (adversary_reconcile, adversary_crash-points, adversary_qcow-restart) and end to end by // `task demo:stage2`. // @@ -61,7 +61,7 @@ const reconcileVirtualSize = 1 << 30 // coming up and the power failure is acknowledged and not persisted, which is what a // device with a volatile write cache and no flush does; the power failure then takes them // all. Deliberately not from the very first write: a state.json that was never durable at -// all comes back zero-length, and ReadState refuses that outright (internal/qcow's state +// all comes back zero-length, and ReadState refuses that outright (qcow's state // table covers it). What this reaches is the dangerous shape instead — a record that is // framed, digest-sound, believable, and one power failure out of date. func reconcileScenario(volatileCache bool) Scenario { diff --git a/internal/dst/scenarios_sweep.go b/internal/dst/scenarios_sweep.go index f10205b..35d0d4e 100644 --- a/internal/dst/scenarios_sweep.go +++ b/internal/dst/scenarios_sweep.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // sweepScenarios drives the one rule in this system that deletes files. @@ -14,7 +14,7 @@ import ( // records and the pointers, and both arrive through I/O this package can break. What it // cannot reach is the live QEMU: `Manager.Apply` asks a running guest which image it has // open and refuses a volume it cannot account for, and a fake for that here would be a -// model of qemu-img agreeing with the scenario. That half is internal/qcow's. +// model of qemu-img agreeing with the scenario. That half is qcow's. // // The fault is a disk that acknowledges an fsync it does not honour. It is aimed here // rather than anywhere else because the sweep's keep-set comes from the records, so a diff --git a/internal/lifecycle/lifecycle.go b/internal/lifecycle/lifecycle.go index 8ba1da6..a09d00f 100644 --- a/internal/lifecycle/lifecycle.go +++ b/internal/lifecycle/lifecycle.go @@ -280,7 +280,7 @@ func (r CordonReason) OverwritableNames() []string { return names(cordonOverwrit // `err.Error()`, which embeds a volume id and a path, so no two rows compare equal, the // -fleet-status column becomes a vocabulary nobody controls, and the first alert written on // it matches a substring. Every value here is a decision at a named line in internal/agent -// or internal/qcow, so extending the vocabulary is the same commit. The sentence an operator +// or qcow, so extending the vocabulary is the same commit. The sentence an operator // needs — which layer, which key — rides alongside as free text nothing branches on // (metadata.VolumeProgress.RefusalDetail). // diff --git a/internal/publisher/metrics_test.go b/internal/publisher/metrics_test.go index d1a5fa1..1b13c8d 100644 --- a/internal/publisher/metrics_test.go +++ b/internal/publisher/metrics_test.go @@ -53,7 +53,7 @@ func TestPublishRecordsWhatLeftTheHost(t *testing.T) { if sent[labels] != stored.Size { t.Errorf("layer_upload_bytes_total = %d, want %d", sent[labels], stored.Size) } - // The plaintext is the number this must *not* be: it is what internal/qcow measures + // The plaintext is the number this must *not* be: it is what qcow measures // locally, and reporting it here would understate the bucket by a tag per frame. if stored.Size == int64(len(w.plain)) { t.Fatal("the sealed object is the same length as the plaintext; this test proves nothing") diff --git a/internal/publisher/publisher.go b/internal/publisher/publisher.go index a41070d..fcce912 100644 --- a/internal/publisher/publisher.go +++ b/internal/publisher/publisher.go @@ -1,6 +1,6 @@ // Package publisher turns a sealed layer on this host's disk into a published commit. // -// internal/qcow owns a volume's local chain and decides *when* a layer is ready; this is +// qcow owns a volume's local chain and decides *when* a layer is ready; this is // *what* happens then — fetch the volume's key, read the file, and run v6 §9's publish // protocol. It needs a Control Plane and an object store; the chain needs neither. package publisher @@ -16,9 +16,9 @@ import ( "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/crypto" "github.com/spin-stack/storage/internal/obs" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/clock" "github.com/spin-stack/storage/internal/simio/objectstore" + "github.com/spin-stack/storage/qcow" ) // Keys hands over a volume's wrapped key material. It is agent.Loop in production: the @@ -81,7 +81,7 @@ func (p *Publisher) Publish(ctx context.Context, l qcow.SealedLayer) error { return err } // The sealed length, which is what left this host and what the bucket is charged - // for; the qcow2's own length is the local number and internal/qcow records it. + // for; the qcow2's own length is the local number and qcow records it. volume := obs.String("volume", l.VolumeID) p.rec.Observe(ctx, "layer_size_bytes", float64(m.Layer.SizeBytes), volume) p.rec.Count(ctx, "layer_upload_bytes_total", m.Layer.SizeBytes, volume) diff --git a/internal/publisher/publisher_test.go b/internal/publisher/publisher_test.go index 8c7fe4c..090bdd7 100644 --- a/internal/publisher/publisher_test.go +++ b/internal/publisher/publisher_test.go @@ -16,8 +16,8 @@ import ( "github.com/spin-stack/storage/internal/crypto" "github.com/spin-stack/storage/internal/ids" "github.com/spin-stack/storage/internal/publisher" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // fakeKeys is the Control Plane's half: it hands over a volume's wrapped DEK, or refuses diff --git a/internal/qmp/qmp.go b/internal/qmp/qmp.go index 61c8c8b..5eeae35 100644 --- a/internal/qmp/qmp.go +++ b/internal/qmp/qmp.go @@ -3,7 +3,7 @@ // // # Why there is a client here at all // -// The Agent does not run QEMU (see internal/qcow): it prepares a volume's chain and has no +// The Agent does not run QEMU (see qcow): it prepares a volume's chain and has no // other way to know whether anything is using it. QMP is the only channel that answers, and // v6 §7 makes it the mandatory one — flush, snapshot, switch to the new tip, and confirm QEMU // is using it. diff --git a/internal/recovery/adversary_blank-disk_test.go b/internal/recovery/adversary_blank-disk_test.go index 127fea5..7e8d1fb 100644 --- a/internal/recovery/adversary_blank-disk_test.go +++ b/internal/recovery/adversary_blank-disk_test.go @@ -11,9 +11,9 @@ import ( "sync" "testing" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" realio "github.com/spin-stack/storage/internal/simio/real" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryAHeadThatNamesNoCommitCrashesTheRestore. diff --git a/internal/recovery/adversary_corrupt-layer_test.go b/internal/recovery/adversary_corrupt-layer_test.go index fa5f1c1..033b342 100644 --- a/internal/recovery/adversary_corrupt-layer_test.go +++ b/internal/recovery/adversary_corrupt-layer_test.go @@ -12,8 +12,8 @@ import ( "testing" "github.com/spin-stack/storage/internal/commit" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" + "github.com/spin-stack/storage/qcow" ) // rottenRunner is a qemu-img that opens the file before it answers about it. diff --git a/internal/recovery/adversary_formats_test.go b/internal/recovery/adversary_formats_test.go index e630394..e508aa6 100644 --- a/internal/recovery/adversary_formats_test.go +++ b/internal/recovery/adversary_formats_test.go @@ -8,9 +8,9 @@ import ( "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/framed" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" "github.com/spin-stack/storage/internal/simio/objectstore" + "github.com/spin-stack/storage/qcow" ) // putFramed writes a hand-built structural object the way a writer of this format would: diff --git a/internal/recovery/adversary_rebuild_test.go b/internal/recovery/adversary_rebuild_test.go index ff4a3ea..943d795 100644 --- a/internal/recovery/adversary_rebuild_test.go +++ b/internal/recovery/adversary_rebuild_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryALayerIdThisHostHoldsForAnotherVolumeIsNotStoodInForThisOne. diff --git a/internal/recovery/depth_cost_test.go b/internal/recovery/depth_cost_test.go index 362629f..bf871c5 100644 --- a/internal/recovery/depth_cost_test.go +++ b/internal/recovery/depth_cost_test.go @@ -18,10 +18,10 @@ import ( "github.com/spin-stack/storage/internal/crypto" "github.com/spin-stack/storage/internal/descriptor" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" "github.com/spin-stack/storage/internal/simio/objectstore" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // countingStore records the reads a rebuild makes, in order. Reads only: a restore writes diff --git a/internal/recovery/lineage_guard_test.go b/internal/recovery/lineage_guard_test.go index c410c38..713a8fa 100644 --- a/internal/recovery/lineage_guard_test.go +++ b/internal/recovery/lineage_guard_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" + "github.com/spin-stack/storage/qcow" ) // TestAGenerationNamingNoCommitIsRefusedRatherThanIndexed. diff --git a/internal/recovery/metrics_test.go b/internal/recovery/metrics_test.go index 3a8a828..6c1e705 100644 --- a/internal/recovery/metrics_test.go +++ b/internal/recovery/metrics_test.go @@ -7,8 +7,8 @@ import ( "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/ids" "github.com/spin-stack/storage/internal/obs" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // The §28 recovery numbers. Both are asserted against what the bucket says the chain diff --git a/internal/recovery/recovery.go b/internal/recovery/recovery.go index b437993..514f7fa 100644 --- a/internal/recovery/recovery.go +++ b/internal/recovery/recovery.go @@ -21,9 +21,9 @@ import ( "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/crypto" "github.com/spin-stack/storage/internal/obs" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/clock" "github.com/spin-stack/storage/internal/simio/objectstore" + "github.com/spin-stack/storage/qcow" ) // ErrIncomplete means this volume has published commits and this host could not assemble diff --git a/internal/recovery/recovery_test.go b/internal/recovery/recovery_test.go index b215f4c..cb4aabb 100644 --- a/internal/recovery/recovery_test.go +++ b/internal/recovery/recovery_test.go @@ -21,10 +21,10 @@ import ( "github.com/spin-stack/storage/internal/crypto" "github.com/spin-stack/storage/internal/framed" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" "github.com/spin-stack/storage/internal/simio/objectstore" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) const ( diff --git a/internal/simio/real/host.go b/internal/simio/real/host.go index 7b2ff04..ae54b2f 100644 --- a/internal/simio/real/host.go +++ b/internal/simio/real/host.go @@ -18,7 +18,7 @@ import ( // a stream — and not disk.Disk, which is a rooted namespace of append-only files that // deliberately hides absolute paths and any way to hand a file to somebody else. Both are // what qcow2 needs: QEMU and `qemu-img` are other processes taking a path on the command -// line. The interfaces these satisfy are declared where they are consumed (internal/qcow, +// line. The interfaces these satisfy are declared where they are consumed (qcow, // internal/qmp), so a test fakes them without importing this package. // Runner runs external programs to completion. It is what `qemu-img` is reached diff --git a/internal/qcow/adversary_blank-disk_test.go b/qcow/adversary_blank-disk_test.go similarity index 99% rename from internal/qcow/adversary_blank-disk_test.go rename to qcow/adversary_blank-disk_test.go index 8c30ddc..2450f84 100644 --- a/internal/qcow/adversary_blank-disk_test.go +++ b/qcow/adversary_blank-disk_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/recovery" "github.com/spin-stack/storage/internal/simio/real" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryTheRealAgentRefusesToStartBecauseNothingWiresTheGuard. diff --git a/internal/qcow/adversary_catalog-down_test.go b/qcow/adversary_catalog-down_test.go similarity index 99% rename from internal/qcow/adversary_catalog-down_test.go rename to qcow/adversary_catalog-down_test.go index 9c63e28..017b782 100644 --- a/internal/qcow/adversary_catalog-down_test.go +++ b/qcow/adversary_catalog-down_test.go @@ -12,8 +12,8 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/api/gen/spin/storage/v1/storagev1connect" "github.com/spin-stack/storage/internal/agent" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/disk" + "github.com/spin-stack/storage/qcow" ) // catalogHost is this host's fleet identity: a real v7 id, because Config.Validate diff --git a/internal/qcow/adversary_crash-points_test.go b/qcow/adversary_crash-points_test.go similarity index 99% rename from internal/qcow/adversary_crash-points_test.go rename to qcow/adversary_crash-points_test.go index cc0f77c..5e89969 100644 --- a/internal/qcow/adversary_crash-points_test.go +++ b/qcow/adversary_crash-points_test.go @@ -10,8 +10,8 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/agent" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // These are adversarial tests. Each one puts the crash exactly where the production diff --git a/internal/qcow/adversary_disk-full_test.go b/qcow/adversary_disk-full_test.go similarity index 99% rename from internal/qcow/adversary_disk-full_test.go rename to qcow/adversary_disk-full_test.go index 385ccf7..f37fc7e 100644 --- a/internal/qcow/adversary_disk-full_test.go +++ b/qcow/adversary_disk-full_test.go @@ -7,8 +7,8 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/disk" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryTheDeviceFillsWhileALayerIsSealedAndNothingSealedIsForgotten is v6 §22's diff --git a/internal/qcow/adversary_fencing_test.go b/qcow/adversary_fencing_test.go similarity index 99% rename from internal/qcow/adversary_fencing_test.go rename to qcow/adversary_fencing_test.go index 41fd1e4..4976996 100644 --- a/internal/qcow/adversary_fencing_test.go +++ b/qcow/adversary_fencing_test.go @@ -6,7 +6,7 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/commit" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryAFencedHostPublishesAgainAfterARestart. PUBLISH_FENCED is the strongest diff --git a/internal/qcow/adversary_formats_test.go b/qcow/adversary_formats_test.go similarity index 97% rename from internal/qcow/adversary_formats_test.go rename to qcow/adversary_formats_test.go index 1a6277d..3964bf2 100644 --- a/internal/qcow/adversary_formats_test.go +++ b/qcow/adversary_formats_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // otherVol is a second volume placed on the same host, as two volumes on one Agent are. diff --git a/internal/qcow/adversary_host-reboot_test.go b/qcow/adversary_host-reboot_test.go similarity index 99% rename from internal/qcow/adversary_host-reboot_test.go rename to qcow/adversary_host-reboot_test.go index 8da4838..2898c93 100644 --- a/internal/qcow/adversary_host-reboot_test.go +++ b/qcow/adversary_host-reboot_test.go @@ -5,7 +5,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryAHostRebootReattachesTheChainItsGuestLetGoOf is the reboot kill-point: the diff --git a/internal/qcow/adversary_live-corruption_test.go b/qcow/adversary_live-corruption_test.go similarity index 98% rename from internal/qcow/adversary_live-corruption_test.go rename to qcow/adversary_live-corruption_test.go index b9a40d8..a5c33eb 100644 --- a/internal/qcow/adversary_live-corruption_test.go +++ b/qcow/adversary_live-corruption_test.go @@ -6,7 +6,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // attachedToCorrupt scripts a QEMU whose guest has corrupted its own image: the same diff --git a/internal/qcow/adversary_qcow-chain_test.go b/qcow/adversary_qcow-chain_test.go similarity index 99% rename from internal/qcow/adversary_qcow-chain_test.go rename to qcow/adversary_qcow-chain_test.go index 86bab9e..53916ad 100644 --- a/internal/qcow/adversary_qcow-chain_test.go +++ b/qcow/adversary_qcow-chain_test.go @@ -6,7 +6,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryTheOldestSealedLayerIsPublishedFirstWhenAPublisherArrives attacks the one diff --git a/internal/qcow/adversary_qcow-restart_test.go b/qcow/adversary_qcow-restart_test.go similarity index 99% rename from internal/qcow/adversary_qcow-restart_test.go rename to qcow/adversary_qcow-restart_test.go index fe367df..12126ea 100644 --- a/internal/qcow/adversary_qcow-restart_test.go +++ b/qcow/adversary_qcow-restart_test.go @@ -8,7 +8,7 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/commit" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryAFencedHostResumesItsForkWhenTheGuestNeverStopped is the re-grant hole diff --git a/internal/qcow/adversary_qcow-twohosts_test.go b/qcow/adversary_qcow-twohosts_test.go similarity index 99% rename from internal/qcow/adversary_qcow-twohosts_test.go rename to qcow/adversary_qcow-twohosts_test.go index 53413ed..a9aa40e 100644 --- a/internal/qcow/adversary_qcow-twohosts_test.go +++ b/qcow/adversary_qcow-twohosts_test.go @@ -5,7 +5,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // successorCommit is what the object store's HEAD names after another host took this diff --git a/internal/qcow/adversary_reconcile_test.go b/qcow/adversary_reconcile_test.go similarity index 99% rename from internal/qcow/adversary_reconcile_test.go rename to qcow/adversary_reconcile_test.go index 1cc201e..3302a7b 100644 --- a/internal/qcow/adversary_reconcile_test.go +++ b/qcow/adversary_reconcile_test.go @@ -6,7 +6,7 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // These two attack the reconciler through the one input it cannot check: a state.json diff --git a/internal/qcow/adversary_snapshot-kill_test.go b/qcow/adversary_snapshot-kill_test.go similarity index 99% rename from internal/qcow/adversary_snapshot-kill_test.go rename to qcow/adversary_snapshot-kill_test.go index 8f01983..90b895f 100644 --- a/internal/qcow/adversary_snapshot-kill_test.go +++ b/qcow/adversary_snapshot-kill_test.go @@ -10,8 +10,8 @@ import ( "time" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // The rotation seam, killed at the two points either side of the QMP command. diff --git a/internal/qcow/adversary_store-down_test.go b/qcow/adversary_store-down_test.go similarity index 99% rename from internal/qcow/adversary_store-down_test.go rename to qcow/adversary_store-down_test.go index d1506ff..90041f5 100644 --- a/internal/qcow/adversary_store-down_test.go +++ b/qcow/adversary_store-down_test.go @@ -6,7 +6,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // TestAdversaryTheBucketIsDownForManyCyclesAndTheGuestKeepsItsDisk is v6 §15's row "S3 diff --git a/internal/qcow/chain.go b/qcow/chain.go similarity index 97% rename from internal/qcow/chain.go rename to qcow/chain.go index 718a2cf..b8b8f77 100644 --- a/internal/qcow/chain.go +++ b/qcow/chain.go @@ -1,20 +1,33 @@ // Package qcow owns a volume's local qcow2 chain: where it lives, how it is created, // how an existing one is opened and checked, and which file is the tip QEMU writes to. // +// It is public because it spans two repositories. ADR-0021 §4 fixed the shape of this +// surface and left its name to settle when a consumer existed; a consumer exists, and +// this is it. Everything under internal/ stays free to change. +// // This process does not launch QEMU. QEMU is the data path (v6 §4) and the Agent only -// controls it over QMP; spin's `cmd/runner` runs the VMs (ADR-0021), and launching them -// here would give this daemon a second responsibility — VM supervision, with the process -// lifetime and crash policy that come with it — which the pivot to qcow2 exists to shed. +// controls it over QMP; launching VMs here would give this daemon a second +// responsibility — VM supervision, with the process lifetime and crash policy that come +// with it — which the pivot to qcow2 exists to shed. // // # The contract with whoever does launch it // // Two paths, and nothing else. Whoever boots the VM must give QEMU: // -// - the image at ActiveImage(root, volumeID) as the disk it writes to, and -// - a QMP socket at QMPSocket(root, volumeID), server side. +// - the qcow2 named by the one line in the file at ActivePointer(root, volumeID) as +// the disk it writes to — the pointer and not a path of the launcher's own, because +// rotation replaces the tip under a running VM, and +// - a QMP socket at QMPSocket(root, volumeID), server side: +// -qmp unix:,server=on,wait=off. // // Both are derived from the volume's directory, so the launcher needs only the data -// directory and the volume id. It is small on purpose: it spans two repositories. +// directory and the volume id. +// +// One prohibition comes with them: **the launcher must leave qcow2 file locking on**, +// which is QEMU's default and which nothing should turn off. It is what makes a running +// guest visible to a tool that cannot ask QEMU — see writeLockRefusal, where the +// measurement is. With locking off this package reads a live volume as an idle one and +// nothing here can detect that. // // # The rule about offline tools // diff --git a/internal/qcow/chain_test.go b/qcow/chain_test.go similarity index 99% rename from internal/qcow/chain_test.go rename to qcow/chain_test.go index ca0b19f..4f234a3 100644 --- a/internal/qcow/chain_test.go +++ b/qcow/chain_test.go @@ -12,7 +12,7 @@ import ( "testing" "github.com/spin-stack/storage/internal/commit" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // fakeRunner stands in for `qemu-img`. It records every argv it was handed, which is diff --git a/internal/qcow/compact.go b/qcow/compact.go similarity index 100% rename from internal/qcow/compact.go rename to qcow/compact.go diff --git a/internal/qcow/compact_test.go b/qcow/compact_test.go similarity index 99% rename from internal/qcow/compact_test.go rename to qcow/compact_test.go index bc104db..91a8338 100644 --- a/internal/qcow/compact_test.go +++ b/qcow/compact_test.go @@ -15,8 +15,8 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/commit" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // A chain of four: three published layers and the tip the guest writes to. The sizes are diff --git a/internal/qcow/diskbytes_test.go b/qcow/diskbytes_test.go similarity index 97% rename from internal/qcow/diskbytes_test.go rename to qcow/diskbytes_test.go index 146045a..73e22a9 100644 --- a/internal/qcow/diskbytes_test.go +++ b/qcow/diskbytes_test.go @@ -4,7 +4,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // local_disk_bytes is what ONE volume's chain occupies, not what the host holds. diff --git a/internal/qcow/export_test.go b/qcow/export_test.go similarity index 100% rename from internal/qcow/export_test.go rename to qcow/export_test.go diff --git a/internal/qcow/lineage_test.go b/qcow/lineage_test.go similarity index 98% rename from internal/qcow/lineage_test.go rename to qcow/lineage_test.go index 095443c..d928487 100644 --- a/internal/qcow/lineage_test.go +++ b/qcow/lineage_test.go @@ -4,7 +4,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // Two generations of ancestry, spelled out the way the Control Plane sends them: oldest diff --git a/internal/qcow/manager.go b/qcow/manager.go similarity index 100% rename from internal/qcow/manager.go rename to qcow/manager.go diff --git a/internal/qcow/manager_test.go b/qcow/manager_test.go similarity index 99% rename from internal/qcow/manager_test.go rename to qcow/manager_test.go index 6af77f8..d3712af 100644 --- a/internal/qcow/manager_test.go +++ b/qcow/manager_test.go @@ -13,8 +13,8 @@ import ( "github.com/spin-stack/storage/internal/agent" "github.com/spin-stack/storage/internal/commit" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" "github.com/spin-stack/storage/internal/simio/sim" + "github.com/spin-stack/storage/qcow" ) // scriptConn is a QMP endpoint whose answers were decided in advance. The exchange is diff --git a/internal/qcow/reclaim.go b/qcow/reclaim.go similarity index 100% rename from internal/qcow/reclaim.go rename to qcow/reclaim.go diff --git a/internal/qcow/reclaim_test.go b/qcow/reclaim_test.go similarity index 99% rename from internal/qcow/reclaim_test.go rename to qcow/reclaim_test.go index 10a665f..0360cac 100644 --- a/internal/qcow/reclaim_test.go +++ b/qcow/reclaim_test.go @@ -6,7 +6,7 @@ import ( "testing" storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // fakeWitness is the object store's answer to "who holds this volume now": the epoch diff --git a/internal/qcow/state.go b/qcow/state.go similarity index 100% rename from internal/qcow/state.go rename to qcow/state.go diff --git a/internal/qcow/state_derive_test.go b/qcow/state_derive_test.go similarity index 100% rename from internal/qcow/state_derive_test.go rename to qcow/state_derive_test.go diff --git a/internal/qcow/state_test.go b/qcow/state_test.go similarity index 99% rename from internal/qcow/state_test.go rename to qcow/state_test.go index 641da75..303d25b 100644 --- a/internal/qcow/state_test.go +++ b/qcow/state_test.go @@ -14,7 +14,7 @@ import ( "github.com/spin-stack/storage/internal/framed" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // statePaths is the little filesystem ReadState and WriteState need: names, and the diff --git a/internal/qcow/sweep.go b/qcow/sweep.go similarity index 100% rename from internal/qcow/sweep.go rename to qcow/sweep.go diff --git a/internal/qcow/sweep_test.go b/qcow/sweep_test.go similarity index 99% rename from internal/qcow/sweep_test.go rename to qcow/sweep_test.go index 29794eb..5c0c629 100644 --- a/internal/qcow/sweep_test.go +++ b/qcow/sweep_test.go @@ -9,7 +9,7 @@ import ( storagev1 "github.com/spin-stack/storage/api/gen/spin/storage/v1" "github.com/spin-stack/storage/internal/ids" - "github.com/spin-stack/storage/internal/qcow" + "github.com/spin-stack/storage/qcow" ) // rotateOnce runs the cycle that seals `tip` and returns the layer the guest moved on From eba53a25722a87292c3c53c3b1eb2bf678a5648b Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 7 Sep 2026 23:13:51 -0300 Subject: [PATCH 2/2] buf breaking has never run on a pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proto compatibility check compares against main through a git input, and buf treats the path as a *remote*: it asks git for `refs/heads/main` inside it. A developer's checkout has that branch. A pull request's does not — actions/checkout leaves HEAD detached at the merge commit and creates no local branch — so the check has failed on every PR it has ever run on, with Failure: could not clone file:///home/runner/work/storage/storage/.git: exit status 128 fatal: couldn't find remote ref main which reads like an infrastructure fault rather than like a check that was never running. A push to main passed, because there HEAD *is* main: the lane was green exactly when it had nothing to compare, and red exactly when it had something. `ref=origin/main` instead, which is what "main is what spin will be built against" meant anyway — the remote's main, and not whatever a local branch of that name happens to point at. Reproduced by cloning this repository, detaching HEAD and deleting the local branch: `branch=main` fails there with the message above and `ref=origin/main` exits 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019GHRRFvbnZi5q3KSTvyW5a --- taskfiles/generate.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/taskfiles/generate.yml b/taskfiles/generate.yml index 473655f..a039303 100644 --- a/taskfiles/generate.yml +++ b/taskfiles/generate.yml @@ -63,4 +63,22 @@ tasks: # # Against main and not against a tag: main is what spin will be built against. # It needs the history, which is why ci.yml checks out with fetch-depth 0. - - "{{.TOOLS_BIN}}/buf breaking --against '../.git#branch=main,subdir=api'" + # + # `ref=origin/main` and not `branch=main`, because buf treats the path as a + # *remote* and asks git for `refs/heads/main` in it. A developer's checkout + # has that; a pull request's does not — actions/checkout leaves HEAD detached + # at the merge commit and creates no local branch — so every PR this check has + # ever run on failed the same way, and it read like an infrastructure fault + # rather than like a check that was never running: + # + # Failure: could not clone file:///home/runner/work/storage/storage/.git: + # exit status 128 + # fatal: couldn't find remote ref main + # + # A push to main passed, because there HEAD *is* main, which is why this went + # unnoticed: the lane was green exactly when it had nothing to compare. + # Reproduced 2026-09-08 by detaching HEAD in a clone and deleting the local + # branch; `ref=origin/main` exits 0 in that same tree, and is what "main is + # what spin will be built against" meant anyway — the remote's main, not + # whatever a local branch of that name happens to point at. + - "{{.TOOLS_BIN}}/buf breaking --against '../.git#ref=origin/main,subdir=api'"