@serfab/cadre-host 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -16
- package/dist/bin/host.js +41 -20
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +27 -4
- package/dist/donation/donation-service.js +57 -6
- package/dist/donation/donation-service.js.map +1 -1
- package/dist/donation/donation-store.d.ts +3 -1
- package/dist/donation/donation-store.js +5 -1
- package/dist/donation/donation-store.js.map +1 -1
- package/dist/donation/grant-service.d.ts +16 -7
- package/dist/donation/grant-service.js +57 -10
- package/dist/donation/grant-service.js.map +1 -1
- package/dist/donation/index.d.ts +2 -2
- package/dist/donation/index.js +1 -1
- package/dist/donation/index.js.map +1 -1
- package/dist/donation/types.d.ts +29 -7
- package/dist/donation/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/nat/address-resolver.d.ts +3 -2
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +1 -1
- package/dist/orchestrator/host-process-orchestrator.js +21 -11
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +6 -39
- package/dist/orchestrator/port-allocator.js +9 -89
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +19 -18
- package/dist/server/index.js +13 -15
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +15 -3
- package/dist/server/routes/grants-admin.js +33 -7
- package/dist/server/routes/grants-admin.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +13 -5
- package/dist/server/routes/nodes.js +58 -39
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/status.d.ts +13 -0
- package/dist/server/routes/status.js +2 -0
- package/dist/server/routes/status.js.map +1 -1
- package/dist/ui/assets/index-C6nELNfC.js +23 -0
- package/dist/ui/assets/index-C6nELNfC.js.map +1 -0
- package/dist/ui/assets/index-TtIYSeZO.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +4 -4
- package/src/__tests__/global-setup.ts +58 -56
- package/src/__tests__/orchestrator-env-scrub.test.ts +12 -11
- package/src/__tests__/orchestrator-handle-contract.ts +134 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +6 -7
- package/src/__tests__/orchestrator-owner.test.ts +6 -13
- package/src/__tests__/orchestrator-pin-keys.test.ts +6 -7
- package/src/__tests__/orchestrator-ports.test.ts +5 -34
- package/src/__tests__/orchestrator-push.test.ts +6 -5
- package/src/__tests__/orchestrator-teardown.ts +65 -0
- package/src/__tests__/orchestrator.test.ts +23 -22
- package/src/auth/__tests__/trust-circle-integration.test.ts +1 -1
- package/src/bin/host.ts +56 -33
- package/src/donation/__tests__/donation-service.test.ts +38 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +19 -14
- package/src/donation/__tests__/fake-orchestrator.ts +21 -3
- package/src/donation/__tests__/grant-service.test.ts +2 -2
- package/src/donation/donation-service.ts +58 -6
- package/src/donation/donation-store.ts +176 -171
- package/src/donation/grant-service.ts +252 -200
- package/src/donation/index.ts +65 -64
- package/src/donation/types.ts +260 -244
- package/src/index.ts +2 -0
- package/src/nat/address-resolver.ts +3 -2
- package/src/orchestrator/host-process-orchestrator.ts +1260 -1251
- package/src/orchestrator/port-allocator.ts +16 -91
- package/src/server/__tests__/fakes.ts +44 -0
- package/src/server/__tests__/grants-admin-route.test.ts +171 -73
- package/src/server/__tests__/nodes-route.test.ts +49 -25
- package/src/server/__tests__/publishers.test.ts +5 -41
- package/src/server/__tests__/server.smoke.test.ts +7 -37
- package/src/server/__tests__/status-route.test.ts +8 -21
- package/src/server/__tests__/strands-route.test.ts +3 -2
- package/src/server/events/types.ts +24 -23
- package/src/server/index.ts +267 -267
- package/src/server/routes/grants-admin.ts +74 -41
- package/src/server/routes/nodes.ts +61 -43
- package/src/server/routes/status.ts +16 -0
- package/dist/donation/__tests__/fake-orchestrator.d.ts +0 -103
- package/dist/donation/__tests__/fake-orchestrator.js +0 -180
- package/dist/donation/__tests__/fake-orchestrator.js.map +0 -1
- package/dist/ui/assets/index-BVoqLjyX.css +0 -1
- package/dist/ui/assets/index-CEoNRwM_.js +0 -17
- package/dist/ui/assets/index-CEoNRwM_.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.dot.svelte-1qa2kn2{display:inline-flex;align-items:center;gap:.5rem;font-weight:500}.bulb.svelte-1qa2kn2{display:inline-block;border-radius:999px;flex:0 0 auto;background:var(--color-text-soft);box-shadow:0 0 0 3px #0000000f}.dot-ok.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-success);box-shadow:0 0 0 3px var(--color-success-bg)}.dot-warn.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-warn);box-shadow:0 0 0 3px var(--color-warn-bg)}.dot-err.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-danger);box-shadow:0 0 0 3px var(--color-danger-bg)}.dot-muted.svelte-1qa2kn2 .bulb:where(.svelte-1qa2kn2){background:var(--color-text-soft)}.dot-ok.svelte-1qa2kn2{color:var(--color-success)}.dot-warn.svelte-1qa2kn2{color:var(--color-warn)}.dot-err.svelte-1qa2kn2{color:var(--color-danger)}.row.svelte-lwwmgz{display:inline-flex;gap:.4rem;flex-wrap:wrap}.banner.svelte-15mtls8{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-radius:var(--radius);background:var(--color-info-bg);color:var(--color-info);border:1px solid var(--color-info)}.summary.svelte-15mtls8{grid-template-columns:repeat(auto-fit,minmax(15rem,1fr))}.kv.svelte-15mtls8{margin:var(--space-3) 0 0 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.9rem}.kv.svelte-15mtls8>div:where(.svelte-15mtls8){display:contents}.kv.svelte-15mtls8 dt:where(.svelte-15mtls8){color:var(--color-text-muted)}.kv.svelte-15mtls8 dd:where(.svelte-15mtls8){margin:0}.big.svelte-15mtls8{font-size:1.75rem;font-weight:600;margin:var(--space-2) 0}.big.svelte-15mtls8 .muted:where(.svelte-15mtls8){font-size:.95rem;font-weight:400}.link.svelte-15mtls8{display:inline-block;margin-top:var(--space-2);font-weight:500}.backdrop.svelte-80vbit{position:fixed;inset:0;background:#0f111673;z-index:1000}.dialog.svelte-80vbit{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:var(--space-5);min-width:min(24rem,calc(100vw - 2rem));max-width:min(32rem,calc(100vw - 2rem));overflow-wrap:anywhere;z-index:1010}.note.svelte-80vbit{background:var(--color-warn-bg);color:var(--color-warn);border-radius:var(--radius);padding:var(--space-2) var(--space-3);font-size:.9rem}.gate.svelte-80vbit{margin-top:var(--space-3);overflow-wrap:anywhere}.actions.svelte-80vbit{display:flex;justify-content:flex-end;gap:.5rem;margin-top:var(--space-4)}.qr.svelte-1uqetyj{background:#fff;border-radius:var(--radius);padding:.5rem;display:inline-block;line-height:0}.qr.svelte-1uqetyj svg{width:100%;height:100%;display:block}.placeholder.svelte-1uqetyj{background:var(--color-surface-alt)}.qr-wrap.svelte-sddtr9{display:flex;justify-content:center;margin:var(--space-3) 0}.token.svelte-sddtr9{display:flex;flex-direction:column;gap:.5rem}.token.svelte-sddtr9 textarea:where(.svelte-sddtr9){font-family:var(--font-mono);font-size:.78rem;word-break:break-all;resize:vertical}.token.svelte-sddtr9 button:where(.svelte-sddtr9){align-self:flex-end;display:inline-flex;align-items:center;gap:.375rem}.backdrop.svelte-1d46kin{position:fixed;inset:0;background:#0f111673;z-index:1000}.dialog.svelte-1d46kin{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:var(--space-5);width:min(28rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);overflow:auto;z-index:1010}.actions.svelte-1d46kin{display:flex;justify-content:flex-end;gap:.5rem;margin-top:var(--space-4)}.small.svelte-1d46kin{font-size:.85rem}.page-header.svelte-3alf51{display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3)}.list.svelte-3alf51{list-style:none;padding:0;margin:var(--space-3) 0 0 0;display:flex;flex-direction:column;gap:.5rem}.list.svelte-3alf51 li:where(.svelte-3alf51){display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius);background:var(--color-surface-alt)}.member-main.svelte-3alf51{display:flex;flex-direction:column;gap:.125rem;min-width:0}.member-label.svelte-3alf51{font-weight:500}.small.svelte-3alf51{font-size:.85rem}.grid.two.svelte-hh3bto{grid-template-columns:repeat(auto-fit,minmax(20rem,1fr))}.kv.svelte-hh3bto{margin:var(--space-3) 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.9rem}.kv.svelte-hh3bto>div:where(.svelte-hh3bto){display:contents}.kv.svelte-hh3bto dt:where(.svelte-hh3bto){color:var(--color-text-muted)}.kv.svelte-hh3bto dd:where(.svelte-hh3bto){margin:0}.row.inline.svelte-hh3bto{gap:.5rem}.row.inline.svelte-hh3bto input[type=checkbox]:where(.svelte-hh3bto){width:auto}.warning.svelte-hh3bto{background:var(--color-warn-bg);border-color:var(--color-warn)}.warning.svelte-hh3bto ul:where(.svelte-hh3bto){margin:0;padding-left:1.25rem}.actions.svelte-hh3bto{display:flex;justify-content:flex-end;gap:.5rem}.small.svelte-hh3bto{font-size:.85rem}table.svelte-7tcksg{width:100%;border-collapse:collapse;font-size:.92rem}th.svelte-7tcksg,td.svelte-7tcksg{padding:.5rem .75rem;text-align:left;border-bottom:1px solid var(--color-border)}th.svelte-7tcksg{font-weight:600;font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;color:var(--color-text-muted);border-bottom-color:var(--color-border-strong)}tr.svelte-7tcksg:last-child td:where(.svelte-7tcksg){border-bottom:none}.log-card.svelte-15ufwrh{display:flex;flex-direction:column;gap:var(--space-3)}.header.svelte-15ufwrh{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)}.logs.svelte-15ufwrh{max-height:24rem;font-size:.78rem;line-height:1.45;white-space:pre}.small.svelte-15ufwrh{font-size:.85rem}button.svelte-15ufwrh{display:inline-flex;align-items:center;gap:.375rem}.back.svelte-1eq4g96{font-size:.92rem}.page-header.svelte-1eq4g96{display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3)}.kv.svelte-1eq4g96{margin:var(--space-3) 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.92rem}.kv.svelte-1eq4g96>div:where(.svelte-1eq4g96){display:contents}.kv.svelte-1eq4g96 dt:where(.svelte-1eq4g96){color:var(--color-text-muted)}.kv.svelte-1eq4g96 dd:where(.svelte-1eq4g96){margin:0}.actions.svelte-1eq4g96{display:flex;gap:.5rem;flex-wrap:wrap}.row.inline.svelte-1cl3wo6{gap:.5rem}.row.inline.svelte-1cl3wo6 input[type=checkbox]:where(.svelte-1cl3wo6){width:auto}.banner.svelte-1cl3wo6{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border-radius:var(--radius);background:var(--color-info-bg);color:var(--color-info);border:1px solid var(--color-info);flex-wrap:wrap}.kv.svelte-1cl3wo6{margin:var(--space-3) 0;display:grid;grid-template-columns:max-content 1fr;gap:var(--space-1) var(--space-3);font-size:.92rem}.kv.svelte-1cl3wo6>div:where(.svelte-1cl3wo6){display:contents}.kv.svelte-1cl3wo6 dt:where(.svelte-1cl3wo6){color:var(--color-text-muted)}.kv.svelte-1cl3wo6 dd:where(.svelte-1cl3wo6){margin:0}.copy-row.svelte-1cl3wo6{display:flex;align-items:center;gap:.375rem}.actions.svelte-1cl3wo6{display:flex;justify-content:flex-end}.small.svelte-1cl3wo6{font-size:.85rem}.error.svelte-1cl3wo6{color:var(--color-danger)}.list.svelte-ngb3vw{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.5rem}.list.svelte-ngb3vw li:where(.svelte-ngb3vw){display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius);background:var(--color-surface-alt)}.strand-main.svelte-ngb3vw{display:flex;flex-direction:column;gap:.25rem;min-width:0}.strand-id.svelte-ngb3vw{overflow-wrap:anywhere;user-select:text}.error.svelte-ngb3vw{color:var(--color-danger)}.meta.svelte-ngb3vw{gap:.5rem}.small.svelte-ngb3vw{font-size:.85rem}.warning.svelte-ngb3vw{background:var(--color-warn-bg);border-color:var(--color-warn)}.warning.svelte-ngb3vw code:where(.svelte-ngb3vw){overflow-wrap:anywhere}.actions.svelte-ngb3vw{display:flex;justify-content:flex-end;gap:.5rem}.backdrop.svelte-1gylbi3{position:fixed;inset:0;background:#0f111673;z-index:1000}.dialog.svelte-1gylbi3{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);padding:var(--space-5);width:min(28rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);overflow:auto;overflow-wrap:anywhere;z-index:1010}.actions.svelte-1gylbi3{display:flex;justify-content:flex-end;gap:.5rem;margin-top:var(--space-4)}.small.svelte-1gylbi3{font-size:.85rem}.page-header.svelte-1jncsno{display:flex;align-items:flex-end;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3)}.list.svelte-1jncsno{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.5rem}.list.svelte-1jncsno>li:where(.svelte-1jncsno){display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--space-3);padding:var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius);background:var(--color-surface-alt)}.grant-main.svelte-1jncsno{display:flex;flex-direction:column;gap:.25rem;min-width:0;overflow-wrap:anywhere}.grant-label.svelte-1jncsno{font-weight:500}.meta.svelte-1jncsno{gap:.5rem}.donations.svelte-1jncsno{list-style:none;padding:0;margin:.25rem 0 0;display:flex;flex-direction:column;gap:.125rem}.donations.svelte-1jncsno li:where(.svelte-1jncsno){display:flex;flex-wrap:wrap;gap:.5rem}.actions.svelte-1jncsno{display:flex;gap:.5rem}.keep.svelte-1jncsno{display:flex;align-items:center;gap:.5rem;margin-top:var(--space-3);font-size:.9rem;font-weight:400;color:var(--color-text)}.error.svelte-1jncsno{color:var(--color-danger)}.small.svelte-1jncsno{font-size:.85rem}.toast-stack.svelte-g7yjw8{position:fixed;right:1rem;bottom:1rem;display:flex;flex-direction:column;gap:.5rem;max-width:min(30rem,calc(100vw - 2rem));z-index:1100}.toast.svelte-g7yjw8{background:var(--color-surface);color:var(--color-text);border:1px solid var(--color-border-strong);border-radius:var(--radius);padding:.625rem .75rem;display:flex;align-items:center;gap:.5rem;box-shadow:var(--shadow);font-size:.92rem}.toast-error.svelte-g7yjw8{border-color:var(--color-danger);background:var(--color-danger-bg);color:var(--color-danger)}.toast-success.svelte-g7yjw8{border-color:var(--color-success);background:var(--color-success-bg);color:var(--color-success)}.text.svelte-g7yjw8{flex:1 1 auto}button.svelte-g7yjw8{font-size:1.1rem;line-height:1;padding:.125rem .5rem;color:currentColor}.topbar.svelte-1r8vfx3{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:var(--space-4);padding:var(--space-3) var(--space-5);background:var(--color-surface);border-bottom:1px solid var(--color-border);backdrop-filter:saturate(180%) blur(8px)}.brand.svelte-1r8vfx3{display:flex;align-items:center;gap:.6rem;font-weight:600}.logo.svelte-1r8vfx3{color:var(--color-accent);font-size:1.1rem}nav.svelte-1r8vfx3{display:flex;gap:.125rem}nav.svelte-1r8vfx3 a:where(.svelte-1r8vfx3){font-size:.92rem;color:var(--color-text-muted);padding:.375rem .75rem;border-radius:var(--radius);text-decoration:none}nav.svelte-1r8vfx3 a:where(.svelte-1r8vfx3):hover{background:var(--color-surface-alt);color:var(--color-text)}nav.svelte-1r8vfx3 a.active:where(.svelte-1r8vfx3){color:var(--color-accent-on);background:var(--color-accent)}main.svelte-1r8vfx3{flex:1 1 auto;width:min(64rem,100%);margin:0 auto;padding:var(--space-5)}@media(max-width:640px){.topbar.svelte-1r8vfx3{flex-direction:column;align-items:flex-start;gap:var(--space-2);padding:var(--space-2) var(--space-3)}nav.svelte-1r8vfx3{width:100%;overflow-x:auto}main.svelte-1r8vfx3{padding:var(--space-3)}}:root{color-scheme:light dark;--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Menlo, monospace;--radius-sm: .25rem;--radius: .5rem;--radius-lg: .75rem;--space-1: .25rem;--space-2: .5rem;--space-3: .75rem;--space-4: 1rem;--space-5: 1.5rem;--space-6: 2rem;--space-7: 3rem;--color-bg: #f7f8fa;--color-surface: #ffffff;--color-surface-alt: #f0f2f6;--color-border: #d9dde3;--color-border-strong: #b8bec7;--color-text: #1d1f24;--color-text-muted: #5b626d;--color-text-soft: #8a8f99;--color-accent: #1d4ed8;--color-accent-strong: #1e3a8a;--color-accent-on: #ffffff;--color-accent-soft: #e0e7ff;--color-success: #15803d;--color-success-bg: #dcfce7;--color-warn: #b45309;--color-warn-bg: #fef3c7;--color-danger: #b91c1c;--color-danger-bg: #fee2e2;--color-info: #1d4ed8;--color-info-bg: #dbeafe;--shadow-sm: 0 1px 2px rgba(15, 23, 42, .05);--shadow: 0 4px 12px rgba(15, 23, 42, .08);--shadow-lg: 0 12px 32px rgba(15, 23, 42, .16)}@media(prefers-color-scheme:dark){:root{--color-bg: #0f1116;--color-surface: #181b22;--color-surface-alt: #20242d;--color-border: #2c313c;--color-border-strong: #3b424f;--color-text: #e8eaf0;--color-text-muted: #a4abb8;--color-text-soft: #777e8b;--color-accent: #93c5fd;--color-accent-strong: #bfdbfe;--color-accent-on: #0f1116;--color-accent-soft: #1e293b;--color-success: #4ade80;--color-success-bg: #14532d;--color-warn: #fbbf24;--color-warn-bg: #78350f;--color-danger: #f87171;--color-danger-bg: #7f1d1d;--color-info: #93c5fd;--color-info-bg: #1e3a8a;--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);--shadow: 0 4px 12px rgba(0, 0, 0, .45);--shadow-lg: 0 12px 32px rgba(0, 0, 0, .6)}}*,*:before,*:after{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--color-bg);color:var(--color-text);font-family:var(--font-sans);font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;min-height:100vh}#app{min-height:100vh;display:flex;flex-direction:column}h1,h2,h3,h4,h5,h6{margin:0 0 var(--space-2) 0;font-weight:600;line-height:1.25;letter-spacing:-.01em}h1{font-size:1.5rem}h2{font-size:1.25rem}h3{font-size:1.05rem}p{margin:0 0 var(--space-3) 0}a{color:var(--color-accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;cursor:pointer;border:1px solid var(--color-border-strong);background:var(--color-surface);color:var(--color-text);padding:var(--space-2) var(--space-3);border-radius:var(--radius);transition:background-color .12s ease,border-color .12s ease}button:hover:not(:disabled){background:var(--color-surface-alt);border-color:var(--color-text-muted)}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}button:disabled{opacity:.55;cursor:not-allowed}button.primary{background:var(--color-accent);border-color:var(--color-accent);color:var(--color-accent-on)}button.primary:hover:not(:disabled){background:var(--color-accent-strong);border-color:var(--color-accent-strong)}button.danger{background:var(--color-danger);border-color:var(--color-danger);color:#fff}button.ghost{background:transparent;border-color:transparent;color:var(--color-text-muted);padding:var(--space-1) var(--space-2)}button.ghost:hover:not(:disabled){color:var(--color-text);background:var(--color-surface-alt)}input[type=text],input[type=url],input[type=number],input[type=password],select,textarea{font:inherit;color:var(--color-text);background:var(--color-surface);border:1px solid var(--color-border-strong);border-radius:var(--radius);padding:var(--space-2) var(--space-3);width:100%}label{display:block;font-size:.85rem;color:var(--color-text-muted);font-weight:500;margin-bottom:var(--space-1)}code,pre{font-family:var(--font-mono);font-size:.85rem}pre{background:var(--color-surface-alt);padding:var(--space-3);border-radius:var(--radius);overflow:auto;margin:0}hr{border:none;border-top:1px solid var(--color-border);margin:var(--space-5) 0}.card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:var(--space-5);box-shadow:var(--shadow-sm)}.muted{color:var(--color-text-muted)}.row{display:flex;gap:var(--space-3);align-items:center;flex-wrap:wrap}.stack{display:flex;flex-direction:column;gap:var(--space-3)}.grid{display:grid;gap:var(--space-4)}.badge{display:inline-flex;align-items:center;gap:var(--space-1);font-size:.78rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase;padding:.125rem .5rem;border-radius:999px;background:var(--color-surface-alt);color:var(--color-text-muted)}.badge.ok{background:var(--color-success-bg);color:var(--color-success)}.badge.warn{background:var(--color-warn-bg);color:var(--color-warn)}.badge.err{background:var(--color-danger-bg);color:var(--color-danger)}.badge.info{background:var(--color-info-bg);color:var(--color-info)}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
package/dist/ui/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<meta name="color-scheme" content="light dark" />
|
|
7
7
|
<title>cadre-host</title>
|
|
8
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
8
|
+
<script type="module" crossorigin src="./assets/index-C6nELNfC.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="./assets/index-TtIYSeZO.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="app"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serfab/cadre-host",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Self-hosted cadre node manager for basement-PC deployments",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -65,9 +65,9 @@
|
|
|
65
65
|
"@libp2p/interface": "^3.1.0",
|
|
66
66
|
"@libp2p/peer-id": "^6.0.4",
|
|
67
67
|
"@multiformats/multiaddr": "^12.5.1",
|
|
68
|
-
"@serfab/cadre-cli": "^1.
|
|
69
|
-
"@serfab/cadre-core": "^1.
|
|
70
|
-
"@serfab/cadre-provider": "^1.
|
|
68
|
+
"@serfab/cadre-cli": "^1.8.0",
|
|
69
|
+
"@serfab/cadre-core": "^1.8.0",
|
|
70
|
+
"@serfab/cadre-provider": "^1.8.0",
|
|
71
71
|
"commander": "^14.0.2",
|
|
72
72
|
"debug": "^4.4.3",
|
|
73
73
|
"fastify": "^5.7.1",
|
|
@@ -1,56 +1,58 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vitest global setup — runs once before the whole cadre-host suite.
|
|
3
|
-
*
|
|
4
|
-
* `src/update/manifest.ts` and `src/push/index.ts` import real symbols from
|
|
5
|
-
* `@serfab/cadre-core` (`canonicalJson`, `validatePushCredentials`), and
|
|
6
|
-
* `trust-circle-integration.test.ts` imports and drives a real `CadreNode` from
|
|
7
|
-
* it too. `@serfab/cadre-core`'s compiled entry point in turn imports
|
|
8
|
-
* `@serfab/quereus-plugin-sereus`, which imports the `@optimystic/*` and
|
|
9
|
-
* `@quereus/quereus` packages linked in from their sibling checkouts.
|
|
10
|
-
* `host-process-orchestrator.ts` also resolves `@serfab/cadre-cli`'s bin path at
|
|
11
|
-
* runtime, and `@serfab/cadre-provider` is a declared dependency in its own
|
|
12
|
-
* right. Every one of those resolves through a `node_modules` symlink whose
|
|
13
|
-
* manifest points at `dist`, so an edit to any of their `src` with no following
|
|
14
|
-
* build is invisible here: the suite runs the previous build and reports green
|
|
15
|
-
* about code it never executed. Fail the run up front instead.
|
|
16
|
-
*
|
|
17
|
-
* NOTE: accepted tradeoff — this does not cover cadre-host's *own* `src` going
|
|
18
|
-
* stale relative to its own `dist`. The CLI smoke tests spawn `dist/bin/host.js`
|
|
19
|
-
* as a child process, which is a same-package staleness concern, not the
|
|
20
|
-
* cross-package one `BuildTarget` is meant to check (see
|
|
21
|
-
* `test-harness/build-freshness.ts`'s module comment). Consequence: a `src`-only
|
|
22
|
-
* edit to `bin/host.ts` is checked by `bin/__tests__/cli-reference.test.ts`
|
|
23
|
-
* (which reads `src`) but not by the smoke tests (which read `dist`), so run
|
|
24
|
-
* `yarn workspace @serfab/cadre-host build:server` before the suite. Revisit if
|
|
25
|
-
* a stale `dist` ever produces a false green rather than merely a weaker check.
|
|
26
|
-
*
|
|
27
|
-
* The guard itself lives at the repo root (`test-harness/build-freshness.ts`),
|
|
28
|
-
* shared with the other suites that call it; the list of packages below is this
|
|
29
|
-
* suite's own concern.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { assertBuildFresh, type BuildTarget } from '../../../../test-harness/build-freshness.js';
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Every package this suite runs compiled code from — the `workspace:` entries of
|
|
36
|
-
* `package.json`'s `dependencies`, plus the `link:` packages reached
|
|
37
|
-
* transitively through them.
|
|
38
|
-
*
|
|
39
|
-
* Exported so `build-targets.test.ts` can hold it against this package's actual
|
|
40
|
-
* `dependencies` — a hand-written list rots silently otherwise.
|
|
41
|
-
*/
|
|
42
|
-
export const TARGETS: BuildTarget[] = [
|
|
43
|
-
{ packageName: '@serfab/cadre-cli', distEntry: 'dist/bin/cadre.js', location: 'workspace' },
|
|
44
|
-
{ packageName: '@serfab/cadre-core', distEntry: 'dist/index.js', location: 'workspace' },
|
|
45
|
-
{ packageName: '@serfab/cadre-provider', distEntry: 'dist/index.js', location: 'workspace' },
|
|
46
|
-
|
|
47
|
-
{ packageName: '@
|
|
48
|
-
{ packageName: '@
|
|
49
|
-
{ packageName: '@optimystic/
|
|
50
|
-
{ packageName: '@optimystic/
|
|
51
|
-
{ packageName: '@
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Vitest global setup — runs once before the whole cadre-host suite.
|
|
3
|
+
*
|
|
4
|
+
* `src/update/manifest.ts` and `src/push/index.ts` import real symbols from
|
|
5
|
+
* `@serfab/cadre-core` (`canonicalJson`, `validatePushCredentials`), and
|
|
6
|
+
* `trust-circle-integration.test.ts` imports and drives a real `CadreNode` from
|
|
7
|
+
* it too. `@serfab/cadre-core`'s compiled entry point in turn imports
|
|
8
|
+
* `@serfab/quereus-plugin-sereus`, which imports the `@optimystic/*` and
|
|
9
|
+
* `@quereus/quereus` packages linked in from their sibling checkouts.
|
|
10
|
+
* `host-process-orchestrator.ts` also resolves `@serfab/cadre-cli`'s bin path at
|
|
11
|
+
* runtime, and `@serfab/cadre-provider` is a declared dependency in its own
|
|
12
|
+
* right. Every one of those resolves through a `node_modules` symlink whose
|
|
13
|
+
* manifest points at `dist`, so an edit to any of their `src` with no following
|
|
14
|
+
* build is invisible here: the suite runs the previous build and reports green
|
|
15
|
+
* about code it never executed. Fail the run up front instead.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: accepted tradeoff — this does not cover cadre-host's *own* `src` going
|
|
18
|
+
* stale relative to its own `dist`. The CLI smoke tests spawn `dist/bin/host.js`
|
|
19
|
+
* as a child process, which is a same-package staleness concern, not the
|
|
20
|
+
* cross-package one `BuildTarget` is meant to check (see
|
|
21
|
+
* `test-harness/build-freshness.ts`'s module comment). Consequence: a `src`-only
|
|
22
|
+
* edit to `bin/host.ts` is checked by `bin/__tests__/cli-reference.test.ts`
|
|
23
|
+
* (which reads `src`) but not by the smoke tests (which read `dist`), so run
|
|
24
|
+
* `yarn workspace @serfab/cadre-host build:server` before the suite. Revisit if
|
|
25
|
+
* a stale `dist` ever produces a false green rather than merely a weaker check.
|
|
26
|
+
*
|
|
27
|
+
* The guard itself lives at the repo root (`test-harness/build-freshness.ts`),
|
|
28
|
+
* shared with the other suites that call it; the list of packages below is this
|
|
29
|
+
* suite's own concern.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { assertBuildFresh, type BuildTarget } from '../../../../test-harness/build-freshness.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every package this suite runs compiled code from — the `workspace:` entries of
|
|
36
|
+
* `package.json`'s `dependencies`, plus the `link:` packages reached
|
|
37
|
+
* transitively through them.
|
|
38
|
+
*
|
|
39
|
+
* Exported so `build-targets.test.ts` can hold it against this package's actual
|
|
40
|
+
* `dependencies` — a hand-written list rots silently otherwise.
|
|
41
|
+
*/
|
|
42
|
+
export const TARGETS: BuildTarget[] = [
|
|
43
|
+
{ packageName: '@serfab/cadre-cli', distEntry: 'dist/bin/cadre.js', location: 'workspace' },
|
|
44
|
+
{ packageName: '@serfab/cadre-core', distEntry: 'dist/index.js', location: 'workspace' },
|
|
45
|
+
{ packageName: '@serfab/cadre-provider', distEntry: 'dist/index.js', location: 'workspace' },
|
|
46
|
+
// Reached through `@serfab/cadre-cli`, whose spawned `dist` loads it.
|
|
47
|
+
{ packageName: '@serfab/config-check', distEntry: 'dist/index.js', location: 'workspace' },
|
|
48
|
+
{ packageName: '@serfab/quereus-plugin-sereus', distEntry: 'dist/index.js', location: 'workspace' },
|
|
49
|
+
{ packageName: '@optimystic/db-core', distEntry: 'dist/src/index.js', location: 'linked' },
|
|
50
|
+
{ packageName: '@optimystic/db-p2p', distEntry: 'dist/src/index.js', location: 'linked' },
|
|
51
|
+
{ packageName: '@optimystic/quereus-plugin-crypto', distEntry: 'dist/index.js', location: 'linked' },
|
|
52
|
+
{ packageName: '@optimystic/quereus-plugin-optimystic', distEntry: 'dist/index.js', location: 'linked' },
|
|
53
|
+
{ packageName: '@quereus/quereus', distEntry: 'dist/src/index.js', location: 'linked' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export default function setup(): void {
|
|
57
|
+
assertBuildFresh(TARGETS, import.meta.url);
|
|
58
|
+
}
|
|
@@ -24,6 +24,7 @@ import { tmpdir } from 'node:os';
|
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
|
|
26
26
|
import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
27
|
+
import { removeAllNodes } from './orchestrator-teardown.js';
|
|
27
28
|
|
|
28
29
|
// Writes the vars under test (as seen in the child env) next to the startup token.
|
|
29
30
|
const FAKE_CLI = `
|
|
@@ -42,7 +43,9 @@ if (tokenPath) {
|
|
|
42
43
|
CADRE_LISTEN_ADDRS: process.env.CADRE_LISTEN_ADDRS ?? null,
|
|
43
44
|
DEBUG: process.env.DEBUG ?? null,
|
|
44
45
|
};
|
|
45
|
-
|
|
46
|
+
// Write then rename: the test polls for the file's existence, and must never read it half-written.
|
|
47
|
+
const seenPath = path.join(dir, 'env-seen.json');
|
|
48
|
+
try { fs.writeFileSync(seenPath + '.tmp', JSON.stringify(record), 'utf8'); fs.renameSync(seenPath + '.tmp', seenPath); } catch (e) { console.error(e); }
|
|
46
49
|
if (token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } }
|
|
47
50
|
}
|
|
48
51
|
process.on('SIGTERM', () => process.exit(0));
|
|
@@ -67,18 +70,16 @@ beforeEach(() => {
|
|
|
67
70
|
});
|
|
68
71
|
|
|
69
72
|
afterEach(async () => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
try {
|
|
74
|
+
await removeAllNodes(orchestrators);
|
|
75
|
+
} finally {
|
|
76
|
+
await sleep(50);
|
|
77
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
78
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
79
|
+
if (value === undefined) delete process.env[key];
|
|
80
|
+
else process.env[key] = value;
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
|
-
orchestrators.length = 0;
|
|
76
|
-
await sleep(50);
|
|
77
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
78
|
-
for (const [key, value] of Object.entries(savedEnv)) {
|
|
79
|
-
if (value === undefined) delete process.env[key];
|
|
80
|
-
else process.env[key] = value;
|
|
81
|
-
}
|
|
82
83
|
});
|
|
83
84
|
|
|
84
85
|
function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handle rules `HostProcessOrchestrator` and its test stand-in
|
|
3
|
+
* `FakeOrchestrator` (`donation/__tests__/fake-orchestrator.ts`) must both obey,
|
|
4
|
+
* written once and run against each: `orchestrator.test.ts` registers them over
|
|
5
|
+
* the real class, `fake-orchestrator.test.ts` over the fake. A rule one of them
|
|
6
|
+
* stops obeying fails in that file's run.
|
|
7
|
+
*
|
|
8
|
+
* Not a `.test.ts` file, so vitest does not collect it on its own.
|
|
9
|
+
*
|
|
10
|
+
* Only what both classes can satisfy belongs here — no ports, no files on disk,
|
|
11
|
+
* none of the fake's recording arrays. Those stay in the two calling files.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import type { OrchestratorCreateRequest, OrchestratorCreateResult } from '@serfab/cadre-provider';
|
|
17
|
+
|
|
18
|
+
import type { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
19
|
+
|
|
20
|
+
/** What the contract needs from either orchestrator. */
|
|
21
|
+
export type ContractOrchestrator = Pick<
|
|
22
|
+
HostProcessOrchestrator,
|
|
23
|
+
'createContainer' | 'stopContainer' | 'removeContainer' | 'resolveDockerId'
|
|
24
|
+
>;
|
|
25
|
+
|
|
26
|
+
export interface HandleContractHarness<O extends ContractOrchestrator> {
|
|
27
|
+
/** A fresh orchestrator. Teardown is the calling file's own afterEach. */
|
|
28
|
+
make(): O;
|
|
29
|
+
request(containerId: string): OrchestratorCreateRequest;
|
|
30
|
+
/** Resolve once the spawn named by `dockerId` reads as running. */
|
|
31
|
+
started(orch: O, dockerId: string): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Make `createContainer` for `containerId` throw from here on. Only called
|
|
34
|
+
* after that container has been spawned once and stopped, and no case creates
|
|
35
|
+
* again afterwards.
|
|
36
|
+
*/
|
|
37
|
+
failNextCreate(orch: O, containerId: string): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CONTAINER = 'grn_contract_a';
|
|
41
|
+
const OTHER_CONTAINER = 'grn_contract_b';
|
|
42
|
+
|
|
43
|
+
export function describeHandleContract<O extends ContractOrchestrator>(
|
|
44
|
+
label: string,
|
|
45
|
+
harness: HandleContractHarness<O>,
|
|
46
|
+
): void {
|
|
47
|
+
async function spawn(orch: O, containerId: string): Promise<OrchestratorCreateResult> {
|
|
48
|
+
const result = await orch.createContainer(harness.request(containerId));
|
|
49
|
+
await harness.started(orch, result.dockerId);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Stops first: a container whose previous child is still running is refused.
|
|
54
|
+
async function respawn(orch: O, containerId: string, previousDockerId: string): Promise<OrchestratorCreateResult> {
|
|
55
|
+
await orch.stopContainer(previousDockerId);
|
|
56
|
+
return spawn(orch, containerId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe(`${label} handle contract`, () => {
|
|
60
|
+
it('rejects a dockerId it never issued', async () => {
|
|
61
|
+
const orch = harness.make();
|
|
62
|
+
|
|
63
|
+
await expectNotFound(orch.stopContainer('never-issued'), 'never-issued');
|
|
64
|
+
await expectNotFound(orch.removeContainer('never-issued'), 'never-issued');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects a dockerId once its container has been removed', async () => {
|
|
68
|
+
const orch = harness.make();
|
|
69
|
+
const { dockerId } = await spawn(orch, CONTAINER);
|
|
70
|
+
await orch.stopContainer(dockerId);
|
|
71
|
+
|
|
72
|
+
await orch.removeContainer(dockerId);
|
|
73
|
+
|
|
74
|
+
await expectNotFound(orch.stopContainer(dockerId), dockerId);
|
|
75
|
+
await expectNotFound(orch.removeContainer(dockerId), dockerId);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('replaces the first spawn\'s dockerId when the container re-spawns', async () => {
|
|
79
|
+
const orch = harness.make();
|
|
80
|
+
const first = await spawn(orch, CONTAINER);
|
|
81
|
+
|
|
82
|
+
const second = await respawn(orch, CONTAINER, first.dockerId);
|
|
83
|
+
|
|
84
|
+
await expectNotFound(orch.stopContainer(first.dockerId), first.dockerId);
|
|
85
|
+
expect(orch.resolveDockerId(CONTAINER)).toBe(second.dockerId);
|
|
86
|
+
await expect(orch.stopContainer(second.dockerId)).resolves.toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Launching over a live child hands its ports to a second child that then
|
|
90
|
+
// dies on them, while the caller records the new dockerId and seed token.
|
|
91
|
+
it('refuses to re-spawn a container whose previous child is still running', async () => {
|
|
92
|
+
const orch = harness.make();
|
|
93
|
+
const first = await spawn(orch, CONTAINER);
|
|
94
|
+
|
|
95
|
+
await expect(orch.createContainer(harness.request(CONTAINER)))
|
|
96
|
+
.rejects.toHaveProperty('message', `container ${CONTAINER} is still running`);
|
|
97
|
+
|
|
98
|
+
expect(orch.resolveDockerId(CONTAINER)).toBe(first.dockerId);
|
|
99
|
+
await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The drop on re-spawn filters on containerId; an orchestrator that cleared
|
|
103
|
+
// every handle would still pass the same-container case above.
|
|
104
|
+
it('leaves another container\'s dockerId alone when one container re-spawns', async () => {
|
|
105
|
+
const orch = harness.make();
|
|
106
|
+
const other = await spawn(orch, OTHER_CONTAINER);
|
|
107
|
+
const first = await spawn(orch, CONTAINER);
|
|
108
|
+
|
|
109
|
+
await respawn(orch, CONTAINER, first.dockerId);
|
|
110
|
+
|
|
111
|
+
expect(orch.resolveDockerId(OTHER_CONTAINER)).toBe(other.dockerId);
|
|
112
|
+
await expect(orch.stopContainer(other.dockerId)).resolves.toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// What lets a caller still clean up the node it knows about after a re-spawn
|
|
116
|
+
// that did not happen.
|
|
117
|
+
it('keeps the first spawn\'s dockerId when the re-spawn fails', async () => {
|
|
118
|
+
const orch = harness.make();
|
|
119
|
+
const first = await spawn(orch, CONTAINER);
|
|
120
|
+
await orch.stopContainer(first.dockerId);
|
|
121
|
+
|
|
122
|
+
harness.failNextCreate(orch, CONTAINER);
|
|
123
|
+
await expect(orch.createContainer(harness.request(CONTAINER))).rejects.toThrow();
|
|
124
|
+
|
|
125
|
+
expect(orch.resolveDockerId(CONTAINER)).toBe(first.dockerId);
|
|
126
|
+
await expect(orch.removeContainer(first.dockerId)).resolves.toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The whole message, not a substring, so the two classes cannot come to word it differently. */
|
|
132
|
+
async function expectNotFound(call: Promise<void>, dockerId: string): Promise<void> {
|
|
133
|
+
await expect(call).rejects.toHaveProperty('message', `Container not found: ${dockerId}`);
|
|
134
|
+
}
|
|
@@ -19,6 +19,7 @@ import { join } from 'node:path';
|
|
|
19
19
|
|
|
20
20
|
import { HostProcessOrchestrator, OWNER_CONTAINER_ID } from '../orchestrator/host-process-orchestrator.js';
|
|
21
21
|
import { loadIdentity } from '../installer/identity.js';
|
|
22
|
+
import { removeAllNodes } from './orchestrator-teardown.js';
|
|
22
23
|
|
|
23
24
|
// Writes the --identity-file argument (as seen on its own command line) next
|
|
24
25
|
// to the startup token, then behaves like a minimal long-lived node.
|
|
@@ -49,14 +50,12 @@ beforeEach(() => {
|
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
afterEach(async () => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
try {
|
|
54
|
+
await removeAllNodes(orchestrators);
|
|
55
|
+
} finally {
|
|
56
|
+
await sleep(50);
|
|
57
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
56
58
|
}
|
|
57
|
-
orchestrators.length = 0;
|
|
58
|
-
await sleep(50);
|
|
59
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
60
59
|
});
|
|
61
60
|
|
|
62
61
|
function makeOrchestrator(
|
|
@@ -17,6 +17,7 @@ import { StateStore } from '../orchestrator/state-store.js';
|
|
|
17
17
|
import { isPidAlive } from '../orchestrator/pid-liveness.js';
|
|
18
18
|
import { decodeDockerId } from '../orchestrator/types.js';
|
|
19
19
|
import { OwnerNodeClient } from '../owner/owner-node-client.js';
|
|
20
|
+
import { removeAllNodes } from './orchestrator-teardown.js';
|
|
20
21
|
|
|
21
22
|
const FAKE_OWNER_CLI = `
|
|
22
23
|
import fs from 'node:fs';
|
|
@@ -70,15 +71,12 @@ beforeEach(() => {
|
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
afterEach(async () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
74
|
+
try {
|
|
75
|
+
await removeAllNodes(orchestrators);
|
|
76
|
+
} finally {
|
|
77
|
+
await sleep(50);
|
|
78
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
78
79
|
}
|
|
79
|
-
orchestrators.length = 0;
|
|
80
|
-
await sleep(50);
|
|
81
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
82
80
|
});
|
|
83
81
|
|
|
84
82
|
function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
|
|
@@ -93,11 +91,6 @@ function makeOrchestrator(rootDir: string): HostProcessOrchestrator {
|
|
|
93
91
|
return orch;
|
|
94
92
|
}
|
|
95
93
|
|
|
96
|
-
function listDockerIds(orch: HostProcessOrchestrator): string[] {
|
|
97
|
-
const state = new StateStore((orch as unknown as { rootDir: string }).rootDir);
|
|
98
|
-
return state.load().handles.map((h) => h.dockerId);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
94
|
async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 4000, intervalMs = 50): Promise<void> {
|
|
102
95
|
const deadline = Date.now() + timeoutMs;
|
|
103
96
|
while (Date.now() < deadline) {
|
|
@@ -20,6 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
|
|
21
21
|
import type { PushCredentials } from '@serfab/cadre-core';
|
|
22
22
|
import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
23
|
+
import { removeAllNodes } from './orchestrator-teardown.js';
|
|
23
24
|
|
|
24
25
|
// Writes CADRE_OWNER_KEYS (as seen in the child env) next to the startup token,
|
|
25
26
|
// then behaves like a minimal long-lived node.
|
|
@@ -52,14 +53,12 @@ beforeEach(() => {
|
|
|
52
53
|
});
|
|
53
54
|
|
|
54
55
|
afterEach(async () => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
try {
|
|
57
|
+
await removeAllNodes(orchestrators);
|
|
58
|
+
} finally {
|
|
59
|
+
await sleep(50);
|
|
60
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
59
61
|
}
|
|
60
|
-
orchestrators.length = 0;
|
|
61
|
-
await sleep(50);
|
|
62
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
63
62
|
});
|
|
64
63
|
|
|
65
64
|
function makeOrchestrator(rootDir: string, pushResolver?: () => Promise<PushCredentials | undefined>): HostProcessOrchestrator {
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pure port-set logic behind `HostProcessOrchestrator`: the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The pure port-set logic behind `HostProcessOrchestrator`: the helpers that
|
|
3
|
+
* allocate, hold, release and reuse one node's set of ports, and the listen
|
|
4
|
+
* addresses a child is started with. No child processes — the behaviour these
|
|
5
5
|
* feed is exercised against a stub child in `orchestrator.test.ts`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
9
|
|
|
10
|
+
import { PortAllocator } from '@serfab/cadre-provider';
|
|
11
|
+
|
|
10
12
|
import { childListenAddrs } from '../orchestrator/host-process-orchestrator.js';
|
|
11
13
|
import {
|
|
12
14
|
allocateNodePorts,
|
|
13
|
-
PortAllocator,
|
|
14
15
|
releaseNodePorts,
|
|
15
16
|
reserveNodePorts,
|
|
16
17
|
reusedNodePorts,
|
|
@@ -25,36 +26,6 @@ function portsWithoutWs(ports: Omit<NodePorts, 'ws'>): NodePorts {
|
|
|
25
26
|
return ports as NodePorts;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
describe('PortAllocator', () => {
|
|
29
|
-
it('allocates sequentially, releases, and reuses', () => {
|
|
30
|
-
const a = new PortAllocator(100, 102);
|
|
31
|
-
expect(a.allocate()).toBe(100);
|
|
32
|
-
expect(a.allocate()).toBe(101);
|
|
33
|
-
expect(a.allocate()).toBe(102);
|
|
34
|
-
expect(() => a.allocate()).toThrow(/No available ports/);
|
|
35
|
-
a.release(101);
|
|
36
|
-
expect(a.allocate()).toBe(101);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('markUsed reserves ports without allocating', () => {
|
|
40
|
-
const a = new PortAllocator(100, 102);
|
|
41
|
-
a.markUsed(101);
|
|
42
|
-
expect(a.allocate()).toBe(100);
|
|
43
|
-
expect(a.allocate()).toBe(102);
|
|
44
|
-
expect(() => a.allocate()).toThrow();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// Without the integer check, `undefined` and NaN pass both range comparisons (each
|
|
48
|
-
// is false) and land in the used-set, as would an in-range fraction.
|
|
49
|
-
it('markUsed ignores a non-integer', () => {
|
|
50
|
-
const a = new PortAllocator(100, 101);
|
|
51
|
-
for (const junk of [undefined as unknown as number, Number.NaN, 100.5]) {
|
|
52
|
-
a.markUsed(junk);
|
|
53
|
-
expect(a.has(junk)).toBe(false);
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
29
|
describe('allocateNodePorts', () => {
|
|
59
30
|
it('allocates the five ports in a fixed order, the newest key last', () => {
|
|
60
31
|
const a = new PortAllocator(10000, 10010);
|
|
@@ -14,6 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
|
|
15
15
|
import type { PushCredentials } from '@serfab/cadre-core';
|
|
16
16
|
import { HostProcessOrchestrator, OWNER_CONTAINER_ID } from '../orchestrator/host-process-orchestrator.js';
|
|
17
|
+
import { removeAllNodes } from './orchestrator-teardown.js';
|
|
17
18
|
|
|
18
19
|
const FAKE_CLI = `
|
|
19
20
|
import fs from 'node:fs';
|
|
@@ -41,12 +42,12 @@ beforeEach(() => {
|
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
afterEach(async () => {
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
try {
|
|
46
|
+
await removeAllNodes(orchestrators);
|
|
47
|
+
} finally {
|
|
48
|
+
await sleep(50);
|
|
49
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
46
50
|
}
|
|
47
|
-
orchestrators.length = 0;
|
|
48
|
-
await sleep(50);
|
|
49
|
-
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
function makeOrchestrator(rootDir: string, pushResolver?: () => Promise<PushCredentials | undefined>): HostProcessOrchestrator {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared teardown for the test files that drive a real HostProcessOrchestrator
|
|
3
|
+
* against a stub child script.
|
|
4
|
+
*/
|
|
5
|
+
import type { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js';
|
|
6
|
+
import { isPidAlive } from '../orchestrator/pid-liveness.js';
|
|
7
|
+
import { decodeDockerId } from '../orchestrator/types.js';
|
|
8
|
+
|
|
9
|
+
interface StartedNode {
|
|
10
|
+
containerId: string;
|
|
11
|
+
pid: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stop and remove every node the given orchestrators hold, then fail if any of
|
|
16
|
+
* their processes is still alive. Empties `orchestrators`.
|
|
17
|
+
*
|
|
18
|
+
* Nodes come from `listNodes()` rather than the state file: two orchestrators
|
|
19
|
+
* over one rootDir each rewrite the whole file from their own memory, so the
|
|
20
|
+
* file can omit a child that one of them still holds.
|
|
21
|
+
*/
|
|
22
|
+
export async function removeAllNodes(orchestrators: HostProcessOrchestrator[]): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
const nodes: StartedNode[] = [];
|
|
25
|
+
const errors: string[] = [];
|
|
26
|
+
for (const orch of orchestrators) {
|
|
27
|
+
await removeNodesOf(orch, nodes, errors);
|
|
28
|
+
}
|
|
29
|
+
// NOTE: liveness is by pid alone, not the startup token. If the OS hands a dead child's pid to an
|
|
30
|
+
// unrelated process before this line, that process is reported and killed; if this ever produces a
|
|
31
|
+
// false failure, verify the token before counting a pid as a survivor.
|
|
32
|
+
const survivors = nodes.filter((n) => isPidAlive(n.pid));
|
|
33
|
+
if (survivors.length === 0) return;
|
|
34
|
+
survivors.forEach((n) => forceKill(n.pid));
|
|
35
|
+
throw new Error(describeSurvivors(survivors, errors));
|
|
36
|
+
} finally {
|
|
37
|
+
orchestrators.length = 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keeps going past a failed removal so one failure does not strand the rest. */
|
|
42
|
+
async function removeNodesOf(orch: HostProcessOrchestrator, nodes: StartedNode[], errors: string[]): Promise<void> {
|
|
43
|
+
for (const node of orch.listNodes()) {
|
|
44
|
+
nodes.push({ containerId: node.id, pid: decodeDockerId(node.dockerId).pid });
|
|
45
|
+
try {
|
|
46
|
+
await orch.removeContainer(node.dockerId);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
errors.push(`${node.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function forceKill(pid: number): void {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 'SIGKILL');
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describeSurvivors(survivors: StartedNode[], errors: string[]): string {
|
|
62
|
+
const names = survivors.map((n) => `${n.containerId} (pid ${n.pid})`).join(', ');
|
|
63
|
+
const causes = errors.length > 0 ? `; removeContainer errors: ${errors.join(' | ')}` : '';
|
|
64
|
+
return `test teardown left child processes running (now force-killed): ${names}${causes}`;
|
|
65
|
+
}
|