@vibesdotdev/integrations-kubernetes 0.0.0-stage → 1.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/dist/helm-chart.d.ts +13 -0
- package/dist/helm-chart.d.ts.map +1 -0
- package/dist/helm-chart.js +508 -0
- package/dist/helm-chart.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/k8s-configmap.d.ts +25 -0
- package/dist/k8s-configmap.d.ts.map +1 -0
- package/dist/k8s-configmap.js +168 -0
- package/dist/k8s-configmap.js.map +1 -0
- package/dist/k8s-gpu-operator.d.ts +3 -0
- package/dist/k8s-gpu-operator.d.ts.map +1 -0
- package/dist/k8s-gpu-operator.js +53 -0
- package/dist/k8s-gpu-operator.js.map +1 -0
- package/dist/k8s-namespace.d.ts +51 -0
- package/dist/k8s-namespace.d.ts.map +1 -0
- package/dist/k8s-namespace.js +280 -0
- package/dist/k8s-namespace.js.map +1 -0
- package/dist/k8s-pod-health.d.ts +60 -0
- package/dist/k8s-pod-health.d.ts.map +1 -0
- package/dist/k8s-pod-health.js +376 -0
- package/dist/k8s-pod-health.js.map +1 -0
- package/dist/k8s-secret.d.ts +55 -0
- package/dist/k8s-secret.d.ts.map +1 -0
- package/dist/k8s-secret.js +391 -0
- package/dist/k8s-secret.js.map +1 -0
- package/dist/kind-cluster.d.ts +84 -0
- package/dist/kind-cluster.d.ts.map +1 -0
- package/dist/kind-cluster.js +694 -0
- package/dist/kind-cluster.js.map +1 -0
- package/dist/kubectl-budget.client.d.ts +13 -0
- package/dist/kubectl-budget.client.d.ts.map +1 -0
- package/dist/kubectl-budget.client.js +38 -0
- package/dist/kubectl-budget.client.js.map +1 -0
- package/dist/kubectl.d.ts +59 -0
- package/dist/kubectl.d.ts.map +1 -0
- package/dist/kubectl.js +121 -0
- package/dist/kubectl.js.map +1 -0
- package/dist/port-forward-command.d.ts +6 -0
- package/dist/port-forward-command.d.ts.map +1 -0
- package/dist/port-forward-command.js +24 -0
- package/dist/port-forward-command.js.map +1 -0
- package/dist/port-forward-compat.d.ts +11 -0
- package/dist/port-forward-compat.d.ts.map +1 -0
- package/dist/port-forward-compat.js +21 -0
- package/dist/port-forward-compat.js.map +1 -0
- package/dist/port-forward-identity.d.ts +23 -0
- package/dist/port-forward-identity.d.ts.map +1 -0
- package/dist/port-forward-identity.js +72 -0
- package/dist/port-forward-identity.js.map +1 -0
- package/dist/port-forward.consumer.plugin.d.ts +3 -0
- package/dist/port-forward.consumer.plugin.d.ts.map +1 -0
- package/dist/port-forward.consumer.plugin.js +10 -0
- package/dist/port-forward.consumer.plugin.js.map +1 -0
- package/dist/port-forward.d.ts +18 -0
- package/dist/port-forward.d.ts.map +1 -0
- package/dist/port-forward.descriptor.d.ts +7 -0
- package/dist/port-forward.descriptor.d.ts.map +1 -0
- package/dist/port-forward.descriptor.js +3 -0
- package/dist/port-forward.descriptor.js.map +1 -0
- package/dist/port-forward.impl.consumer.d.ts +22 -0
- package/dist/port-forward.impl.consumer.d.ts.map +1 -0
- package/dist/port-forward.impl.consumer.js +32 -0
- package/dist/port-forward.impl.consumer.js.map +1 -0
- package/dist/port-forward.js +26 -0
- package/dist/port-forward.js.map +1 -0
- package/dist/port-forward.kind.d.ts +13 -0
- package/dist/port-forward.kind.d.ts.map +1 -0
- package/dist/port-forward.kind.js +12 -0
- package/dist/port-forward.kind.js.map +1 -0
- package/dist/resource-providers.consumer.plugin.d.ts +3 -0
- package/dist/resource-providers.consumer.plugin.d.ts.map +1 -0
- package/dist/resource-providers.consumer.plugin.js +56 -0
- package/dist/resource-providers.consumer.plugin.js.map +1 -0
- package/dist/schemas/helm-chart.d.ts +14 -0
- package/dist/schemas/helm-chart.d.ts.map +1 -0
- package/dist/schemas/helm-chart.js +12 -0
- package/dist/schemas/helm-chart.js.map +1 -0
- package/dist/schemas/k8s-pod-health.d.ts +17 -0
- package/dist/schemas/k8s-pod-health.d.ts.map +1 -0
- package/dist/schemas/k8s-pod-health.js +21 -0
- package/dist/schemas/k8s-pod-health.js.map +1 -0
- package/dist/schemas/port-forward.d.ts +62 -0
- package/dist/schemas/port-forward.d.ts.map +1 -0
- package/dist/schemas/port-forward.js +24 -0
- package/dist/schemas/port-forward.js.map +1 -0
- package/dist/tool-env.d.ts +19 -0
- package/dist/tool-env.d.ts.map +1 -0
- package/dist/tool-env.js +32 -0
- package/dist/tool-env.js.map +1 -0
- package/package.json +80 -4
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kind Cluster Resource Provider
|
|
3
|
+
* Manages Kind (Kubernetes in Docker) cluster lifecycle including local registry
|
|
4
|
+
*
|
|
5
|
+
* Handles:
|
|
6
|
+
* - Cluster creation with local registry
|
|
7
|
+
* - Orphaned registry detection and cleanup
|
|
8
|
+
* - Cluster recovery (restart vs recreate)
|
|
9
|
+
* - Full lifecycle management
|
|
10
|
+
*/
|
|
11
|
+
import { CapabilityError, requireRuntimeCwd } from '@vibesdotdev/kernel';
|
|
12
|
+
import { getLogger } from '@vibesdotdev/logging';
|
|
13
|
+
import * as z from 'zod/v4';
|
|
14
|
+
import { createSha256 } from '@vibesdotdev/shared/hashing';
|
|
15
|
+
import { composePMChildEnv } from '@vibesdotdev/pm/helpers/process-env';
|
|
16
|
+
import { pollUntil } from '@vibesdotdev/pm/utils/bounded-wait';
|
|
17
|
+
import { runCapture, runTimedCapture } from '@vibesdotdev/pm/utils/exec';
|
|
18
|
+
import { attempt } from '@vibesdotdev/pm/utils/attempt';
|
|
19
|
+
import { hasErrno, rethrowFailure } from '@vibesdotdev/pm/helpers/failure';
|
|
20
|
+
import { pmFileSystem, readBytesFileIfPresent } from '@vibesdotdev/pm/helpers/runtime-files';
|
|
21
|
+
import { toolFailure, toolOutputExcerpt } from '@vibesdotdev/pm/resources/tool-env';
|
|
22
|
+
import { DOCKER_TOOL_INHERIT_ENV } from "./tool-env.js";
|
|
23
|
+
/**
|
|
24
|
+
* Kind cluster provider configuration schema
|
|
25
|
+
*/
|
|
26
|
+
const KindClusterConfigSchema = z.object({
|
|
27
|
+
/** Cluster name (defaults to resource ID) */
|
|
28
|
+
name: z.string().min(1).optional(),
|
|
29
|
+
/** Enable local registry (default: true) */
|
|
30
|
+
registry: z.boolean().default(true),
|
|
31
|
+
/** Registry port (default: 5001) */
|
|
32
|
+
registryPort: z.number().int().min(1024).max(65535).default(5001),
|
|
33
|
+
/** Registry container name (default: kind-registry) */
|
|
34
|
+
registryName: z.string().default('kind-registry'),
|
|
35
|
+
/** Kind config file path (optional) */
|
|
36
|
+
configFile: z.string().optional(),
|
|
37
|
+
/** Node image (optional, uses kind default) */
|
|
38
|
+
nodeImage: z.string().optional(),
|
|
39
|
+
/** Wait timeout for cluster ready (ms) */
|
|
40
|
+
waitTimeout: z.number().int().min(0).default(120000)
|
|
41
|
+
});
|
|
42
|
+
/** What a cluster's stored state remembers: the names it was created under. */
|
|
43
|
+
const KindStateMetadataSchema = z.object({
|
|
44
|
+
clusterName: z.string().optional().catch(undefined),
|
|
45
|
+
registryName: z.string().optional().catch(undefined)
|
|
46
|
+
});
|
|
47
|
+
/** A container's published ports (`docker inspect -f '{{json .NetworkSettings.Ports}}'`): container port to host bindings. */
|
|
48
|
+
const PortBindingsSchema = z
|
|
49
|
+
.record(z.string(), z.array(z.object({ HostIp: z.string().optional(), HostPort: z.string().optional() })).nullable())
|
|
50
|
+
.nullable()
|
|
51
|
+
.catch(null);
|
|
52
|
+
const STATUS_CHECK_TIMEOUT_MS = 5000;
|
|
53
|
+
async function detectDockerHost(runtime) {
|
|
54
|
+
const tryDocker = async (env) => {
|
|
55
|
+
const probe = await attempt(() => runTimedCapture(runtime, 'docker', ['info'], {
|
|
56
|
+
env,
|
|
57
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV,
|
|
58
|
+
signal: runtime.lifecycle.signal,
|
|
59
|
+
timeoutMs: 3000
|
|
60
|
+
}));
|
|
61
|
+
if (probe.ok)
|
|
62
|
+
return !probe.value.timedOut && probe.value.exitCode === 0;
|
|
63
|
+
// No docker binary (ENOENT) or a daemon that did not answer within the
|
|
64
|
+
// probe's 3s budget: this host is not usable, a typed "no". Anything
|
|
65
|
+
// else is real.
|
|
66
|
+
const { failure } = probe;
|
|
67
|
+
const timedOut = failure instanceof Error && failure.name === 'TimeoutError';
|
|
68
|
+
if (hasErrno(failure, 'ENOENT') || timedOut)
|
|
69
|
+
return false;
|
|
70
|
+
return rethrowFailure(failure);
|
|
71
|
+
};
|
|
72
|
+
if (await tryDocker())
|
|
73
|
+
return null;
|
|
74
|
+
const home = runtime.getEnvSnapshot().HOME || '';
|
|
75
|
+
const candidates = [
|
|
76
|
+
`unix://${home}/Library/Containers/com.docker.docker/Data/docker.raw.sock`,
|
|
77
|
+
`unix://${home}/.docker/run/docker.sock`,
|
|
78
|
+
'unix:///var/run/docker.sock'
|
|
79
|
+
];
|
|
80
|
+
const files = await pmFileSystem(runtime);
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
const socketPath = candidate.replace('unix://', '');
|
|
83
|
+
// A candidate socket that is not there is skipped.
|
|
84
|
+
if (!(await files.exists(socketPath)))
|
|
85
|
+
continue;
|
|
86
|
+
if (await tryDocker({ ...(await dockerBaseEnv(runtime)), DOCKER_HOST: candidate })) {
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The docker/kind CLIs' environment (C15): PM's base set and the host's
|
|
94
|
+
* re-entry facts and roots, plus the container-engine variables they read.
|
|
95
|
+
*/
|
|
96
|
+
function dockerBaseEnv(runtime) {
|
|
97
|
+
return composePMChildEnv(runtime, {
|
|
98
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function dockerEnv(runtime) {
|
|
102
|
+
const host = await detectDockerHost(runtime);
|
|
103
|
+
const env = await dockerBaseEnv(runtime);
|
|
104
|
+
if (host)
|
|
105
|
+
env.DOCKER_HOST = host;
|
|
106
|
+
return env;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Check if a Docker container exists
|
|
110
|
+
*/
|
|
111
|
+
async function containerExists(runtime, name) {
|
|
112
|
+
const { exitCode } = await runCapture(runtime, 'docker', ['inspect', name], {
|
|
113
|
+
env: await dockerEnv(runtime),
|
|
114
|
+
signal: runtime.lifecycle.signal,
|
|
115
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
116
|
+
});
|
|
117
|
+
return exitCode === 0;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Check if a Docker container is running
|
|
121
|
+
*/
|
|
122
|
+
async function containerRunning(runtime, name) {
|
|
123
|
+
const { stdout, exitCode } = await runCapture(runtime, 'docker', ['inspect', '--format', '{{.State.Running}}', name], {
|
|
124
|
+
env: await dockerEnv(runtime),
|
|
125
|
+
signal: runtime.lifecycle.signal,
|
|
126
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
127
|
+
});
|
|
128
|
+
return exitCode === 0 && stdout.trim() === 'true';
|
|
129
|
+
}
|
|
130
|
+
/** Check the cluster list within the caller's probe budget. */
|
|
131
|
+
async function clusterExists(runtime, name, timeoutMs = STATUS_CHECK_TIMEOUT_MS) {
|
|
132
|
+
const result = await runTimedCapture(runtime, 'kind', ['get', 'clusters'], {
|
|
133
|
+
timeoutMs,
|
|
134
|
+
env: await dockerEnv(runtime),
|
|
135
|
+
signal: runtime.lifecycle.signal,
|
|
136
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
137
|
+
});
|
|
138
|
+
return !result.timedOut && result.exitCode === 0 && result.stdout.trim().split('\n').includes(name);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if kind cluster node is ready
|
|
142
|
+
*/
|
|
143
|
+
async function isNodeReady(runtime, clusterName, timeoutMs) {
|
|
144
|
+
const args = [
|
|
145
|
+
'--context', `kind-${clusterName}`, 'get', 'nodes', '-o',
|
|
146
|
+
'jsonpath={.items[0].status.conditions[?(@.type=="Ready")].status}'
|
|
147
|
+
];
|
|
148
|
+
const options = { signal: runtime.lifecycle.signal, inheritEnv: DOCKER_TOOL_INHERIT_ENV };
|
|
149
|
+
const result = timeoutMs === undefined
|
|
150
|
+
? await runCapture(runtime, 'kubectl', args, options)
|
|
151
|
+
: await runTimedCapture(runtime, 'kubectl', args, { ...options, timeoutMs });
|
|
152
|
+
return !('timedOut' in result && result.timedOut) && result.exitCode === 0 && result.stdout.trim() === 'True';
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Detect orphaned registry container (registry exists but cluster doesn't)
|
|
156
|
+
*/
|
|
157
|
+
async function detectOrphanedRegistry(runtime, clusterName, registryName) {
|
|
158
|
+
const [hasCluster, registryExists, registryRunning] = await Promise.all([
|
|
159
|
+
clusterExists(runtime, clusterName),
|
|
160
|
+
containerExists(runtime, registryName),
|
|
161
|
+
containerRunning(runtime, registryName)
|
|
162
|
+
]);
|
|
163
|
+
// Orphaned if registry exists but cluster doesn't
|
|
164
|
+
if (!hasCluster && registryExists) {
|
|
165
|
+
return { orphaned: true, running: registryRunning };
|
|
166
|
+
}
|
|
167
|
+
return { orphaned: false, running: registryRunning };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Clean up orphaned registry container
|
|
171
|
+
*/
|
|
172
|
+
async function cleanupOrphanedRegistry(runtime, registryName) {
|
|
173
|
+
const env = await dockerEnv(runtime);
|
|
174
|
+
await runCapture(runtime, 'docker', ['stop', registryName], {
|
|
175
|
+
env,
|
|
176
|
+
signal: runtime.lifecycle.signal,
|
|
177
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
178
|
+
});
|
|
179
|
+
await runCapture(runtime, 'docker', ['rm', registryName], {
|
|
180
|
+
env,
|
|
181
|
+
signal: runtime.lifecycle.signal,
|
|
182
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Create local Docker registry for kind
|
|
187
|
+
*/
|
|
188
|
+
async function createRegistry(runtime, name, port) {
|
|
189
|
+
// Check if already exists and running
|
|
190
|
+
if (await containerRunning(runtime, name)) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Remove if exists but not running
|
|
194
|
+
if (await containerExists(runtime, name)) {
|
|
195
|
+
await runCapture(runtime, 'docker', ['rm', name], {
|
|
196
|
+
env: await dockerEnv(runtime),
|
|
197
|
+
signal: runtime.lifecycle.signal,
|
|
198
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Create registry container
|
|
202
|
+
const { exitCode, stderr } = await runCapture(runtime, 'docker', [
|
|
203
|
+
'run',
|
|
204
|
+
'-d',
|
|
205
|
+
'--restart=always',
|
|
206
|
+
'-p',
|
|
207
|
+
`127.0.0.1:${port}:5000`,
|
|
208
|
+
'--network',
|
|
209
|
+
'bridge',
|
|
210
|
+
'--name',
|
|
211
|
+
name,
|
|
212
|
+
'registry:2'
|
|
213
|
+
], {
|
|
214
|
+
env: await dockerEnv(runtime),
|
|
215
|
+
signal: runtime.lifecycle.signal,
|
|
216
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
217
|
+
});
|
|
218
|
+
if (exitCode !== 0) {
|
|
219
|
+
throw toolFailure({ tool: 'docker', action: 'run registry', outcome: { exitCode, stderr } });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Create kind cluster with registry configuration
|
|
224
|
+
*/
|
|
225
|
+
async function createCluster(runtime, name, config) {
|
|
226
|
+
const args = ['create', 'cluster', '--name', name];
|
|
227
|
+
if (config.nodeImage) {
|
|
228
|
+
args.push('--image', config.nodeImage);
|
|
229
|
+
}
|
|
230
|
+
if (config.configFile) {
|
|
231
|
+
args.push('--config', config.configFile);
|
|
232
|
+
}
|
|
233
|
+
// Create cluster
|
|
234
|
+
const { exitCode, stderr } = await runCapture(runtime, 'kind', args, {
|
|
235
|
+
env: await dockerEnv(runtime),
|
|
236
|
+
signal: runtime.lifecycle.signal,
|
|
237
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
238
|
+
});
|
|
239
|
+
if (exitCode !== 0) {
|
|
240
|
+
throw toolFailure({ tool: 'kind', action: 'create cluster', outcome: { exitCode, stderr } });
|
|
241
|
+
}
|
|
242
|
+
// Connect registry to kind network if registry is enabled
|
|
243
|
+
if (config.registry) {
|
|
244
|
+
await connectRegistryToCluster(runtime, name, config.registryName);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Connect registry container to kind network
|
|
249
|
+
*/
|
|
250
|
+
async function connectRegistryToCluster(runtime, clusterName, registryName) {
|
|
251
|
+
const networkName = 'kind';
|
|
252
|
+
const env = await dockerEnv(runtime);
|
|
253
|
+
// Check if already connected
|
|
254
|
+
const { stdout } = await runCapture(runtime, 'docker', [
|
|
255
|
+
'inspect',
|
|
256
|
+
'--format',
|
|
257
|
+
'{{range $key, $value := .NetworkSettings.Networks}}{{$key}} {{end}}',
|
|
258
|
+
registryName
|
|
259
|
+
], {
|
|
260
|
+
env,
|
|
261
|
+
signal: runtime.lifecycle.signal,
|
|
262
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
263
|
+
});
|
|
264
|
+
if (stdout.includes(networkName)) {
|
|
265
|
+
return; // Already connected
|
|
266
|
+
}
|
|
267
|
+
// Connect to kind network
|
|
268
|
+
const { exitCode, stderr } = await runCapture(runtime, 'docker', ['network', 'connect', networkName, registryName], {
|
|
269
|
+
env,
|
|
270
|
+
signal: runtime.lifecycle.signal,
|
|
271
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
272
|
+
});
|
|
273
|
+
// Ignore "already connected" errors
|
|
274
|
+
if (exitCode !== 0 && !stderr.includes('already exists')) {
|
|
275
|
+
getLogger('pm:kind-cluster', runtime).warn('Could not connect registry to kind network', {
|
|
276
|
+
registryName,
|
|
277
|
+
exitCode,
|
|
278
|
+
output: toolOutputExcerpt(stderr)
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Configure nodes to use the local registry
|
|
282
|
+
await configureRegistryInCluster(runtime, clusterName, registryName);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Configure kind cluster nodes to use local registry
|
|
286
|
+
*/
|
|
287
|
+
async function configureRegistryInCluster(runtime, clusterName, _registryName) {
|
|
288
|
+
const context = `kind-${clusterName}`;
|
|
289
|
+
// Create ConfigMap for local registry hosting
|
|
290
|
+
const configMap = `
|
|
291
|
+
apiVersion: v1
|
|
292
|
+
kind: ConfigMap
|
|
293
|
+
metadata:
|
|
294
|
+
name: local-registry-hosting
|
|
295
|
+
namespace: kube-public
|
|
296
|
+
data:
|
|
297
|
+
localRegistryHosting.v1: |
|
|
298
|
+
host: "localhost:5001"
|
|
299
|
+
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
|
300
|
+
`;
|
|
301
|
+
await runCapture(runtime, 'kubectl', ['--context', context, 'apply', '-f', '-'], {
|
|
302
|
+
signal: runtime.lifecycle.signal,
|
|
303
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV,
|
|
304
|
+
stdin: configMap
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Read a container's host-side port bindings.
|
|
309
|
+
* Returns a map of containerPort/proto → [{ HostIp, HostPort }, ...].
|
|
310
|
+
* Empty object on inspect failure.
|
|
311
|
+
*/
|
|
312
|
+
async function getContainerPortBindings(runtime, containerName) {
|
|
313
|
+
const { stdout, exitCode } = await runCapture(runtime, 'docker', ['inspect', '--format', '{{json .NetworkSettings.Ports}}', containerName], {
|
|
314
|
+
env: await dockerEnv(runtime),
|
|
315
|
+
signal: runtime.lifecycle.signal,
|
|
316
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
317
|
+
});
|
|
318
|
+
if (exitCode !== 0)
|
|
319
|
+
return {};
|
|
320
|
+
// docker's own JSON that does not parse is a failure, never "no ports".
|
|
321
|
+
return PortBindingsSchema.parse(JSON.parse(stdout)) ?? {};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* List PersistentVolumeClaims in the cluster whose underlying storage
|
|
325
|
+
* would be lost on `kind delete cluster`. By default, kind uses the
|
|
326
|
+
* local-path-provisioner which writes data into the kind container's
|
|
327
|
+
* filesystem — NOT a host bind mount — so PVCs are destroyed with the
|
|
328
|
+
* cluster. Returns a list of `<namespace>/<name>` strings.
|
|
329
|
+
*
|
|
330
|
+
* Returns an empty list when kubectl is unavailable, the context doesn't
|
|
331
|
+
* exist, or there are no PVCs — the recreate command treats that as
|
|
332
|
+
* "no stateful data at risk".
|
|
333
|
+
*/
|
|
334
|
+
export async function listAtRiskPVCs(runtime, clusterName) {
|
|
335
|
+
const context = `kind-${clusterName}`;
|
|
336
|
+
const { stdout, exitCode, timedOut } = await runTimedCapture(runtime, 'kubectl', [
|
|
337
|
+
'--context',
|
|
338
|
+
context,
|
|
339
|
+
'get',
|
|
340
|
+
'pvc',
|
|
341
|
+
'--all-namespaces',
|
|
342
|
+
'-o',
|
|
343
|
+
'jsonpath={range .items[*]}{.metadata.namespace}/{.metadata.name}|{.spec.storageClassName}{"\\n"}{end}'
|
|
344
|
+
], {
|
|
345
|
+
timeoutMs: STATUS_CHECK_TIMEOUT_MS,
|
|
346
|
+
env: await dockerEnv(runtime),
|
|
347
|
+
signal: runtime.lifecycle.signal,
|
|
348
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
349
|
+
});
|
|
350
|
+
if (timedOut || exitCode !== 0 || !stdout)
|
|
351
|
+
return [];
|
|
352
|
+
const atRisk = [];
|
|
353
|
+
for (const line of stdout.split('\n')) {
|
|
354
|
+
const trimmed = line.trim();
|
|
355
|
+
if (!trimmed)
|
|
356
|
+
continue;
|
|
357
|
+
const [identity, storageClass = ''] = trimmed.split('|');
|
|
358
|
+
// local-path is kind's default provisioner — backed by the kind
|
|
359
|
+
// container's filesystem, lost on cluster delete. Any other
|
|
360
|
+
// storageClass is opt-in and assumed to be persistent.
|
|
361
|
+
if (storageClass === '' ||
|
|
362
|
+
storageClass === 'standard' ||
|
|
363
|
+
storageClass.startsWith('local-path')) {
|
|
364
|
+
atRisk.push(identity);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return atRisk;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Check if a container's port bindings are actually active.
|
|
371
|
+
* After a Docker Desktop restart, containers may have HostConfig.PortBindings
|
|
372
|
+
* configured but NetworkSettings.Ports empty — meaning Docker failed to bind them.
|
|
373
|
+
*/
|
|
374
|
+
async function hasActivePortBindings(runtime, containerName) {
|
|
375
|
+
const ports = await getContainerPortBindings(runtime, containerName);
|
|
376
|
+
for (const bindings of Object.values(ports)) {
|
|
377
|
+
if (bindings && bindings.length > 0)
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Assert that the registry container is bound to the configured host
|
|
384
|
+
* port. Docker won't change port mappings on a running container, so a
|
|
385
|
+
* mismatch means the user changed `registryPort` in config but the
|
|
386
|
+
* change can't take effect without recreating the container.
|
|
387
|
+
*/
|
|
388
|
+
async function assertRegistryPortMatches(runtime, registryName, desiredHostPort) {
|
|
389
|
+
if (!(await containerExists(runtime, registryName)))
|
|
390
|
+
return;
|
|
391
|
+
const bindings = await getContainerPortBindings(runtime, registryName);
|
|
392
|
+
const containerPort = '5000/tcp';
|
|
393
|
+
const actual = bindings[containerPort] ?? [];
|
|
394
|
+
const matched = actual.some((b) => b.HostPort === String(desiredHostPort));
|
|
395
|
+
if (matched)
|
|
396
|
+
return;
|
|
397
|
+
const actualPorts = actual
|
|
398
|
+
.map((b) => b.HostPort)
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join(', ') || '<none>';
|
|
401
|
+
throw new CapabilityError(`Registry container '${registryName}' is bound to host port ${actualPorts} but config requests ${desiredHostPort}. ` +
|
|
402
|
+
`Docker can't change port mappings on a running container — run ` +
|
|
403
|
+
`\`vibes pm resource recreate ${registryName === 'kind-registry' ? 'kind-vibes-local' : registryName}\` ` +
|
|
404
|
+
`(with --force if the cluster has stateful data) to rebuild with the new port.`, 'CONFLICT', {
|
|
405
|
+
reason: 'pm-kind-registry-port-mismatch',
|
|
406
|
+
retryable: false,
|
|
407
|
+
details: { registryName, desiredHostPort }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Attempt to restart a stopped cluster container
|
|
412
|
+
*/
|
|
413
|
+
async function restartClusterContainer(runtime, clusterName) {
|
|
414
|
+
const containerName = `${clusterName}-control-plane`;
|
|
415
|
+
// Check if container exists
|
|
416
|
+
if (!(await containerExists(runtime, containerName))) {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
// Check for broken port bindings (common after Docker Desktop restart).
|
|
420
|
+
// If port bindings are gone, a simple restart won't fix it — the cluster
|
|
421
|
+
// must be fully recreated.
|
|
422
|
+
if (!(await hasActivePortBindings(runtime, containerName))) {
|
|
423
|
+
getLogger('pm:kind-cluster', runtime).info('Port bindings lost (Docker Desktop restart?), the cluster must be recreated', {
|
|
424
|
+
containerName
|
|
425
|
+
});
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
// Try to restart
|
|
429
|
+
const { exitCode } = await runCapture(runtime, 'docker', ['restart', containerName], {
|
|
430
|
+
env: await dockerEnv(runtime),
|
|
431
|
+
signal: runtime.lifecycle.signal,
|
|
432
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
433
|
+
});
|
|
434
|
+
if (exitCode !== 0) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
// Wait for the node to become ready (up to 30s), bounded by the runtime
|
|
438
|
+
// lifecycle (RC07). Poll: the kind node (owner) answers readiness only when
|
|
439
|
+
// asked (`kubectl get nodes`); it publishes no event to this process.
|
|
440
|
+
return pollUntil(runtime, () => isNodeReady(runtime, clusterName), {
|
|
441
|
+
timeoutMs: 30_000,
|
|
442
|
+
intervalMs: 1_000,
|
|
443
|
+
reason: 'pm-kind-node-ready'
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Delete kind cluster
|
|
448
|
+
*/
|
|
449
|
+
async function deleteCluster(runtime, name) {
|
|
450
|
+
const { exitCode, stderr } = await runCapture(runtime, 'kind', ['delete', 'cluster', '--name', name], {
|
|
451
|
+
env: await dockerEnv(runtime),
|
|
452
|
+
signal: runtime.lifecycle.signal,
|
|
453
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
454
|
+
});
|
|
455
|
+
if (exitCode !== 0 && !stderr.includes('no kind cluster')) {
|
|
456
|
+
throw toolFailure({ tool: 'kind', action: 'delete cluster', outcome: { exitCode, stderr } });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Delete registry container
|
|
461
|
+
*/
|
|
462
|
+
async function deleteRegistry(runtime, name) {
|
|
463
|
+
if (await containerExists(runtime, name)) {
|
|
464
|
+
const env = await dockerEnv(runtime);
|
|
465
|
+
await runCapture(runtime, 'docker', ['stop', name], {
|
|
466
|
+
env,
|
|
467
|
+
signal: runtime.lifecycle.signal,
|
|
468
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
469
|
+
});
|
|
470
|
+
await runCapture(runtime, 'docker', ['rm', name], {
|
|
471
|
+
env,
|
|
472
|
+
signal: runtime.lifecycle.signal,
|
|
473
|
+
inheritEnv: DOCKER_TOOL_INHERIT_ENV
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Kind Cluster Provider
|
|
479
|
+
* Creates and manages Kind clusters with local registry support
|
|
480
|
+
*/
|
|
481
|
+
export class KindClusterProvider {
|
|
482
|
+
type = 'kind';
|
|
483
|
+
subType = 'cluster';
|
|
484
|
+
configStore = new Map();
|
|
485
|
+
/**
|
|
486
|
+
* Setup (create or ensure) a kind cluster
|
|
487
|
+
*/
|
|
488
|
+
async setup(config, { runtime }) {
|
|
489
|
+
const clusterConfig = this.validateConfig(config.config);
|
|
490
|
+
const clusterName = clusterConfig.name || config.id;
|
|
491
|
+
const registryName = clusterConfig.registryName;
|
|
492
|
+
this.configStore.set(config.id, config);
|
|
493
|
+
const log = getLogger('pm:kind-cluster', runtime);
|
|
494
|
+
// Step 1: Check for orphaned registry and clean up
|
|
495
|
+
const orphanCheck = await detectOrphanedRegistry(runtime, clusterName, registryName);
|
|
496
|
+
if (orphanCheck.orphaned) {
|
|
497
|
+
log.info('Cleaning up orphaned registry', { registryName });
|
|
498
|
+
await cleanupOrphanedRegistry(runtime, registryName);
|
|
499
|
+
}
|
|
500
|
+
// Step 2: Check if cluster already exists and is healthy
|
|
501
|
+
const hasCluster = await clusterExists(runtime, clusterName);
|
|
502
|
+
if (hasCluster) {
|
|
503
|
+
// Catch port-mapping drift before reporting success — Docker
|
|
504
|
+
// won't change port bindings on a running container, so a
|
|
505
|
+
// config change to `registryPort` is silently ineffective
|
|
506
|
+
// without an explicit recreate.
|
|
507
|
+
if (clusterConfig.registry) {
|
|
508
|
+
await assertRegistryPortMatches(runtime, registryName, clusterConfig.registryPort);
|
|
509
|
+
}
|
|
510
|
+
const nodeReady = await isNodeReady(runtime, clusterName);
|
|
511
|
+
if (nodeReady) {
|
|
512
|
+
// Cluster exists and is healthy
|
|
513
|
+
return this.createSuccessResult(config, clusterName, clusterConfig, 'existing');
|
|
514
|
+
}
|
|
515
|
+
// Cluster exists but node not ready - try restart
|
|
516
|
+
log.info('Cluster exists but its node is not ready, attempting restart', { clusterName });
|
|
517
|
+
const restarted = await restartClusterContainer(runtime, clusterName);
|
|
518
|
+
if (restarted) {
|
|
519
|
+
return this.createSuccessResult(config, clusterName, clusterConfig, 'restarted');
|
|
520
|
+
}
|
|
521
|
+
// Restart failed - recreate
|
|
522
|
+
log.info('Restart failed, recreating the cluster', { clusterName });
|
|
523
|
+
await deleteCluster(runtime, clusterName);
|
|
524
|
+
}
|
|
525
|
+
// Step 3: Create registry if enabled
|
|
526
|
+
if (clusterConfig.registry) {
|
|
527
|
+
log.info('Creating local registry', { registryName, port: clusterConfig.registryPort });
|
|
528
|
+
await createRegistry(runtime, registryName, clusterConfig.registryPort);
|
|
529
|
+
}
|
|
530
|
+
// Step 4: Create cluster
|
|
531
|
+
log.info('Creating kind cluster', { clusterName });
|
|
532
|
+
await createCluster(runtime, clusterName, clusterConfig);
|
|
533
|
+
return this.createSuccessResult(config, clusterName, clusterConfig, 'created');
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Create success result
|
|
537
|
+
*/
|
|
538
|
+
createSuccessResult(config, clusterName, clusterConfig, action) {
|
|
539
|
+
const state = {
|
|
540
|
+
id: config.id,
|
|
541
|
+
type: 'kind/cluster',
|
|
542
|
+
status: 'ready',
|
|
543
|
+
createdAt: new Date(),
|
|
544
|
+
lastChecked: new Date(),
|
|
545
|
+
metadata: {
|
|
546
|
+
clusterName,
|
|
547
|
+
context: `kind-${clusterName}`,
|
|
548
|
+
registryEnabled: clusterConfig.registry,
|
|
549
|
+
registryName: clusterConfig.registryName,
|
|
550
|
+
registryPort: clusterConfig.registryPort,
|
|
551
|
+
action
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
return {
|
|
555
|
+
state,
|
|
556
|
+
metadata: {
|
|
557
|
+
clusterName,
|
|
558
|
+
context: `kind-${clusterName}`,
|
|
559
|
+
action,
|
|
560
|
+
message: action === 'created'
|
|
561
|
+
? `Kind cluster '${clusterName}' created successfully`
|
|
562
|
+
: action === 'restarted'
|
|
563
|
+
? `Kind cluster '${clusterName}' restarted successfully`
|
|
564
|
+
: `Kind cluster '${clusterName}' already exists and is healthy`
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Teardown (delete) cluster and registry
|
|
570
|
+
*/
|
|
571
|
+
async teardown(id, state, { runtime }) {
|
|
572
|
+
const storedConfig = this.configStore.get(id);
|
|
573
|
+
const clusterConfig = storedConfig
|
|
574
|
+
? this.validateConfig(storedConfig.config)
|
|
575
|
+
: {
|
|
576
|
+
name: id,
|
|
577
|
+
registry: true,
|
|
578
|
+
registryName: 'kind-registry',
|
|
579
|
+
registryPort: 5001,
|
|
580
|
+
waitTimeout: 120000
|
|
581
|
+
};
|
|
582
|
+
const remembered = KindStateMetadataSchema.parse(state?.metadata ?? {});
|
|
583
|
+
const clusterName = remembered.clusterName || clusterConfig.name || id;
|
|
584
|
+
const registryName = remembered.registryName || clusterConfig.registryName;
|
|
585
|
+
// Delete cluster first
|
|
586
|
+
await deleteCluster(runtime, clusterName);
|
|
587
|
+
// Delete registry if it was created by us
|
|
588
|
+
if (clusterConfig.registry) {
|
|
589
|
+
await deleteRegistry(runtime, registryName);
|
|
590
|
+
}
|
|
591
|
+
this.configStore.delete(id);
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Get status of cluster
|
|
595
|
+
*/
|
|
596
|
+
async status(id, { runtime }) {
|
|
597
|
+
const storedConfig = this.configStore.get(id);
|
|
598
|
+
const clusterName = storedConfig ? this.validateConfig(storedConfig.config).name || id : id;
|
|
599
|
+
const hasCluster = await clusterExists(runtime, clusterName, STATUS_CHECK_TIMEOUT_MS);
|
|
600
|
+
if (!hasCluster) {
|
|
601
|
+
return { status: 'missing' };
|
|
602
|
+
}
|
|
603
|
+
const nodeReady = await isNodeReady(runtime, clusterName, STATUS_CHECK_TIMEOUT_MS);
|
|
604
|
+
if (!nodeReady) {
|
|
605
|
+
return {
|
|
606
|
+
status: 'error',
|
|
607
|
+
error: 'Cluster exists but node is not ready'
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
status: 'ready',
|
|
612
|
+
metadata: {
|
|
613
|
+
clusterName,
|
|
614
|
+
context: `kind-${clusterName}`
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Check if cluster is ready
|
|
620
|
+
*/
|
|
621
|
+
async checkReady(config, _state, { runtime }) {
|
|
622
|
+
const clusterConfig = this.validateConfig(config.config);
|
|
623
|
+
const clusterName = clusterConfig.name || config.id;
|
|
624
|
+
// Check cluster exists
|
|
625
|
+
const hasCluster = await clusterExists(runtime, clusterName, STATUS_CHECK_TIMEOUT_MS);
|
|
626
|
+
if (!hasCluster) {
|
|
627
|
+
return {
|
|
628
|
+
ready: false,
|
|
629
|
+
reason: `Kind cluster '${clusterName}' does not exist`,
|
|
630
|
+
retryAfter: 5000
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
// Check node is ready
|
|
634
|
+
const nodeReady = await isNodeReady(runtime, clusterName, STATUS_CHECK_TIMEOUT_MS);
|
|
635
|
+
if (!nodeReady) {
|
|
636
|
+
return {
|
|
637
|
+
ready: false,
|
|
638
|
+
reason: `Kind cluster '${clusterName}' node is not ready`,
|
|
639
|
+
retryAfter: 2000
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
return { ready: true };
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Validate provider-specific configuration
|
|
646
|
+
*/
|
|
647
|
+
validateConfig(config) {
|
|
648
|
+
return KindClusterConfigSchema.parse(config);
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Get human-readable description
|
|
652
|
+
*/
|
|
653
|
+
describe(config) {
|
|
654
|
+
const clusterConfig = this.validateConfig(config.config);
|
|
655
|
+
const clusterName = clusterConfig.name || config.id;
|
|
656
|
+
const registry = clusterConfig.registry
|
|
657
|
+
? ` with registry on port ${clusterConfig.registryPort}`
|
|
658
|
+
: '';
|
|
659
|
+
return `Kind cluster '${clusterName}'${registry}`;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Data a `kind delete cluster` would destroy: the cluster's PVCs on
|
|
663
|
+
* kind's default local-path provisioner (see {@link listAtRiskPVCs}).
|
|
664
|
+
*/
|
|
665
|
+
async listAtRiskData(config, { runtime }) {
|
|
666
|
+
const clusterConfig = this.validateConfig(config.config);
|
|
667
|
+
return listAtRiskPVCs(runtime, clusterConfig.name || config.id);
|
|
668
|
+
}
|
|
669
|
+
async computeSourceHash(config, { runtime }) {
|
|
670
|
+
const clusterConfig = this.validateConfig(config.config);
|
|
671
|
+
const hash = createSha256();
|
|
672
|
+
hash.update(`name:${clusterConfig.name || config.id}\n`);
|
|
673
|
+
hash.update(`registry:${clusterConfig.registry}\n`);
|
|
674
|
+
hash.update(`registryPort:${clusterConfig.registryPort}\n`);
|
|
675
|
+
hash.update(`registryName:${clusterConfig.registryName}\n`);
|
|
676
|
+
if (clusterConfig.nodeImage)
|
|
677
|
+
hash.update(`nodeImage:${clusterConfig.nodeImage}\n`);
|
|
678
|
+
if (clusterConfig.configFile) {
|
|
679
|
+
const files = await pmFileSystem(runtime);
|
|
680
|
+
const absPath = files.resolve(requireRuntimeCwd(runtime), clusterConfig.configFile);
|
|
681
|
+
const content = await readBytesFileIfPresent(files, absPath);
|
|
682
|
+
if (content === undefined) {
|
|
683
|
+
// A missing config file is part of the hash.
|
|
684
|
+
hash.update(`configFile:${clusterConfig.configFile}:missing\n`);
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
hash.update(`configFile:${clusterConfig.configFile}\n`);
|
|
688
|
+
hash.update(content);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return hash.digestHex();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
//# sourceMappingURL=kind-cluster.js.map
|