@agent-relay/factory 0.1.49 → 0.1.51
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 +93 -11
- package/dist/cli/fleet.d.ts +4 -1
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +87 -42
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1671 -10
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +83 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/templates.d.ts +6 -0
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +16 -0
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/environments/connection-registry.d.ts +617 -0
- package/dist/environments/connection-registry.d.ts.map +1 -0
- package/dist/environments/connection-registry.js +174 -0
- package/dist/environments/connection-registry.js.map +1 -0
- package/dist/environments/index.d.ts +21 -0
- package/dist/environments/index.d.ts.map +1 -0
- package/dist/environments/index.js +11 -0
- package/dist/environments/index.js.map +1 -0
- package/dist/environments/k6-job.d.ts +64 -0
- package/dist/environments/k6-job.d.ts.map +1 -0
- package/dist/environments/k6-job.js +376 -0
- package/dist/environments/k6-job.js.map +1 -0
- package/dist/environments/kubernetes-client.d.ts +72 -0
- package/dist/environments/kubernetes-client.d.ts.map +1 -0
- package/dist/environments/kubernetes-client.js +337 -0
- package/dist/environments/kubernetes-client.js.map +1 -0
- package/dist/environments/kubernetes-command.d.ts +30 -0
- package/dist/environments/kubernetes-command.d.ts.map +1 -0
- package/dist/environments/kubernetes-command.js +108 -0
- package/dist/environments/kubernetes-command.js.map +1 -0
- package/dist/environments/kubernetes-environment.d.ts +38 -0
- package/dist/environments/kubernetes-environment.d.ts.map +1 -0
- package/dist/environments/kubernetes-environment.js +333 -0
- package/dist/environments/kubernetes-environment.js.map +1 -0
- package/dist/environments/kubernetes-provider.d.ts +83 -0
- package/dist/environments/kubernetes-provider.d.ts.map +1 -0
- package/dist/environments/kubernetes-provider.js +956 -0
- package/dist/environments/kubernetes-provider.js.map +1 -0
- package/dist/environments/load-harness.d.ts +199 -0
- package/dist/environments/load-harness.d.ts.map +1 -0
- package/dist/environments/load-harness.js +259 -0
- package/dist/environments/load-harness.js.map +1 -0
- package/dist/environments/load-profile.d.ts +294 -0
- package/dist/environments/load-profile.d.ts.map +1 -0
- package/dist/environments/load-profile.js +125 -0
- package/dist/environments/load-profile.js.map +1 -0
- package/dist/environments/stack-deployer.d.ts +22 -0
- package/dist/environments/stack-deployer.d.ts.map +1 -0
- package/dist/environments/stack-deployer.js +27 -0
- package/dist/environments/stack-deployer.js.map +1 -0
- package/dist/environments/stack-descriptor.d.ts +243 -0
- package/dist/environments/stack-descriptor.d.ts.map +1 -0
- package/dist/environments/stack-descriptor.js +88 -0
- package/dist/environments/stack-descriptor.js.map +1 -0
- package/dist/environments/verification-pipeline.d.ts +117 -0
- package/dist/environments/verification-pipeline.d.ts.map +1 -0
- package/dist/environments/verification-pipeline.js +643 -0
- package/dist/environments/verification-pipeline.js.map +1 -0
- package/dist/environments/verification-stack-deployer.d.ts +73 -0
- package/dist/environments/verification-stack-deployer.d.ts.map +1 -0
- package/dist/environments/verification-stack-deployer.js +484 -0
- package/dist/environments/verification-stack-deployer.js.map +1 -0
- package/dist/environments/verification-stack-descriptor.d.ts +1134 -0
- package/dist/environments/verification-stack-descriptor.d.ts.map +1 -0
- package/dist/environments/verification-stack-descriptor.js +275 -0
- package/dist/environments/verification-stack-descriptor.js.map +1 -0
- package/dist/environments/verification-stack.d.ts +29 -0
- package/dist/environments/verification-stack.d.ts.map +1 -0
- package/dist/environments/verification-stack.js +43 -0
- package/dist/environments/verification-stack.js.map +1 -0
- package/dist/featuremap/generate.d.ts +72 -0
- package/dist/featuremap/generate.d.ts.map +1 -0
- package/dist/featuremap/generate.js +854 -0
- package/dist/featuremap/generate.js.map +1 -0
- package/dist/featuremap/index.d.ts +2 -0
- package/dist/featuremap/index.d.ts.map +1 -1
- package/dist/featuremap/index.js +1 -0
- package/dist/featuremap/index.js.map +1 -1
- package/dist/featuremap/validate.d.ts +6 -0
- package/dist/featuremap/validate.d.ts.map +1 -1
- package/dist/featuremap/validate.js +95 -13
- package/dist/featuremap/validate.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +2 -0
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +1 -0
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +8 -1
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +31 -6
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +4 -1
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +142 -1
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/git/agent-worktree.d.ts +4 -1
- package/dist/git/agent-worktree.d.ts.map +1 -1
- package/dist/git/agent-worktree.js +151 -8
- package/dist/git/agent-worktree.js.map +1 -1
- package/dist/index.d.ts +32 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +6 -2
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +8 -2
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +11 -1
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/node/factory-node.d.ts +2 -0
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +107 -2
- package/dist/node/factory-node.js.map +1 -1
- package/dist/node/preview-process.d.ts +65 -0
- package/dist/node/preview-process.d.ts.map +1 -0
- package/dist/node/preview-process.js +431 -0
- package/dist/node/preview-process.js.map +1 -0
- package/dist/node/tailscale-preview.d.ts +45 -0
- package/dist/node/tailscale-preview.d.ts.map +1 -0
- package/dist/node/tailscale-preview.js +748 -0
- package/dist/node/tailscale-preview.js.map +1 -0
- package/dist/observability/events.d.ts +1838 -28
- package/dist/observability/events.d.ts.map +1 -1
- package/dist/observability/events.js +52 -0
- package/dist/observability/events.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +1416 -78
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +2 -1
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/reaper.d.ts +24 -0
- package/dist/orchestrator/reaper.d.ts.map +1 -1
- package/dist/orchestrator/reaper.js +117 -0
- package/dist/orchestrator/reaper.js.map +1 -1
- package/dist/ports/environment.d.ts +83 -0
- package/dist/ports/environment.d.ts.map +1 -0
- package/dist/ports/environment.js +2 -0
- package/dist/ports/environment.js.map +1 -0
- package/dist/ports/fleet.d.ts +68 -1
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +3 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +9 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +17 -1
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/worktree.d.ts +14 -0
- package/dist/ports/worktree.d.ts.map +1 -1
- package/dist/state/file-state-store.js +28 -1
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/subscriptions/index.d.ts +2 -0
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +1 -0
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/subscriptions/resource-subscriptions.d.ts +77 -0
- package/dist/subscriptions/resource-subscriptions.d.ts.map +1 -0
- package/dist/subscriptions/resource-subscriptions.js +91 -0
- package/dist/subscriptions/resource-subscriptions.js.map +1 -0
- package/dist/testing/fakes.d.ts +9 -1
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +40 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +32 -32
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -4
- package/schemas/kubernetes-environment-stack.schema.json +86 -0
- package/schemas/verification-stack.schema.json +292 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { get } from 'node:http';
|
|
5
|
+
import { get as getHttps } from 'node:https';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import lockfile from 'proper-lockfile';
|
|
10
|
+
import { PreviewProcessSupervisor, } from './preview-process.js';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const REGISTRY_VERSION = 1;
|
|
13
|
+
const MANAGED_BY = '@agent-relay/factory';
|
|
14
|
+
const PREVIEW_LOCK_STALE_MS = 30_000;
|
|
15
|
+
const PREVIEW_PROVIDER_COMMAND_TIMEOUT_MS = 30_000;
|
|
16
|
+
const PREVIEW_READY_TIMEOUT_MS = 5 * 60_000;
|
|
17
|
+
const PREVIEW_PUBLISHED_READY_TIMEOUT_MS = 60_000;
|
|
18
|
+
const PREVIEW_READY_POLL_INTERVAL_MS = 250;
|
|
19
|
+
const PREVIEW_PROVISIONING_GRACE_MS = 10 * 60_000;
|
|
20
|
+
/**
|
|
21
|
+
* Thin lifecycle adapter around Tailscale Serve. `tailscaled` owns background
|
|
22
|
+
* proxying, while this registry records the exact routes Factory may remove.
|
|
23
|
+
* Every destructive operation checks the live Serve target before issuing
|
|
24
|
+
* `off`, so a stale registry can never turn off a port repurposed by a human.
|
|
25
|
+
*/
|
|
26
|
+
export class TailscalePreviewManager {
|
|
27
|
+
#config;
|
|
28
|
+
#run;
|
|
29
|
+
#isPortAvailable;
|
|
30
|
+
#isReady;
|
|
31
|
+
#isPublishedReady;
|
|
32
|
+
#processes;
|
|
33
|
+
#sleep;
|
|
34
|
+
#readyTimeoutMs;
|
|
35
|
+
#publishedReadyTimeoutMs;
|
|
36
|
+
#readyPollIntervalMs;
|
|
37
|
+
#now;
|
|
38
|
+
#operation = Promise.resolve();
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.#config = options.config;
|
|
41
|
+
this.#run = options.run ?? (async (file, args) => {
|
|
42
|
+
const result = await execFileAsync(file, args, {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
timeout: PREVIEW_PROVIDER_COMMAND_TIMEOUT_MS,
|
|
45
|
+
});
|
|
46
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
47
|
+
});
|
|
48
|
+
this.#isPortAvailable = options.isPortAvailable ?? portAvailable;
|
|
49
|
+
this.#isReady = options.isReady ?? httpReady;
|
|
50
|
+
this.#isPublishedReady = options.isPublishedReady ?? httpsReady;
|
|
51
|
+
this.#processes = options.processSupervisor ?? new PreviewProcessSupervisor();
|
|
52
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
53
|
+
this.#readyTimeoutMs = options.readyTimeoutMs ?? PREVIEW_READY_TIMEOUT_MS;
|
|
54
|
+
this.#publishedReadyTimeoutMs = options.publishedReadyTimeoutMs ?? PREVIEW_PUBLISHED_READY_TIMEOUT_MS;
|
|
55
|
+
this.#readyPollIntervalMs = options.readyPollIntervalMs ?? PREVIEW_READY_POLL_INTERVAL_MS;
|
|
56
|
+
this.#now = options.now ?? (() => new Date());
|
|
57
|
+
}
|
|
58
|
+
async start(input) {
|
|
59
|
+
return await this.#exclusive(async () => {
|
|
60
|
+
const registry = await this.#readRegistry();
|
|
61
|
+
let status = await this.#serveStatus();
|
|
62
|
+
const existing = registry.previews.find((preview) => preview.namespace === input.namespace &&
|
|
63
|
+
preview.owner === input.owner &&
|
|
64
|
+
preview.service === input.service);
|
|
65
|
+
const existingMatchesRequest = existing &&
|
|
66
|
+
existing.repo === input.repo &&
|
|
67
|
+
(existing.configuredTargetPort ?? existing.targetPort) === input.targetPort &&
|
|
68
|
+
(input.preferredHttpsPort === undefined || existing.httpsPort === input.preferredHttpsPort) &&
|
|
69
|
+
existing.startCommand === input.startCommand &&
|
|
70
|
+
(!existing.process || existing.process.cwd === input.checkoutPath);
|
|
71
|
+
const existingRoute = existing
|
|
72
|
+
? liveRoute(status, existing.httpsPort, existing.targetPort)
|
|
73
|
+
: undefined;
|
|
74
|
+
if (existingMatchesRequest && existingRoute && !existingRoute.funnel) {
|
|
75
|
+
const process = await this.#ensureManagedProcess(existing, input.checkoutPath);
|
|
76
|
+
await this.#awaitReady(existing.targetPort, process);
|
|
77
|
+
try {
|
|
78
|
+
await this.#assertListenerOwned(existing.targetPort, process);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// The exact Factory root is unsafe to keep published when a different
|
|
82
|
+
// process owns its upstream. Other mounts survive the path-scoped off.
|
|
83
|
+
await this.#disable(existing);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const liveUrl = previewUrl(existingRoute.host, existing.httpsPort);
|
|
87
|
+
await this.#awaitPublishedReady(liveUrl, process);
|
|
88
|
+
const active = activatePreview({ ...existing, process }, liveUrl);
|
|
89
|
+
if (existing.state === 'pending' ||
|
|
90
|
+
existing.url !== liveUrl ||
|
|
91
|
+
!sameProcess(existing.process, process)) {
|
|
92
|
+
registry.previews = registry.previews.map((preview) => preview.id === existing.id ? active : preview);
|
|
93
|
+
await this.#writeRegistry(registry);
|
|
94
|
+
}
|
|
95
|
+
return referenceFrom(active);
|
|
96
|
+
}
|
|
97
|
+
if (existing) {
|
|
98
|
+
// A changed service declaration replaces only the old route whose live
|
|
99
|
+
// upstream still matches Factory's registry identity.
|
|
100
|
+
if (liveRouteMatches(status, existing)) {
|
|
101
|
+
await this.#disable(existing);
|
|
102
|
+
status = await this.#serveStatus();
|
|
103
|
+
}
|
|
104
|
+
if (!await this.#stopManagedProcess(existing)) {
|
|
105
|
+
throw new Error(`Unable to confirm teardown of replaced preview process ${existing.id}`);
|
|
106
|
+
}
|
|
107
|
+
registry.previews = registry.previews.filter((preview) => preview.id !== existing.id);
|
|
108
|
+
await this.#writeRegistry(registry);
|
|
109
|
+
}
|
|
110
|
+
const serviceConfig = this.#config.services[input.service];
|
|
111
|
+
const lastTargetPort = Math.min(65_535, input.targetPort + (serviceConfig?.portSpan ?? 100) - 1);
|
|
112
|
+
const occupiedTargetPorts = new Set([
|
|
113
|
+
...registry.previews.map((preview) => preview.targetPort),
|
|
114
|
+
...liveUpstreamPorts(status),
|
|
115
|
+
]);
|
|
116
|
+
let targetPort;
|
|
117
|
+
for (const candidate of portRange(input.targetPort, lastTargetPort)) {
|
|
118
|
+
if (occupiedTargetPorts.has(candidate))
|
|
119
|
+
continue;
|
|
120
|
+
if (await this.#isPortAvailable(candidate)) {
|
|
121
|
+
targetPort = candidate;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (targetPort === undefined) {
|
|
126
|
+
throw new Error(`Unable to allocate a local target port for ${input.service}`);
|
|
127
|
+
}
|
|
128
|
+
const occupied = new Set(registry.previews.map((preview) => preview.httpsPort));
|
|
129
|
+
const candidates = input.preferredHttpsPort !== undefined
|
|
130
|
+
? [input.preferredHttpsPort]
|
|
131
|
+
: portRange(this.#config.httpsPortRange[0], this.#config.httpsPortRange[1]);
|
|
132
|
+
let lastError;
|
|
133
|
+
for (const httpsPort of candidates) {
|
|
134
|
+
if (occupied.has(httpsPort) || livePortConfigured(status, httpsPort))
|
|
135
|
+
continue;
|
|
136
|
+
const pending = {
|
|
137
|
+
id: randomUUID(),
|
|
138
|
+
provider: 'tailscale-serve',
|
|
139
|
+
namespace: input.namespace,
|
|
140
|
+
owner: input.owner,
|
|
141
|
+
service: input.service,
|
|
142
|
+
repo: input.repo,
|
|
143
|
+
configuredTargetPort: input.targetPort,
|
|
144
|
+
targetPort,
|
|
145
|
+
httpsPort,
|
|
146
|
+
access: 'tailnet',
|
|
147
|
+
lifetime: 'issue',
|
|
148
|
+
createdAt: this.#now().toISOString(),
|
|
149
|
+
startCommand: input.startCommand,
|
|
150
|
+
checkoutPath: input.checkoutPath,
|
|
151
|
+
...(input.node && input.node !== 'self' ? { node: input.node } : {}),
|
|
152
|
+
managedBy: MANAGED_BY,
|
|
153
|
+
state: 'pending',
|
|
154
|
+
};
|
|
155
|
+
registry.previews.push(pending);
|
|
156
|
+
// Persist intent before starting either external resource. The
|
|
157
|
+
// deterministic process marker closes the spawn/persist crash gap,
|
|
158
|
+
// while the pending route identity closes the Serve API crash gap.
|
|
159
|
+
await this.#writeRegistry(registry);
|
|
160
|
+
let commandSucceeded = false;
|
|
161
|
+
try {
|
|
162
|
+
const process = await this.#processes.start({
|
|
163
|
+
id: pending.id,
|
|
164
|
+
command: input.startCommand,
|
|
165
|
+
cwd: input.checkoutPath,
|
|
166
|
+
port: targetPort,
|
|
167
|
+
});
|
|
168
|
+
pending.process = process;
|
|
169
|
+
await this.#writeRegistry(registry);
|
|
170
|
+
await this.#awaitReady(targetPort, process);
|
|
171
|
+
await this.#assertListenerOwned(targetPort, process);
|
|
172
|
+
await this.#configure(pending);
|
|
173
|
+
commandSucceeded = true;
|
|
174
|
+
const configured = await this.#serveStatus();
|
|
175
|
+
const route = liveRoute(configured, httpsPort, targetPort);
|
|
176
|
+
if (!route || route.funnel) {
|
|
177
|
+
throw new Error(route?.funnel
|
|
178
|
+
? `tailscale serve reported public Funnel access on HTTPS port ${httpsPort}`
|
|
179
|
+
: `tailscale serve did not report the configured route on HTTPS port ${httpsPort}`);
|
|
180
|
+
}
|
|
181
|
+
const liveUrl = previewUrl(route.host, httpsPort);
|
|
182
|
+
await this.#awaitPublishedReady(liveUrl, process);
|
|
183
|
+
const preview = activatePreview(pending, liveUrl);
|
|
184
|
+
registry.previews = registry.previews.map((candidate) => candidate.id === pending.id ? preview : candidate);
|
|
185
|
+
await this.#writeRegistry(registry);
|
|
186
|
+
return referenceFrom(preview);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
lastError = error;
|
|
190
|
+
let routeRollbackCompleted = false;
|
|
191
|
+
let processRollbackCompleted = false;
|
|
192
|
+
try {
|
|
193
|
+
const cleanupStatus = await this.#serveStatus();
|
|
194
|
+
if (liveRoute(cleanupStatus, httpsPort, targetPort)) {
|
|
195
|
+
await this.#disable({ httpsPort });
|
|
196
|
+
routeRollbackCompleted = true;
|
|
197
|
+
}
|
|
198
|
+
else if (!commandSucceeded) {
|
|
199
|
+
// A rejected command that left no exact route has no provider
|
|
200
|
+
// mutation Factory can safely undo. Clear the pending intent;
|
|
201
|
+
// genuinely ambiguous status failures retain it for sweeping.
|
|
202
|
+
routeRollbackCompleted = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (cleanupError) {
|
|
206
|
+
lastError = new AggregateError([error, cleanupError], `Preview route setup and guarded rollback both failed on HTTPS port ${httpsPort}`);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
processRollbackCompleted = await this.#stopManagedProcess(pending);
|
|
210
|
+
}
|
|
211
|
+
catch (cleanupError) {
|
|
212
|
+
lastError = new AggregateError([lastError, cleanupError], `Preview setup and managed-process rollback both failed for ${pending.id}`);
|
|
213
|
+
}
|
|
214
|
+
if (routeRollbackCompleted && processRollbackCompleted) {
|
|
215
|
+
registry.previews = registry.previews.filter((candidate) => candidate.id !== pending.id);
|
|
216
|
+
try {
|
|
217
|
+
await this.#writeRegistry(registry);
|
|
218
|
+
}
|
|
219
|
+
catch (registryError) {
|
|
220
|
+
lastError = new AggregateError([lastError, registryError], `Preview route rollback succeeded but its pending registry entry could not be cleared on HTTPS port ${httpsPort}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Status already selected a free port. A command or verification
|
|
224
|
+
// failure is provider-wide, not a reason to hammer the whole range.
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`Unable to allocate a Tailscale Serve HTTPS port for ${input.service}`, lastError === undefined ? undefined : { cause: lastError });
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
async remove(reference) {
|
|
232
|
+
return await this.#exclusive(async () => {
|
|
233
|
+
const registry = await this.#readRegistry();
|
|
234
|
+
const preview = registry.previews.find((candidate) => candidate.managedBy === MANAGED_BY &&
|
|
235
|
+
candidate.id === reference.id &&
|
|
236
|
+
candidate.namespace === reference.namespace &&
|
|
237
|
+
candidate.owner === reference.owner &&
|
|
238
|
+
candidate.httpsPort === reference.httpsPort &&
|
|
239
|
+
candidate.targetPort === reference.targetPort);
|
|
240
|
+
// Idempotent teardown: an absent ownership record means this exact
|
|
241
|
+
// Factory route was already removed by a previous attempt or sweep.
|
|
242
|
+
if (!preview)
|
|
243
|
+
return true;
|
|
244
|
+
const status = await this.#serveStatus();
|
|
245
|
+
if (!liveRouteMatches(status, preview)) {
|
|
246
|
+
const processRemoved = await this.#stopManagedProcess(preview);
|
|
247
|
+
if (livePortConfigured(status, preview.httpsPort)) {
|
|
248
|
+
// The port definitively points at another route. Forget the stale
|
|
249
|
+
// ownership record, but never disable the replacement.
|
|
250
|
+
if (!processRemoved)
|
|
251
|
+
return false;
|
|
252
|
+
registry.previews = registry.previews.filter((candidate) => candidate.id !== preview.id);
|
|
253
|
+
await this.#writeRegistry(registry);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
if (preview.state === 'active' && processRemoved) {
|
|
257
|
+
// Active means Factory previously observed and published the route.
|
|
258
|
+
// Once both that route and its exact managed process are confirmed
|
|
259
|
+
// absent, teardown is complete and retaining the row would make every
|
|
260
|
+
// terminal retry fail forever. Pending rows remain conservative below
|
|
261
|
+
// because they may represent an in-flight provider mutation.
|
|
262
|
+
registry.previews = registry.previews.filter((candidate) => candidate.id !== preview.id);
|
|
263
|
+
await this.#writeRegistry(registry);
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
// An unconfigured port can be a transient status gap. Preserve the
|
|
267
|
+
// identity so a later startup sweep can still remove a route that
|
|
268
|
+
// becomes visible after this check.
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
await this.#disable(preview);
|
|
272
|
+
if (!await this.#stopManagedProcess(preview))
|
|
273
|
+
return false;
|
|
274
|
+
registry.previews = registry.previews.filter((candidate) => candidate.id !== preview.id);
|
|
275
|
+
await this.#writeRegistry(registry);
|
|
276
|
+
return true;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
async sweep(input) {
|
|
280
|
+
return await this.#exclusive(async () => {
|
|
281
|
+
const active = new Set(input.activeOwners);
|
|
282
|
+
const activePreviewIds = input.activePreviewIds
|
|
283
|
+
? new Set(input.activePreviewIds)
|
|
284
|
+
: undefined;
|
|
285
|
+
const registry = await this.#readRegistry();
|
|
286
|
+
let status = await this.#serveStatus();
|
|
287
|
+
const reaped = [];
|
|
288
|
+
const skipped = [];
|
|
289
|
+
const retained = [];
|
|
290
|
+
for (const preview of registry.previews) {
|
|
291
|
+
const ownerActive = active.has(preview.owner);
|
|
292
|
+
const exactActive = ownerActive && (!activePreviewIds || activePreviewIds.has(preview.id));
|
|
293
|
+
// Cross-daemon provisioning necessarily creates the provider record
|
|
294
|
+
// before the orchestrator can persist its returned ID. Give only recent
|
|
295
|
+
// records for a known-active owner a bounded chance to cross that gap.
|
|
296
|
+
const provisioningActive = ownerActive &&
|
|
297
|
+
activePreviewIds !== undefined &&
|
|
298
|
+
!activePreviewIds.has(preview.id) &&
|
|
299
|
+
this.#withinProvisioningGrace(preview);
|
|
300
|
+
const authoritativeActive = exactActive || provisioningActive;
|
|
301
|
+
if (preview.managedBy !== MANAGED_BY ||
|
|
302
|
+
preview.namespace !== input.namespace) {
|
|
303
|
+
retained.push(preview);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (authoritativeActive) {
|
|
307
|
+
try {
|
|
308
|
+
const checkoutPath = preview.checkoutPath ?? preview.process?.cwd;
|
|
309
|
+
if (!checkoutPath)
|
|
310
|
+
throw new Error('managed preview checkout is not recoverable');
|
|
311
|
+
const process = await this.#ensureManagedProcess(preview, checkoutPath);
|
|
312
|
+
await this.#awaitReady(preview.targetPort, process);
|
|
313
|
+
try {
|
|
314
|
+
await this.#assertListenerOwned(preview.targetPort, process);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
// This is a point-in-time ownership check. Arbitrary dev servers
|
|
318
|
+
// cannot accept a reserved socket from Factory, so a narrow
|
|
319
|
+
// check/use race remains; the final URL probe and every active
|
|
320
|
+
// sweep repeat validation without introducing a bespoke proxy.
|
|
321
|
+
if (liveRoute(status, preview.httpsPort, preview.targetPort)) {
|
|
322
|
+
await this.#disable(preview);
|
|
323
|
+
status = await this.#serveStatus();
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
let route = liveRoute(status, preview.httpsPort, preview.targetPort);
|
|
328
|
+
if (route?.funnel) {
|
|
329
|
+
// This exact root route is ours, so it is safe to remove before
|
|
330
|
+
// recreating it as tailnet-only Serve. Other mounts survive due
|
|
331
|
+
// to the explicit root path selector.
|
|
332
|
+
await this.#disable(preview);
|
|
333
|
+
await this.#configure(preview);
|
|
334
|
+
status = await this.#serveStatus();
|
|
335
|
+
route = liveRoute(status, preview.httpsPort, preview.targetPort);
|
|
336
|
+
}
|
|
337
|
+
else if (!route) {
|
|
338
|
+
if (livePortConfigured(status, preview.httpsPort)) {
|
|
339
|
+
throw new Error('refusing route recovery because the HTTPS port has been repurposed');
|
|
340
|
+
}
|
|
341
|
+
await this.#configure(preview);
|
|
342
|
+
status = await this.#serveStatus();
|
|
343
|
+
route = liveRoute(status, preview.httpsPort, preview.targetPort);
|
|
344
|
+
}
|
|
345
|
+
if (!route || route.funnel) {
|
|
346
|
+
throw new Error(route?.funnel
|
|
347
|
+
? 'recovered route still allows public Funnel access'
|
|
348
|
+
: 'recovered Serve route is not observable');
|
|
349
|
+
}
|
|
350
|
+
const liveUrl = previewUrl(route.host, preview.httpsPort);
|
|
351
|
+
await this.#awaitPublishedReady(liveUrl, process);
|
|
352
|
+
retained.push(activatePreview({ ...preview, checkoutPath, process }, liveUrl));
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
retained.push(preview);
|
|
356
|
+
skipped.push({
|
|
357
|
+
id: preview.id,
|
|
358
|
+
reason: `active preview recovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
359
|
+
node: preview.node,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const route = liveRoute(status, preview.httpsPort, preview.targetPort);
|
|
365
|
+
if (!route) {
|
|
366
|
+
const repurposed = livePortConfigured(status, preview.httpsPort);
|
|
367
|
+
let processRemoved = false;
|
|
368
|
+
try {
|
|
369
|
+
processRemoved = await this.#stopManagedProcess(preview);
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
skipped.push({
|
|
373
|
+
id: preview.id,
|
|
374
|
+
reason: `managed process teardown failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
375
|
+
node: preview.node,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (!repurposed && preview.state === 'active' && processRemoved) {
|
|
379
|
+
reaped.push(referenceFrom(preview));
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
if (!repurposed || !processRemoved)
|
|
383
|
+
retained.push(preview);
|
|
384
|
+
skipped.push({
|
|
385
|
+
id: preview.id,
|
|
386
|
+
reason: repurposed
|
|
387
|
+
? 'live route identity mismatch'
|
|
388
|
+
: 'live route is not currently observable; retained for retry',
|
|
389
|
+
node: preview.node,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
await this.#disable(preview);
|
|
396
|
+
if (!await this.#stopManagedProcess(preview)) {
|
|
397
|
+
throw new Error('managed process identity could not be confirmed stopped');
|
|
398
|
+
}
|
|
399
|
+
reaped.push(referenceFrom(activatePreview(preview, previewUrl(route.host, preview.httpsPort))));
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
retained.push(preview);
|
|
403
|
+
skipped.push({
|
|
404
|
+
id: preview.id,
|
|
405
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
406
|
+
node: preview.node,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
registry.previews = retained;
|
|
411
|
+
await this.#writeRegistry(registry);
|
|
412
|
+
return { reaped, skipped };
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async #awaitReady(port, process) {
|
|
416
|
+
await this.#awaitProbe(process, async () => await this.#isReady(port), `Preview command exited before local HTTP port ${port} became ready`, `Preview command did not make local HTTP port ${port} ready within ${this.#readyTimeoutMs}ms`, this.#readyTimeoutMs);
|
|
417
|
+
}
|
|
418
|
+
async #awaitPublishedReady(url, process) {
|
|
419
|
+
await this.#awaitProbe(process, async () => await this.#isPublishedReady(url), `Preview command exited before published URL ${url} became ready`, `Published preview URL ${url} did not become ready within ${this.#publishedReadyTimeoutMs}ms`, this.#publishedReadyTimeoutMs);
|
|
420
|
+
}
|
|
421
|
+
async #assertListenerOwned(port, process) {
|
|
422
|
+
const ownership = await this.#processes.listenerOwnership?.(process, port) ?? 'indeterminate';
|
|
423
|
+
if (ownership !== 'owned') {
|
|
424
|
+
throw new Error(ownership === 'unrelated'
|
|
425
|
+
? `Local HTTP port ${port} is owned by a process outside the managed preview tree`
|
|
426
|
+
: `Ownership of local HTTP port ${port} could not be determined`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async #awaitProbe(process, probe, exitedMessage, timeoutMessage, timeoutMs) {
|
|
430
|
+
const interval = Math.max(1, this.#readyPollIntervalMs);
|
|
431
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
432
|
+
while (true) {
|
|
433
|
+
if (!await this.#processes.isRunning(process)) {
|
|
434
|
+
throw new Error(exitedMessage);
|
|
435
|
+
}
|
|
436
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
437
|
+
if (await resolveBeforeDeadline(probe(), remaining, false))
|
|
438
|
+
return;
|
|
439
|
+
const remainingAfterProbe = deadline - Date.now();
|
|
440
|
+
if (remainingAfterProbe <= 0)
|
|
441
|
+
break;
|
|
442
|
+
await this.#sleep(Math.min(interval, remainingAfterProbe));
|
|
443
|
+
}
|
|
444
|
+
throw new Error(timeoutMessage);
|
|
445
|
+
}
|
|
446
|
+
#withinProvisioningGrace(preview) {
|
|
447
|
+
const createdAtMs = Date.parse(preview.createdAt);
|
|
448
|
+
if (!Number.isFinite(createdAtMs))
|
|
449
|
+
return false;
|
|
450
|
+
const ageMs = this.#now().getTime() - createdAtMs;
|
|
451
|
+
return ageMs >= 0 && ageMs <= PREVIEW_PROVISIONING_GRACE_MS;
|
|
452
|
+
}
|
|
453
|
+
async #stopManagedProcess(preview) {
|
|
454
|
+
if (preview.process && await this.#processes.stop(preview.process))
|
|
455
|
+
return true;
|
|
456
|
+
const recovered = await this.#processes.find(preview.id);
|
|
457
|
+
if (!recovered)
|
|
458
|
+
return true;
|
|
459
|
+
return await this.#processes.stop(recovered);
|
|
460
|
+
}
|
|
461
|
+
async #ensureManagedProcess(preview, checkoutPath) {
|
|
462
|
+
const recorded = preview.process && await this.#processes.isRunning(preview.process)
|
|
463
|
+
? preview.process
|
|
464
|
+
: undefined;
|
|
465
|
+
const recovered = recorded ?? await this.#processes.find(preview.id);
|
|
466
|
+
if (!recovered && !await this.#isPortAvailable(preview.targetPort)) {
|
|
467
|
+
throw new Error(`Refusing to attach preview ${preview.id} to occupied unowned port ${preview.targetPort}`);
|
|
468
|
+
}
|
|
469
|
+
return await this.#processes.start({
|
|
470
|
+
id: preview.id,
|
|
471
|
+
command: preview.startCommand,
|
|
472
|
+
cwd: checkoutPath,
|
|
473
|
+
port: preview.targetPort,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
async #disable(preview) {
|
|
477
|
+
await this.#run(this.#config.tailscaleBinary, [
|
|
478
|
+
'serve',
|
|
479
|
+
'--yes',
|
|
480
|
+
`--https=${preview.httpsPort}`,
|
|
481
|
+
'--set-path=/',
|
|
482
|
+
'off',
|
|
483
|
+
]);
|
|
484
|
+
}
|
|
485
|
+
async #configure(preview) {
|
|
486
|
+
await this.#run(this.#config.tailscaleBinary, [
|
|
487
|
+
'serve',
|
|
488
|
+
'--bg',
|
|
489
|
+
'--yes',
|
|
490
|
+
`--https=${preview.httpsPort}`,
|
|
491
|
+
'--set-path=/',
|
|
492
|
+
`http://127.0.0.1:${preview.targetPort}`,
|
|
493
|
+
]);
|
|
494
|
+
}
|
|
495
|
+
async #serveStatus() {
|
|
496
|
+
const result = await this.#run(this.#config.tailscaleBinary, ['serve', 'status', '--json']);
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(result.stdout);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
throw new Error('tailscale serve status returned invalid JSON', { cause: error });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async #readRegistry() {
|
|
505
|
+
try {
|
|
506
|
+
const value = JSON.parse(await readFile(this.#config.registryPath, 'utf8'));
|
|
507
|
+
if (!isRecord(value) || value.version !== REGISTRY_VERSION || !Array.isArray(value.previews)) {
|
|
508
|
+
throw new Error('invalid preview registry');
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
version: REGISTRY_VERSION,
|
|
512
|
+
previews: value.previews.filter(isRegistryPreview),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
if (isMissingFileError(error))
|
|
517
|
+
return { version: REGISTRY_VERSION, previews: [] };
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async #writeRegistry(registry) {
|
|
522
|
+
await mkdir(dirname(this.#config.registryPath), { recursive: true });
|
|
523
|
+
const temporary = `${this.#config.registryPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
524
|
+
try {
|
|
525
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
526
|
+
try {
|
|
527
|
+
await handle.writeFile(`${JSON.stringify(registry, null, 2)}\n`);
|
|
528
|
+
await handle.sync();
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
await handle.close();
|
|
532
|
+
}
|
|
533
|
+
await rename(temporary, this.#config.registryPath);
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
await rm(temporary, { force: true });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async #exclusive(operation) {
|
|
540
|
+
const withLock = async () => {
|
|
541
|
+
await mkdir(dirname(this.#config.registryPath), { recursive: true, mode: 0o700 });
|
|
542
|
+
const release = await lockfile.lock(this.#config.registryPath, {
|
|
543
|
+
realpath: false,
|
|
544
|
+
stale: PREVIEW_LOCK_STALE_MS,
|
|
545
|
+
update: PREVIEW_LOCK_STALE_MS / 2,
|
|
546
|
+
retries: {
|
|
547
|
+
forever: true,
|
|
548
|
+
factor: 1.2,
|
|
549
|
+
minTimeout: 10,
|
|
550
|
+
maxTimeout: 100,
|
|
551
|
+
randomize: true,
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
try {
|
|
555
|
+
return await operation();
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
await release();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const result = this.#operation.then(withLock, withLock);
|
|
562
|
+
this.#operation = result.then(() => undefined, () => undefined);
|
|
563
|
+
return await result;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const activatePreview = (preview, url) => ({
|
|
567
|
+
...preview,
|
|
568
|
+
state: 'active',
|
|
569
|
+
url,
|
|
570
|
+
});
|
|
571
|
+
const referenceFrom = ({ managedBy: _managedBy, state: _state, checkoutPath: _checkoutPath, ...preview }) => preview;
|
|
572
|
+
const sameProcess = (left, right) => Boolean(left && right &&
|
|
573
|
+
left.pid === right.pid &&
|
|
574
|
+
left.startTime === right.startTime &&
|
|
575
|
+
left.cmdline === right.cmdline &&
|
|
576
|
+
left.cwd === right.cwd &&
|
|
577
|
+
left.marker === right.marker);
|
|
578
|
+
const portRange = (start, end) => Array.from({ length: end - start + 1 }, (_, index) => start + index);
|
|
579
|
+
const livePortConfigured = (status, httpsPort) => {
|
|
580
|
+
for (const config of serveConfigs(status)) {
|
|
581
|
+
const tcp = isRecord(config.TCP) ? config.TCP : undefined;
|
|
582
|
+
if (tcp && Object.hasOwn(tcp, String(httpsPort)))
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
return false;
|
|
586
|
+
};
|
|
587
|
+
const liveUpstreamPorts = (status) => {
|
|
588
|
+
const ports = new Set();
|
|
589
|
+
for (const config of serveConfigs(status)) {
|
|
590
|
+
const web = isRecord(config.Web) ? config.Web : undefined;
|
|
591
|
+
for (const rawServer of Object.values(web ?? {})) {
|
|
592
|
+
if (!isRecord(rawServer))
|
|
593
|
+
continue;
|
|
594
|
+
const handlers = isRecord(rawServer.Handlers) ? rawServer.Handlers : undefined;
|
|
595
|
+
for (const rawHandler of Object.values(handlers ?? {})) {
|
|
596
|
+
if (!isRecord(rawHandler) || typeof rawHandler.Proxy !== 'string')
|
|
597
|
+
continue;
|
|
598
|
+
const match = rawHandler.Proxy.match(/^http:\/\/127\.0\.0\.1:(\d{1,5})(?:\/|$)/u);
|
|
599
|
+
const port = Number(match?.[1]);
|
|
600
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65_535)
|
|
601
|
+
ports.add(port);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return ports;
|
|
606
|
+
};
|
|
607
|
+
const liveRouteMatches = (status, preview) => Boolean(liveRoute(status, preview.httpsPort, preview.targetPort));
|
|
608
|
+
const liveRoute = (status, httpsPort, targetPort) => {
|
|
609
|
+
const expectedProxy = `http://127.0.0.1:${targetPort}`;
|
|
610
|
+
for (const config of serveConfigs(status)) {
|
|
611
|
+
const tcp = isRecord(config.TCP) ? config.TCP : undefined;
|
|
612
|
+
const tcpHandler = tcp && isRecord(tcp[String(httpsPort)])
|
|
613
|
+
? tcp[String(httpsPort)]
|
|
614
|
+
: undefined;
|
|
615
|
+
if (tcpHandler?.HTTPS !== true)
|
|
616
|
+
continue;
|
|
617
|
+
const web = isRecord(config.Web) ? config.Web : undefined;
|
|
618
|
+
for (const [hostPort, rawServer] of Object.entries(web ?? {})) {
|
|
619
|
+
if (!hostPort.endsWith(`:${httpsPort}`) || !isRecord(rawServer))
|
|
620
|
+
continue;
|
|
621
|
+
const handlers = isRecord(rawServer.Handlers) ? rawServer.Handlers : undefined;
|
|
622
|
+
const root = handlers && isRecord(handlers['/']) ? handlers['/'] : undefined;
|
|
623
|
+
if (root?.Proxy === expectedProxy) {
|
|
624
|
+
const allowFunnel = isRecord(config.AllowFunnel) ? config.AllowFunnel : undefined;
|
|
625
|
+
return {
|
|
626
|
+
host: hostPort.slice(0, -String(httpsPort).length - 1),
|
|
627
|
+
funnel: allowFunnel?.[hostPort] === true,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return undefined;
|
|
633
|
+
};
|
|
634
|
+
const serveConfigs = (status) => {
|
|
635
|
+
if (!isRecord(status))
|
|
636
|
+
return [];
|
|
637
|
+
const configs = [];
|
|
638
|
+
const visit = (config) => {
|
|
639
|
+
configs.push(config);
|
|
640
|
+
for (const nestedKey of ['Services', 'Foreground']) {
|
|
641
|
+
const nested = isRecord(config[nestedKey]) ? config[nestedKey] : undefined;
|
|
642
|
+
for (const child of Object.values(nested ?? {}).filter(isRecord))
|
|
643
|
+
visit(child);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
visit(status);
|
|
647
|
+
return configs;
|
|
648
|
+
};
|
|
649
|
+
const previewUrl = (host, httpsPort) => `https://${host}${httpsPort === 443 ? '' : `:${httpsPort}`}/`;
|
|
650
|
+
const isRegistryPreview = (value) => {
|
|
651
|
+
if (!isRecord(value))
|
|
652
|
+
return false;
|
|
653
|
+
const identity = value.managedBy === MANAGED_BY &&
|
|
654
|
+
value.provider === 'tailscale-serve' &&
|
|
655
|
+
value.access === 'tailnet' &&
|
|
656
|
+
value.lifetime === 'issue' &&
|
|
657
|
+
typeof value.id === 'string' &&
|
|
658
|
+
typeof value.namespace === 'string' &&
|
|
659
|
+
typeof value.owner === 'string' &&
|
|
660
|
+
typeof value.service === 'string' &&
|
|
661
|
+
typeof value.repo === 'string' &&
|
|
662
|
+
typeof value.startCommand === 'string' &&
|
|
663
|
+
typeof value.targetPort === 'number' &&
|
|
664
|
+
typeof value.httpsPort === 'number' &&
|
|
665
|
+
typeof value.createdAt === 'string';
|
|
666
|
+
if (!identity)
|
|
667
|
+
return false;
|
|
668
|
+
if (value.configuredTargetPort !== undefined && typeof value.configuredTargetPort !== 'number')
|
|
669
|
+
return false;
|
|
670
|
+
if (value.checkoutPath !== undefined && typeof value.checkoutPath !== 'string')
|
|
671
|
+
return false;
|
|
672
|
+
if (value.process !== undefined && !isPreviewProcessIdentity(value.process))
|
|
673
|
+
return false;
|
|
674
|
+
return value.state === 'pending' || (value.state === 'active' && typeof value.url === 'string');
|
|
675
|
+
};
|
|
676
|
+
const isPreviewProcessIdentity = (value) => isRecord(value) &&
|
|
677
|
+
Number.isInteger(value.pid) &&
|
|
678
|
+
Number(value.pid) > 0 &&
|
|
679
|
+
typeof value.startTime === 'string' &&
|
|
680
|
+
typeof value.cmdline === 'string' &&
|
|
681
|
+
typeof value.cwd === 'string' &&
|
|
682
|
+
typeof value.marker === 'string';
|
|
683
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
684
|
+
const isMissingFileError = (error) => isRecord(error) && error.code === 'ENOENT';
|
|
685
|
+
const portAvailable = async (port) => await new Promise((resolve) => {
|
|
686
|
+
const server = createServer();
|
|
687
|
+
let settled = false;
|
|
688
|
+
const finish = (available) => {
|
|
689
|
+
if (settled)
|
|
690
|
+
return;
|
|
691
|
+
settled = true;
|
|
692
|
+
resolve(available);
|
|
693
|
+
};
|
|
694
|
+
server.once('error', () => finish(false));
|
|
695
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
|
|
696
|
+
server.close((error) => finish(!error));
|
|
697
|
+
});
|
|
698
|
+
server.unref();
|
|
699
|
+
});
|
|
700
|
+
const httpReady = async (port) => await new Promise((resolve) => {
|
|
701
|
+
let settled = false;
|
|
702
|
+
const finish = (ready) => {
|
|
703
|
+
if (settled)
|
|
704
|
+
return;
|
|
705
|
+
settled = true;
|
|
706
|
+
resolve(ready);
|
|
707
|
+
};
|
|
708
|
+
const request = get({ host: '127.0.0.1', port, path: '/', timeout: 1_000 }, (response) => {
|
|
709
|
+
response.resume();
|
|
710
|
+
finish(true);
|
|
711
|
+
});
|
|
712
|
+
request.once('error', () => finish(false));
|
|
713
|
+
request.once('timeout', () => {
|
|
714
|
+
request.destroy();
|
|
715
|
+
finish(false);
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
const httpsReady = async (url) => await new Promise((resolve) => {
|
|
719
|
+
let settled = false;
|
|
720
|
+
const finish = (ready) => {
|
|
721
|
+
if (settled)
|
|
722
|
+
return;
|
|
723
|
+
settled = true;
|
|
724
|
+
resolve(ready);
|
|
725
|
+
};
|
|
726
|
+
const request = getHttps(url, { timeout: 1_000 }, (response) => {
|
|
727
|
+
response.resume();
|
|
728
|
+
finish(true);
|
|
729
|
+
});
|
|
730
|
+
request.once('error', () => finish(false));
|
|
731
|
+
request.once('timeout', () => {
|
|
732
|
+
request.destroy();
|
|
733
|
+
finish(false);
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
const resolveBeforeDeadline = async (operation, timeoutMs, fallback) => await new Promise((resolve, reject) => {
|
|
737
|
+
let settled = false;
|
|
738
|
+
const finish = (result) => {
|
|
739
|
+
if (settled)
|
|
740
|
+
return;
|
|
741
|
+
settled = true;
|
|
742
|
+
clearTimeout(timer);
|
|
743
|
+
result();
|
|
744
|
+
};
|
|
745
|
+
const timer = setTimeout(() => finish(() => resolve(fallback)), Math.max(0, timeoutMs));
|
|
746
|
+
void operation.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
747
|
+
});
|
|
748
|
+
//# sourceMappingURL=tailscale-preview.js.map
|