| … | … | @@ -24,7 +24,7 @@ import os from '#node:os' |
| 24 | 24 | import { spawn } from '#node:child_process' |
| 25 | 25 | |
| 26 | 26 | import { loadCIConfig, pickChannel, deriveVersion, runSteps, collectArtifacts } from './ci-runner.js' |
| 27 | import { publishVersionToDrive } from './vdrive-publish.js' | |
| 27 | import { publishVersionToDrive, deriveVdrivePrimaryKey } from './vdrive-publish.js' | |
| 28 | 28 | |
| 29 | 29 | export async function buildAndPublish ({ |
| 30 | 30 | sync, |
| … | … | @@ -35,6 +35,7 @@ export async function buildAndPublish ({ |
| 35 | 35 | version: forcedVersion, |
| 36 | 36 | channel: forcedChannel, |
| 37 | 37 | vdriveSeeder, |
| 38 | vdriveRootKey = null, | |
| 38 | 39 | onLog |
| 39 | 40 | } = {}) { |
| 40 | 41 | if (!sync) throw new Error('buildAndPublish requires sync service') |
| … | … | @@ -141,6 +142,9 @@ export async function buildAndPublish ({ |
| 141 | 142 | version, |
| 142 | 143 | channel, |
| 143 | 144 | vdrive: config.vdrive, |
| 145 | // Derived from the daemon's long-lived corestore, so losing the drive | |
| 146 | // store is a rebuild rather than a permanently dead link (#145). | |
| 147 | primaryKey: vdriveRootKey ? deriveVdrivePrimaryKey(vdriveRootKey, repo) : null, | |
| 144 | 148 | onLog |
| 145 | 149 | }) |
| 146 | 150 | if (vdrive.skipped) log(onLog, `[ci] vdrive skipped: ${vdrive.reason}`) |
| … | … | @@ -222,7 +226,7 @@ export async function snapshotRefs (repoDir) { |
| 222 | 226 | * Only refs under refs/heads/ (branches) and refs/tags/ (tags) are |
| 223 | 227 | * considered — refs/stash, refs/notes, etc. are ignored. |
| 224 | 228 | */ |
| 225 | export async function autoBuildFromPush ({ sync, releaseManager, repo, before, after, onLog, pendingBuilds, vdriveSeeder } = {}) { | |
| 229 | export async function autoBuildFromPush ({ sync, releaseManager, repo, before, after, onLog, pendingBuilds, vdriveSeeder, vdriveRootKey = null } = {}) { | |
| 226 | 230 | if (!sync || !releaseManager) return { triggered: [] } |
| 227 | 231 | const triggered = [] |
| 228 | 232 | |
| … | … | @@ -249,6 +253,7 @@ export async function autoBuildFromPush ({ sync, releaseManager, repo, before, a |
| 249 | 253 | sync, |
| 250 | 254 | releaseManager, |
| 251 | 255 | vdriveSeeder, |
| 256 | vdriveRootKey, | |
| 252 | 257 | repo, |
| 253 | 258 | ref: t.ref, |
| 254 | 259 | isTag: t.isTag, |
| … | … | @@ -255,6 +255,7 @@ export function releaseHandlers (svc) { |
| 255 | 255 | // cannot drop the seeder's exclusive lock on the store, and the new |
| 256 | 256 | // drive is never announced. The push path threads this already. |
| 257 | 257 | vdriveSeeder: svc._vdriveSeeder, |
| 258 | vdriveRootKey: svc._vdriveRootKey || null, | |
| 258 | 259 | repo, |
| 259 | 260 | ref, |
| 260 | 261 | isTag: !!isTag, |
| … | … | @@ -1225,6 +1225,11 @@ export async function startPearGit (opts = {}) { |
| 1225 | 1225 | // vdrive seeder — announce every repo drive that CI has published, so the |
| 1226 | 1226 | // pear:// links the build log prints actually resolve. Inert when no repo has |
| 1227 | 1227 | // opted in (`vdrive:` in ci.yaml): the release dirs simply contain no .vdrive. |
| 1228 | // Durable root for deriving each repo's vdrive key. corestore is NOT a mounted | |
| 1229 | // module, so this cannot be reached via svc.modules — expose it explicitly, the | |
| 1230 | // same way _sync/_releases/_vdriveSeeder are. | |
| 1231 | svc._vdriveRootKey = corestore.primaryKey || null | |
| 1232 | ||
| 1228 | 1233 | svc._vdriveSeeder = new VdriveSeeder({ |
| 1229 | 1234 | releaseDir: svc._releases ? svc._releases.releaseDir : null, |
| 1230 | 1235 | wire: (repo, opened) => svc.wireVdrive(repo, opened), |
| … | … | @@ -2707,6 +2712,9 @@ export async function startPearGit (opts = {}) { |
| 2707 | 2712 | const after = new Map([[params.ref, head]]) |
| 2708 | 2713 | await autoBuildFromPush({ |
| 2709 | 2714 | vdriveSeeder: svc._vdriveSeeder, |
| 2715 | // Durable root for the drive key: derived per repo, so losing a | |
| 2716 | // drive store is a rebuild, not a dead pear:// link (#145). | |
| 2717 | vdriveRootKey: svc._vdriveRootKey, | |
| 2710 | 2718 | sync: svc._sync, |
| 2711 | 2719 | releaseManager: svc._releases, |
| 2712 | 2720 | pendingBuilds: svc._pendingBuilds, |
| … | … | @@ -23,11 +23,53 @@ |
| 23 | 23 | |
| 24 | 24 | import fsp from '#node:fs/promises' |
| 25 | 25 | import path from '#node:path' |
| 26 | import crypto from 'hypercore-crypto' | |
| 26 | 27 | |
| 27 | 28 | export function vdriveStoreDir (releaseDir, repo) { |
| 28 | 29 | return path.join(releaseDir, repo, '.vdrive') |
| 29 | 30 | } |
| 30 | 31 | |
| 32 | // Namespace so this can never collide with another derivation off the same root. | |
| 33 | const VDRIVE_NS = 'pear-git-v3/vdrive/1' | |
| 34 | ||
| 35 | /** | |
| 36 | * Derive a drive's corestore primaryKey from durable state. | |
| 37 | * | |
| 38 | * The drive key derives from the store's primaryKey, and that primaryKey used to | |
| 39 | * be RANDOM — minted by corestore on first open and persisted only inside | |
| 40 | * `<releaseDir>/<repo>/.vdrive`. So losing that directory destroyed the pear:// | |
| 41 | * link permanently: nothing recorded the secret and nothing could regenerate it. | |
| 42 | * (Upstream `pear touch` has the same shape — a random name nobody records.) | |
| 43 | * | |
| 44 | * Deriving it instead from the daemon's long-lived corestore primaryKey plus the | |
| 45 | * repo name makes the link a pure function of state that already survives — | |
| 46 | * backed up, replicated, and far more durable than a per-repo scratch store. | |
| 47 | * Losing the drive store becomes a rebuild rather than data loss. | |
| 48 | * | |
| 49 | * Per-machine by design. Two daemons have different roots, so this does NOT make | |
| 50 | * the link reproducible across the fleet — that is the multisig question (#145), | |
| 51 | * deliberately deferred. What it buys is recoverability, which is the part that | |
| 52 | * was actually urgent. | |
| 53 | */ | |
| 54 | // corestore's own wording when a store's persisted seed differs from the | |
| 55 | // primaryKey it was handed (corestore/index.js `_open`). Matched on the message | |
| 56 | // because it throws a plain Error with no code. | |
| 57 | function isKeyMismatch (err) { | |
| 58 | return /another corestore is stored here/i.test((err && err.message) || '') | |
| 59 | } | |
| 60 | ||
| 61 | export function deriveVdrivePrimaryKey (rootPrimaryKey, repo) { | |
| 62 | if (!rootPrimaryKey || rootPrimaryKey.byteLength !== 32) { | |
| 63 | throw new Error('deriveVdrivePrimaryKey requires a 32-byte root primaryKey') | |
| 64 | } | |
| 65 | if (!repo) throw new Error('deriveVdrivePrimaryKey requires a repo name') | |
| 66 | return crypto.hash([ | |
| 67 | Buffer.from(VDRIVE_NS), | |
| 68 | rootPrimaryKey, | |
| 69 | Buffer.from(repo) | |
| 70 | ]) | |
| 71 | } | |
| 72 | ||
| 31 | 73 | /** |
| 32 | 74 | * Record the drive in the version's manifest.json. |
| 33 | 75 | * |
| … | … | @@ -155,6 +197,7 @@ export async function publishVersionToDrive ({ |
| 155 | 197 | channel, |
| 156 | 198 | vdrive, |
| 157 | 199 | chunk, |
| 200 | primaryKey, | |
| 158 | 201 | onLog |
| 159 | 202 | } = {}) { |
| 160 | 203 | if (!vdrive || !vdrive.enabled) return { ok: false, skipped: true, reason: 'not enabled' } |
| … | … | @@ -189,14 +232,40 @@ export async function publishVersionToDrive ({ |
| 189 | 232 | const names = await walk(versionDir) |
| 190 | 233 | if (names.length === 0) return { ok: false, skipped: true, reason: 'no artifacts to publish' } |
| 191 | 234 | |
| 192 | const result = await publishTree({ | |
| 193 | storeDir: vdriveStoreDir(releaseDir, repo), | |
| 194 | // locations are recorded relative to the repo's release root, so a seeder | |
| 195 | // can resolve them later without re-chunking anything | |
| 196 | root: path.join(releaseDir, repo), | |
| 197 | files: names.map((name) => ({ name, file: path.join(versionDir, name) })), | |
| 198 | chunk: chunker | |
| 199 | }) | |
| 235 | let result | |
| 236 | try { | |
| 237 | result = await publishTree({ | |
| 238 | storeDir: vdriveStoreDir(releaseDir, repo), | |
| 239 | // locations are recorded relative to the repo's release root, so a seeder | |
| 240 | // can resolve them later without re-chunking anything | |
| 241 | root: path.join(releaseDir, repo), | |
| 242 | files: names.map((name) => ({ name, file: path.join(versionDir, name) })), | |
| 243 | chunk: chunker, | |
| 244 | // `unsafe` is corestore's required acknowledgement for supplying a | |
| 245 | // primaryKey at all. It is exactly what we want here: the key is the point. | |
| 246 | corestoreOpts: primaryKey ? { primaryKey, unsafe: true } : undefined | |
| 247 | }) | |
| 248 | } catch (err) { | |
| 249 | // ONLY the key-mismatch case becomes a skip. corestore refuses to open a | |
| 250 | // store whose persisted seed differs from the primaryKey it was handed | |
| 251 | // ("Another corestore is stored here") rather than silently rekeying it, | |
| 252 | // which would change the pear:// link without saying so. That is permanent | |
| 253 | // and needs a deliberate operator action, so it is reported, not thrown. | |
| 254 | // | |
| 255 | // Everything else must keep propagating — in particular the seeder's | |
| 256 | // exclusive store lock ("File descriptor could not be locked"), which is | |
| 257 | // TRANSIENT and is what drives the orchestrator's drop/publish/re-announce | |
| 258 | // sequence. Swallowing that into a skip would hide a condition the caller | |
| 259 | // resolves by retrying. See test/vdrive-seeder.test.js. | |
| 260 | if (!isKeyMismatch(err)) throw err | |
| 261 | return { | |
| 262 | ok: false, | |
| 263 | skipped: true, | |
| 264 | reason: | |
| 265 | 'drive store does not match the derived key (' + err.message + '). ' + | |
| 266 | 'Move ' + vdriveStoreDir(releaseDir, repo) + ' aside to mint a new drive.' | |
| 267 | } | |
| 268 | } | |
| 200 | 269 | |
| 201 | 270 | const annotated = await annotateManifest(releaseDir, repo, version, result) |
| 202 | 271 | const locationsCopied = await copyLocations(releaseDir, repo, version) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.237", | |
| 3 | "version": "0.1.238", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,137 @@ |
| 1 | /** | |
| 2 | * The drive key must be a deliberate input, not a random seed (org #145). | |
| 3 | * | |
| 4 | * `publishTree` opened `new Corestore(new Storage(storeDir))` with no primaryKey, | |
| 5 | * so corestore minted a random seed and the drive key derived from it. Lose | |
| 6 | * `<releaseDir>/<repo>/.vdrive` and the pear:// link died permanently — the | |
| 7 | * secret existed only in the directory that was lost, and nothing anywhere | |
| 8 | * recorded how to regenerate it. | |
| 9 | * | |
| 10 | * Deriving the primaryKey from the daemon's long-lived corestore plus the repo | |
| 11 | * name makes the link a function of state that already survives. Losing the | |
| 12 | * drive store stops being data loss and becomes a rebuild. | |
| 13 | * | |
| 14 | * Note corestore requires `unsafe: true` to accept a primaryKey at all, and | |
| 15 | * refuses to open an existing store whose stored seed differs ("Another | |
| 16 | * corestore is stored here"). Both are load-bearing and asserted here. | |
| 17 | */ | |
| 18 | import test from 'brittle' | |
| 19 | import fsp from '#node:fs/promises' | |
| 20 | import path from '#node:path' | |
| 21 | import os from '#node:os' | |
| 22 | import crypto from 'hypercore-crypto' | |
| 23 | ||
| 24 | import { | |
| 25 | publishVersionToDrive, | |
| 26 | vdriveStoreDir, | |
| 27 | deriveVdrivePrimaryKey | |
| 28 | } from '../lib/vdrive-publish.js' | |
| 29 | ||
| 30 | function tmpdir (name) { | |
| 31 | return fsp.mkdtemp(path.join(os.tmpdir(), 'pgv3-vdkey-' + name + '-')) | |
| 32 | } | |
| 33 | ||
| 34 | const PAYLOAD = Buffer.alloc(256 * 1024, 5) | |
| 35 | ||
| 36 | async function publish (releaseDir, repo, version, primaryKey) { | |
| 37 | const versionDir = path.join(releaseDir, repo, 'versions', version) | |
| 38 | await fsp.mkdir(versionDir, { recursive: true }) | |
| 39 | await fsp.writeFile(path.join(versionDir, 'app.bin'), PAYLOAD) | |
| 40 | await fsp.writeFile(path.join(versionDir, 'notes.txt'), 'same bytes every time') | |
| 41 | const { chunk } = await import('hyper-overlay/chunker') | |
| 42 | return publishVersionToDrive({ | |
| 43 | releaseDir, | |
| 44 | repo, | |
| 45 | version, | |
| 46 | channel: 'production', | |
| 47 | vdrive: { enabled: true, channels: null }, | |
| 48 | primaryKey, | |
| 49 | chunk | |
| 50 | }) | |
| 51 | } | |
| 52 | ||
| 53 | test('derivation is deterministic and namespaced per repo', (t) => { | |
| 54 | const root = crypto.randomBytes(32) | |
| 55 | const other = crypto.randomBytes(32) | |
| 56 | ||
| 57 | const a = deriveVdrivePrimaryKey(root, 'demo') | |
| 58 | const b = deriveVdrivePrimaryKey(root, 'demo') | |
| 59 | t.alike(a, b, 'same inputs, same key') | |
| 60 | t.is(a.byteLength, 32, 'and it is a 32-byte primaryKey') | |
| 61 | ||
| 62 | t.unlike(deriveVdrivePrimaryKey(root, 'other'), a, 'a different repo differs') | |
| 63 | t.unlike(deriveVdrivePrimaryKey(other, 'demo'), a, 'a different root differs') | |
| 64 | }) | |
| 65 | ||
| 66 | test('losing the drive store no longer loses the link', async (t) => { | |
| 67 | // The whole point. Publish, destroy the drive store, rebuild from the same | |
| 68 | // deliberate key, and the pear:// link must come back identical. | |
| 69 | const releaseDir = await tmpdir('recover') | |
| 70 | const repo = 'demo' | |
| 71 | const primaryKey = deriveVdrivePrimaryKey(crypto.randomBytes(32), repo) | |
| 72 | ||
| 73 | const first = await publish(releaseDir, repo, '1.0.0', primaryKey) | |
| 74 | t.is(first.ok, true, 'published') | |
| 75 | t.ok(first.link.startsWith('pear://'), 'has a link') | |
| 76 | ||
| 77 | // simulate losing the store (a temp dir this test created, not user state) | |
| 78 | await fsp.rm(vdriveStoreDir(releaseDir, repo), { recursive: true, force: true }) | |
| 79 | ||
| 80 | const again = await publish(releaseDir, repo, '1.0.0', primaryKey) | |
| 81 | t.is(again.ok, true, 'republished after the store was gone') | |
| 82 | t.is(again.link, first.link, 'and the link is the SAME') | |
| 83 | ||
| 84 | await fsp.rm(releaseDir, { recursive: true, force: true }) | |
| 85 | }) | |
| 86 | ||
| 87 | test('the same key and content reproduce the link on another machine', async (t) => { | |
| 88 | // Two independent release dirs stand in for two machines holding the same | |
| 89 | // artifacts. Same deliberate key + same bytes => same link. | |
| 90 | const repo = 'demo' | |
| 91 | const primaryKey = deriveVdrivePrimaryKey(crypto.randomBytes(32), repo) | |
| 92 | ||
| 93 | const one = await tmpdir('m1') | |
| 94 | const two = await tmpdir('m2') | |
| 95 | ||
| 96 | const a = await publish(one, repo, '1.0.0', primaryKey) | |
| 97 | const b = await publish(two, repo, '1.0.0', primaryKey) | |
| 98 | ||
| 99 | t.is(a.ok && b.ok, true, 'both published') | |
| 100 | t.is(b.link, a.link, 'identical link from an independent build') | |
| 101 | ||
| 102 | await fsp.rm(one, { recursive: true, force: true }) | |
| 103 | await fsp.rm(two, { recursive: true, force: true }) | |
| 104 | }) | |
| 105 | ||
| 106 | test('a different key yields a different drive', async (t) => { | |
| 107 | const repo = 'demo' | |
| 108 | const one = await tmpdir('k1') | |
| 109 | const two = await tmpdir('k2') | |
| 110 | ||
| 111 | const a = await publish(one, repo, '1.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo)) | |
| 112 | const b = await publish(two, repo, '1.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo)) | |
| 113 | ||
| 114 | t.is(a.ok && b.ok, true, 'both published') | |
| 115 | t.unlike(b.link, a.link, 'different keys, different links') | |
| 116 | ||
| 117 | await fsp.rm(one, { recursive: true, force: true }) | |
| 118 | await fsp.rm(two, { recursive: true, force: true }) | |
| 119 | }) | |
| 120 | ||
| 121 | test('a store minted under a different key is refused, not silently rekeyed', async (t) => { | |
| 122 | // corestore throws "Another corestore is stored here" rather than adopting the | |
| 123 | // new key. Publishing must surface that as a clear, actionable skip instead of | |
| 124 | // taking the release down with it. | |
| 125 | const releaseDir = await tmpdir('mismatch') | |
| 126 | const repo = 'demo' | |
| 127 | ||
| 128 | const first = await publish(releaseDir, repo, '1.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo)) | |
| 129 | t.is(first.ok, true, 'first publish created the store') | |
| 130 | ||
| 131 | const second = await publish(releaseDir, repo, '2.0.0', deriveVdrivePrimaryKey(crypto.randomBytes(32), repo)) | |
| 132 | t.is(second.ok, false, 'a mismatched key does not silently rekey the drive') | |
| 133 | t.ok(second.skipped, 'it is reported as skipped') | |
| 134 | t.ok(/another corestore|stored here|key/i.test(second.reason || ''), 'with a reason naming the cause: ' + second.reason) | |
| 135 | ||
| 136 | await fsp.rm(releaseDir, { recursive: true, force: true }) | |
| 137 | }) |