@skaile/provider-k8s 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +7 -2
- package/dist/index.d.ts +38 -1
- package/dist/index.js +113 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @skaile/provider-k8s
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#1100](https://github.com/skaile-ai/workspaces/pull/1100) [`3a3aa72`](https://github.com/skaile-ai/workspaces/commit/3a3aa72a169904ebc758a7d029ffa95b2c8178a7) Thanks [@mortegro](https://github.com/mortegro)! - Peer range for `@skaile/workspaces` is now `^6.0.0` (harness replaces driver in 6.0.0; these plugins use no renamed API).
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- [#99](https://github.com/skaile-ai/workspaces/pull/99) [`fb76100`](https://github.com/skaile-ai/workspaces/commit/fb76100d1524784ccfafb52520280e1764e0c38a) Thanks [@peteralbert](https://github.com/peteralbert)! - The deploy target now establishes and manages the `kubectl port-forward` tunnel
|
|
14
|
+
itself: `create()` spawns the forward after the Pod + Service are ready, `wsUrl`
|
|
15
|
+
reflects the actually-bound local port, `stop()` tears the tunnel down, and
|
|
16
|
+
`restore()` re-establishes it. Previously the URL was advertised but the operator
|
|
17
|
+
had to run the port-forward by hand.
|
|
18
|
+
|
|
19
|
+
Also: avoid leaking a Pod when Service (or port-forward) creation fails during
|
|
20
|
+
`create()` — the already-created Pod is best-effort deleted (mirroring `stop()`'s
|
|
21
|
+
teardown) before the error is re-thrown.
|
|
22
|
+
|
|
3
23
|
## 0.1.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -50,8 +50,13 @@ deploy:
|
|
|
50
50
|
- `@kubernetes/client-node` is lazy-loaded via a dynamic `import()` inside
|
|
51
51
|
`create()`/`restore()`, so this package builds and typechecks without the SDK
|
|
52
52
|
installed (offline-safe).
|
|
53
|
-
- Creates a Pod + ClusterIP Service
|
|
54
|
-
|
|
53
|
+
- Creates a Pod + ClusterIP Service and waits for the Pod to reach `Running`.
|
|
54
|
+
- The provider itself spawns and manages a `kubectl port-forward svc/<name>`
|
|
55
|
+
tunnel — you don't establish it. It resolves once `kubectl` reports it is
|
|
56
|
+
forwarding, exposes `ws://127.0.0.1:<bound-local-port>` (the port `kubectl`
|
|
57
|
+
actually bound, honouring `localForwardPort` when set or letting the OS pick a
|
|
58
|
+
free one otherwise), and kills the child process on `stop()`. `restore()`
|
|
59
|
+
re-establishes the tunnel, preferring the previously bound port.
|
|
55
60
|
- `tlsTermination: "edge"` (TLS terminates at the cluster ingress; the
|
|
56
61
|
port-forward tunnel is loopback-only on the operator's machine).
|
|
57
62
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import { DeployTarget, PluginRegistry } from '@skaile/workspaces/plugin-registry';
|
|
2
2
|
import * as z from 'zod';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Port-forward seam for the k8s deploy target.
|
|
6
|
+
*
|
|
7
|
+
* The provider reaches a ClusterIP Service through `kubectl port-forward`. We
|
|
8
|
+
* shell out to `kubectl` rather than driving `@kubernetes/client-node`'s
|
|
9
|
+
* `PortForward` + a local `net.Server`: a child process is a single injectable
|
|
10
|
+
* seam (mirroring the SDK loader) that tests can fake with no real socket, and
|
|
11
|
+
* it reuses the operator's existing kubeconfig/auth without re-implementing the
|
|
12
|
+
* stream plumbing. The spawn is injectable so the suite stays hermetic.
|
|
13
|
+
*/
|
|
14
|
+
/** A live port-forward tunnel. `localPort` is the port `kubectl` actually bound. */
|
|
15
|
+
interface PortForward {
|
|
16
|
+
readonly localPort: number;
|
|
17
|
+
/** Idempotent: kill the tunnel; safe to call more than once. */
|
|
18
|
+
stop(): void;
|
|
19
|
+
}
|
|
20
|
+
/** Inputs needed to establish a `svc/<name>` forward. */
|
|
21
|
+
interface PortForwardSpec {
|
|
22
|
+
serviceName: string;
|
|
23
|
+
namespace: string;
|
|
24
|
+
targetPort: number;
|
|
25
|
+
/** Local port to bind; `0` (or omitted) lets the OS pick a free one. */
|
|
26
|
+
localPort?: number;
|
|
27
|
+
/** Cap on how long to wait for `kubectl` to report it is forwarding. */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Seam: establish a forward and resolve once it is actually serving locally. */
|
|
31
|
+
type PortForwarder = (spec: PortForwardSpec) => Promise<PortForward>;
|
|
32
|
+
/**
|
|
33
|
+
* Default forwarder: spawn `kubectl port-forward svc/<name> <local>:<target>`
|
|
34
|
+
* and resolve with the bound local port once kubectl reports it is forwarding.
|
|
35
|
+
* Rejects (and kills the child) on early exit, timeout, or a missing binary.
|
|
36
|
+
*/
|
|
37
|
+
declare const defaultPortForwarder: PortForwarder;
|
|
38
|
+
|
|
4
39
|
/**
|
|
5
40
|
* Minimal local interface for `@kubernetes/client-node` — only the members this
|
|
6
41
|
* provider actually calls.
|
|
@@ -101,6 +136,8 @@ type K8sPayload = z.infer<typeof k8sPayloadSchema>;
|
|
|
101
136
|
interface K8sTargetOptions {
|
|
102
137
|
loadSdk?: SdkLoader;
|
|
103
138
|
pollIntervalMs?: number;
|
|
139
|
+
/** Override the `kubectl port-forward` spawn so tests stay hermetic. */
|
|
140
|
+
portForward?: PortForwarder;
|
|
104
141
|
}
|
|
105
142
|
/** Build the Kubernetes deploy target with optional injected seams. */
|
|
106
143
|
declare function makeK8sDeployTarget(opts?: K8sTargetOptions): DeployTarget<K8sConfig, K8sPayload>;
|
|
@@ -132,4 +169,4 @@ declare const k8sDeployTarget: DeployTarget<{
|
|
|
132
169
|
/** Register this plugin's deploy target with a registry. */
|
|
133
170
|
declare function register(registry: PluginRegistry): void;
|
|
134
171
|
|
|
135
|
-
export { type CoreV1Api, type K8sModule, type K8sTargetOptions, type KubeConfig, type SdkLoader, k8sDeployTarget, makeK8sDeployTarget, register };
|
|
172
|
+
export { type CoreV1Api, type K8sModule, type K8sTargetOptions, type KubeConfig, type PortForward, type PortForwardSpec, type PortForwarder, type SdkLoader, defaultPortForwarder, k8sDeployTarget, makeK8sDeployTarget, register };
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,75 @@ import {
|
|
|
6
6
|
} from "@skaile/workspaces/plugin-registry";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
|
|
9
|
+
// src/port-forward.ts
|
|
10
|
+
import { spawn } from "child_process";
|
|
11
|
+
var DEFAULT_FORWARD_TIMEOUT_MS = 3e4;
|
|
12
|
+
var FORWARDING_RE = /Forwarding from (?:127\.0\.0\.1|\[::1\]):(\d+)/;
|
|
13
|
+
var defaultPortForwarder = (spec) => {
|
|
14
|
+
const timeoutMs = spec.timeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS;
|
|
15
|
+
const local = spec.localPort ?? 0;
|
|
16
|
+
const child = spawn(
|
|
17
|
+
"kubectl",
|
|
18
|
+
[
|
|
19
|
+
"port-forward",
|
|
20
|
+
`svc/${spec.serviceName}`,
|
|
21
|
+
`${local}:${spec.targetPort}`,
|
|
22
|
+
"-n",
|
|
23
|
+
spec.namespace
|
|
24
|
+
],
|
|
25
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
26
|
+
);
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let settled = false;
|
|
29
|
+
let stderr = "";
|
|
30
|
+
const cleanup = () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
child.stdout?.removeAllListeners();
|
|
33
|
+
child.stderr?.removeAllListeners();
|
|
34
|
+
};
|
|
35
|
+
const stop = () => {
|
|
36
|
+
try {
|
|
37
|
+
child.kill();
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const fail = (err) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
settled = true;
|
|
44
|
+
cleanup();
|
|
45
|
+
stop();
|
|
46
|
+
reject(err);
|
|
47
|
+
};
|
|
48
|
+
const timer = setTimeout(
|
|
49
|
+
() => fail(new Error(`timed out waiting for kubectl port-forward to bind (${timeoutMs}ms)`)),
|
|
50
|
+
timeoutMs
|
|
51
|
+
);
|
|
52
|
+
child.stdout?.on("data", (chunk) => {
|
|
53
|
+
const m = FORWARDING_RE.exec(chunk.toString());
|
|
54
|
+
if (m && !settled) {
|
|
55
|
+
settled = true;
|
|
56
|
+
cleanup();
|
|
57
|
+
resolve({ localPort: Number(m[1]), stop });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
child.stderr?.on("data", (chunk) => {
|
|
61
|
+
stderr += chunk.toString();
|
|
62
|
+
});
|
|
63
|
+
child.on(
|
|
64
|
+
"error",
|
|
65
|
+
(err) => fail(new Error(`failed to spawn kubectl port-forward: ${err.message}`))
|
|
66
|
+
);
|
|
67
|
+
child.on(
|
|
68
|
+
"exit",
|
|
69
|
+
(code) => fail(
|
|
70
|
+
new Error(
|
|
71
|
+
`kubectl port-forward exited (code=${code ?? "null"}) before forwarding${stderr ? `: ${stderr.trim()}` : ""}`
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
9
78
|
// src/sdk-types.ts
|
|
10
79
|
var defaultSdkLoader = async () => {
|
|
11
80
|
const mod = await import("@kubernetes/client-node");
|
|
@@ -38,17 +107,17 @@ var k8sPayloadSchema = z.object({
|
|
|
38
107
|
});
|
|
39
108
|
var DEFAULT_READY_TIMEOUT_MS = 12e4;
|
|
40
109
|
var DEFAULT_POLL_INTERVAL_MS = 1e3;
|
|
110
|
+
var DEFAULT_TARGET_PORT = 8080;
|
|
41
111
|
async function makeApi(loadSdk) {
|
|
42
112
|
const k8s = await loadSdk();
|
|
43
113
|
const kc = new k8s.KubeConfig();
|
|
44
114
|
kc.loadFromDefault();
|
|
45
115
|
return kc.makeApiClient(k8s.CoreV1Api);
|
|
46
116
|
}
|
|
47
|
-
function buildHandle(api, payload,
|
|
48
|
-
const forwardPort = payload.localForwardPort ?? config.port;
|
|
117
|
+
function buildHandle(api, payload, forward, ctx, ready, initialState) {
|
|
49
118
|
return makeDeployHandle({
|
|
50
|
-
// Reached over
|
|
51
|
-
wsUrl: `ws://127.0.0.1:${
|
|
119
|
+
// Reached over the local port-forward tunnel kubectl established.
|
|
120
|
+
wsUrl: `ws://127.0.0.1:${forward.localPort}`,
|
|
52
121
|
ctx,
|
|
53
122
|
payload,
|
|
54
123
|
initialState,
|
|
@@ -67,6 +136,7 @@ function buildHandle(api, payload, config, ctx, ready, initialState) {
|
|
|
67
136
|
}
|
|
68
137
|
},
|
|
69
138
|
async stop() {
|
|
139
|
+
forward.stop();
|
|
70
140
|
await api.deleteNamespacedService({ name: payload.serviceName, namespace: payload.namespace }).catch(() => {
|
|
71
141
|
});
|
|
72
142
|
await api.deleteNamespacedPod({ name: payload.podName, namespace: payload.namespace }).catch(() => {
|
|
@@ -77,6 +147,7 @@ function buildHandle(api, payload, config, ctx, ready, initialState) {
|
|
|
77
147
|
function makeK8sDeployTarget(opts = {}) {
|
|
78
148
|
const loadSdk = opts.loadSdk ?? defaultSdkLoader;
|
|
79
149
|
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
150
|
+
const portForward = opts.portForward ?? defaultPortForwarder;
|
|
80
151
|
return {
|
|
81
152
|
id: "k8s",
|
|
82
153
|
displayName: "Kubernetes (ClusterIP + port-forward)",
|
|
@@ -116,13 +187,35 @@ function makeK8sDeployTarget(opts = {}) {
|
|
|
116
187
|
ports: [{ port: config.port, targetPort: config.port }]
|
|
117
188
|
}
|
|
118
189
|
};
|
|
119
|
-
|
|
190
|
+
try {
|
|
191
|
+
await api.createNamespacedService({ namespace: config.namespace, body: serviceBody });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
await api.deleteNamespacedPod({ name: podName, namespace: config.namespace }).catch(() => {
|
|
194
|
+
});
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
120
197
|
ctx.log.info("k8s pod + service created", { podName, serviceName });
|
|
198
|
+
let forward;
|
|
199
|
+
try {
|
|
200
|
+
forward = await portForward({
|
|
201
|
+
serviceName,
|
|
202
|
+
namespace: config.namespace,
|
|
203
|
+
targetPort: config.port,
|
|
204
|
+
localPort: config.localForwardPort
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
await api.deleteNamespacedService({ name: serviceName, namespace: config.namespace }).catch(() => {
|
|
208
|
+
});
|
|
209
|
+
await api.deleteNamespacedPod({ name: podName, namespace: config.namespace }).catch(() => {
|
|
210
|
+
});
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
ctx.log.info("k8s port-forward established", { serviceName, localPort: forward.localPort });
|
|
121
214
|
const payload = {
|
|
122
215
|
namespace: config.namespace,
|
|
123
216
|
podName,
|
|
124
217
|
serviceName,
|
|
125
|
-
localForwardPort:
|
|
218
|
+
localForwardPort: forward.localPort
|
|
126
219
|
};
|
|
127
220
|
const ready = (timeoutMs, signal) => pollUntil({
|
|
128
221
|
check: async () => (await api.readNamespacedPod({ name: podName, namespace: config.namespace })).status?.phase === "Running",
|
|
@@ -131,7 +224,7 @@ function makeK8sDeployTarget(opts = {}) {
|
|
|
131
224
|
signal,
|
|
132
225
|
timeoutMessage: `timed out waiting for pod ${podName} to reach Running`
|
|
133
226
|
});
|
|
134
|
-
return buildHandle(api, payload,
|
|
227
|
+
return buildHandle(api, payload, forward, ctx, ready, "starting");
|
|
135
228
|
},
|
|
136
229
|
async restore(payload, ctx) {
|
|
137
230
|
const api = await makeApi(loadSdk);
|
|
@@ -144,8 +237,18 @@ function makeK8sDeployTarget(opts = {}) {
|
|
|
144
237
|
`k8s pod ${payload.podName} is not running (phase=${pod.status?.phase ?? "unknown"})`
|
|
145
238
|
);
|
|
146
239
|
}
|
|
147
|
-
|
|
148
|
-
|
|
240
|
+
const forward = await portForward({
|
|
241
|
+
serviceName: payload.serviceName,
|
|
242
|
+
namespace: payload.namespace,
|
|
243
|
+
targetPort: DEFAULT_TARGET_PORT,
|
|
244
|
+
localPort: payload.localForwardPort
|
|
245
|
+
});
|
|
246
|
+
ctx.log.info("re-attached to k8s pod", {
|
|
247
|
+
podName: payload.podName,
|
|
248
|
+
localPort: forward.localPort
|
|
249
|
+
});
|
|
250
|
+
const restored = { ...payload, localForwardPort: forward.localPort };
|
|
251
|
+
return buildHandle(api, restored, forward, ctx, async () => {
|
|
149
252
|
}, "ready");
|
|
150
253
|
}
|
|
151
254
|
};
|
|
@@ -157,6 +260,7 @@ function register(registry) {
|
|
|
157
260
|
registry.register("deployTarget", k8sDeployTarget);
|
|
158
261
|
}
|
|
159
262
|
export {
|
|
263
|
+
defaultPortForwarder,
|
|
160
264
|
k8sDeployTarget,
|
|
161
265
|
makeK8sDeployTarget,
|
|
162
266
|
register
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/target.ts","../src/sdk-types.ts","../src/index.ts"],"sourcesContent":["/**\n * `k8sDeployTarget` — deploy a workspace as a Pod + ClusterIP Service in\n * Kubernetes, reached via `kubectl port-forward`. `tlsTermination: \"edge\"`\n * (TLS terminates at the cluster ingress; v0.1.0 reaches the service over a\n * local port-forward tunnel).\n *\n * `buildStrategy` defaults to `local` — k8s is bring-your-own-image, so the\n * operator supplies `config.image`. `@kubernetes/client-node` is an *optional*\n * peer, lazy-loaded inside `create()`/`restore()` via an injectable loader so\n * the package builds standalone and the test stays hermetic.\n */\n\nimport {\n buildStrategySchema,\n type DeployContext,\n type DeployHandle,\n type DeployTarget,\n makeDeployHandle,\n pollUntil,\n} from \"@skaile/workspaces/plugin-registry\";\nimport * as z from \"zod\";\nimport { type CoreV1Api, defaultSdkLoader, type SdkLoader } from \"./sdk-types.js\";\n\nconst k8sConfigSchema = z\n .object({\n namespace: z.string().default(\"default\"),\n image: z.string().min(1),\n port: z.number().int().positive().default(8080),\n serviceAccount: z.string().optional(),\n env: z.record(z.string(), z.string()).default({}),\n // v0.1.0 ships ClusterIP + port-forward only; NodePort/LoadBalancer/Ingress\n // are documented as future work.\n serviceType: z.literal(\"ClusterIP\").default(\"ClusterIP\"),\n localForwardPort: z.number().int().positive().optional(),\n buildStrategy: buildStrategySchema.default(\"local\"),\n })\n .strict();\n\ntype K8sConfig = z.infer<typeof k8sConfigSchema>;\n\nconst k8sPayloadSchema = z.object({\n namespace: z.string(),\n podName: z.string(),\n serviceName: z.string(),\n localForwardPort: z.number().optional(),\n});\n\ntype K8sPayload = z.infer<typeof k8sPayloadSchema>;\n\nconst DEFAULT_READY_TIMEOUT_MS = 120_000;\nconst DEFAULT_POLL_INTERVAL_MS = 1_000;\n\n/** Injectable seams so tests never load the real `@kubernetes/client-node`. */\nexport interface K8sTargetOptions {\n loadSdk?: SdkLoader;\n pollIntervalMs?: number;\n}\n\nasync function makeApi(loadSdk: SdkLoader): Promise<CoreV1Api> {\n const k8s = await loadSdk();\n const kc = new k8s.KubeConfig();\n kc.loadFromDefault();\n return kc.makeApiClient(k8s.CoreV1Api);\n}\n\n/**\n * Build the per-target seams `makeDeployHandle` drives. `health` reads the pod\n * phase; `stop` does idempotent teardown only — the factory writes state.\n */\nfunction buildHandle(\n api: CoreV1Api,\n payload: K8sPayload,\n config: Pick<K8sConfig, \"port\">,\n ctx: DeployContext,\n ready: (timeoutMs: number, signal: AbortSignal) => Promise<void>,\n initialState: DeployHandle[\"state\"],\n): DeployHandle<K8sPayload> {\n const forwardPort = payload.localForwardPort ?? config.port;\n return makeDeployHandle<K8sPayload>({\n // Reached over a local port-forward tunnel to the ClusterIP service.\n wsUrl: `ws://127.0.0.1:${forwardPort}`,\n ctx,\n payload,\n initialState,\n defaultReadyTimeoutMs: DEFAULT_READY_TIMEOUT_MS,\n ready,\n async health() {\n try {\n const pod = await api.readNamespacedPod({\n name: payload.podName,\n namespace: payload.namespace,\n });\n const healthy = pod.status?.phase === \"Running\";\n return healthy\n ? { healthy }\n : { healthy, detail: `pod ${payload.podName} phase=${pod.status?.phase ?? \"unknown\"}` };\n } catch (err) {\n return { healthy: false, detail: (err as Error).message };\n }\n },\n async stop() {\n // Idempotent teardown: ignore 404s when the objects are already gone.\n await api\n .deleteNamespacedService({ name: payload.serviceName, namespace: payload.namespace })\n .catch(() => {});\n await api\n .deleteNamespacedPod({ name: payload.podName, namespace: payload.namespace })\n .catch(() => {});\n },\n });\n}\n\n/** Build the Kubernetes deploy target with optional injected seams. */\nexport function makeK8sDeployTarget(\n opts: K8sTargetOptions = {},\n): DeployTarget<K8sConfig, K8sPayload> {\n const loadSdk = opts.loadSdk ?? defaultSdkLoader;\n const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n\n return {\n id: \"k8s\",\n displayName: \"Kubernetes (ClusterIP + port-forward)\",\n apiVersion: 1,\n tlsTermination: \"edge\",\n configSchema: k8sConfigSchema as unknown as z.ZodType<K8sConfig>,\n payloadSchema: k8sPayloadSchema,\n\n async create(config, ctx) {\n const api = await makeApi(loadSdk);\n const podName = `skaile-ws-${ctx.workspaceId}`;\n const serviceName = `skaile-ws-${ctx.workspaceId}-svc`;\n const image = config.image ?? ctx.imageRef;\n if (!image) throw new Error(\"k8s deploy requires an image (buildStrategy is local)\");\n\n const podBody = {\n metadata: { name: podName, namespace: config.namespace, labels: { app: podName } },\n spec: {\n serviceAccountName: config.serviceAccount,\n containers: [\n {\n name: \"workspace\",\n image,\n ports: [{ containerPort: config.port }],\n env: Object.entries({ ...config.env, ...ctx.env }).map(([name, value]) => ({\n name,\n value,\n })),\n },\n ],\n },\n };\n await api.createNamespacedPod({ namespace: config.namespace, body: podBody });\n\n const serviceBody = {\n metadata: { name: serviceName, namespace: config.namespace },\n spec: {\n type: config.serviceType,\n selector: { app: podName },\n ports: [{ port: config.port, targetPort: config.port }],\n },\n };\n await api.createNamespacedService({ namespace: config.namespace, body: serviceBody });\n ctx.log.info(\"k8s pod + service created\", { podName, serviceName });\n\n const payload: K8sPayload = {\n namespace: config.namespace,\n podName,\n serviceName,\n localForwardPort: config.localForwardPort,\n };\n const ready = (timeoutMs: number, signal: AbortSignal) =>\n pollUntil({\n check: async () =>\n (await api.readNamespacedPod({ name: podName, namespace: config.namespace })).status\n ?.phase === \"Running\",\n timeoutMs,\n intervalMs: pollIntervalMs,\n signal,\n timeoutMessage: `timed out waiting for pod ${podName} to reach Running`,\n });\n return buildHandle(api, payload, config, ctx, ready, \"starting\");\n },\n\n async restore(payload, ctx) {\n const api = await makeApi(loadSdk);\n const pod = await api.readNamespacedPod({\n name: payload.podName,\n namespace: payload.namespace,\n });\n if (pod.status?.phase !== \"Running\") {\n throw new Error(\n `k8s pod ${payload.podName} is not running (phase=${pod.status?.phase ?? \"unknown\"})`,\n );\n }\n ctx.log.info(\"re-attached to k8s pod\", { podName: payload.podName });\n return buildHandle(api, payload, { port: 8080 }, ctx, async () => {}, \"ready\");\n },\n };\n}\n\n/** Default Kubernetes deploy target registered by `register()`. */\nexport const k8sDeployTarget = makeK8sDeployTarget();\n","/**\n * Minimal local interface for `@kubernetes/client-node` — only the members this\n * provider actually calls.\n *\n * The SDK is an *optional* peer dependency, lazy-loaded via a string-literal\n * dynamic `import()`. Typing against this minimal surface (rather than the real\n * package's types) keeps the package building + typechecking standalone\n * offline; the host project supplies the real SDK at runtime.\n */\n\n/** A `V1Pod`-ish object (subset we read). */\nexport interface K8sPod {\n metadata?: { name?: string; namespace?: string };\n status?: { phase?: string };\n}\n\n/** A `V1Service`-ish object (subset we read). */\nexport interface K8sService {\n metadata?: { name?: string; namespace?: string };\n}\n\n/** `CoreV1Api` subset. */\nexport interface CoreV1Api {\n createNamespacedPod(arg: { namespace: string; body: unknown }): Promise<K8sPod>;\n readNamespacedPod(arg: { name: string; namespace: string }): Promise<K8sPod>;\n deleteNamespacedPod(arg: { name: string; namespace: string }): Promise<unknown>;\n createNamespacedService(arg: { namespace: string; body: unknown }): Promise<K8sService>;\n deleteNamespacedService(arg: { name: string; namespace: string }): Promise<unknown>;\n}\n\n/** `KubeConfig` subset. */\nexport interface KubeConfig {\n loadFromDefault(): void;\n // biome-ignore lint/suspicious/noExplicitAny: api ctor passed through opaquely\n makeApiClient(api: any): CoreV1Api;\n}\n\n/** The `@kubernetes/client-node` module surface we depend on. */\nexport interface K8sModule {\n KubeConfig: new () => KubeConfig;\n CoreV1Api: unknown;\n}\n\n/** Loader seam: returns the SDK module. Default does a real dynamic import. */\nexport type SdkLoader = () => Promise<K8sModule>;\n\n/**\n * Default loader: lazy `import(\"@kubernetes/client-node\")`. The string literal\n * keeps bundlers from eagerly resolving the optional peer; only evaluated when\n * `create()`/`restore()` run.\n */\nexport const defaultSdkLoader: SdkLoader = async () => {\n const mod = (await import(\"@kubernetes/client-node\" as string)) as unknown as K8sModule;\n if (!mod?.KubeConfig) {\n throw new Error(\n \"@kubernetes/client-node is not installed. Run `skaile plugin install @skaile/provider-k8s` and add @kubernetes/client-node to your project.\",\n );\n }\n return mod;\n};\n","/**\n * `@skaile/provider-k8s` — Kubernetes deploy target for `@skaile/workspaces`.\n *\n * v0.1.0 ships ClusterIP + port-forward only. Requires the optional peer\n * `@kubernetes/client-node` to be installed in the host project (lazy-loaded at\n * runtime). Install via `skaile plugin install @skaile/provider-k8s`.\n */\n\nimport type { PluginRegistry } from \"@skaile/workspaces/plugin-registry\";\nimport { k8sDeployTarget } from \"./target.js\";\n\nexport { k8sDeployTarget, makeK8sDeployTarget } from \"./target.js\";\nexport type { K8sTargetOptions } from \"./target.js\";\nexport type { CoreV1Api, K8sModule, KubeConfig, SdkLoader } from \"./sdk-types.js\";\n\n/** Register this plugin's deploy target with a registry. */\nexport function register(registry: PluginRegistry): void {\n registry.register(\"deployTarget\", k8sDeployTarget);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/target.ts","../src/port-forward.ts","../src/sdk-types.ts","../src/index.ts"],"sourcesContent":["/**\n * `k8sDeployTarget` — deploy a workspace as a Pod + ClusterIP Service in\n * Kubernetes, reached via `kubectl port-forward`. `tlsTermination: \"edge\"`\n * (TLS terminates at the cluster ingress; v0.1.0 reaches the service over a\n * local port-forward tunnel).\n *\n * `buildStrategy` defaults to `local` — k8s is bring-your-own-image, so the\n * operator supplies `config.image`. `@kubernetes/client-node` is an *optional*\n * peer, lazy-loaded inside `create()`/`restore()` via an injectable loader so\n * the package builds standalone and the test stays hermetic.\n */\n\nimport {\n buildStrategySchema,\n type DeployContext,\n type DeployHandle,\n type DeployTarget,\n makeDeployHandle,\n pollUntil,\n} from \"@skaile/workspaces/plugin-registry\";\nimport * as z from \"zod\";\nimport { defaultPortForwarder, type PortForward, type PortForwarder } from \"./port-forward.js\";\nimport { type CoreV1Api, defaultSdkLoader, type SdkLoader } from \"./sdk-types.js\";\n\nconst k8sConfigSchema = z\n .object({\n namespace: z.string().default(\"default\"),\n image: z.string().min(1),\n port: z.number().int().positive().default(8080),\n serviceAccount: z.string().optional(),\n env: z.record(z.string(), z.string()).default({}),\n // v0.1.0 ships ClusterIP + port-forward only; NodePort/LoadBalancer/Ingress\n // are documented as future work.\n serviceType: z.literal(\"ClusterIP\").default(\"ClusterIP\"),\n localForwardPort: z.number().int().positive().optional(),\n buildStrategy: buildStrategySchema.default(\"local\"),\n })\n .strict();\n\ntype K8sConfig = z.infer<typeof k8sConfigSchema>;\n\nconst k8sPayloadSchema = z.object({\n namespace: z.string(),\n podName: z.string(),\n serviceName: z.string(),\n localForwardPort: z.number().optional(),\n});\n\ntype K8sPayload = z.infer<typeof k8sPayloadSchema>;\n\nconst DEFAULT_READY_TIMEOUT_MS = 120_000;\nconst DEFAULT_POLL_INTERVAL_MS = 1_000;\n// Container port to forward to on restore; the payload doesn't carry it, so we\n// fall back to the config schema's default (k8s is ClusterIP, port-only).\nconst DEFAULT_TARGET_PORT = 8080;\n\n/** Injectable seams so tests never load the real `@kubernetes/client-node`. */\nexport interface K8sTargetOptions {\n loadSdk?: SdkLoader;\n pollIntervalMs?: number;\n /** Override the `kubectl port-forward` spawn so tests stay hermetic. */\n portForward?: PortForwarder;\n}\n\nasync function makeApi(loadSdk: SdkLoader): Promise<CoreV1Api> {\n const k8s = await loadSdk();\n const kc = new k8s.KubeConfig();\n kc.loadFromDefault();\n return kc.makeApiClient(k8s.CoreV1Api);\n}\n\n/**\n * Build the per-target seams `makeDeployHandle` drives. `health` reads the pod\n * phase; `stop` does idempotent teardown only — the factory writes state.\n * `wsUrl` reflects the port `kubectl` actually bound, and `stop` kills the\n * tunnel alongside the cluster objects.\n */\nfunction buildHandle(\n api: CoreV1Api,\n payload: K8sPayload,\n forward: PortForward,\n ctx: DeployContext,\n ready: (timeoutMs: number, signal: AbortSignal) => Promise<void>,\n initialState: DeployHandle[\"state\"],\n): DeployHandle<K8sPayload> {\n return makeDeployHandle<K8sPayload>({\n // Reached over the local port-forward tunnel kubectl established.\n wsUrl: `ws://127.0.0.1:${forward.localPort}`,\n ctx,\n payload,\n initialState,\n defaultReadyTimeoutMs: DEFAULT_READY_TIMEOUT_MS,\n ready,\n async health() {\n try {\n const pod = await api.readNamespacedPod({\n name: payload.podName,\n namespace: payload.namespace,\n });\n const healthy = pod.status?.phase === \"Running\";\n return healthy\n ? { healthy }\n : { healthy, detail: `pod ${payload.podName} phase=${pod.status?.phase ?? \"unknown\"}` };\n } catch (err) {\n return { healthy: false, detail: (err as Error).message };\n }\n },\n async stop() {\n // Kill the tunnel first so no client lingers on a soon-to-be-deleted svc.\n forward.stop();\n // Idempotent teardown: ignore 404s when the objects are already gone.\n await api\n .deleteNamespacedService({ name: payload.serviceName, namespace: payload.namespace })\n .catch(() => {});\n await api\n .deleteNamespacedPod({ name: payload.podName, namespace: payload.namespace })\n .catch(() => {});\n },\n });\n}\n\n/** Build the Kubernetes deploy target with optional injected seams. */\nexport function makeK8sDeployTarget(\n opts: K8sTargetOptions = {},\n): DeployTarget<K8sConfig, K8sPayload> {\n const loadSdk = opts.loadSdk ?? defaultSdkLoader;\n const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const portForward = opts.portForward ?? defaultPortForwarder;\n\n return {\n id: \"k8s\",\n displayName: \"Kubernetes (ClusterIP + port-forward)\",\n apiVersion: 1,\n tlsTermination: \"edge\",\n configSchema: k8sConfigSchema as unknown as z.ZodType<K8sConfig>,\n payloadSchema: k8sPayloadSchema,\n\n async create(config, ctx) {\n const api = await makeApi(loadSdk);\n const podName = `skaile-ws-${ctx.workspaceId}`;\n const serviceName = `skaile-ws-${ctx.workspaceId}-svc`;\n const image = config.image ?? ctx.imageRef;\n if (!image) throw new Error(\"k8s deploy requires an image (buildStrategy is local)\");\n\n const podBody = {\n metadata: { name: podName, namespace: config.namespace, labels: { app: podName } },\n spec: {\n serviceAccountName: config.serviceAccount,\n containers: [\n {\n name: \"workspace\",\n image,\n ports: [{ containerPort: config.port }],\n env: Object.entries({ ...config.env, ...ctx.env }).map(([name, value]) => ({\n name,\n value,\n })),\n },\n ],\n },\n };\n await api.createNamespacedPod({ namespace: config.namespace, body: podBody });\n\n const serviceBody = {\n metadata: { name: serviceName, namespace: config.namespace },\n spec: {\n type: config.serviceType,\n selector: { app: podName },\n ports: [{ port: config.port, targetPort: config.port }],\n },\n };\n try {\n await api.createNamespacedService({ namespace: config.namespace, body: serviceBody });\n } catch (err) {\n // Service create failed after the pod was created: best-effort delete the\n // orphaned pod (ignore 404s, mirroring stop()) before propagating.\n await api\n .deleteNamespacedPod({ name: podName, namespace: config.namespace })\n .catch(() => {});\n throw err;\n }\n ctx.log.info(\"k8s pod + service created\", { podName, serviceName });\n\n // Establish the tunnel now so wsUrl reflects the port kubectl actually\n // bound. A forward failure must not leak the pod/service we just created.\n let forward: PortForward;\n try {\n forward = await portForward({\n serviceName,\n namespace: config.namespace,\n targetPort: config.port,\n localPort: config.localForwardPort,\n });\n } catch (err) {\n await api\n .deleteNamespacedService({ name: serviceName, namespace: config.namespace })\n .catch(() => {});\n await api\n .deleteNamespacedPod({ name: podName, namespace: config.namespace })\n .catch(() => {});\n throw err;\n }\n ctx.log.info(\"k8s port-forward established\", { serviceName, localPort: forward.localPort });\n\n const payload: K8sPayload = {\n namespace: config.namespace,\n podName,\n serviceName,\n localForwardPort: forward.localPort,\n };\n const ready = (timeoutMs: number, signal: AbortSignal) =>\n pollUntil({\n check: async () =>\n (await api.readNamespacedPod({ name: podName, namespace: config.namespace })).status\n ?.phase === \"Running\",\n timeoutMs,\n intervalMs: pollIntervalMs,\n signal,\n timeoutMessage: `timed out waiting for pod ${podName} to reach Running`,\n });\n return buildHandle(api, payload, forward, ctx, ready, \"starting\");\n },\n\n async restore(payload, ctx) {\n const api = await makeApi(loadSdk);\n const pod = await api.readNamespacedPod({\n name: payload.podName,\n namespace: payload.namespace,\n });\n if (pod.status?.phase !== \"Running\") {\n throw new Error(\n `k8s pod ${payload.podName} is not running (phase=${pod.status?.phase ?? \"unknown\"})`,\n );\n }\n // The forward is a local child process that doesn't survive a restart, so\n // re-establish it. Prefer the previously bound port for a stable wsUrl.\n const forward = await portForward({\n serviceName: payload.serviceName,\n namespace: payload.namespace,\n targetPort: DEFAULT_TARGET_PORT,\n localPort: payload.localForwardPort,\n });\n ctx.log.info(\"re-attached to k8s pod\", {\n podName: payload.podName,\n localPort: forward.localPort,\n });\n const restored: K8sPayload = { ...payload, localForwardPort: forward.localPort };\n return buildHandle(api, restored, forward, ctx, async () => {}, \"ready\");\n },\n };\n}\n\n/** Default Kubernetes deploy target registered by `register()`. */\nexport const k8sDeployTarget = makeK8sDeployTarget();\n","/**\n * Port-forward seam for the k8s deploy target.\n *\n * The provider reaches a ClusterIP Service through `kubectl port-forward`. We\n * shell out to `kubectl` rather than driving `@kubernetes/client-node`'s\n * `PortForward` + a local `net.Server`: a child process is a single injectable\n * seam (mirroring the SDK loader) that tests can fake with no real socket, and\n * it reuses the operator's existing kubeconfig/auth without re-implementing the\n * stream plumbing. The spawn is injectable so the suite stays hermetic.\n */\n\nimport { spawn } from \"node:child_process\";\n\n/** A live port-forward tunnel. `localPort` is the port `kubectl` actually bound. */\nexport interface PortForward {\n readonly localPort: number;\n /** Idempotent: kill the tunnel; safe to call more than once. */\n stop(): void;\n}\n\n/** Inputs needed to establish a `svc/<name>` forward. */\nexport interface PortForwardSpec {\n serviceName: string;\n namespace: string;\n targetPort: number;\n /** Local port to bind; `0` (or omitted) lets the OS pick a free one. */\n localPort?: number;\n /** Cap on how long to wait for `kubectl` to report it is forwarding. */\n timeoutMs?: number;\n}\n\n/** Seam: establish a forward and resolve once it is actually serving locally. */\nexport type PortForwarder = (spec: PortForwardSpec) => Promise<PortForward>;\n\nconst DEFAULT_FORWARD_TIMEOUT_MS = 30_000;\n// kubectl prints e.g. \"Forwarding from 127.0.0.1:54321 -> 8080\" once bound.\nconst FORWARDING_RE = /Forwarding from (?:127\\.0\\.0\\.1|\\[::1\\]):(\\d+)/;\n\n/**\n * Default forwarder: spawn `kubectl port-forward svc/<name> <local>:<target>`\n * and resolve with the bound local port once kubectl reports it is forwarding.\n * Rejects (and kills the child) on early exit, timeout, or a missing binary.\n */\nexport const defaultPortForwarder: PortForwarder = (spec) => {\n const timeoutMs = spec.timeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS;\n const local = spec.localPort ?? 0;\n const child = spawn(\n \"kubectl\",\n [\n \"port-forward\",\n `svc/${spec.serviceName}`,\n `${local}:${spec.targetPort}`,\n \"-n\",\n spec.namespace,\n ],\n { stdio: [\"ignore\", \"pipe\", \"pipe\"] },\n );\n\n return new Promise<PortForward>((resolve, reject) => {\n let settled = false;\n let stderr = \"\";\n\n const cleanup = () => {\n clearTimeout(timer);\n child.stdout?.removeAllListeners();\n child.stderr?.removeAllListeners();\n };\n const stop = () => {\n try {\n child.kill();\n } catch {\n /* already gone */\n }\n };\n const fail = (err: Error) => {\n if (settled) return;\n settled = true;\n cleanup();\n stop();\n reject(err);\n };\n\n const timer = setTimeout(\n () => fail(new Error(`timed out waiting for kubectl port-forward to bind (${timeoutMs}ms)`)),\n timeoutMs,\n );\n\n child.stdout?.on(\"data\", (chunk: Buffer) => {\n const m = FORWARDING_RE.exec(chunk.toString());\n if (m && !settled) {\n settled = true;\n cleanup();\n resolve({ localPort: Number(m[1]), stop });\n }\n });\n child.stderr?.on(\"data\", (chunk: Buffer) => {\n stderr += chunk.toString();\n });\n child.on(\"error\", (err) =>\n fail(new Error(`failed to spawn kubectl port-forward: ${err.message}`)),\n );\n child.on(\"exit\", (code) =>\n fail(\n new Error(\n `kubectl port-forward exited (code=${code ?? \"null\"}) before forwarding${stderr ? `: ${stderr.trim()}` : \"\"}`,\n ),\n ),\n );\n });\n};\n","/**\n * Minimal local interface for `@kubernetes/client-node` — only the members this\n * provider actually calls.\n *\n * The SDK is an *optional* peer dependency, lazy-loaded via a string-literal\n * dynamic `import()`. Typing against this minimal surface (rather than the real\n * package's types) keeps the package building + typechecking standalone\n * offline; the host project supplies the real SDK at runtime.\n */\n\n/** A `V1Pod`-ish object (subset we read). */\nexport interface K8sPod {\n metadata?: { name?: string; namespace?: string };\n status?: { phase?: string };\n}\n\n/** A `V1Service`-ish object (subset we read). */\nexport interface K8sService {\n metadata?: { name?: string; namespace?: string };\n}\n\n/** `CoreV1Api` subset. */\nexport interface CoreV1Api {\n createNamespacedPod(arg: { namespace: string; body: unknown }): Promise<K8sPod>;\n readNamespacedPod(arg: { name: string; namespace: string }): Promise<K8sPod>;\n deleteNamespacedPod(arg: { name: string; namespace: string }): Promise<unknown>;\n createNamespacedService(arg: { namespace: string; body: unknown }): Promise<K8sService>;\n deleteNamespacedService(arg: { name: string; namespace: string }): Promise<unknown>;\n}\n\n/** `KubeConfig` subset. */\nexport interface KubeConfig {\n loadFromDefault(): void;\n // biome-ignore lint/suspicious/noExplicitAny: api ctor passed through opaquely\n makeApiClient(api: any): CoreV1Api;\n}\n\n/** The `@kubernetes/client-node` module surface we depend on. */\nexport interface K8sModule {\n KubeConfig: new () => KubeConfig;\n CoreV1Api: unknown;\n}\n\n/** Loader seam: returns the SDK module. Default does a real dynamic import. */\nexport type SdkLoader = () => Promise<K8sModule>;\n\n/**\n * Default loader: lazy `import(\"@kubernetes/client-node\")`. The string literal\n * keeps bundlers from eagerly resolving the optional peer; only evaluated when\n * `create()`/`restore()` run.\n */\nexport const defaultSdkLoader: SdkLoader = async () => {\n const mod = (await import(\"@kubernetes/client-node\" as string)) as unknown as K8sModule;\n if (!mod?.KubeConfig) {\n throw new Error(\n \"@kubernetes/client-node is not installed. Run `skaile plugin install @skaile/provider-k8s` and add @kubernetes/client-node to your project.\",\n );\n }\n return mod;\n};\n","/**\n * `@skaile/provider-k8s` — Kubernetes deploy target for `@skaile/workspaces`.\n *\n * v0.1.0 ships ClusterIP + port-forward only. Requires the optional peer\n * `@kubernetes/client-node` to be installed in the host project (lazy-loaded at\n * runtime). Install via `skaile plugin install @skaile/provider-k8s`.\n */\n\nimport type { PluginRegistry } from \"@skaile/workspaces/plugin-registry\";\nimport { k8sDeployTarget } from \"./target.js\";\n\nexport type { PortForward, PortForwarder, PortForwardSpec } from \"./port-forward.js\";\nexport { defaultPortForwarder } from \"./port-forward.js\";\nexport type { CoreV1Api, K8sModule, KubeConfig, SdkLoader } from \"./sdk-types.js\";\nexport type { K8sTargetOptions } from \"./target.js\";\nexport { k8sDeployTarget, makeK8sDeployTarget } from \"./target.js\";\n\n/** Register this plugin's deploy target with a registry. */\nexport function register(registry: PluginRegistry): void {\n registry.register(\"deployTarget\", k8sDeployTarget);\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaile/provider-k8s",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Kubernetes (ClusterIP + port-forward) deploy target for @skaile/workspaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"node": ">=20"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@skaile/workspaces": "
|
|
25
|
+
"@skaile/workspaces": "^6.0.0",
|
|
26
26
|
"@kubernetes/client-node": ">=0.20.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependenciesMeta": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@skaile/workspaces": "^0.
|
|
34
|
+
"@skaile/workspaces": "^6.0.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsup",
|