@vtechcom/hexcore 2.0.0-beta.1
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/CHANGELOG.md +127 -0
- package/LICENSE +201 -0
- package/README.md +283 -0
- package/dist/app.module.js +55 -0
- package/dist/app.module.js.map +1 -0
- package/dist/auth.js +151 -0
- package/dist/auth.js.map +1 -0
- package/dist/chain/cost-models.js +81 -0
- package/dist/chain/cost-models.js.map +1 -0
- package/dist/chain/provider.js +269 -0
- package/dist/chain/provider.js.map +1 -0
- package/dist/chain/socket.js +48 -0
- package/dist/chain/socket.js.map +1 -0
- package/dist/cli/doctor.js +43 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/hexcore.js +435 -0
- package/dist/cli/hexcore.js.map +1 -0
- package/dist/common/auth.guard.js +66 -0
- package/dist/common/auth.guard.js.map +1 -0
- package/dist/common/http.js +171 -0
- package/dist/common/http.js.map +1 -0
- package/dist/common/zod-validation.pipe.js +47 -0
- package/dist/common/zod-validation.pipe.js.map +1 -0
- package/dist/config.js +354 -0
- package/dist/config.js.map +1 -0
- package/dist/config.module.js +35 -0
- package/dist/config.module.js.map +1 -0
- package/dist/controllers/cardano.controller.js +173 -0
- package/dist/controllers/cardano.controller.js.map +1 -0
- package/dist/controllers/heads.controller.js +225 -0
- package/dist/controllers/heads.controller.js.map +1 -0
- package/dist/controllers/setup.controller.js +127 -0
- package/dist/controllers/setup.controller.js.map +1 -0
- package/dist/controllers/system.controller.js +153 -0
- package/dist/controllers/system.controller.js.map +1 -0
- package/dist/db/database.module.js +73 -0
- package/dist/db/database.module.js.map +1 -0
- package/dist/db/index.js +135 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/repositories.js +220 -0
- package/dist/db/repositories.js.map +1 -0
- package/dist/docker/container-stats.js +134 -0
- package/dist/docker/container-stats.js.map +1 -0
- package/dist/docker/docker.service.js +335 -0
- package/dist/docker/docker.service.js.map +1 -0
- package/dist/hydra/argv.js +172 -0
- package/dist/hydra/argv.js.map +1 -0
- package/dist/hydra/blockfrost-keys.js +15 -0
- package/dist/hydra/blockfrost-keys.js.map +1 -0
- package/dist/hydra/boot-progress.js +165 -0
- package/dist/hydra/boot-progress.js.map +1 -0
- package/dist/hydra/capabilities.js +131 -0
- package/dist/hydra/capabilities.js.map +1 -0
- package/dist/hydra/head.service.js +1072 -0
- package/dist/hydra/head.service.js.map +1 -0
- package/dist/hydra/keys.js +301 -0
- package/dist/hydra/keys.js.map +1 -0
- package/dist/hydra/node-monitor.service.js +265 -0
- package/dist/hydra/node-monitor.service.js.map +1 -0
- package/dist/hydra/ws-client.js +177 -0
- package/dist/hydra/ws-client.js.map +1 -0
- package/dist/main.js +106 -0
- package/dist/main.js.map +1 -0
- package/dist/preflight.js +215 -0
- package/dist/preflight.js.map +1 -0
- package/dist/settings.js +243 -0
- package/dist/settings.js.map +1 -0
- package/dist/setup.service.js +257 -0
- package/dist/setup.service.js.map +1 -0
- package/dist/version.js +46 -0
- package/dist/version.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/api.d.ts +730 -0
- package/node_modules/@hexcore/shared/dist/cjs/api.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/api.js +358 -0
- package/node_modules/@hexcore/shared/dist/cjs/api.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/domain.d.ts +298 -0
- package/node_modules/@hexcore/shared/dist/cjs/domain.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/domain.js +49 -0
- package/node_modules/@hexcore/shared/dist/cjs/domain.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.d.ts +279 -0
- package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.js +453 -0
- package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/index.d.ts +7 -0
- package/node_modules/@hexcore/shared/dist/cjs/index.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/index.js +23 -0
- package/node_modules/@hexcore/shared/dist/cjs/index.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/naming.d.ts +118 -0
- package/node_modules/@hexcore/shared/dist/cjs/naming.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/naming.js +103 -0
- package/node_modules/@hexcore/shared/dist/cjs/naming.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/offline.d.ts +73 -0
- package/node_modules/@hexcore/shared/dist/cjs/offline.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/offline.js +80 -0
- package/node_modules/@hexcore/shared/dist/cjs/offline.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/protocol-params.d.ts +79 -0
- package/node_modules/@hexcore/shared/dist/cjs/protocol-params.d.ts.map +1 -0
- package/node_modules/@hexcore/shared/dist/cjs/protocol-params.js +223 -0
- package/node_modules/@hexcore/shared/dist/cjs/protocol-params.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/api.js +355 -0
- package/node_modules/@hexcore/shared/dist/esm/api.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/domain.js +46 -0
- package/node_modules/@hexcore/shared/dist/esm/domain.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/hydra-versions.js +441 -0
- package/node_modules/@hexcore/shared/dist/esm/hydra-versions.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/index.js +7 -0
- package/node_modules/@hexcore/shared/dist/esm/index.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/naming.js +95 -0
- package/node_modules/@hexcore/shared/dist/esm/naming.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/offline.js +75 -0
- package/node_modules/@hexcore/shared/dist/esm/offline.js.map +1 -0
- package/node_modules/@hexcore/shared/dist/esm/package.json +3 -0
- package/node_modules/@hexcore/shared/dist/esm/protocol-params.js +217 -0
- package/node_modules/@hexcore/shared/dist/esm/protocol-params.js.map +1 -0
- package/node_modules/@hexcore/shared/package.json +15 -0
- package/package.json +67 -0
- package/web/200.html +23 -0
- package/web/404.html +23 -0
- package/web/_nuxt/2MxohCD-.js +1 -0
- package/web/_nuxt/4pFXjB_u.js +1 -0
- package/web/_nuxt/B-v0dEQX.js +1 -0
- package/web/_nuxt/B4yE3U25.js +1 -0
- package/web/_nuxt/BFh8Ai0L.js +1 -0
- package/web/_nuxt/BgPn4NJH.js +1 -0
- package/web/_nuxt/Bj1xviSh.js +1 -0
- package/web/_nuxt/BnotlrL7.js +1 -0
- package/web/_nuxt/C1YRg2ic.js +1 -0
- package/web/_nuxt/CGD7MYQH.js +1 -0
- package/web/_nuxt/CQcG7Bu4.js +1 -0
- package/web/_nuxt/CS3KqpH_.js +1 -0
- package/web/_nuxt/CT-yt694.js +1 -0
- package/web/_nuxt/CZK6xUH-.js +1 -0
- package/web/_nuxt/CcdyYRYz.js +1 -0
- package/web/_nuxt/CePNTU1S.js +1 -0
- package/web/_nuxt/CgPLsCeU.js +5 -0
- package/web/_nuxt/CkCrahXt.js +1 -0
- package/web/_nuxt/CnOqDT9k.js +3 -0
- package/web/_nuxt/CqsSss-D.js +1 -0
- package/web/_nuxt/Cr8dL64Q.js +1 -0
- package/web/_nuxt/D3CwzwU0.js +1 -0
- package/web/_nuxt/DDANn6-H.js +1 -0
- package/web/_nuxt/DDl9uKIA.js +1 -0
- package/web/_nuxt/DL9i0ayn.js +1 -0
- package/web/_nuxt/DLNoK9BC.js +1 -0
- package/web/_nuxt/DLztFeOQ.js +1 -0
- package/web/_nuxt/DPihKXdt.js +1 -0
- package/web/_nuxt/DXyr6Naa.js +1 -0
- package/web/_nuxt/DeDg5p0D.js +1 -0
- package/web/_nuxt/DgDlQiDU.js +1 -0
- package/web/_nuxt/DyPI7qJE.js +30 -0
- package/web/_nuxt/LGfPBi70.js +1 -0
- package/web/_nuxt/Onf26W-r.js +1 -0
- package/web/_nuxt/QZNQe_OX.js +1 -0
- package/web/_nuxt/Y8IJM-iz.js +1 -0
- package/web/_nuxt/YUGC0nt0.js +1 -0
- package/web/_nuxt/builds/latest.json +1 -0
- package/web/_nuxt/builds/meta/7e461629-93e2-4694-b237-6d5c6a85c97c.json +1 -0
- package/web/_nuxt/builds/meta/dev.json +1 -0
- package/web/_nuxt/entry.BxsAxZxf.css +1 -0
- package/web/_nuxt/error-404.XbtAw-qw.css +1 -0
- package/web/_nuxt/error-500.CNHVltWM.css +1 -0
- package/web/_nuxt/rS90pPZ_.js +1 -0
- package/web/_nuxt/rhEiP908.js +1 -0
- package/web/_nuxt/vSj9Ec0g.js +1 -0
- package/web/_nuxt/woNjBJMN.js +1 -0
- package/web/favicon.svg +28 -0
- package/web/heads/index.html +23 -0
- package/web/heads/new/index.html +23 -0
- package/web/index.html +23 -0
- package/web/login/index.html +23 -0
- package/web/logo.svg +56 -0
- package/web/settings/index.html +23 -0
- package/web/setup/index.html +23 -0
|
@@ -0,0 +1,1072 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var HeadService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.HeadService = exports.HeadValidationError = exports.HeadConflictError = exports.HeadNotFoundError = void 0;
|
|
17
|
+
/**
|
|
18
|
+
* Head lifecycle service.
|
|
19
|
+
*
|
|
20
|
+
* Owns the whole "boot N hydra-nodes that form one Head" flow:
|
|
21
|
+
* create -> allocate ports, generate keys, write envelopes, persist rows
|
|
22
|
+
* start -> fetch protocol params, ensure image/network, create N containers
|
|
23
|
+
* wired as peers of each other, wait for every node to greet
|
|
24
|
+
* stop -> remove containers, keep keys + persistence
|
|
25
|
+
* reset -> wipe persistence (optionally keys) for a clean restart
|
|
26
|
+
*
|
|
27
|
+
* Differences from v1 that matter:
|
|
28
|
+
* - keys are generated here, not supplied by the caller
|
|
29
|
+
* - ports are deterministic per (head, node index) instead of "scan upward
|
|
30
|
+
* from 10005 until something is free", which made peer wiring unpredictable
|
|
31
|
+
* - a failed boot rolls back every container it created and records the real
|
|
32
|
+
* error on the head row instead of leaving orphans behind
|
|
33
|
+
*/
|
|
34
|
+
const promises_1 = require("node:fs/promises");
|
|
35
|
+
const shared_1 = require("@hexcore/shared");
|
|
36
|
+
const common_1 = require("@nestjs/common");
|
|
37
|
+
const config_module_1 = require("../config.module");
|
|
38
|
+
const repositories_1 = require("../db/repositories");
|
|
39
|
+
const docker_service_1 = require("../docker/docker.service");
|
|
40
|
+
const container_stats_1 = require("../docker/container-stats");
|
|
41
|
+
const capabilities_1 = require("./capabilities");
|
|
42
|
+
const node_monitor_service_1 = require("./node-monitor.service");
|
|
43
|
+
const boot_progress_1 = require("./boot-progress");
|
|
44
|
+
const blockfrost_keys_1 = require("./blockfrost-keys");
|
|
45
|
+
const socket_1 = require("../chain/socket");
|
|
46
|
+
const argv_1 = require("./argv");
|
|
47
|
+
const keys_1 = require("./keys");
|
|
48
|
+
const ws_client_1 = require("./ws-client");
|
|
49
|
+
const provider_1 = require("../chain/provider");
|
|
50
|
+
class HeadNotFoundError extends Error {
|
|
51
|
+
constructor(id) {
|
|
52
|
+
super(`Head ${id} not found`);
|
|
53
|
+
this.name = 'HeadNotFoundError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.HeadNotFoundError = HeadNotFoundError;
|
|
57
|
+
class HeadConflictError extends Error {
|
|
58
|
+
constructor(message) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = 'HeadConflictError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.HeadConflictError = HeadConflictError;
|
|
64
|
+
/**
|
|
65
|
+
* A request that is well-formed but cannot be satisfied by THIS server — the
|
|
66
|
+
* cardano-node socket is missing, or no Blockfrost key exists at any level.
|
|
67
|
+
* Zod cannot see server state, so these checks live in the service, but they
|
|
68
|
+
* are field errors to the caller and are reported as such: the create form
|
|
69
|
+
* highlights the input instead of showing a bare toast.
|
|
70
|
+
*/
|
|
71
|
+
class HeadValidationError extends Error {
|
|
72
|
+
field;
|
|
73
|
+
constructor(field, message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.field = field;
|
|
76
|
+
this.name = 'HeadValidationError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
exports.HeadValidationError = HeadValidationError;
|
|
80
|
+
let HeadService = HeadService_1 = class HeadService {
|
|
81
|
+
config;
|
|
82
|
+
docker;
|
|
83
|
+
heads;
|
|
84
|
+
nodes;
|
|
85
|
+
events;
|
|
86
|
+
capabilities;
|
|
87
|
+
monitor;
|
|
88
|
+
boot;
|
|
89
|
+
logger = new common_1.Logger(HeadService_1.name);
|
|
90
|
+
/** Heads currently being started/stopped — guards against double-boot. */
|
|
91
|
+
inFlight = new Set();
|
|
92
|
+
constructor(config, docker, heads, nodes, events, capabilities, monitor, boot) {
|
|
93
|
+
this.config = config;
|
|
94
|
+
this.docker = docker;
|
|
95
|
+
this.heads = heads;
|
|
96
|
+
this.nodes = nodes;
|
|
97
|
+
this.events = events;
|
|
98
|
+
this.capabilities = capabilities;
|
|
99
|
+
this.monitor = monitor;
|
|
100
|
+
this.boot = boot;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Adopt reality once, at boot.
|
|
104
|
+
*
|
|
105
|
+
* The control-plane can be restarted while heads are up — a deploy, a crash,
|
|
106
|
+
* a machine reboot — and the status columns only ever recorded what the last
|
|
107
|
+
* lifecycle call intended. Reconciling here means a head that died while
|
|
108
|
+
* nothing was watching is already corrected, and its live nodes already have
|
|
109
|
+
* a monitor connection, before the first request arrives.
|
|
110
|
+
*
|
|
111
|
+
* Best effort: Docker being down at boot is not a reason to refuse to start.
|
|
112
|
+
*/
|
|
113
|
+
onApplicationBootstrap() {
|
|
114
|
+
void this.reconcile(0).catch((err) => this.logger.warn(`initial reconcile failed: ${err.message}`));
|
|
115
|
+
}
|
|
116
|
+
// ---------------------------------------------------------------- queries
|
|
117
|
+
/** Whether a node with no key of its own would still find one at boot. */
|
|
118
|
+
get serverKeyConfigured() {
|
|
119
|
+
return Boolean(this.config.blockfrostProjectId);
|
|
120
|
+
}
|
|
121
|
+
async list() {
|
|
122
|
+
await this.reconcile();
|
|
123
|
+
return this.heads.list().map(h => (0, repositories_1.toHeadView)(h, this.nodes.byHead(h.id), this.serverKeyConfigured));
|
|
124
|
+
}
|
|
125
|
+
async get(id) {
|
|
126
|
+
await this.reconcile();
|
|
127
|
+
return this.view(id);
|
|
128
|
+
}
|
|
129
|
+
/** Read a head from the DB without reconciling — for callers that just did. */
|
|
130
|
+
view(id) {
|
|
131
|
+
const row = this.heads.findById(id);
|
|
132
|
+
if (!row)
|
|
133
|
+
throw new HeadNotFoundError(id);
|
|
134
|
+
return (0, repositories_1.toHeadView)(row, this.nodes.byHead(id), this.serverKeyConfigured);
|
|
135
|
+
}
|
|
136
|
+
recentEvents(headId, limit = 100) {
|
|
137
|
+
return this.events.recent(headId, limit);
|
|
138
|
+
}
|
|
139
|
+
// ------------------------------------------------------------- reconcile
|
|
140
|
+
/** Last reconciliation, so a burst of requests does not hit Docker N times. */
|
|
141
|
+
lastReconcileAt = 0;
|
|
142
|
+
reconcileInFlight = null;
|
|
143
|
+
/**
|
|
144
|
+
* Make the stored status agree with what Docker actually has.
|
|
145
|
+
*
|
|
146
|
+
* The status columns were only ever written by the lifecycle methods, so
|
|
147
|
+
* they recorded *intent*, not fact: a head whose containers died — OOM, a
|
|
148
|
+
* daemon restart, `docker rm` by hand — kept reporting RUNNING forever,
|
|
149
|
+
* because nothing ever looked again. The same gap left a head stuck on
|
|
150
|
+
* STARTING when the control-plane was restarted mid-boot.
|
|
151
|
+
*
|
|
152
|
+
* One `listContainers` call answers for every head, so this is cheap enough
|
|
153
|
+
* to run on the list and detail endpoints the UI already polls, rather than
|
|
154
|
+
* on a timer that would keep a laptop awake.
|
|
155
|
+
*
|
|
156
|
+
* Two rules keep it from doing harm:
|
|
157
|
+
* - **A head with an action in flight is skipped.** Reconciling mid-start
|
|
158
|
+
* would race the boot it is watching and mark half-created containers
|
|
159
|
+
* failed.
|
|
160
|
+
* - **An unreachable Docker changes nothing.** If the daemon is down every
|
|
161
|
+
* container looks gone, and writing that would turn a transient outage
|
|
162
|
+
* into every head being marked FAILED.
|
|
163
|
+
*/
|
|
164
|
+
async reconcile(minIntervalMs = 2000) {
|
|
165
|
+
if (this.reconcileInFlight)
|
|
166
|
+
return this.reconcileInFlight;
|
|
167
|
+
if (Date.now() - this.lastReconcileAt < minIntervalMs)
|
|
168
|
+
return;
|
|
169
|
+
this.reconcileInFlight = this.runReconcile().finally(() => {
|
|
170
|
+
this.lastReconcileAt = Date.now();
|
|
171
|
+
this.reconcileInFlight = null;
|
|
172
|
+
});
|
|
173
|
+
return this.reconcileInFlight;
|
|
174
|
+
}
|
|
175
|
+
async runReconcile() {
|
|
176
|
+
let running;
|
|
177
|
+
try {
|
|
178
|
+
const containers = await this.docker.listManaged();
|
|
179
|
+
running = new Set(containers
|
|
180
|
+
.filter(c => c.State === 'running')
|
|
181
|
+
.flatMap(c => c.Names ?? [])
|
|
182
|
+
// Docker returns names with a leading slash.
|
|
183
|
+
.map(n => n.replace(/^\//, '')));
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
this.logger.debug(`skipping reconcile — Docker unreachable: ${err.message}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
for (const head of this.heads.list()) {
|
|
190
|
+
if (this.inFlight.has(head.id))
|
|
191
|
+
continue;
|
|
192
|
+
const nodeRows = this.nodes.byHead(head.id);
|
|
193
|
+
if (nodeRows.length === 0)
|
|
194
|
+
continue;
|
|
195
|
+
let up = 0;
|
|
196
|
+
for (const node of nodeRows) {
|
|
197
|
+
const isUp = running.has(node.name);
|
|
198
|
+
if (isUp)
|
|
199
|
+
up++;
|
|
200
|
+
if (isUp) {
|
|
201
|
+
// Keep a connection to every node that is actually up, so a
|
|
202
|
+
// peer dropping out is observed rather than guessed at.
|
|
203
|
+
this.monitor.watch(node.name, node.api_port);
|
|
204
|
+
if (node.status !== 'RUNNING')
|
|
205
|
+
this.nodes.setStatus(node.id, 'RUNNING', node.container_id);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
this.monitor.unwatch(node.name);
|
|
209
|
+
// FAILED rather than STOPPED: `stop` sets STOPPED itself, so
|
|
210
|
+
// a container that vanished from under a RUNNING row did not
|
|
211
|
+
// go away on purpose.
|
|
212
|
+
if (node.status === 'RUNNING' || node.status === 'STARTING') {
|
|
213
|
+
this.nodes.setStatus(node.id, 'FAILED', null);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this.reconcileHeadStatus(head, nodeRows.length, up);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Head-level verdict from how many of its containers are actually up. */
|
|
221
|
+
reconcileHeadStatus(head, total, up) {
|
|
222
|
+
const status = head.status;
|
|
223
|
+
if (up === 0 && (status === 'RUNNING' || status === 'STARTING')) {
|
|
224
|
+
const message = status === 'STARTING'
|
|
225
|
+
? `Head ${head.id} was left STARTING with no containers running — the start never finished.`
|
|
226
|
+
: `Head ${head.id} was marked RUNNING but none of its ${total} container(s) are running.`;
|
|
227
|
+
this.heads.setStatus(head.id, 'FAILED', message);
|
|
228
|
+
this.heads.setHydraState(head.id, 'UNKNOWN');
|
|
229
|
+
this.nodes.setStatusForHead(head.id, 'FAILED');
|
|
230
|
+
this.events.add(head.id, 'head.desynced', message, 'error');
|
|
231
|
+
this.logger.warn(message);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Containers are up under a head that does not think it is running:
|
|
235
|
+
// a start that completed after the control-plane went down, or someone
|
|
236
|
+
// starting the containers by hand. Adopt them rather than showing a
|
|
237
|
+
// stopped head with live nodes.
|
|
238
|
+
if (up > 0 && status !== 'RUNNING' && status !== 'STARTING') {
|
|
239
|
+
const message = `Head ${head.id} has ${up}/${total} container(s) running while marked ${status} — adopted as RUNNING.`;
|
|
240
|
+
this.heads.setStatus(head.id, 'RUNNING', null);
|
|
241
|
+
this.events.add(head.id, 'head.desynced', message, 'warn');
|
|
242
|
+
this.logger.warn(message);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
// A start that finished while nothing was watching.
|
|
246
|
+
if (up === total && status === 'STARTING') {
|
|
247
|
+
this.heads.setStatus(head.id, 'RUNNING', null);
|
|
248
|
+
this.events.add(head.id, 'head.started', `All ${total} node(s) are up`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// ----------------------------------------------------------------- create
|
|
252
|
+
/**
|
|
253
|
+
* Allocate the next free block of ports. Each node occupies PORTS_PER_NODE
|
|
254
|
+
* consecutive numbers, so we simply continue past the highest port in use.
|
|
255
|
+
*/
|
|
256
|
+
nextSlot() {
|
|
257
|
+
const max = this.nodes.maxApiPort();
|
|
258
|
+
if (max === null)
|
|
259
|
+
return 0;
|
|
260
|
+
return Math.floor((max - this.config.portBase) / shared_1.PORTS_PER_NODE) + 1;
|
|
261
|
+
}
|
|
262
|
+
async create(input) {
|
|
263
|
+
if (this.heads.findByName(input.name)) {
|
|
264
|
+
throw new HeadConflictError(`A head named "${input.name}" already exists`);
|
|
265
|
+
}
|
|
266
|
+
const network = input.network ?? this.config.network;
|
|
267
|
+
const chainBackend = input.chainBackend ?? this.config.chainBackend;
|
|
268
|
+
/**
|
|
269
|
+
* Refuse a head that could never boot, while the caller is still on the
|
|
270
|
+
* form and can do something about it.
|
|
271
|
+
*
|
|
272
|
+
* Neither credential is required to run the control-plane any more, so
|
|
273
|
+
* both gaps are possible at once: no node socket, and no Blockfrost key
|
|
274
|
+
* anywhere. Left to `start`, either one surfaces as a container that
|
|
275
|
+
* dies during option parsing — a readiness timeout that names nothing.
|
|
276
|
+
*/
|
|
277
|
+
if (chainBackend === 'offline') {
|
|
278
|
+
/**
|
|
279
|
+
* Offline mode needs no credential at all — that is the point of it
|
|
280
|
+
* — but its node cap is lower, and the schema can only enforce that
|
|
281
|
+
* when the request names the backend explicitly. A server whose
|
|
282
|
+
* CHAIN_BACKEND default is `offline` reaches here instead.
|
|
283
|
+
*/
|
|
284
|
+
if (input.nodeCount > shared_1.MAX_OFFLINE_NODES) {
|
|
285
|
+
throw new HeadValidationError('nodeCount', `An offline head runs at most ${shared_1.MAX_OFFLINE_NODES} nodes — they all share this machine, ` +
|
|
286
|
+
'with no layer 1 to spread the load over.');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (chainBackend === 'cardano-node') {
|
|
290
|
+
const socket = await (0, socket_1.checkNodeSocket)(this.config.cardanoNodeSocket);
|
|
291
|
+
if (!socket.available) {
|
|
292
|
+
throw new HeadValidationError('chainBackend', `cardano-node backend unavailable: ${socket.reason}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const nodeKeys = input.nodeBlockfrostProjectIds;
|
|
297
|
+
const missing = Array.from({ length: input.nodeCount }, (_, i) => (0, blockfrost_keys_1.resolveBlockfrostKey)(nodeKeys?.[i], input.blockfrostProjectId, this.config.blockfrostProjectId)).some(k => k.key === null);
|
|
298
|
+
if (missing) {
|
|
299
|
+
throw new HeadValidationError(nodeKeys ? 'nodeBlockfrostProjectIds' : 'blockfrostProjectId', 'A Blockfrost project id is required: this server has no default one configured, ' +
|
|
300
|
+
'so every node needs a key. Get a free one at https://blockfrost.io.');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Parse every supplied Cardano key before writing anything. Creating a
|
|
305
|
+
* head is a row, N node rows and four files per node — a key that only
|
|
306
|
+
* fails on the last node would otherwise leave a half-built head behind.
|
|
307
|
+
*/
|
|
308
|
+
input.nodeCardanoKeys?.forEach((provided, index) => {
|
|
309
|
+
if (!provided)
|
|
310
|
+
return;
|
|
311
|
+
try {
|
|
312
|
+
(0, keys_1.validateCardanoKeyInput)(provided, network);
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
throw new HeadValidationError(`nodeCardanoKeys.${index}.signingKey`, `Node ${index}: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
const offline = chainBackend === 'offline' ? input.offline : undefined;
|
|
319
|
+
const logging = input.logging;
|
|
320
|
+
const headId = this.heads.create({
|
|
321
|
+
name: input.name,
|
|
322
|
+
description: input.description ?? null,
|
|
323
|
+
network,
|
|
324
|
+
chain_backend: chainBackend,
|
|
325
|
+
node_count: input.nodeCount,
|
|
326
|
+
contestation_period: input.contestationPeriod,
|
|
327
|
+
deposit_period: input.depositPeriod,
|
|
328
|
+
persistence_rotate_after: input.persistenceRotateAfter,
|
|
329
|
+
// Stored as given: an empty override object is not the same as no
|
|
330
|
+
// overrides, and the difference is visible in the head view.
|
|
331
|
+
protocol_parameters: input.protocolParameters ? JSON.stringify(input.protocolParameters) : null,
|
|
332
|
+
blockfrost_project_id: input.blockfrostProjectId ?? null,
|
|
333
|
+
// Pinned per head at creation: a later HYDRA_NODE_IMAGE bump must
|
|
334
|
+
// not silently move an existing head onto another version, since
|
|
335
|
+
// 2.x cannot resume persistence written by 0.22.x.
|
|
336
|
+
hydra_node_image: input.hydraNodeImage ?? this.config.hydraNodeImage,
|
|
337
|
+
log_quiet: logging?.quiet ? 1 : 0,
|
|
338
|
+
log_max_size_mb: logging?.maxSizeMb ?? 10,
|
|
339
|
+
log_max_files: logging?.maxFiles ?? 3,
|
|
340
|
+
// Filled in below once the row id exists, which is what the default
|
|
341
|
+
// seed is derived from.
|
|
342
|
+
offline_head_seed: offline?.headSeed ?? null,
|
|
343
|
+
offline_initial_utxo: offline?.initialUtxo ? JSON.stringify(offline.initialUtxo) : null,
|
|
344
|
+
offline_seed_lovelace: chainBackend === 'offline' ? (offline?.seedLovelace ?? shared_1.DEFAULT_OFFLINE_SEED_LOVELACE) : null,
|
|
345
|
+
offline_ledger_genesis: offline?.ledgerGenesis ? JSON.stringify(offline.ledgerGenesis) : null,
|
|
346
|
+
});
|
|
347
|
+
/**
|
|
348
|
+
* Pin the seed now that the id exists, instead of deriving it on every
|
|
349
|
+
* start. hydra-node derives the offline head id from this value, so it
|
|
350
|
+
* has to be stable for the life of the head — and stored, so an
|
|
351
|
+
* operator can read back what their head is actually keyed on.
|
|
352
|
+
*/
|
|
353
|
+
if (chainBackend === 'offline' && !offline?.headSeed) {
|
|
354
|
+
this.heads.update(headId, { offline_head_seed: (0, shared_1.defaultOfflineHeadSeed)(headId) });
|
|
355
|
+
}
|
|
356
|
+
const hp = (0, shared_1.headPaths)(this.config.stateDir, headId);
|
|
357
|
+
await (0, promises_1.mkdir)(hp.keys, { recursive: true });
|
|
358
|
+
await (0, promises_1.mkdir)(hp.persistence, { recursive: true });
|
|
359
|
+
const baseSlot = this.nextSlot();
|
|
360
|
+
for (let i = 0; i < input.nodeCount; i++) {
|
|
361
|
+
const ports = (0, shared_1.portsForSlot)(baseSlot + i, this.config.portBase);
|
|
362
|
+
// Generating keys up front means the UI can show fundable addresses
|
|
363
|
+
// the moment a head is created, before anything is started. A node
|
|
364
|
+
// may instead bring its own Cardano key, so an already-funded
|
|
365
|
+
// address can be reused rather than filled from the faucet again.
|
|
366
|
+
const keys = await (0, keys_1.ensureNodeKeys)(this.config.stateDir, headId, i, network, input.nodeCardanoKeys?.[i] ?? undefined);
|
|
367
|
+
this.nodes.create({
|
|
368
|
+
head_id: headId,
|
|
369
|
+
node_index: i,
|
|
370
|
+
name: (0, shared_1.nodeContainerName)(headId, i),
|
|
371
|
+
api_port: ports.api,
|
|
372
|
+
p2p_port: ports.p2p,
|
|
373
|
+
monitoring_port: ports.monitoring,
|
|
374
|
+
hydra_vkey: keys.hydraVk.cborHex,
|
|
375
|
+
cardano_address: keys.cardanoAddress,
|
|
376
|
+
// Only stored when it differs from the head's; otherwise the
|
|
377
|
+
// head's key stays the single place to change it later.
|
|
378
|
+
blockfrost_project_id: input.nodeBlockfrostProjectIds?.[i] ?? null,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
this.events.add(headId, 'head.created', `Head "${input.name}" created with ${input.nodeCount} node(s)`);
|
|
382
|
+
this.logger.log(`head ${headId} created with ${input.nodeCount} node(s)`);
|
|
383
|
+
return this.view(headId);
|
|
384
|
+
}
|
|
385
|
+
/** Patch mutable head settings. Takes effect on the next start. */
|
|
386
|
+
update(id, patch) {
|
|
387
|
+
const head = this.heads.findById(id);
|
|
388
|
+
if (!head)
|
|
389
|
+
throw new HeadNotFoundError(id);
|
|
390
|
+
if (patch.name && patch.name !== head.name && this.heads.findByName(patch.name)) {
|
|
391
|
+
throw new HeadConflictError(`A head named "${patch.name}" already exists`);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Swapping the image under a live head would leave the running
|
|
395
|
+
* containers and the row disagreeing about what is deployed, so it is
|
|
396
|
+
* only allowed while nothing is up. Even then it is not free: hydra-node
|
|
397
|
+
* 2.x cannot resume persistence written by 0.22.x (no commit phase,
|
|
398
|
+
* different event store, different script hashes), so a cross-line
|
|
399
|
+
* change needs a reset — say so instead of failing at the next start.
|
|
400
|
+
*/
|
|
401
|
+
const imageChanged = patch.hydraNodeImage !== undefined && patch.hydraNodeImage !== head.hydra_node_image;
|
|
402
|
+
if (imageChanged && (head.status === 'RUNNING' || head.status === 'STARTING')) {
|
|
403
|
+
throw new HeadConflictError(`Cannot change the hydra-node image while head ${id} is ${head.status.toLowerCase()}. Stop it first.`);
|
|
404
|
+
}
|
|
405
|
+
this.heads.update(id, {
|
|
406
|
+
...(patch.name !== undefined ? { name: patch.name } : {}),
|
|
407
|
+
...(patch.description !== undefined ? { description: patch.description } : {}),
|
|
408
|
+
...(patch.contestationPeriod !== undefined ? { contestation_period: patch.contestationPeriod } : {}),
|
|
409
|
+
...(patch.depositPeriod !== undefined ? { deposit_period: patch.depositPeriod } : {}),
|
|
410
|
+
...(patch.persistenceRotateAfter !== undefined
|
|
411
|
+
? { persistence_rotate_after: patch.persistenceRotateAfter }
|
|
412
|
+
: {}),
|
|
413
|
+
...(patch.protocolParameters !== undefined
|
|
414
|
+
? { protocol_parameters: JSON.stringify(patch.protocolParameters) }
|
|
415
|
+
: {}),
|
|
416
|
+
...(patch.blockfrostProjectId !== undefined ? { blockfrost_project_id: patch.blockfrostProjectId } : {}),
|
|
417
|
+
...(patch.hydraNodeImage !== undefined ? { hydra_node_image: patch.hydraNodeImage } : {}),
|
|
418
|
+
...(patch.logging !== undefined
|
|
419
|
+
? {
|
|
420
|
+
log_quiet: patch.logging.quiet ? 1 : 0,
|
|
421
|
+
log_max_size_mb: patch.logging.maxSizeMb,
|
|
422
|
+
log_max_files: patch.logging.maxFiles,
|
|
423
|
+
}
|
|
424
|
+
: {}),
|
|
425
|
+
/**
|
|
426
|
+
* Offline settings are patchable except the seed, which is left
|
|
427
|
+
* out on purpose: it determines the offline head id, so changing it
|
|
428
|
+
* under existing persistence points the node at a head that its own
|
|
429
|
+
* state does not describe. Recreate the head instead.
|
|
430
|
+
*/
|
|
431
|
+
...(patch.offline?.initialUtxo !== undefined
|
|
432
|
+
? { offline_initial_utxo: JSON.stringify(patch.offline.initialUtxo) }
|
|
433
|
+
: {}),
|
|
434
|
+
...(patch.offline?.seedLovelace !== undefined ? { offline_seed_lovelace: patch.offline.seedLovelace } : {}),
|
|
435
|
+
...(patch.offline?.ledgerGenesis !== undefined
|
|
436
|
+
? { offline_ledger_genesis: JSON.stringify(patch.offline.ledgerGenesis) }
|
|
437
|
+
: {}),
|
|
438
|
+
});
|
|
439
|
+
this.events.add(id, 'head.updated', 'Head settings updated');
|
|
440
|
+
if (imageChanged) {
|
|
441
|
+
const from = (0, shared_1.findVersionProfile)(head.hydra_node_image);
|
|
442
|
+
const to = (0, shared_1.findVersionProfile)(patch.hydraNodeImage);
|
|
443
|
+
const crossLine = from?.id !== to?.id;
|
|
444
|
+
this.events.add(id, 'head.image_changed', `hydra-node image ${head.hydra_node_image} → ${patch.hydraNodeImage}` +
|
|
445
|
+
(crossLine ? ' — reset this head before starting; its persistence is not portable' : ''), crossLine ? 'warn' : 'info');
|
|
446
|
+
}
|
|
447
|
+
return this.view(id);
|
|
448
|
+
}
|
|
449
|
+
// ------------------------------------------------------------------ start
|
|
450
|
+
async start(id) {
|
|
451
|
+
const head = this.heads.findById(id);
|
|
452
|
+
if (!head)
|
|
453
|
+
throw new HeadNotFoundError(id);
|
|
454
|
+
if (this.inFlight.has(id))
|
|
455
|
+
throw new HeadConflictError(`Head ${id} is already starting or stopping`);
|
|
456
|
+
const nodeRows = this.nodes.byHead(id);
|
|
457
|
+
if (nodeRows.length === 0)
|
|
458
|
+
throw new HeadConflictError(`Head ${id} has no nodes`);
|
|
459
|
+
const runningNow = this.nodes.countRunning();
|
|
460
|
+
if (runningNow + nodeRows.length > this.config.maxActiveNodes) {
|
|
461
|
+
throw new HeadConflictError(`Starting this head would exceed MAX_ACTIVE_NODES (${this.config.maxActiveNodes}). ` +
|
|
462
|
+
`Currently running: ${runningNow}, this head needs ${nodeRows.length}.`);
|
|
463
|
+
}
|
|
464
|
+
this.inFlight.add(id);
|
|
465
|
+
this.heads.setStatus(id, 'STARTING');
|
|
466
|
+
this.nodes.setStatusForHead(id, 'STARTING');
|
|
467
|
+
this.events.add(id, 'head.starting', `Starting ${nodeRows.length} node(s)`);
|
|
468
|
+
/**
|
|
469
|
+
* The live view of this boot, polled by the UI while the POST is still
|
|
470
|
+
* in flight. Starting a head is six phases and the image pull alone can
|
|
471
|
+
* run for minutes; a spinner over all of it says nothing about whether
|
|
472
|
+
* anything is happening, and on failure the single error left on the row
|
|
473
|
+
* is usually the last symptom rather than the cause.
|
|
474
|
+
*/
|
|
475
|
+
const run = this.boot.begin(id);
|
|
476
|
+
run.log(`Starting ${nodeRows.length} node(s) on ${head.hydra_node_image}`);
|
|
477
|
+
const created = [];
|
|
478
|
+
try {
|
|
479
|
+
run.step('docker');
|
|
480
|
+
await this.docker.ping();
|
|
481
|
+
await this.docker.ensureNetwork();
|
|
482
|
+
run.step('image');
|
|
483
|
+
if (await this.docker.hasImage(head.hydra_node_image)) {
|
|
484
|
+
run.detail('image', 'already present');
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
// A first pull is the longest part of a cold start by far, and
|
|
488
|
+
// the only one worth streaming: without it the operator cannot
|
|
489
|
+
// tell a slow download from a stuck one.
|
|
490
|
+
run.detail('image', 'pulling — this is the slow part of a first run');
|
|
491
|
+
await this.docker.ensureImage(head.hydra_node_image, line => run.detail('image', line));
|
|
492
|
+
run.detail('image', 'pulled');
|
|
493
|
+
}
|
|
494
|
+
// Settle which flags this image accepts before building any argv:
|
|
495
|
+
// an unknown flag aborts hydra-node during option parsing, and the
|
|
496
|
+
// resulting container death reads as a generic readiness timeout.
|
|
497
|
+
// A declared version profile answers it; anything else is probed.
|
|
498
|
+
run.step('flags');
|
|
499
|
+
const resolved = await this.capabilities.resolve(head.hydra_node_image, this.config.hydraArgOverrides);
|
|
500
|
+
this.logger.log(`head ${id}: ${head.hydra_node_image} → ${resolved.caps.version} ` +
|
|
501
|
+
`(${resolved.source}${resolved.profileId ? ` ${resolved.profileId}` : ''})`);
|
|
502
|
+
run.detail('flags', `${resolved.caps.version} (${resolved.source})`);
|
|
503
|
+
run.log(`Flags settled from the ${resolved.source}: ${resolved.caps.version}`);
|
|
504
|
+
const hp = (0, shared_1.headPaths)(this.config.stateDir, id);
|
|
505
|
+
await (0, promises_1.mkdir)(hp.persistence, { recursive: true });
|
|
506
|
+
/**
|
|
507
|
+
* Re-check the backend's credential here as well as at creation:
|
|
508
|
+
* a socket can disappear with the node that owned it, and a head
|
|
509
|
+
* created while one existed would otherwise boot into containers
|
|
510
|
+
* that die during option parsing.
|
|
511
|
+
*/
|
|
512
|
+
if (head.chain_backend === 'cardano-node') {
|
|
513
|
+
const socket = await (0, socket_1.checkNodeSocket)(this.config.cardanoNodeSocket);
|
|
514
|
+
if (!socket.available) {
|
|
515
|
+
throw new Error(`cardano-node backend unavailable: ${socket.reason}. ` +
|
|
516
|
+
'Fix CARDANO_NODE_SOCKET, or recreate this head on the Blockfrost backend.');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Warn about upstream defects this version carries on this backend.
|
|
521
|
+
*
|
|
522
|
+
* Recorded as an event, not just logged: the create form shows the
|
|
523
|
+
* same warning, but a head created through the API never saw it, and
|
|
524
|
+
* the image cannot be changed later without resetting the head. This
|
|
525
|
+
* is the last point where someone can still see it.
|
|
526
|
+
*/
|
|
527
|
+
for (const issue of (0, shared_1.knownIssuesFor)(head.hydra_node_image, head.chain_backend, (0, shared_1.toHostPlatform)(process.platform, process.arch))) {
|
|
528
|
+
const message =
|
|
529
|
+
// Not every issue has an upstream number — an image built
|
|
530
|
+
// for the wrong architecture is not a bug in the node.
|
|
531
|
+
`${head.hydra_node_image}: ${issue.title}${issue.id ? ` (hydra#${issue.id})` : ''}. ` +
|
|
532
|
+
`${issue.impact}` +
|
|
533
|
+
(issue.fixedIn ? ` Fixed in hydra-node ${issue.fixedIn}.` : ` See ${issue.url}`);
|
|
534
|
+
this.events.add(id, 'head.known_issue', message, issue.severity === 'blocker' ? 'warn' : 'info');
|
|
535
|
+
this.logger.warn(`head ${id}: ${message}`);
|
|
536
|
+
run.log(message, 'warn');
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* An image with no offline branch cannot run this head at all —
|
|
540
|
+
* there is no online argv to fall back to, since the head has no
|
|
541
|
+
* credential for either backend. Better a named error here than a
|
|
542
|
+
* container that dies in option parsing.
|
|
543
|
+
*/
|
|
544
|
+
if (head.chain_backend === 'offline' && !resolved.caps.offline) {
|
|
545
|
+
throw new Error(`${head.hydra_node_image} does not support offline mode (no --offline-head-seed). ` +
|
|
546
|
+
'Use a hydra-node image that does, or recreate this head on a chain backend.');
|
|
547
|
+
}
|
|
548
|
+
run.step('config');
|
|
549
|
+
// Resolved once and reused: the protocol parameters fetch and each
|
|
550
|
+
// node's token file must not disagree about which key is in play.
|
|
551
|
+
const nodeKeys = new Map(nodeRows.map(node => [
|
|
552
|
+
node.node_index,
|
|
553
|
+
(0, blockfrost_keys_1.resolveBlockfrostKey)(node.blockfrost_project_id, head.blockfrost_project_id, this.config.blockfrostProjectId),
|
|
554
|
+
]));
|
|
555
|
+
/**
|
|
556
|
+
* Protocol parameters for the L2 ledger. Read over Blockfrost even
|
|
557
|
+
* for a cardano-node head, so any key that resolves will do — but
|
|
558
|
+
* NOT for an offline head: one that reached out to Blockfrost at
|
|
559
|
+
* start would fail on a machine with no network, which is exactly
|
|
560
|
+
* where offline mode is worth having. It gets the static baseline,
|
|
561
|
+
* which now carries real cost models (see chain/cost-models.ts).
|
|
562
|
+
*
|
|
563
|
+
* Whatever the source, the head's own overrides are merged last, so
|
|
564
|
+
* a pinned parameter survives an epoch that moves the live value.
|
|
565
|
+
*/
|
|
566
|
+
const overrides = head.protocol_parameters
|
|
567
|
+
? JSON.parse(head.protocol_parameters)
|
|
568
|
+
: undefined;
|
|
569
|
+
const params = await (0, provider_1.fetchProtocolParameters)({
|
|
570
|
+
network: head.network,
|
|
571
|
+
projectId: head.chain_backend === 'offline'
|
|
572
|
+
? ''
|
|
573
|
+
: nodeKeys.get(nodeRows[0].node_index)?.key ||
|
|
574
|
+
head.blockfrost_project_id ||
|
|
575
|
+
this.config.blockfrostProjectId,
|
|
576
|
+
baseUrl: this.config.blockfrostBaseUrl,
|
|
577
|
+
overrides,
|
|
578
|
+
});
|
|
579
|
+
await (0, promises_1.writeFile)(hp.protocolParameters, JSON.stringify(params, null, 2), 'utf8');
|
|
580
|
+
run.log(head.chain_backend === 'offline'
|
|
581
|
+
? 'Protocol parameters written from the built-in baseline'
|
|
582
|
+
: 'Protocol parameters written from the chain');
|
|
583
|
+
if (head.chain_backend === 'offline') {
|
|
584
|
+
await this.writeOfflineFiles(head, nodeRows);
|
|
585
|
+
run.log('Offline starting UTxO written');
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* One token file per node, read by hydra-node with `--blockfrost`.
|
|
589
|
+
* Nodes sharing a key just get identical contents; a node with its
|
|
590
|
+
* own key gets its own budget, which is the point of allowing it.
|
|
591
|
+
*/
|
|
592
|
+
if (head.chain_backend === 'blockfrost') {
|
|
593
|
+
for (const node of nodeRows) {
|
|
594
|
+
const { key } = nodeKeys.get(node.node_index);
|
|
595
|
+
if (!key) {
|
|
596
|
+
throw new Error(`${node.name} has no Blockfrost project id, and neither the head nor the ` +
|
|
597
|
+
'server supplies one. Set BLOCKFROST_PROJECT_ID or recreate the head with a key.');
|
|
598
|
+
}
|
|
599
|
+
await (0, promises_1.writeFile)(hp.nodeBlockfrostToken(node.node_index), key, {
|
|
600
|
+
encoding: 'utf8',
|
|
601
|
+
mode: 0o600,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
// Heads created before per-node keys left one shared file here;
|
|
605
|
+
// nothing reads it now, so do not leave a project id on disk.
|
|
606
|
+
await (0, promises_1.rm)(hp.legacyBlockfrostToken, { force: true });
|
|
607
|
+
}
|
|
608
|
+
for (const node of nodeRows) {
|
|
609
|
+
await (0, promises_1.mkdir)(hp.nodePersistence(node.node_index), { recursive: true });
|
|
610
|
+
await (0, keys_1.ensureNodeKeys)(this.config.stateDir, id, node.node_index, head.network);
|
|
611
|
+
}
|
|
612
|
+
run.step('containers', `0/${nodeRows.length} created`);
|
|
613
|
+
for (const node of nodeRows) {
|
|
614
|
+
const peers = nodeRows.filter(n => n.id !== node.id);
|
|
615
|
+
const cmd = this.buildNodeCommand(head, node, peers, resolved);
|
|
616
|
+
const container = await this.docker.createAndStart({
|
|
617
|
+
name: node.name,
|
|
618
|
+
image: head.hydra_node_image,
|
|
619
|
+
cmd,
|
|
620
|
+
binds: this.buildBinds(head),
|
|
621
|
+
ports: [node.api_port, node.p2p_port, node.monitoring_port],
|
|
622
|
+
labels: this.docker.labelsFor(id, node.id, node.node_index),
|
|
623
|
+
// Docker's json-file driver is unbounded by default, so a
|
|
624
|
+
// head left running writes logs until the disk fills.
|
|
625
|
+
logRotation: { maxSizeMb: head.log_max_size_mb, maxFiles: head.log_max_files },
|
|
626
|
+
env: [
|
|
627
|
+
'ETCD_AUTO_COMPACTION_MODE=periodic',
|
|
628
|
+
'ETCD_AUTO_COMPACTION_RETENTION=168h',
|
|
629
|
+
'ETCD_HEARTBEAT_INTERVAL=1000',
|
|
630
|
+
'ETCD_ELECTION_TIMEOUT=5000',
|
|
631
|
+
],
|
|
632
|
+
});
|
|
633
|
+
created.push(node.name);
|
|
634
|
+
this.nodes.setStatus(node.id, 'STARTING', container.id);
|
|
635
|
+
run.detail('containers', `${created.length}/${nodeRows.length} created`);
|
|
636
|
+
run.log(`${node.name} created on api ${node.api_port}`);
|
|
637
|
+
}
|
|
638
|
+
// Wait for every node to answer on its API port.
|
|
639
|
+
run.step('ready', `0/${nodeRows.length} ready`);
|
|
640
|
+
const ports = nodeRows.map(n => n.api_port);
|
|
641
|
+
const results = await (0, ws_client_1.waitForNodes)(ports, {
|
|
642
|
+
timeoutMs: 180_000,
|
|
643
|
+
onProgress: (ready, total) => {
|
|
644
|
+
this.logger.log(`head ${id}: ${ready}/${total} nodes ready`);
|
|
645
|
+
run.detail('ready', `${ready}/${total} ready`);
|
|
646
|
+
},
|
|
647
|
+
/**
|
|
648
|
+
* Pull what the containers have printed on every poll. This is
|
|
649
|
+
* the part that turns "node never became ready" into an answer:
|
|
650
|
+
* hydra-node explains its own refusals — an unknown flag, a key
|
|
651
|
+
* it cannot read, a chain it cannot reach — and until now none
|
|
652
|
+
* of that reached anyone watching the head come up.
|
|
653
|
+
*/
|
|
654
|
+
onTick: () => this.collectBootLogs(run, nodeRows),
|
|
655
|
+
});
|
|
656
|
+
// One last sweep, so whatever a node printed just before giving up
|
|
657
|
+
// is in the pane the operator is about to read.
|
|
658
|
+
await this.collectBootLogs(run, nodeRows);
|
|
659
|
+
const unreachable = nodeRows.filter(n => !results.get(n.api_port)?.reachable);
|
|
660
|
+
if (unreachable.length > 0) {
|
|
661
|
+
const detail = unreachable
|
|
662
|
+
.map(n => `${n.name} (port ${n.api_port}): ${results.get(n.api_port)?.error ?? 'no response'}`)
|
|
663
|
+
.join('; ');
|
|
664
|
+
throw new Error(`Node(s) never became ready — ${detail}`);
|
|
665
|
+
}
|
|
666
|
+
for (const node of nodeRows) {
|
|
667
|
+
this.nodes.setStatus(node.id, 'RUNNING', node.container_id);
|
|
668
|
+
// Hold a connection open from here on: a peer dropping out later
|
|
669
|
+
// is announced as an event, and nothing that connects after the
|
|
670
|
+
// fact can see it.
|
|
671
|
+
this.monitor.watch(node.name, node.api_port);
|
|
672
|
+
}
|
|
673
|
+
const state = (0, ws_client_1.aggregateHydraState)(nodeRows.map(n => results.get(n.api_port)?.info?.hydraState ?? 'UNKNOWN'));
|
|
674
|
+
this.heads.setStatus(id, 'RUNNING', null);
|
|
675
|
+
this.heads.setHydraState(id, state);
|
|
676
|
+
this.events.add(id, 'head.started', `All ${nodeRows.length} node(s) ready (state ${state})`);
|
|
677
|
+
this.logger.log(`head ${id} running (state ${state})`);
|
|
678
|
+
run.succeed(`All ${nodeRows.length} node(s) ready (Hydra state ${state})`);
|
|
679
|
+
return this.view(id);
|
|
680
|
+
}
|
|
681
|
+
catch (err) {
|
|
682
|
+
const message = err.message;
|
|
683
|
+
this.logger.error(`head ${id} start failed — rolling back: ${message}`);
|
|
684
|
+
/**
|
|
685
|
+
* Fail the run BEFORE the rollback removes the containers: the pane
|
|
686
|
+
* is the only place their output survives, and it is what turns a
|
|
687
|
+
* readiness timeout into a reason.
|
|
688
|
+
*/
|
|
689
|
+
run.fail(message);
|
|
690
|
+
// Roll back every container we created so a retry starts clean.
|
|
691
|
+
for (const name of created) {
|
|
692
|
+
this.monitor.unwatch(name);
|
|
693
|
+
await this.docker.removeContainer(name);
|
|
694
|
+
}
|
|
695
|
+
run.log(`Rolled back ${created.length} container(s) so a retry starts clean`, 'warn');
|
|
696
|
+
this.nodes.setStatusForHead(id, 'FAILED');
|
|
697
|
+
this.heads.setStatus(id, 'FAILED', message);
|
|
698
|
+
this.events.add(id, 'head.failed', message, 'error');
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
this.inFlight.delete(id);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// ------------------------------------------------------------------- stop
|
|
706
|
+
async stop(id) {
|
|
707
|
+
const head = this.heads.findById(id);
|
|
708
|
+
if (!head)
|
|
709
|
+
throw new HeadNotFoundError(id);
|
|
710
|
+
if (this.inFlight.has(id))
|
|
711
|
+
throw new HeadConflictError(`Head ${id} is busy`);
|
|
712
|
+
this.inFlight.add(id);
|
|
713
|
+
try {
|
|
714
|
+
for (const node of this.nodes.byHead(id)) {
|
|
715
|
+
// Drop the connection before the container goes, so its close
|
|
716
|
+
// does not read as a node that failed on its own.
|
|
717
|
+
this.monitor.unwatch(node.name);
|
|
718
|
+
await this.docker.removeContainer(node.name);
|
|
719
|
+
this.nodes.setStatus(node.id, 'STOPPED', null);
|
|
720
|
+
}
|
|
721
|
+
this.heads.setStatus(id, 'STOPPED', null);
|
|
722
|
+
this.heads.setHydraState(id, 'UNKNOWN');
|
|
723
|
+
this.events.add(id, 'head.stopped', 'Head stopped');
|
|
724
|
+
return this.view(id);
|
|
725
|
+
}
|
|
726
|
+
finally {
|
|
727
|
+
this.inFlight.delete(id);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async restart(id) {
|
|
731
|
+
await this.stop(id);
|
|
732
|
+
return this.start(id);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Wipe the head's persistence so the next start begins from a fresh Hydra
|
|
736
|
+
* state. In v1 this was the only way out of a corrupted head, but it was a
|
|
737
|
+
* manual `rm -rf` on the server.
|
|
738
|
+
*/
|
|
739
|
+
async reset(id, includeKeys = false) {
|
|
740
|
+
const head = this.heads.findById(id);
|
|
741
|
+
if (!head)
|
|
742
|
+
throw new HeadNotFoundError(id);
|
|
743
|
+
if (head.status === 'RUNNING' || head.status === 'STARTING') {
|
|
744
|
+
throw new HeadConflictError('Stop the head before resetting it');
|
|
745
|
+
}
|
|
746
|
+
const hp = (0, shared_1.headPaths)(this.config.stateDir, id);
|
|
747
|
+
await (0, promises_1.rm)(hp.persistence, { recursive: true, force: true });
|
|
748
|
+
await (0, promises_1.mkdir)(hp.persistence, { recursive: true });
|
|
749
|
+
if (includeKeys) {
|
|
750
|
+
await (0, promises_1.rm)(hp.keys, { recursive: true, force: true });
|
|
751
|
+
await (0, promises_1.mkdir)(hp.keys, { recursive: true });
|
|
752
|
+
for (const node of this.nodes.byHead(id)) {
|
|
753
|
+
const keys = await (0, keys_1.ensureNodeKeys)(this.config.stateDir, id, node.node_index, head.network);
|
|
754
|
+
// New keys mean new addresses — the UI must show the fresh ones.
|
|
755
|
+
this.nodes.setStatus(node.id, 'CREATED', null);
|
|
756
|
+
this.nodes.setIdentity(node.id, keys.hydraVk.cborHex, keys.cardanoAddress);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
this.heads.setStatus(id, 'CONFIGURED', null);
|
|
760
|
+
this.heads.setHydraState(id, 'UNKNOWN');
|
|
761
|
+
this.events.add(id, 'head.reset', includeKeys ? 'Persistence and keys wiped' : 'Persistence wiped');
|
|
762
|
+
return this.view(id);
|
|
763
|
+
}
|
|
764
|
+
async delete(id) {
|
|
765
|
+
const head = this.heads.findById(id);
|
|
766
|
+
if (!head)
|
|
767
|
+
throw new HeadNotFoundError(id);
|
|
768
|
+
/**
|
|
769
|
+
* A start in flight is still creating containers and, on failure, rolls
|
|
770
|
+
* back by name. Deleting underneath it would race that rollback and
|
|
771
|
+
* leave containers whose head row is already gone — unreachable from
|
|
772
|
+
* the UI and only removable by hand.
|
|
773
|
+
*/
|
|
774
|
+
if (this.inFlight.has(id)) {
|
|
775
|
+
throw new HeadConflictError(`Head ${id} is starting or stopping — wait for that to finish first`);
|
|
776
|
+
}
|
|
777
|
+
for (const node of this.nodes.byHead(id)) {
|
|
778
|
+
this.monitor.unwatch(node.name);
|
|
779
|
+
await this.docker.removeContainer(node.name);
|
|
780
|
+
}
|
|
781
|
+
await (0, promises_1.rm)((0, shared_1.headPaths)(this.config.stateDir, id).root, { recursive: true, force: true });
|
|
782
|
+
this.heads.delete(id);
|
|
783
|
+
this.events.add(null, 'head.deleted', `Head ${id} ("${head.name}") deleted`);
|
|
784
|
+
}
|
|
785
|
+
// ----------------------------------------------------------------- status
|
|
786
|
+
/**
|
|
787
|
+
* Live status of a head.
|
|
788
|
+
*
|
|
789
|
+
* Three sources, and each answers something the others cannot:
|
|
790
|
+
* - **Docker** says whether the container exists and runs. A node that is
|
|
791
|
+
* simply gone is a different failure from one that is up but silent, and
|
|
792
|
+
* treating them the same is what let a dead head report RUNNING.
|
|
793
|
+
* - **The monitor's open connection** carries the node's own view, folded
|
|
794
|
+
* from its event stream — including the connectivity events (a peer
|
|
795
|
+
* dropping out) that nothing can observe after the fact.
|
|
796
|
+
* - **A one-shot probe**, only as a fallback for a node the monitor has not
|
|
797
|
+
* heard from yet, so a status request right after a start still answers.
|
|
798
|
+
*/
|
|
799
|
+
async liveStatus(id) {
|
|
800
|
+
await this.reconcile();
|
|
801
|
+
const head = this.heads.findById(id);
|
|
802
|
+
if (!head)
|
|
803
|
+
throw new HeadNotFoundError(id);
|
|
804
|
+
const rows = this.nodes.byHead(id);
|
|
805
|
+
const nodes = await Promise.all(rows.map(async (row) => {
|
|
806
|
+
const containerRunning = await this.docker.isRunning(row.name);
|
|
807
|
+
const base = { nodeId: row.id, name: row.name, containerRunning };
|
|
808
|
+
if (!containerRunning) {
|
|
809
|
+
return {
|
|
810
|
+
...base,
|
|
811
|
+
reachable: false,
|
|
812
|
+
hydraState: 'UNKNOWN',
|
|
813
|
+
error: 'container not running',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const live = this.monitor.stateFor(row.name);
|
|
817
|
+
if (live?.connected) {
|
|
818
|
+
return {
|
|
819
|
+
...base,
|
|
820
|
+
reachable: true,
|
|
821
|
+
hydraState: live.hydraState,
|
|
822
|
+
version: live.version ?? undefined,
|
|
823
|
+
snapshotUtxoCount: live.snapshotUtxoCount ?? undefined,
|
|
824
|
+
peersDown: [...live.peersDown],
|
|
825
|
+
networkConnected: live.networkConnected ?? undefined,
|
|
826
|
+
chainSynced: live.chainSynced ?? undefined,
|
|
827
|
+
headId: live.headId ?? undefined,
|
|
828
|
+
lastMessageAt: live.lastMessageAt ?? undefined,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
// The container is up but our connection is not — either it just
|
|
832
|
+
// started, or the API is not answering. A probe distinguishes
|
|
833
|
+
// those two, which the monitor alone cannot.
|
|
834
|
+
const probe = await (0, ws_client_1.probeNode)(row.api_port, 5000);
|
|
835
|
+
return {
|
|
836
|
+
...base,
|
|
837
|
+
reachable: probe.reachable,
|
|
838
|
+
hydraState: probe.info?.hydraState ?? 'UNKNOWN',
|
|
839
|
+
version: probe.info?.version,
|
|
840
|
+
snapshotUtxoCount: probe.info?.snapshotUtxoCount,
|
|
841
|
+
headId: probe.info?.headId,
|
|
842
|
+
chainSynced: probe.info?.chainSynced,
|
|
843
|
+
error: probe.error ?? live?.lastError ?? undefined,
|
|
844
|
+
};
|
|
845
|
+
}));
|
|
846
|
+
const state = (0, ws_client_1.aggregateHydraState)(nodes.map(n => n.hydraState));
|
|
847
|
+
if (state !== head.hydra_state)
|
|
848
|
+
this.heads.setHydraState(id, state);
|
|
849
|
+
const containersRunning = nodes.filter(n => n.containerRunning).length;
|
|
850
|
+
const { degraded, degradedReasons } = this.describeHealth(head.status, nodes, containersRunning);
|
|
851
|
+
return {
|
|
852
|
+
headId: id,
|
|
853
|
+
status: head.status,
|
|
854
|
+
hydraState: state,
|
|
855
|
+
nodes,
|
|
856
|
+
checkedAt: new Date().toISOString(),
|
|
857
|
+
degraded,
|
|
858
|
+
degradedReasons,
|
|
859
|
+
containersRunning,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Is the head whole, and if not, what is missing.
|
|
864
|
+
*
|
|
865
|
+
* Kept off `status` on purpose. A three-node head with one node down is
|
|
866
|
+
* still RUNNING and still serving the other two — but a head that is short a
|
|
867
|
+
* party cannot reach consensus on a snapshot, so reporting it as plain
|
|
868
|
+
* "running" is how a half-dead head goes unnoticed until someone asks why
|
|
869
|
+
* nothing confirms.
|
|
870
|
+
*
|
|
871
|
+
* Only meaningful for a head that is supposed to be up; a STOPPED head is
|
|
872
|
+
* not degraded, it is stopped.
|
|
873
|
+
*/
|
|
874
|
+
describeHealth(status, nodes, containersRunning) {
|
|
875
|
+
if (status !== 'RUNNING' && status !== 'STARTING')
|
|
876
|
+
return { degraded: false, degradedReasons: [] };
|
|
877
|
+
const reasons = [];
|
|
878
|
+
const missing = nodes.filter(n => !n.containerRunning);
|
|
879
|
+
if (missing.length > 0) {
|
|
880
|
+
reasons.push(`${missing.length} of ${nodes.length} container(s) not running: ${missing.map(n => n.name).join(', ')}`);
|
|
881
|
+
}
|
|
882
|
+
const silent = nodes.filter(n => n.containerRunning && !n.reachable);
|
|
883
|
+
if (silent.length > 0) {
|
|
884
|
+
reasons.push(`${silent.map(n => n.name).join(', ')} running but not answering the Hydra API`);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* The signal a peer dropping out actually produces. The head's own state
|
|
888
|
+
* stays Open — losing a peer is a connectivity fact, not a protocol
|
|
889
|
+
* transition — so `GET /head` will never report this and only the
|
|
890
|
+
* surviving nodes' event streams can.
|
|
891
|
+
*/
|
|
892
|
+
const isolated = nodes.filter(n => n.reachable && n.networkConnected === false);
|
|
893
|
+
if (isolated.length > 0) {
|
|
894
|
+
reasons.push(`${isolated.map(n => n.name).join(', ')} lost the Hydra network — a peer is unreachable`);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Peers a node told us went away. Only departures are tracked — the
|
|
898
|
+
* connections that predate our own are never replayed to us — so a
|
|
899
|
+
* non-empty list is a fact, while an empty one only means nothing has
|
|
900
|
+
* been reported. See NodeLiveStatus.peersDown.
|
|
901
|
+
*/
|
|
902
|
+
const lostPeers = nodes.filter(n => (n.peersDown?.length ?? 0) > 0);
|
|
903
|
+
if (lostPeers.length > 0) {
|
|
904
|
+
reasons.push(lostPeers.map(n => `${n.name} lost peer(s) ${n.peersDown.join(', ')}`).join('; '));
|
|
905
|
+
}
|
|
906
|
+
const behind = nodes.filter(n => n.chainSynced === 'CatchingUp');
|
|
907
|
+
if (behind.length > 0) {
|
|
908
|
+
reasons.push(`${behind.map(n => n.name).join(', ')} is catching up with the chain`);
|
|
909
|
+
}
|
|
910
|
+
return { degraded: reasons.length > 0 || containersRunning < nodes.length, degradedReasons: reasons };
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* What this head is costing the machine right now.
|
|
914
|
+
*
|
|
915
|
+
* Sampled for every node in one call rather than per node, because the
|
|
916
|
+
* question this answers is a head-level one: docs/PRODUCT.md sizes the
|
|
917
|
+
* node-count ceilings on roughly 500 MB per hydra-node at peak, and an
|
|
918
|
+
* offline head runs up to five of them side by side on the one machine. A
|
|
919
|
+
* per-node figure cannot be checked against that budget; the total can.
|
|
920
|
+
*
|
|
921
|
+
* Nodes are sampled in parallel and one failure does not sink the rest —
|
|
922
|
+
* a container removed between the row read and the sample is reported as
|
|
923
|
+
* not running, which is exactly what it is.
|
|
924
|
+
*/
|
|
925
|
+
async resourceUsage(id) {
|
|
926
|
+
const head = this.heads.findById(id);
|
|
927
|
+
if (!head)
|
|
928
|
+
throw new HeadNotFoundError(id);
|
|
929
|
+
const sampledAt = new Date().toISOString();
|
|
930
|
+
const nodes = await Promise.all(this.nodes.byHead(id).map(async (row) => {
|
|
931
|
+
const base = { nodeId: row.id, name: row.name, sampledAt };
|
|
932
|
+
try {
|
|
933
|
+
const raw = await this.docker.stats(row.name);
|
|
934
|
+
if (!raw)
|
|
935
|
+
return { ...base, running: false, ...(0, container_stats_1.emptyUsage)() };
|
|
936
|
+
return { ...base, running: true, ...(0, container_stats_1.readContainerStats)(raw) };
|
|
937
|
+
}
|
|
938
|
+
catch (err) {
|
|
939
|
+
// Docker being unreachable must not turn a status pane into
|
|
940
|
+
// an error page — the head view above it still works.
|
|
941
|
+
this.logger.debug(`stats for ${row.name} failed: ${err.message}`);
|
|
942
|
+
return { ...base, running: false, ...(0, container_stats_1.emptyUsage)() };
|
|
943
|
+
}
|
|
944
|
+
}));
|
|
945
|
+
const running = nodes.filter(n => n.running);
|
|
946
|
+
const sum = (pick) => {
|
|
947
|
+
const values = running.map(pick).filter((v) => v !== null);
|
|
948
|
+
return values.length > 0 ? values.reduce((a, b) => a + b, 0) : null;
|
|
949
|
+
};
|
|
950
|
+
return {
|
|
951
|
+
headId: id,
|
|
952
|
+
nodes,
|
|
953
|
+
totalMemoryBytes: sum(n => n.memoryBytes),
|
|
954
|
+
totalCpuPercent: sum(n => n.cpuPercent),
|
|
955
|
+
sampledAt,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
async nodeLogs(headId, nodeIndex, tail = 200) {
|
|
959
|
+
const node = this.nodes.byHead(headId).find(n => n.node_index === nodeIndex);
|
|
960
|
+
if (!node)
|
|
961
|
+
throw new HeadNotFoundError(headId);
|
|
962
|
+
return this.docker.logs(node.name, tail);
|
|
963
|
+
}
|
|
964
|
+
// ---------------------------------------------------------------- private
|
|
965
|
+
/**
|
|
966
|
+
* The two files an offline head boots from.
|
|
967
|
+
*
|
|
968
|
+
* `--initial-utxo` is the L2 ledger's entire starting state — there is no
|
|
969
|
+
* chain to commit from, so whatever is in this file is all the head will
|
|
970
|
+
* ever hold. Written fresh on every start so an edited seed amount takes
|
|
971
|
+
* effect after a reset, and so the addresses follow a `reset --includeKeys`
|
|
972
|
+
* that changed them.
|
|
973
|
+
*
|
|
974
|
+
* `--ledger-genesis` is only written when the operator supplied one.
|
|
975
|
+
* hydra-node's own default is to run without it, and a hand-built partial
|
|
976
|
+
* genesis fails to decode and kills the node during startup — so an absent
|
|
977
|
+
* genesis stays absent rather than being invented.
|
|
978
|
+
*/
|
|
979
|
+
async writeOfflineFiles(head, nodeRows) {
|
|
980
|
+
const hp = (0, shared_1.headPaths)(this.config.stateDir, head.id);
|
|
981
|
+
let utxo;
|
|
982
|
+
if (head.offline_initial_utxo) {
|
|
983
|
+
utxo = JSON.parse(head.offline_initial_utxo);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
// Seeding the nodes' OWN addresses is what makes the default useful:
|
|
987
|
+
// the operator already holds those signing keys, so everything in
|
|
988
|
+
// the head is spendable without importing anything.
|
|
989
|
+
const funded = nodeRows.filter(n => n.cardano_address);
|
|
990
|
+
if (funded.length === 0) {
|
|
991
|
+
throw new Error(`Head ${head.id} has no Cardano address to seed its offline UTxO with. ` +
|
|
992
|
+
'Reset the head with key regeneration, or supply an initial UTxO explicitly.');
|
|
993
|
+
}
|
|
994
|
+
utxo = (0, shared_1.buildInitialUtxo)(funded.map(n => ({
|
|
995
|
+
address: n.cardano_address,
|
|
996
|
+
lovelace: head.offline_seed_lovelace ?? shared_1.DEFAULT_OFFLINE_SEED_LOVELACE,
|
|
997
|
+
})));
|
|
998
|
+
}
|
|
999
|
+
await (0, promises_1.writeFile)(hp.initialUtxo, JSON.stringify(utxo, null, 2), 'utf8');
|
|
1000
|
+
if (head.offline_ledger_genesis) {
|
|
1001
|
+
await (0, promises_1.writeFile)(hp.ledgerGenesis, head.offline_ledger_genesis, 'utf8');
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
// A file left behind by a head that used to have one would still be
|
|
1005
|
+
// found by the flag if it were ever re-enabled.
|
|
1006
|
+
await (0, promises_1.rm)(hp.ledgerGenesis, { force: true });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Pull each container's recent output into the boot pane.
|
|
1011
|
+
*
|
|
1012
|
+
* Best effort by design: a container that has not been created yet, or has
|
|
1013
|
+
* already died, answers with nothing useful, and a logging failure must
|
|
1014
|
+
* never be the reason a head fails to start.
|
|
1015
|
+
*/
|
|
1016
|
+
async collectBootLogs(run, nodeRows) {
|
|
1017
|
+
await Promise.all(nodeRows.map(async (node) => {
|
|
1018
|
+
try {
|
|
1019
|
+
const logs = await this.docker.logs(node.name, 40);
|
|
1020
|
+
if (logs)
|
|
1021
|
+
run.appendContainerLogs(node.name, logs);
|
|
1022
|
+
}
|
|
1023
|
+
catch {
|
|
1024
|
+
/* the container is not up yet, or is already gone */
|
|
1025
|
+
}
|
|
1026
|
+
}));
|
|
1027
|
+
}
|
|
1028
|
+
/** Live progress of the current or last boot, for the UI to poll. */
|
|
1029
|
+
bootProgress(id) {
|
|
1030
|
+
if (!this.heads.findById(id))
|
|
1031
|
+
throw new HeadNotFoundError(id);
|
|
1032
|
+
return this.boot.get(id) ?? null;
|
|
1033
|
+
}
|
|
1034
|
+
buildBinds(head) {
|
|
1035
|
+
const binds = [`${this.config.stateDir}:/data`];
|
|
1036
|
+
if (head.chain_backend === 'cardano-node' && this.config.cardanoNodeSocket) {
|
|
1037
|
+
binds.push(`${this.config.cardanoNodeSocket}:/cardano-node/node.socket`);
|
|
1038
|
+
}
|
|
1039
|
+
return binds;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Assemble the hydra-node argv.
|
|
1043
|
+
*
|
|
1044
|
+
* Delegates to the pure builders in argv.ts so the exact flag set can be
|
|
1045
|
+
* asserted in tests without Docker or a Blockfrost key. `resolved.caps`
|
|
1046
|
+
* gates the flags that only exist on newer images, and `resolved.args`
|
|
1047
|
+
* carries the values that version wants — see capabilities.ts.
|
|
1048
|
+
*/
|
|
1049
|
+
buildNodeCommand(head, node, peers, resolved) {
|
|
1050
|
+
return (0, argv_1.buildNodeArgv)(head, node, peers, {
|
|
1051
|
+
caps: resolved.caps,
|
|
1052
|
+
hydraScriptsTxId: this.config.hydraScriptsTxId,
|
|
1053
|
+
unsyncedPeriod: resolved.args.unsyncedPeriod,
|
|
1054
|
+
blockfrostQueryTimeout: resolved.args.blockfrostQueryTimeout,
|
|
1055
|
+
blockfrostRetryTimeout: resolved.args.blockfrostRetryTimeout,
|
|
1056
|
+
extraArgs: resolved.args.extraArgs,
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
exports.HeadService = HeadService;
|
|
1061
|
+
exports.HeadService = HeadService = HeadService_1 = __decorate([
|
|
1062
|
+
(0, common_1.Injectable)(),
|
|
1063
|
+
__param(0, (0, common_1.Inject)(config_module_1.APP_CONFIG)),
|
|
1064
|
+
__metadata("design:paramtypes", [Object, docker_service_1.DockerService,
|
|
1065
|
+
repositories_1.HeadRepository,
|
|
1066
|
+
repositories_1.NodeRepository,
|
|
1067
|
+
repositories_1.EventRepository,
|
|
1068
|
+
capabilities_1.NodeCapabilitiesService,
|
|
1069
|
+
node_monitor_service_1.NodeMonitorService,
|
|
1070
|
+
boot_progress_1.BootTracker])
|
|
1071
|
+
], HeadService);
|
|
1072
|
+
//# sourceMappingURL=head.service.js.map
|