| … | … | @@ -204,6 +204,27 @@ export function releaseHandlers (svc) { |
| 204 | 204 | } |
| 205 | 205 | }, |
| 206 | 206 | |
| 207 | { | |
| 208 | // What virtual Hyperdrives is this daemon serving, and under what links. | |
| 209 | // | |
| 210 | // NOT in ANONYMOUS_METHODS (pear-git.js), so it needs a trusted caller — | |
| 211 | // UDS operator or a writer. The links themselves are public keys and grant | |
| 212 | // nothing on their own, but enumerating every repo's drive would hand an | |
| 213 | // anonymous caller a map of what this daemon publishes, which the repo | |
| 214 | // listing already gates. | |
| 215 | name: 'vdrive-status', | |
| 216 | fn: async (ctx, params = {}) => { | |
| 217 | await requireWriter(ctx, registry()) | |
| 218 | const seeder = svc._vdriveSeeder | |
| 219 | if (!seeder) return { enabled: false, drives: [] } | |
| 220 | const drives = seeder.stats() | |
| 221 | if (params.repo) { | |
| 222 | return { enabled: seeder.enabled, drives: drives.filter((d) => d.repo === params.repo) } | |
| 223 | } | |
| 224 | return { enabled: seeder.enabled, drives } | |
| 225 | } | |
| 226 | }, | |
| 227 | ||
| 207 | 228 | { |
| 208 | 229 | // Run the repo's .pear-git/ci.yaml against a ref, collect artifacts, |
| 209 | 230 | // publish the resulting release. Returns { ok, version, channel, |
| … | … | @@ -28,6 +28,33 @@ export function vdriveStoreDir (releaseDir, repo) { |
| 28 | 28 | return path.join(releaseDir, repo, '.vdrive') |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | /** | |
| 32 | * Record the drive in the version's manifest.json. | |
| 33 | * | |
| 34 | * The link is otherwise unrecoverable: the manifest never carried it, the | |
| 35 | * deploy log never printed it, and once the seeder holds the store the only | |
| 36 | * trace is a daemon log line. Writing it here makes `pgh deploy log` and any | |
| 37 | * manifest reader able to answer "what is this release's pear:// link". | |
| 38 | * | |
| 39 | * Non-fatal: a manifest we cannot annotate must not fail a published release. | |
| 40 | */ | |
| 41 | async function annotateManifest (releaseDir, repo, version, vdrive) { | |
| 42 | const manifestPath = path.join(releaseDir, repo, 'versions', version, 'manifest.json') | |
| 43 | try { | |
| 44 | const raw = JSON.parse(await fsp.readFile(manifestPath, 'utf8')) | |
| 45 | raw.vdrive = { | |
| 46 | link: vdrive.link, | |
| 47 | key: vdrive.key, | |
| 48 | blocks: vdrive.blocks, | |
| 49 | locations: vdrive.locations | |
| 50 | } | |
| 51 | await fsp.writeFile(manifestPath, JSON.stringify(raw, null, 2)) | |
| 52 | return true | |
| 53 | } catch { | |
| 54 | return false | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 31 | 58 | async function dirSize (dir) { |
| 32 | 59 | let total = 0 |
| 33 | 60 | for (const e of await fsp.readdir(dir, { withFileTypes: true })) { |
| … | … | @@ -107,6 +134,8 @@ export async function publishVersionToDrive ({ |
| 107 | 134 | chunk: chunker |
| 108 | 135 | }) |
| 109 | 136 | |
| 137 | const annotated = await annotateManifest(releaseDir, repo, version, result) | |
| 138 | ||
| 110 | 139 | if (onLog) { |
| 111 | 140 | // Report the pointer/literal split and the store size. Without them a large |
| 112 | 141 | // .vdrive is ambiguous — it reads the same whether the redirect failed (all |
| … | … | @@ -126,5 +155,5 @@ export async function publishVersionToDrive ({ |
| 126 | 155 | ) |
| 127 | 156 | } |
| 128 | 157 | |
| 129 | return { ok: true, ...result } | |
| 158 | return { ok: true, ...result, annotated } | |
| 130 | 159 | } |
| … | … | @@ -28,6 +28,9 @@ const RELEASES_STYLES = ` |
| 28 | 28 | .version-pinned { color: #3fb950; font-size: 0.7rem; font-weight: 500; } |
| 29 | 29 | .version-stale { color: #484f58; } |
| 30 | 30 | .artifact-list { font-family: 'SF Mono', monospace; font-size: 0.7rem; color: #8b949e; } |
| 31 | .vdrive-link { margin-top: 0.35rem; font-family: 'SF Mono', monospace; font-size: 0.68rem; color: #3fb950; cursor: pointer; word-break: break-all; } | |
| 32 | .vdrive-link:hover { color: #56d364; } | |
| 33 | .vdrive-label { display: inline-block; background: #1c2128; border: 1px solid #30363d; border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; color: #8b949e; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.5px; } | |
| 31 | 34 | .artifact-list a { color: #58a6ff; } |
| 32 | 35 | </style>` |
| 33 | 36 | |
| … | … | @@ -219,6 +222,16 @@ function renderVersionRow (v, pinned, channels, base, repoName, prefix) { |
| 219 | 222 | return `<a href="${href}" download>${escapeHtml(a.path)}</a> <span class="meta" style="margin-right:0.5rem">${formatBytes(a.size)}</span>` |
| 220 | 223 | }).join(' · ') |
| 221 | 224 | |
| 225 | // A virtual Hyperdrive published alongside the artifacts (ci.yaml `vdrive: true`). | |
| 226 | // The link is the only durable handle on it — the drive itself is pointers, and | |
| 227 | // once the daemon seeds it the store is locked — so surface it here, click to copy. | |
| 228 | const vdriveLink = v.vdrive && v.vdrive.link | |
| 229 | ? `<div class="vdrive-link" title="Click to copy — pear install ${escapeHtml(v.vdrive.link)}" | |
| 230 | onclick="navigator.clipboard.writeText('${escapeHtml(v.vdrive.link)}')"> | |
| 231 | <span class="vdrive-label">pear</span> ${escapeHtml(v.vdrive.link)} | |
| 232 | </div>` | |
| 233 | : '' | |
| 234 | ||
| 222 | 235 | const promoteBtn = ` |
| 223 | 236 | <form action="${base}/releases/promote" method="POST" style="display:inline;margin:0"> |
| 224 | 237 | <input type="hidden" name="version" value="${escapeHtml(v.version)}"> |
| … | … | @@ -238,7 +251,7 @@ function renderVersionRow (v, pinned, channels, base, repoName, prefix) { |
| 238 | 251 | <td><span class="channel-version" style="font-size:0.85rem">${escapeHtml(v.version)}</span> ${isPinned ? '<span class="version-pinned">pinned</span>' : ''}</td> |
| 239 | 252 | <td>${timeAgo(v.builtAt)}</td> |
| 240 | 253 | <td>${size}</td> |
| 241 | <td class="artifact-list">${artifactLinks}</td> | |
| 254 | <td class="artifact-list">${artifactLinks}${vdriveLink}</td> | |
| 242 | 255 | <td style="text-align:right;white-space:nowrap">${promoteBtn} ${deleteBtn}</td> |
| 243 | 256 | </tr>` |
| 244 | 257 | } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.231", | |
| 3 | "version": "0.1.232", | |
| 4 | 4 | "description": "P2P git forge \u2014 multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -125,3 +125,41 @@ async function dirSize (dir) { |
| 125 | 125 | } |
| 126 | 126 | return total |
| 127 | 127 | } |
| 128 | ||
| 129 | test('the drive is recorded in the release manifest', async (t) => { | |
| 130 | // Without this the link is unrecoverable: it was never in the manifest, never | |
| 131 | // in `pgh deploy log`, and once the seeder holds the store the only trace is a | |
| 132 | // daemon log line. | |
| 133 | const releaseDir = await tmpdir('manifest') | |
| 134 | const repo = 'demo' | |
| 135 | const version = '1.0.0' | |
| 136 | const versionDir = path.join(releaseDir, repo, 'versions', version) | |
| 137 | await fsp.mkdir(versionDir, { recursive: true }) | |
| 138 | await fsp.writeFile(path.join(versionDir, 'app.bin'), Buffer.alloc(256 * 1024, 4)) | |
| 139 | // a manifest as ReleaseManager would have written it | |
| 140 | await fsp.writeFile( | |
| 141 | path.join(versionDir, 'manifest.json'), | |
| 142 | JSON.stringify({ version, builtAt: Date.now(), artifacts: [] }, null, 2) | |
| 143 | ) | |
| 144 | ||
| 145 | const { chunk } = await import('hyper-overlay/chunker') | |
| 146 | const res = await publishVersionToDrive({ | |
| 147 | releaseDir, | |
| 148 | repo, | |
| 149 | version, | |
| 150 | channel: 'production', | |
| 151 | vdrive: { enabled: true, channels: null }, | |
| 152 | chunk | |
| 153 | }) | |
| 154 | ||
| 155 | t.is(res.ok, true) | |
| 156 | t.is(res.annotated, true, 'manifest was annotated') | |
| 157 | ||
| 158 | const manifest = JSON.parse(await fsp.readFile(path.join(versionDir, 'manifest.json'), 'utf8')) | |
| 159 | t.ok(manifest.vdrive, 'manifest carries a vdrive block') | |
| 160 | t.is(manifest.vdrive.link, res.link, 'and the link matches what was published') | |
| 161 | t.ok(manifest.vdrive.key && manifest.vdrive.blocks > 0, 'with key and block count') | |
| 162 | t.ok(manifest.version === version, 'existing manifest fields survive') | |
| 163 | ||
| 164 | await fsp.rm(releaseDir, { recursive: true, force: true }) | |
| 165 | }) |