@vellumai/cli 0.10.2-dev.202606250916.8422b74 → 0.10.2-dev.202606251257.2eba8a4
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/package.json +1 -1
- package/src/__tests__/statefulset.test.ts +1 -0
- package/src/commands/rollback.ts +6 -0
- package/src/commands/upgrade.ts +8 -0
- package/src/lib/__tests__/docker.test.ts +6 -2
- package/src/lib/assistant-config.ts +8 -0
- package/src/lib/docker.ts +32 -2
- package/src/lib/port-allocator.ts +3 -1
- package/src/lib/statefulset.ts +15 -4
- package/src/lib/upgrade-lifecycle.ts +8 -0
package/package.json
CHANGED
|
@@ -111,6 +111,7 @@ describe("getBuilderManagedEnvKeys", () => {
|
|
|
111
111
|
describe("buildServiceRunArgs extra env routing", () => {
|
|
112
112
|
const opts: BuildServiceRunArgsOpts = {
|
|
113
113
|
gatewayPort: 18080,
|
|
114
|
+
assistantPort: 18081,
|
|
114
115
|
imageTags: {
|
|
115
116
|
assistant: "assistant:test",
|
|
116
117
|
gateway: "gateway:test",
|
package/src/commands/rollback.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
captureImageRefs,
|
|
10
10
|
GATEWAY_INTERNAL_PORT,
|
|
11
|
+
ASSISTANT_INTERNAL_PORT,
|
|
11
12
|
dockerResourceNames,
|
|
12
13
|
startContainers,
|
|
13
14
|
stopContainers,
|
|
@@ -324,6 +325,9 @@ export async function rollback(): Promise<void> {
|
|
|
324
325
|
// use default
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
// Recover the assistant host port from the entry, fall back to default.
|
|
329
|
+
const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
|
|
330
|
+
|
|
327
331
|
// Notify connected clients that a rollback is about to begin (best-effort)
|
|
328
332
|
console.log("📢 Notifying connected clients...");
|
|
329
333
|
await broadcastUpgradeEvent(
|
|
@@ -373,6 +377,7 @@ export async function rollback(): Promise<void> {
|
|
|
373
377
|
extraAssistantEnv,
|
|
374
378
|
extraGatewayEnv,
|
|
375
379
|
gatewayPort,
|
|
380
|
+
assistantPort,
|
|
376
381
|
imageTags: previousImageRefs,
|
|
377
382
|
instanceName,
|
|
378
383
|
res,
|
|
@@ -399,6 +404,7 @@ export async function rollback(): Promise<void> {
|
|
|
399
404
|
gatewayDigest: newDigests?.gateway,
|
|
400
405
|
cesDigest: newDigests?.["credential-executor"],
|
|
401
406
|
networkName: res.network,
|
|
407
|
+
assistantPort,
|
|
402
408
|
},
|
|
403
409
|
previousContainerInfo: entry.containerInfo,
|
|
404
410
|
// Clear the backup path — it belonged to the upgrade we just rolled back
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
captureImageRefs,
|
|
16
16
|
GATEWAY_INTERNAL_PORT,
|
|
17
|
+
ASSISTANT_INTERNAL_PORT,
|
|
17
18
|
dockerResourceNames,
|
|
18
19
|
startContainers,
|
|
19
20
|
stopContainers,
|
|
@@ -432,6 +433,9 @@ async function upgradeDocker(
|
|
|
432
433
|
// use default
|
|
433
434
|
}
|
|
434
435
|
|
|
436
|
+
// Recover the assistant host port from the entry, fall back to default.
|
|
437
|
+
const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
|
|
438
|
+
|
|
435
439
|
// Create pre-upgrade backup (best-effort, daemon must be running)
|
|
436
440
|
await broadcastUpgradeEvent(
|
|
437
441
|
entry.runtimeUrl,
|
|
@@ -483,6 +487,7 @@ async function upgradeDocker(
|
|
|
483
487
|
extraAssistantEnv,
|
|
484
488
|
extraGatewayEnv,
|
|
485
489
|
gatewayPort,
|
|
490
|
+
assistantPort,
|
|
486
491
|
imageTags,
|
|
487
492
|
instanceName,
|
|
488
493
|
res,
|
|
@@ -506,6 +511,7 @@ async function upgradeDocker(
|
|
|
506
511
|
gatewayDigest: newDigests?.gateway,
|
|
507
512
|
cesDigest: newDigests?.["credential-executor"],
|
|
508
513
|
networkName: res.network,
|
|
514
|
+
assistantPort,
|
|
509
515
|
},
|
|
510
516
|
previousContainerInfo: entry.containerInfo,
|
|
511
517
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
@@ -589,6 +595,7 @@ async function upgradeDocker(
|
|
|
589
595
|
extraAssistantEnv,
|
|
590
596
|
extraGatewayEnv,
|
|
591
597
|
gatewayPort,
|
|
598
|
+
assistantPort,
|
|
592
599
|
imageTags: previousImageRefs,
|
|
593
600
|
instanceName,
|
|
594
601
|
res,
|
|
@@ -654,6 +661,7 @@ async function upgradeDocker(
|
|
|
654
661
|
rollbackDigests?.["credential-executor"] ??
|
|
655
662
|
previousImageRefs["credential-executor"],
|
|
656
663
|
networkName: res.network,
|
|
664
|
+
assistantPort,
|
|
657
665
|
},
|
|
658
666
|
previousContainerInfo: undefined,
|
|
659
667
|
previousDbMigrationVersion: undefined,
|
|
@@ -27,6 +27,7 @@ function buildAssistantArgs(
|
|
|
27
27
|
const res = dockerResourceNames(instanceName);
|
|
28
28
|
const builders = buildServiceRunArgs({
|
|
29
29
|
gatewayPort: 7830,
|
|
30
|
+
assistantPort: 7821,
|
|
30
31
|
imageTags,
|
|
31
32
|
instanceName,
|
|
32
33
|
res,
|
|
@@ -41,6 +42,7 @@ function buildGatewayArgs(
|
|
|
41
42
|
const res = dockerResourceNames(instanceName);
|
|
42
43
|
const builders = buildServiceRunArgs({
|
|
43
44
|
gatewayPort: 7830,
|
|
45
|
+
assistantPort: 7821,
|
|
44
46
|
imageTags,
|
|
45
47
|
instanceName,
|
|
46
48
|
res,
|
|
@@ -84,13 +86,15 @@ describe("buildServiceRunArgs — assistant", () => {
|
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
test("publishes the assistant HTTP port on all host interfaces so sibling bot containers can reach the daemon via host.docker.internal on both Docker Desktop and Linux", () => {
|
|
87
|
-
const args = buildAssistantArgs();
|
|
89
|
+
const args = buildAssistantArgs({ assistantPort: 18000 });
|
|
88
90
|
// The port mapping is expressed as two adjacent args: "-p" then the spec.
|
|
89
91
|
// Bound to all interfaces (no `127.0.0.1:` prefix) because on vanilla
|
|
90
92
|
// Linux Docker, host.docker.internal:host-gateway resolves to the Docker
|
|
91
93
|
// bridge gateway IP — packets arrive at the bridge interface, not
|
|
92
94
|
// loopback, so a 127.0.0.1 DNAT rule would not match.
|
|
93
|
-
|
|
95
|
+
// The host-side port is dynamically allocated (not fixed at 7821) so
|
|
96
|
+
// concurrent instances on the same host don't collide.
|
|
97
|
+
const portSpec = `18000:${ASSISTANT_INTERNAL_PORT}`;
|
|
94
98
|
const portIndex = args.indexOf(portSpec);
|
|
95
99
|
expect(portIndex).toBeGreaterThan(0);
|
|
96
100
|
expect(args[portIndex - 1]).toBe("-p");
|
|
@@ -74,6 +74,14 @@ export interface ContainerInfo {
|
|
|
74
74
|
cesDigest?: string;
|
|
75
75
|
/** Docker network name for the service group */
|
|
76
76
|
networkName?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Host-side port the assistant HTTP API is published on. Dynamically
|
|
79
|
+
* allocated at hatch time so concurrent instances don't collide on the
|
|
80
|
+
* default (7821). Stored so rollback/upgrade can rebind the same port
|
|
81
|
+
* instead of re-allocating (which could grab a different port if another
|
|
82
|
+
* process took it in the interim).
|
|
83
|
+
*/
|
|
84
|
+
assistantPort?: number;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
/**
|
package/src/lib/docker.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
|
|
|
23
23
|
import { buildServiceRunArgs } from "./statefulset.js";
|
|
24
24
|
import type { Species } from "./constants";
|
|
25
25
|
import { getOrCreateHostDeviceId } from "./device-id.js";
|
|
26
|
-
import { getDefaultPorts } from "./environments/paths.js";
|
|
26
|
+
import { ASSISTANT_INTERNAL_PORT, getDefaultPorts } from "./environments/paths.js";
|
|
27
27
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
28
28
|
import { leaseGuardianToken } from "./guardian-token";
|
|
29
29
|
import { logHatchNextSteps } from "./hatch-next-steps.js";
|
|
@@ -711,6 +711,7 @@ export async function startContainers(
|
|
|
711
711
|
extraAssistantEnv?: Record<string, string>;
|
|
712
712
|
extraGatewayEnv?: Record<string, string>;
|
|
713
713
|
gatewayPort: number;
|
|
714
|
+
assistantPort: number;
|
|
714
715
|
imageTags: Record<ServiceName, string>;
|
|
715
716
|
instanceName: string;
|
|
716
717
|
res: ReturnType<typeof dockerResourceNames>;
|
|
@@ -934,6 +935,7 @@ function startFileWatcher(opts: {
|
|
|
934
935
|
extraAssistantEnv?: Record<string, string>;
|
|
935
936
|
extraGatewayEnv?: Record<string, string>;
|
|
936
937
|
gatewayPort: number;
|
|
938
|
+
assistantPort: number;
|
|
937
939
|
imageTags: Record<ServiceName, string>;
|
|
938
940
|
instanceName: string;
|
|
939
941
|
repoRoot: string;
|
|
@@ -941,7 +943,7 @@ function startFileWatcher(opts: {
|
|
|
941
943
|
netnsContainer?: string;
|
|
942
944
|
assistantCaCertPath?: string;
|
|
943
945
|
}): () => void {
|
|
944
|
-
const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
|
|
946
|
+
const { gatewayPort, assistantPort, imageTags, instanceName, repoRoot, res } = opts;
|
|
945
947
|
|
|
946
948
|
const { dirs: watchDirs, files: watchFiles } = collectWatchTargets(repoRoot);
|
|
947
949
|
|
|
@@ -957,6 +959,7 @@ function startFileWatcher(opts: {
|
|
|
957
959
|
extraAssistantEnv: opts.extraAssistantEnv,
|
|
958
960
|
extraGatewayEnv: opts.extraGatewayEnv,
|
|
959
961
|
gatewayPort,
|
|
962
|
+
assistantPort,
|
|
960
963
|
imageTags,
|
|
961
964
|
instanceName,
|
|
962
965
|
res,
|
|
@@ -1136,6 +1139,30 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1136
1139
|
}
|
|
1137
1140
|
}
|
|
1138
1141
|
|
|
1142
|
+
// Allocate the assistant HTTP API host port. Same dynamic-allocation
|
|
1143
|
+
// strategy as the gateway port: the env-default (production 7821 /
|
|
1144
|
+
// non-prod overrides) is the *preferred* starting point, and we walk
|
|
1145
|
+
// upward until we find a free port. Without this, two concurrent
|
|
1146
|
+
// `vellum hatch --remote docker` on the same host collide on a fixed
|
|
1147
|
+
// 7821 bind ("port is already allocated"). Unused when netnsContainer
|
|
1148
|
+
// is set — no host ports are published in that mode.
|
|
1149
|
+
let assistantPort: number;
|
|
1150
|
+
if (params.netnsContainer) {
|
|
1151
|
+
assistantPort = ASSISTANT_INTERNAL_PORT;
|
|
1152
|
+
} else {
|
|
1153
|
+
const preferredAssistantPort = getDefaultPorts(
|
|
1154
|
+
getCurrentEnvironment(),
|
|
1155
|
+
).daemon;
|
|
1156
|
+
assistantPort = await findOpenPort(preferredAssistantPort, {
|
|
1157
|
+
exclude: [gatewayPort],
|
|
1158
|
+
});
|
|
1159
|
+
if (assistantPort !== preferredAssistantPort) {
|
|
1160
|
+
log(
|
|
1161
|
+
`Preferred assistant port ${preferredAssistantPort} is in use; allocated ${assistantPort} for this instance.`,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1139
1166
|
const imageTags: Record<ServiceName, string> = {
|
|
1140
1167
|
assistant: "",
|
|
1141
1168
|
"credential-executor": "",
|
|
@@ -1404,6 +1431,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1404
1431
|
extraAssistantEnv,
|
|
1405
1432
|
extraGatewayEnv,
|
|
1406
1433
|
gatewayPort,
|
|
1434
|
+
assistantPort,
|
|
1407
1435
|
imageTags,
|
|
1408
1436
|
instanceName,
|
|
1409
1437
|
res,
|
|
@@ -1432,6 +1460,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1432
1460
|
gatewayDigest: imageDigests?.gateway,
|
|
1433
1461
|
cesDigest: imageDigests?.["credential-executor"],
|
|
1434
1462
|
networkName: res.network,
|
|
1463
|
+
assistantPort,
|
|
1435
1464
|
},
|
|
1436
1465
|
};
|
|
1437
1466
|
emitProgress(5, 6, "Saving configuration...");
|
|
@@ -1502,6 +1531,7 @@ export async function hatchDocker(params: HatchDockerParams): Promise<void> {
|
|
|
1502
1531
|
extraAssistantEnv,
|
|
1503
1532
|
extraGatewayEnv,
|
|
1504
1533
|
gatewayPort,
|
|
1534
|
+
assistantPort,
|
|
1505
1535
|
imageTags,
|
|
1506
1536
|
instanceName,
|
|
1507
1537
|
repoRoot,
|
|
@@ -21,10 +21,11 @@ import { createServer } from "net";
|
|
|
21
21
|
*/
|
|
22
22
|
export async function findOpenPort(
|
|
23
23
|
preferred: number,
|
|
24
|
-
options: { maxAttempts?: number; host?: string } = {},
|
|
24
|
+
options: { maxAttempts?: number; host?: string; exclude?: number[] } = {},
|
|
25
25
|
): Promise<number> {
|
|
26
26
|
const maxAttempts = options.maxAttempts ?? 50;
|
|
27
27
|
const host = options.host ?? "0.0.0.0";
|
|
28
|
+
const exclude = new Set(options.exclude ?? []);
|
|
28
29
|
|
|
29
30
|
if (!Number.isInteger(preferred) || preferred < 1 || preferred > 65535) {
|
|
30
31
|
throw new Error(
|
|
@@ -41,6 +42,7 @@ export async function findOpenPort(
|
|
|
41
42
|
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
42
43
|
const port = preferred + offset;
|
|
43
44
|
if (port > 65535) break;
|
|
45
|
+
if (exclude.has(port)) continue;
|
|
44
46
|
try {
|
|
45
47
|
await probePort(port, host);
|
|
46
48
|
return port;
|
package/src/lib/statefulset.ts
CHANGED
|
@@ -85,8 +85,9 @@ interface VolumeMount {
|
|
|
85
85
|
interface PortSpec {
|
|
86
86
|
containerPort: number;
|
|
87
87
|
/**
|
|
88
|
-
* Host-side port. Literal string = use as-is. `"{{ gatewayPort }}"`
|
|
89
|
-
*
|
|
88
|
+
* Host-side port. Literal string = use as-is. `"{{ gatewayPort }}"` and
|
|
89
|
+
* `"{{ assistantPort }}"` are sentinels replaced with the instance-specific
|
|
90
|
+
* gateway / assistant host ports at build time.
|
|
90
91
|
*/
|
|
91
92
|
hostPort?: string;
|
|
92
93
|
description?: string;
|
|
@@ -172,7 +173,7 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
|
|
|
172
173
|
},
|
|
173
174
|
{
|
|
174
175
|
containerPort: ASSISTANT_INTERNAL_PORT,
|
|
175
|
-
hostPort:
|
|
176
|
+
hostPort: "{{ assistantPort }}",
|
|
176
177
|
description: "Assistant HTTP API",
|
|
177
178
|
},
|
|
178
179
|
],
|
|
@@ -261,6 +262,13 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
|
|
|
261
262
|
|
|
262
263
|
export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
|
|
263
264
|
gatewayPort: number;
|
|
265
|
+
/**
|
|
266
|
+
* Host-side port for the assistant HTTP API. Allocated dynamically by
|
|
267
|
+
* `hatchDocker` (mirroring `gatewayPort`) so concurrent instances on the
|
|
268
|
+
* same host don't collide on a fixed port bind. Unused when
|
|
269
|
+
* `netnsContainer` is set (no host ports are published in that mode).
|
|
270
|
+
*/
|
|
271
|
+
assistantPort: number;
|
|
264
272
|
imageTags: Record<ServiceName, string>;
|
|
265
273
|
instanceName: string;
|
|
266
274
|
res: DockerResourceNames;
|
|
@@ -353,6 +361,7 @@ export function buildServiceRunArgs(
|
|
|
353
361
|
): Record<ServiceName, () => string[]> {
|
|
354
362
|
const {
|
|
355
363
|
gatewayPort,
|
|
364
|
+
assistantPort,
|
|
356
365
|
imageTags,
|
|
357
366
|
instanceName,
|
|
358
367
|
res,
|
|
@@ -396,7 +405,9 @@ export function buildServiceRunArgs(
|
|
|
396
405
|
const hostSide =
|
|
397
406
|
port.hostPort === "{{ gatewayPort }}"
|
|
398
407
|
? `${gatewayPort}`
|
|
399
|
-
: port.hostPort
|
|
408
|
+
: port.hostPort === "{{ assistantPort }}"
|
|
409
|
+
? `${assistantPort}`
|
|
410
|
+
: port.hostPort;
|
|
400
411
|
if (hostSide !== undefined) {
|
|
401
412
|
args.push("-p", `${hostSide}:${port.containerPort}`);
|
|
402
413
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
DOCKER_READY_TIMEOUT_MS,
|
|
14
14
|
dockerResourceNames,
|
|
15
15
|
GATEWAY_INTERNAL_PORT,
|
|
16
|
+
ASSISTANT_INTERNAL_PORT,
|
|
16
17
|
startContainers,
|
|
17
18
|
stopContainers,
|
|
18
19
|
} from "./docker.js";
|
|
@@ -685,6 +686,9 @@ export async function performDockerRollback(
|
|
|
685
686
|
// use default
|
|
686
687
|
}
|
|
687
688
|
|
|
689
|
+
// Recover the assistant host port from the entry, fall back to default.
|
|
690
|
+
const assistantPort = entry.containerInfo?.assistantPort ?? ASSISTANT_INTERNAL_PORT;
|
|
691
|
+
|
|
688
692
|
// Broadcast SSE "starting" event
|
|
689
693
|
console.log("📢 Notifying connected clients...");
|
|
690
694
|
await broadcastUpgradeEvent(
|
|
@@ -746,6 +750,7 @@ export async function performDockerRollback(
|
|
|
746
750
|
extraAssistantEnv,
|
|
747
751
|
extraGatewayEnv,
|
|
748
752
|
gatewayPort,
|
|
753
|
+
assistantPort,
|
|
749
754
|
imageTags: targetImageTags,
|
|
750
755
|
instanceName,
|
|
751
756
|
res,
|
|
@@ -785,6 +790,7 @@ export async function performDockerRollback(
|
|
|
785
790
|
gatewayDigest: newDigests?.gateway,
|
|
786
791
|
cesDigest: newDigests?.["credential-executor"],
|
|
787
792
|
networkName: res.network,
|
|
793
|
+
assistantPort,
|
|
788
794
|
},
|
|
789
795
|
previousContainerInfo: entry.containerInfo,
|
|
790
796
|
previousDbMigrationVersion: preMigrationState.dbVersion,
|
|
@@ -867,6 +873,7 @@ export async function performDockerRollback(
|
|
|
867
873
|
extraAssistantEnv,
|
|
868
874
|
extraGatewayEnv,
|
|
869
875
|
gatewayPort,
|
|
876
|
+
assistantPort,
|
|
870
877
|
imageTags: currentImageRefs,
|
|
871
878
|
instanceName,
|
|
872
879
|
res,
|
|
@@ -919,6 +926,7 @@ export async function performDockerRollback(
|
|
|
919
926
|
revertDigests?.["credential-executor"] ??
|
|
920
927
|
currentImageRefs["credential-executor"],
|
|
921
928
|
networkName: res.network,
|
|
929
|
+
assistantPort,
|
|
922
930
|
},
|
|
923
931
|
previousContainerInfo: undefined,
|
|
924
932
|
previousDbMigrationVersion: undefined,
|