@cat-factory/cli 0.10.5 → 0.11.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 +8 -0
- package/dist/args.d.ts +16 -1
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +44 -14
- package/dist/args.js.map +1 -1
- package/dist/host-shell.d.ts +26 -0
- package/dist/host-shell.d.ts.map +1 -1
- package/dist/host-shell.js +19 -0
- package/dist/host-shell.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/k3s-handler.d.ts +35 -20
- package/dist/k3s-handler.d.ts.map +1 -1
- package/dist/k3s-handler.js +54 -26
- package/dist/k3s-handler.js.map +1 -1
- package/dist/k3s-ingress.d.ts +259 -0
- package/dist/k3s-ingress.d.ts.map +1 -0
- package/dist/k3s-ingress.js +372 -0
- package/dist/k3s-ingress.js.map +1 -0
- package/dist/k3s-probe.d.ts +38 -3
- package/dist/k3s-probe.d.ts.map +1 -1
- package/dist/k3s-probe.js +93 -5
- package/dist/k3s-probe.js.map +1 -1
- package/dist/k3s-provision.d.ts +92 -18
- package/dist/k3s-provision.d.ts.map +1 -1
- package/dist/k3s-provision.js +280 -28
- package/dist/k3s-provision.js.map +1 -1
- package/dist/k3s.d.ts +3 -0
- package/dist/k3s.d.ts.map +1 -1
- package/dist/k3s.js +149 -24
- package/dist/k3s.js.map +1 -1
- package/package.json +3 -3
package/dist/k3s-handler.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"k3s-handler.js","sourceRoot":"","sources":["../src/k3s-handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAA2B,MAAM,oBAAoB,CAAA;AAE5D;;;;;GAKG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,UAAU,CAAA;AAEzD,qGAAqG;AACrG,MAAM,CAAC,MAAM,0BAA0B,GAAG,uBAAuB,CAAA;
|
|
1
|
+
{"version":3,"file":"k3s-handler.js","sourceRoot":"","sources":["../src/k3s-handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACtE,OAAO,EAA2B,MAAM,oBAAoB,CAAA;AAE5D;;;;;GAKG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,UAAU,CAAA;AAEzD,qGAAqG;AACrG,MAAM,CAAC,MAAM,0BAA0B,GAAG,uBAAuB,CAAA;AA2BjE,qGAAqG;AACrG,MAAM,UAAU,YAAY,CAAC,UAA8B;IACzD,OAAO,UAAU,CAAC,WAAW,CAAC,CAAC,CAAC,cAAc,UAAU,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC,WAAW,CAAA;AACvF,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,eAAe,CAAC,UAA8B;IAC5D,MAAM,YAAY,GAAG,mBAAmB,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;IAC5D,IAAI,YAAY,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IACtC,MAAM,IAAI,GAAG,cAAc,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;IAC/C,OAAO;QACL,aAAa,EAAE,YAAY;QAC3B,MAAM,EAAE;YACN,MAAM,EAAE,WAAW;YACnB,UAAU,EAAE;gBACV,KAAK,EAAE,YAAY,CAAC,UAAU,CAAC;gBAC/B,YAAY,EAAE,UAAU,CAAC,YAAY;gBACrC,qBAAqB,EAAE,IAAI;gBAC3B,iBAAiB,EAAE,0BAA0B;gBAC7C,GAAG,EAAE;oBACH,MAAM,EAAE,iBAAiB;oBACzB,YAAY;oBACZ,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;oBAClC,uFAAuF;oBACvF,sFAAsF;oBACtF,qFAAqF;oBACrF,MAAM,EAAE,MAAM;iBACf;aACF;SACF;QACD,OAAO,EAAE,EAAE,CAAC,+BAA+B,CAAC,EAAE,UAAU,CAAC,QAAQ,EAAE;KACpE,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAAC,UAAkB,EAAE,UAA8B;IACjF,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,CAAA;IAC/B,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,CAAA;IAC/B,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,WAAW,CAAC,CAAA;IACrC,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,YAAY,CAAC,UAAU,CAAC,CAAC,CAAA;IAC7C,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,UAAU,CAAC,YAAY,CAAC,CAAA;IACnD,MAAM,CAAC,GAAG,CAAC,mBAAmB,EAAE,0BAA0B,CAAC,CAAA;IAC3D,MAAM,OAAO,GAAG,eAAe,CAAC,UAAU,CAAC,CAAA;IAC3C,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAA;QACvC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,CAAC,CAAC,YAAY,CAAC,CAAA;QAC1C,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;QAC9B,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS;YAAE,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAA;IACrE,CAAC;IACD,IAAI,UAAU,CAAC,qBAAqB;QAAE,MAAM,CAAC,GAAG,CAAC,uBAAuB,EAAE,GAAG,CAAC,CAAA;IAC9E,OAAO,GAAG,CAAC,QAAQ,EAAE,CAAA;AACvB,CAAC"}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { type Command, type HostShell, type ShellResult } from './host-shell.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether an environment URL derived from an ingress HOST TEMPLATE can actually be served, which
|
|
4
|
+
* takes two independent things and not one:
|
|
5
|
+
*
|
|
6
|
+
* 1. an ingress CONTROLLER inside the cluster (an `IngressClass` is its observable trace), and
|
|
7
|
+
* 2. a HOST PORT published into it, because every local distribution runs the cluster inside
|
|
8
|
+
* Docker and forwards only the ports it was asked for at create time.
|
|
9
|
+
*
|
|
10
|
+
* Both were previously assumed. `cat-factory k3s` printed `{{branch}}.127.0.0.1.nip.io` as wired
|
|
11
|
+
* on every path, including a reused cluster it had never looked at, and the failure landed at the
|
|
12
|
+
* `tester` step against a URL that answered nothing: environment readiness is WORKLOAD readiness,
|
|
13
|
+
* so provisioning still reported success.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Default host port published into the ingress controller's plain-HTTP entrypoint.
|
|
17
|
+
*
|
|
18
|
+
* ONE port is published and it maps to the controller's plain-HTTP entrypoint, so `http` is the
|
|
19
|
+
* scheme that port serves and the only one the CLI can claim. Publishing the TLS entrypoint
|
|
20
|
+
* instead would trade a connection error for a CERTIFICATE error, because a local ingress
|
|
21
|
+
* controller serves a self-signed default cert: a worse version of the failure this module exists
|
|
22
|
+
* to remove, since it surfaces at the tester rather than here.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_INGRESS_PORT = 80;
|
|
25
|
+
/**
|
|
26
|
+
* The host the rendered template resolves to. `nip.io` is wildcard DNS that maps
|
|
27
|
+
* `<anything>.127.0.0.1.nip.io` to loopback with no local DNS setup, so the port probe below
|
|
28
|
+
* targets loopback directly rather than resolving a sample hostname.
|
|
29
|
+
*/
|
|
30
|
+
export declare const INGRESS_PROBE_HOST = "127.0.0.1";
|
|
31
|
+
/**
|
|
32
|
+
* The ingress HOST template, with no port in it. It is deliberately portless: the rendered value
|
|
33
|
+
* is also the Ingress `spec.rules[].host` a service's manifests declare, and Kubernetes rejects a
|
|
34
|
+
* `host` carrying a port. A non-default host port travels as the URL source's own `port` field
|
|
35
|
+
* instead (see {@link ingressUrlPort}).
|
|
36
|
+
*/
|
|
37
|
+
export declare const INGRESS_HOST_TEMPLATE = "{{branch}}.127.0.0.1.nip.io";
|
|
38
|
+
/** The container port an ingress controller serves plain HTTP on (Traefik `web`, nginx `http`). */
|
|
39
|
+
export declare const INGRESS_CONTAINER_HTTP_PORT = 80;
|
|
40
|
+
/** What a TCP connect attempt established. `unknown` is NOT `closed`: see {@link classifyIngress}. */
|
|
41
|
+
export type PortState = 'open' | 'closed' | 'unknown';
|
|
42
|
+
/** Which half of the ingress path is missing. Two halves, two different fixes. */
|
|
43
|
+
export type IngressGap = 'controller' | 'hostPort';
|
|
44
|
+
/**
|
|
45
|
+
* WHY a probe could not answer. Four causes that need four different things done about them, kept
|
|
46
|
+
* apart rather than folded into one "could not read the cluster": telling an operator with no
|
|
47
|
+
* `kubectl` on PATH to "re-run once the cluster has settled" is advice for a problem they do not
|
|
48
|
+
* have.
|
|
49
|
+
*/
|
|
50
|
+
export type IngressProbeCause = 'kubectl-missing' | 'cluster-unreachable' | 'cluster-refused' | 'unparseable' | 'host-port-filtered';
|
|
51
|
+
/**
|
|
52
|
+
* What established that the answering host port belongs to THIS cluster.
|
|
53
|
+
*
|
|
54
|
+
* A TCP connect proves only that something listens: an unrelated web server on port 80 answers it
|
|
55
|
+
* exactly as an ingress controller does, which is how a cluster with no published port could be
|
|
56
|
+
* reported as ready. `cluster` means the container runtime confirmed the cluster publishes its
|
|
57
|
+
* controller entrypoint on that host port; `unattributed` means the port answers and the check
|
|
58
|
+
* could not run, which the summary says out loud rather than claiming the stronger fact.
|
|
59
|
+
*/
|
|
60
|
+
export type PortAttribution = 'cluster' | 'unattributed';
|
|
61
|
+
/**
|
|
62
|
+
* The three-state verdict. `unknown` is its own state rather than folded into `missing` for the
|
|
63
|
+
* reason the acceptance suite's `preflight.ts` states: a probe that could not read an answer has
|
|
64
|
+
* not established the negative either, and reporting one as the other sends an operator to fix a
|
|
65
|
+
* cluster that was fine.
|
|
66
|
+
*/
|
|
67
|
+
export type IngressReadiness = {
|
|
68
|
+
status: 'ready';
|
|
69
|
+
port: number;
|
|
70
|
+
controller: string;
|
|
71
|
+
attribution: PortAttribution;
|
|
72
|
+
} | {
|
|
73
|
+
status: 'missing';
|
|
74
|
+
port: number;
|
|
75
|
+
gaps: readonly IngressGap[];
|
|
76
|
+
/**
|
|
77
|
+
* The host port the cluster DOES publish its controller on, when the runtime reported one
|
|
78
|
+
* that is not the requested port. It turns "nothing serves 8080" into "the cluster serves
|
|
79
|
+
* 18080", which is a different (and much shorter) fix.
|
|
80
|
+
*/
|
|
81
|
+
publishedOn?: number;
|
|
82
|
+
} | {
|
|
83
|
+
status: 'unknown';
|
|
84
|
+
port: number;
|
|
85
|
+
cause: IngressProbeCause;
|
|
86
|
+
probeFailure: string;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The outcome of reading the cluster's `IngressClass` list: the names, or the CAUSE that stopped
|
|
90
|
+
* it. A bare `null` for "no answer" is what collapsed a missing binary, an unreachable apiserver,
|
|
91
|
+
* an RBAC refusal and a garbled payload into one message.
|
|
92
|
+
*/
|
|
93
|
+
export type IngressClassRead = {
|
|
94
|
+
ok: true;
|
|
95
|
+
controllers: readonly string[];
|
|
96
|
+
} | {
|
|
97
|
+
ok: false;
|
|
98
|
+
cause: IngressProbeCause;
|
|
99
|
+
detail: string;
|
|
100
|
+
};
|
|
101
|
+
/** Whether the cluster publishes its controller entrypoint on the host, and where. */
|
|
102
|
+
export type PortPublication = {
|
|
103
|
+
checked: true;
|
|
104
|
+
hostPorts: readonly number[];
|
|
105
|
+
}
|
|
106
|
+
/** The check could not run (no such container, no Docker, not a Docker-hosted cluster). */
|
|
107
|
+
| {
|
|
108
|
+
checked: false;
|
|
109
|
+
};
|
|
110
|
+
/** The raw facts {@link classifyIngress} reduces. Kept as plain data so the reduction is pure. */
|
|
111
|
+
export interface IngressFacts {
|
|
112
|
+
port: number;
|
|
113
|
+
classes: IngressClassRead;
|
|
114
|
+
hostPort: PortState;
|
|
115
|
+
publication: PortPublication;
|
|
116
|
+
}
|
|
117
|
+
/** `kubectl get ingressclass -o json`, targeting `context` when one is supplied. */
|
|
118
|
+
export declare function listIngressClassesCommand(context?: string): Command;
|
|
119
|
+
/**
|
|
120
|
+
* `docker port <cluster container>`: every host port the cluster's own container forwards. This is
|
|
121
|
+
* the ONLY check that attributes an answering host port to the cluster rather than to whatever
|
|
122
|
+
* else may be bound there, and it is the same one used by hand to establish that a default k3d
|
|
123
|
+
* create publishes nothing but the apiserver.
|
|
124
|
+
*
|
|
125
|
+
* The container is the one each distribution puts the forward on: k3d's load balancer, kind's
|
|
126
|
+
* control-plane node. The whole table is asked for rather than one port, because a container that
|
|
127
|
+
* forwards NOTHING answers that with an empty success, where naming the port makes the same
|
|
128
|
+
* cluster fail exactly as an unknown container does.
|
|
129
|
+
*/
|
|
130
|
+
export declare function publishedPortsCommand(runtime: 'k3d' | 'kind', clusterName: string): Command;
|
|
131
|
+
/**
|
|
132
|
+
* Host ports mapped to the ingress container port, out of `docker port` output
|
|
133
|
+
* (`80/tcp -> 0.0.0.0:18080`, one mapping per line). Reads the text after the LAST colon so an
|
|
134
|
+
* IPv6 bind address (`[::]:18080`) does not parse as the port.
|
|
135
|
+
*/
|
|
136
|
+
export declare function parsePublishedHostPorts(stdout: string): number[];
|
|
137
|
+
/**
|
|
138
|
+
* Controller names out of a `kubectl get ingressclass -o json` payload (an `items` list).
|
|
139
|
+
* Returns `null` for anything unparseable, which the reduction reports as `unknown` rather than
|
|
140
|
+
* as an absent controller.
|
|
141
|
+
*/
|
|
142
|
+
export declare function parseIngressClasses(stdout: string): string[] | null;
|
|
143
|
+
/**
|
|
144
|
+
* Turn the `kubectl get ingressclass` result into a read outcome that NAMES its cause.
|
|
145
|
+
*
|
|
146
|
+
* The shell already distinguishes a missing binary from a watchdog kill from a real non-zero
|
|
147
|
+
* exit, and the exit carries the apiserver's own words (an RBAC refusal, a bad context). Throwing
|
|
148
|
+
* that away and reporting "could not read the cluster's IngressClasses" is the degrade-quietly
|
|
149
|
+
* failure: four fixes, one message, and a remedy that fits none of them.
|
|
150
|
+
*/
|
|
151
|
+
export declare function readIngressClasses(result: ShellResult): IngressClassRead;
|
|
152
|
+
/**
|
|
153
|
+
* Reduce the probed facts to one verdict.
|
|
154
|
+
*
|
|
155
|
+
* An UNREADABLE cluster is `unknown` outright: with no controller list, neither half is settled.
|
|
156
|
+
* An EMPTY list is a definitive `missing`, even when the port probe could not tell, because a
|
|
157
|
+
* cluster with no ingress controller cannot serve the URL whatever the host port does.
|
|
158
|
+
*
|
|
159
|
+
* The PUBLICATION check outranks the socket on the port half, in both directions: a runtime that
|
|
160
|
+
* reports the cluster forwarding no such host port makes an answering socket somebody ELSE'S
|
|
161
|
+
* listener (the case that used to read as ready), and one that reports the forward makes an
|
|
162
|
+
* answering socket attributable to the cluster rather than merely coincident with it.
|
|
163
|
+
*/
|
|
164
|
+
export declare function classifyIngress(facts: IngressFacts): IngressReadiness;
|
|
165
|
+
/** Context the remedy lines are rendered from, so each names the operator's actual cluster. */
|
|
166
|
+
export interface IngressRemedyContext {
|
|
167
|
+
/** The distribution behind the cluster when it is known: it changes the CONTROLLER remedy. */
|
|
168
|
+
runtime?: 'k3d' | 'kind';
|
|
169
|
+
/**
|
|
170
|
+
* The command that rebuilds the cluster with the port published, when there IS one to give.
|
|
171
|
+
*
|
|
172
|
+
* Absent means no recreate line is printed at all, and that case is real: on the reuse path the
|
|
173
|
+
* cluster may be one this CLI cannot name (a bare k3s service, a shared context), and
|
|
174
|
+
* `--recreate` only ever targets a k3d/kind cluster it can name. Printing it anyway produced a
|
|
175
|
+
* command the CLI itself refuses, which is worse than printing none.
|
|
176
|
+
*/
|
|
177
|
+
recreateCommand?: string;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The remedy for each gap, rendered from what the probe just read.
|
|
181
|
+
*
|
|
182
|
+
* A missing HOST PORT has exactly one fix on every local distribution: neither k3d's `-p` nor
|
|
183
|
+
* kind's `extraPortMappings` can be added to a cluster that already exists, so the cluster has to
|
|
184
|
+
* be built again. That is why a recreate is named here rather than a `docker` incantation that
|
|
185
|
+
* does not work.
|
|
186
|
+
*/
|
|
187
|
+
export declare function ingressRemedies(readiness: IngressReadiness, context?: IngressRemedyContext): string[];
|
|
188
|
+
/**
|
|
189
|
+
* The `hostTemplate` an `ingressTemplate` URL source should carry for a verified ingress, or
|
|
190
|
+
* `null` when the ingress was not established.
|
|
191
|
+
*
|
|
192
|
+
* Always PORTLESS. The rendered value is the Ingress `host` a service's manifests declare, and
|
|
193
|
+
* Kubernetes rejects a `host` with a port in it, so a non-default port travels as the URL source's
|
|
194
|
+
* own `port` field ({@link ingressUrlPort}) instead of being smuggled into the host string.
|
|
195
|
+
*/
|
|
196
|
+
export declare function ingressHostTemplate(readiness: Extract<IngressReadiness, {
|
|
197
|
+
status: 'ready';
|
|
198
|
+
}>): string;
|
|
199
|
+
export declare function ingressHostTemplate(readiness: IngressReadiness): string | null;
|
|
200
|
+
/**
|
|
201
|
+
* The `port` an `ingressTemplate` URL source should carry: the verified host port when it is not
|
|
202
|
+
* the scheme's default, else `null` (the derivation composes `scheme://host` with no port).
|
|
203
|
+
*/
|
|
204
|
+
export declare function ingressUrlPort(readiness: IngressReadiness): number | null;
|
|
205
|
+
/**
|
|
206
|
+
* TCP reachability seam, the sibling of {@link HostShell}: the ingress half that no `kubectl`
|
|
207
|
+
* command can answer is whether the HOST forwards a port into the cluster.
|
|
208
|
+
*/
|
|
209
|
+
export interface TcpProbe {
|
|
210
|
+
/** Never rejects: an unreachable port is a {@link PortState}, not an exception. */
|
|
211
|
+
probe(host: string, port: number, timeoutMs: number): Promise<PortState>;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The real, socket-backed probe.
|
|
215
|
+
*
|
|
216
|
+
* `ECONNREFUSED` is the one error that settles the negative: something answered the SYN with a
|
|
217
|
+
* reset, so nothing is listening. A timeout or any other error leaves it undecided (a firewall
|
|
218
|
+
* drops packets silently), and the reduction above keeps that distinct.
|
|
219
|
+
*/
|
|
220
|
+
export declare function createNodeTcpProbe(): TcpProbe;
|
|
221
|
+
/** Injectable dependencies for {@link probeIngress}. */
|
|
222
|
+
export interface IngressProbeDeps {
|
|
223
|
+
shell: HostShell;
|
|
224
|
+
tcp: TcpProbe;
|
|
225
|
+
/** Delay between attempts (real setTimeout; a no-op in tests). */
|
|
226
|
+
sleep?: (ms: number) => Promise<void>;
|
|
227
|
+
/**
|
|
228
|
+
* The clock the wait is measured against (default `Date.now`). Injected because the budget is
|
|
229
|
+
* WALL CLOCK: charging only the sleeps let each attempt's own cost (a 2s port timeout plus a 5s
|
|
230
|
+
* apiserver request) ride for free, so a documented 90s wait ran for minutes.
|
|
231
|
+
*/
|
|
232
|
+
now?: () => number;
|
|
233
|
+
}
|
|
234
|
+
/** How long a freshly created cluster is given to bring its bundled controller up. */
|
|
235
|
+
export declare const INGRESS_SETTLE_WAIT_MS = 90000;
|
|
236
|
+
/** Which cluster the publication check reads, when the CLI can name one. */
|
|
237
|
+
export interface IngressProbeCluster {
|
|
238
|
+
runtime: 'k3d' | 'kind';
|
|
239
|
+
clusterName: string;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Probe every half and reduce, retrying while `waitMs` of WALL CLOCK remains.
|
|
243
|
+
*
|
|
244
|
+
* The wait exists because a k3d cluster's bundled Traefik is installed by a Job that completes
|
|
245
|
+
* ~20-30s AFTER `k3d cluster create` returns: probing once, immediately, would report a
|
|
246
|
+
* definitive `missing` for a cluster that is merely still starting, which is the same class of
|
|
247
|
+
* lie as the promise this replaces. A settled cluster answers on the first attempt, so the wait
|
|
248
|
+
* costs nothing on the reuse path, where callers pass 0.
|
|
249
|
+
*
|
|
250
|
+
* The budget covers the attempts as well as the gaps between them, so a run whose every probe is
|
|
251
|
+
* slow gives up on time rather than multiplying the deadline by the cost of an attempt.
|
|
252
|
+
*/
|
|
253
|
+
export declare function probeIngress(deps: IngressProbeDeps, options: {
|
|
254
|
+
context?: string;
|
|
255
|
+
port: number;
|
|
256
|
+
waitMs?: number;
|
|
257
|
+
cluster?: IngressProbeCluster;
|
|
258
|
+
}): Promise<IngressReadiness>;
|
|
259
|
+
//# sourceMappingURL=k3s-ingress.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"k3s-ingress.d.ts","sourceRoot":"","sources":["../src/k3s-ingress.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,OAAO,EACZ,KAAK,SAAS,EAEd,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAA;AAExB;;;;;;;;;;;;GAYG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,cAAc,CAAA;AAE7C;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,gCAAgC,CAAA;AAElE,mGAAmG;AACnG,eAAO,MAAM,2BAA2B,KAAK,CAAA;AAE7C,sGAAsG;AACtG,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAA;AAErD,kFAAkF;AAClF,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,UAAU,CAAA;AAElD;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GACzB,iBAAiB,GACjB,qBAAqB,GACrB,iBAAiB,GACjB,aAAa,GACb,oBAAoB,CAAA;AAExB;;;;;;;;GAQG;AACH,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,cAAc,CAAA;AAExD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,eAAe,CAAA;CAAE,GACnF;IACE,MAAM,EAAE,SAAS,CAAA;IACjB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,SAAS,UAAU,EAAE,CAAA;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,GACD;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,iBAAiB,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAA;AAEvF;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,GACxB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC5C;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,iBAAiB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAA;AAE3D,sFAAsF;AACtF,MAAM,MAAM,eAAe,GACvB;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE;AACjD,2FAA2F;GACzF;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAA;AAEtB,kGAAkG;AAClG,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,gBAAgB,CAAA;IACzB,QAAQ,EAAE,SAAS,CAAA;IACnB,WAAW,EAAE,eAAe,CAAA;CAC7B;AAMD,oFAAoF;AACpF,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,KAAK,GAAG,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAI3F;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAShE;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAgBnE;AAWD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,WAAW,GAAG,gBAAgB,CA+BxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,GAAG,gBAAgB,CAuCrE;AAED,+FAA+F;AAC/F,MAAM,WAAW,oBAAoB;IACnC,8FAA8F;IAC9F,OAAO,CAAC,EAAE,KAAK,GAAG,MAAM,CAAA;IACxB;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,gBAAgB,EAC3B,OAAO,GAAE,oBAAyB,GACjC,MAAM,EAAE,CA6BV;AA6CD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,OAAO,CAAC,gBAAgB,EAAE;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC,GACxD,MAAM,CAAA;AACT,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAAA;AAK/E;;;GAGG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,gBAAgB,GAAG,MAAM,GAAG,IAAI,CAGzE;AAMD;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,mFAAmF;IACnF,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAAA;CACzE;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,IAAI,QAAQ,CAqB7C;AAED,wDAAwD;AACxD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,SAAS,CAAA;IAChB,GAAG,EAAE,QAAQ,CAAA;IACb,kEAAkE;IAClE,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IACrC;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,sFAAsF;AACtF,eAAO,MAAM,sBAAsB,QAAS,CAAA;AAM5C,4EAA4E;AAC5E,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,KAAK,GAAG,MAAM,CAAA;IACvB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,mBAAmB,CAAA;CAAE,GAC1F,OAAO,CAAC,gBAAgB,CAAC,CAmB3B"}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { COMMAND_NOT_FOUND, COMMAND_TIMED_OUT, runCommand, } from './host-shell.js';
|
|
3
|
+
/**
|
|
4
|
+
* Whether an environment URL derived from an ingress HOST TEMPLATE can actually be served, which
|
|
5
|
+
* takes two independent things and not one:
|
|
6
|
+
*
|
|
7
|
+
* 1. an ingress CONTROLLER inside the cluster (an `IngressClass` is its observable trace), and
|
|
8
|
+
* 2. a HOST PORT published into it, because every local distribution runs the cluster inside
|
|
9
|
+
* Docker and forwards only the ports it was asked for at create time.
|
|
10
|
+
*
|
|
11
|
+
* Both were previously assumed. `cat-factory k3s` printed `{{branch}}.127.0.0.1.nip.io` as wired
|
|
12
|
+
* on every path, including a reused cluster it had never looked at, and the failure landed at the
|
|
13
|
+
* `tester` step against a URL that answered nothing: environment readiness is WORKLOAD readiness,
|
|
14
|
+
* so provisioning still reported success.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Default host port published into the ingress controller's plain-HTTP entrypoint.
|
|
18
|
+
*
|
|
19
|
+
* ONE port is published and it maps to the controller's plain-HTTP entrypoint, so `http` is the
|
|
20
|
+
* scheme that port serves and the only one the CLI can claim. Publishing the TLS entrypoint
|
|
21
|
+
* instead would trade a connection error for a CERTIFICATE error, because a local ingress
|
|
22
|
+
* controller serves a self-signed default cert: a worse version of the failure this module exists
|
|
23
|
+
* to remove, since it surfaces at the tester rather than here.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_INGRESS_PORT = 80;
|
|
26
|
+
/**
|
|
27
|
+
* The host the rendered template resolves to. `nip.io` is wildcard DNS that maps
|
|
28
|
+
* `<anything>.127.0.0.1.nip.io` to loopback with no local DNS setup, so the port probe below
|
|
29
|
+
* targets loopback directly rather than resolving a sample hostname.
|
|
30
|
+
*/
|
|
31
|
+
export const INGRESS_PROBE_HOST = '127.0.0.1';
|
|
32
|
+
/**
|
|
33
|
+
* The ingress HOST template, with no port in it. It is deliberately portless: the rendered value
|
|
34
|
+
* is also the Ingress `spec.rules[].host` a service's manifests declare, and Kubernetes rejects a
|
|
35
|
+
* `host` carrying a port. A non-default host port travels as the URL source's own `port` field
|
|
36
|
+
* instead (see {@link ingressUrlPort}).
|
|
37
|
+
*/
|
|
38
|
+
export const INGRESS_HOST_TEMPLATE = '{{branch}}.127.0.0.1.nip.io';
|
|
39
|
+
/** The container port an ingress controller serves plain HTTP on (Traefik `web`, nginx `http`). */
|
|
40
|
+
export const INGRESS_CONTAINER_HTTP_PORT = 80;
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Pure planners + reduction: no shell-out, no sockets, so unit-testable alone.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/** `kubectl get ingressclass -o json`, targeting `context` when one is supplied. */
|
|
45
|
+
export function listIngressClassesCommand(context) {
|
|
46
|
+
const args = ['get', 'ingressclass', '-o', 'json', '--request-timeout=5s'];
|
|
47
|
+
return { cmd: 'kubectl', args: context ? [...args, '--context', context] : args };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `docker port <cluster container>`: every host port the cluster's own container forwards. This is
|
|
51
|
+
* the ONLY check that attributes an answering host port to the cluster rather than to whatever
|
|
52
|
+
* else may be bound there, and it is the same one used by hand to establish that a default k3d
|
|
53
|
+
* create publishes nothing but the apiserver.
|
|
54
|
+
*
|
|
55
|
+
* The container is the one each distribution puts the forward on: k3d's load balancer, kind's
|
|
56
|
+
* control-plane node. The whole table is asked for rather than one port, because a container that
|
|
57
|
+
* forwards NOTHING answers that with an empty success, where naming the port makes the same
|
|
58
|
+
* cluster fail exactly as an unknown container does.
|
|
59
|
+
*/
|
|
60
|
+
export function publishedPortsCommand(runtime, clusterName) {
|
|
61
|
+
const container = runtime === 'k3d' ? `k3d-${clusterName}-serverlb` : `${clusterName}-control-plane`;
|
|
62
|
+
return { cmd: 'docker', args: ['port', container] };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Host ports mapped to the ingress container port, out of `docker port` output
|
|
66
|
+
* (`80/tcp -> 0.0.0.0:18080`, one mapping per line). Reads the text after the LAST colon so an
|
|
67
|
+
* IPv6 bind address (`[::]:18080`) does not parse as the port.
|
|
68
|
+
*/
|
|
69
|
+
export function parsePublishedHostPorts(stdout) {
|
|
70
|
+
const ports = new Set();
|
|
71
|
+
for (const line of stdout.split('\n')) {
|
|
72
|
+
const [containerPort, hostAddress] = line.split('->').map((part) => part.trim());
|
|
73
|
+
if (containerPort !== `${INGRESS_CONTAINER_HTTP_PORT}/tcp` || !hostAddress)
|
|
74
|
+
continue;
|
|
75
|
+
const port = Number(hostAddress.slice(hostAddress.lastIndexOf(':') + 1));
|
|
76
|
+
if (Number.isInteger(port) && port > 0)
|
|
77
|
+
ports.add(port);
|
|
78
|
+
}
|
|
79
|
+
return [...ports];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Controller names out of a `kubectl get ingressclass -o json` payload (an `items` list).
|
|
83
|
+
* Returns `null` for anything unparseable, which the reduction reports as `unknown` rather than
|
|
84
|
+
* as an absent controller.
|
|
85
|
+
*/
|
|
86
|
+
export function parseIngressClasses(stdout) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(stdout);
|
|
89
|
+
if (!Array.isArray(parsed.items))
|
|
90
|
+
return null;
|
|
91
|
+
return parsed.items
|
|
92
|
+
.map((item) => {
|
|
93
|
+
const spec = item?.spec;
|
|
94
|
+
const name = item?.metadata?.name;
|
|
95
|
+
if (typeof spec?.controller === 'string' && spec.controller.length > 0)
|
|
96
|
+
return spec.controller;
|
|
97
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
98
|
+
})
|
|
99
|
+
.filter((c) => c !== undefined);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** How much of a command's own error output a message carries before it stops being readable. */
|
|
106
|
+
const DETAIL_CAP = 300;
|
|
107
|
+
/** The first line of a failure's output, capped, so a message names the cause it was given. */
|
|
108
|
+
function firstDetailLine(result) {
|
|
109
|
+
const raw = (result.stderr || result.stdout).trim().split('\n')[0]?.trim() ?? '';
|
|
110
|
+
return raw.length > DETAIL_CAP ? `${raw.slice(0, DETAIL_CAP)}…` : raw;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Turn the `kubectl get ingressclass` result into a read outcome that NAMES its cause.
|
|
114
|
+
*
|
|
115
|
+
* The shell already distinguishes a missing binary from a watchdog kill from a real non-zero
|
|
116
|
+
* exit, and the exit carries the apiserver's own words (an RBAC refusal, a bad context). Throwing
|
|
117
|
+
* that away and reporting "could not read the cluster's IngressClasses" is the degrade-quietly
|
|
118
|
+
* failure: four fixes, one message, and a remedy that fits none of them.
|
|
119
|
+
*/
|
|
120
|
+
export function readIngressClasses(result) {
|
|
121
|
+
if (result.code === COMMAND_NOT_FOUND) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
cause: 'kubectl-missing',
|
|
125
|
+
detail: 'kubectl is not installed, or not on PATH',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (result.code === COMMAND_TIMED_OUT) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
cause: 'cluster-unreachable',
|
|
132
|
+
detail: 'the apiserver did not answer before the probe timed out',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (result.code !== 0) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
cause: 'cluster-refused',
|
|
139
|
+
detail: firstDetailLine(result) || `kubectl exited ${result.code}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const controllers = parseIngressClasses(result.stdout);
|
|
143
|
+
if (controllers === null) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
cause: 'unparseable',
|
|
147
|
+
detail: 'kubectl answered with something this could not read as an IngressClass list',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, controllers };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reduce the probed facts to one verdict.
|
|
154
|
+
*
|
|
155
|
+
* An UNREADABLE cluster is `unknown` outright: with no controller list, neither half is settled.
|
|
156
|
+
* An EMPTY list is a definitive `missing`, even when the port probe could not tell, because a
|
|
157
|
+
* cluster with no ingress controller cannot serve the URL whatever the host port does.
|
|
158
|
+
*
|
|
159
|
+
* The PUBLICATION check outranks the socket on the port half, in both directions: a runtime that
|
|
160
|
+
* reports the cluster forwarding no such host port makes an answering socket somebody ELSE'S
|
|
161
|
+
* listener (the case that used to read as ready), and one that reports the forward makes an
|
|
162
|
+
* answering socket attributable to the cluster rather than merely coincident with it.
|
|
163
|
+
*/
|
|
164
|
+
export function classifyIngress(facts) {
|
|
165
|
+
const { port, classes, hostPort, publication } = facts;
|
|
166
|
+
if (!classes.ok) {
|
|
167
|
+
return {
|
|
168
|
+
status: 'unknown',
|
|
169
|
+
port,
|
|
170
|
+
cause: classes.cause,
|
|
171
|
+
probeFailure: classes.detail,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const published = publication.checked ? publication.hostPorts.includes(port) : undefined;
|
|
175
|
+
const gaps = [];
|
|
176
|
+
if (classes.controllers.length === 0)
|
|
177
|
+
gaps.push('controller');
|
|
178
|
+
if (hostPort === 'closed' || published === false)
|
|
179
|
+
gaps.push('hostPort');
|
|
180
|
+
if (gaps.length > 0) {
|
|
181
|
+
const elsewhere = publication.checked
|
|
182
|
+
? publication.hostPorts.find((p) => p !== port)
|
|
183
|
+
: undefined;
|
|
184
|
+
return {
|
|
185
|
+
status: 'missing',
|
|
186
|
+
port,
|
|
187
|
+
gaps,
|
|
188
|
+
...(published === false && elsewhere !== undefined ? { publishedOn: elsewhere } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (hostPort === 'unknown') {
|
|
192
|
+
return {
|
|
193
|
+
status: 'unknown',
|
|
194
|
+
port,
|
|
195
|
+
cause: 'host-port-filtered',
|
|
196
|
+
probeFailure: `nothing answered on host port ${port} and the probe could not tell whether it is closed or filtered`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
status: 'ready',
|
|
201
|
+
port,
|
|
202
|
+
controller: classes.controllers[0] ?? 'unknown',
|
|
203
|
+
attribution: published === true ? 'cluster' : 'unattributed',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The remedy for each gap, rendered from what the probe just read.
|
|
208
|
+
*
|
|
209
|
+
* A missing HOST PORT has exactly one fix on every local distribution: neither k3d's `-p` nor
|
|
210
|
+
* kind's `extraPortMappings` can be added to a cluster that already exists, so the cluster has to
|
|
211
|
+
* be built again. That is why a recreate is named here rather than a `docker` incantation that
|
|
212
|
+
* does not work.
|
|
213
|
+
*/
|
|
214
|
+
export function ingressRemedies(readiness, context = {}) {
|
|
215
|
+
const { runtime, recreateCommand } = context;
|
|
216
|
+
if (readiness.status === 'ready')
|
|
217
|
+
return [];
|
|
218
|
+
if (readiness.status === 'unknown')
|
|
219
|
+
return [...unknownRemedies(readiness), URL_SOURCE_ALTERNATIVE];
|
|
220
|
+
const lines = [];
|
|
221
|
+
if (readiness.gaps.includes('controller')) {
|
|
222
|
+
lines.push(runtime === 'kind'
|
|
223
|
+
? 'kind ships no ingress controller. Install one, e.g. `kubectl apply -f https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml`, then wait for it to be Ready.'
|
|
224
|
+
: 'The cluster has no ingress controller. A default k3d/k3s cluster installs Traefik unless it was created with `--disable=traefik`; install an ingress controller, or build the cluster again without that flag.');
|
|
225
|
+
}
|
|
226
|
+
if (readiness.gaps.includes('hostPort')) {
|
|
227
|
+
lines.push(readiness.publishedOn !== undefined
|
|
228
|
+
? `The cluster publishes its ingress controller on host port ${readiness.publishedOn}, not ${readiness.port}. Re-run with \`--ingress-port ${readiness.publishedOn}\` to use it as it is.`
|
|
229
|
+
: `Nothing on the host serves port ${readiness.port} for this cluster. A published host port cannot be added to an existing k3d/kind cluster, so the cluster has to be created again:`);
|
|
230
|
+
if (readiness.publishedOn === undefined) {
|
|
231
|
+
lines.push(recreateCommand
|
|
232
|
+
? ` ${recreateCommand}`
|
|
233
|
+
: 'This command can only rebuild a k3d/kind cluster it can name, which this is not, so re-create it with the tool that made it, publishing the host port into the ingress controller.');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
lines.push(URL_SOURCE_ALTERNATIVE);
|
|
237
|
+
return lines;
|
|
238
|
+
}
|
|
239
|
+
/** The fix that needs no cluster change at all, and therefore applies to every negative verdict. */
|
|
240
|
+
const URL_SOURCE_ALTERNATIVE = 'Or leave ingress alone and switch the connect form\'s "Environment URL source" to "Service status", naming the Service your manifests expose.';
|
|
241
|
+
/** One remedy per {@link IngressProbeCause}, because each cause needs a different thing done. */
|
|
242
|
+
function unknownRemedies(readiness) {
|
|
243
|
+
const byHand = `Check it by hand: \`kubectl get ingressclass\` and \`curl -sv http://cat-factory-probe.127.0.0.1.nip.io:${readiness.port}/\`.`;
|
|
244
|
+
switch (readiness.cause) {
|
|
245
|
+
case 'kubectl-missing':
|
|
246
|
+
return [
|
|
247
|
+
'Install `kubectl` (or put it on PATH) and re-run `cat-factory k3s`: without it nothing can read what the cluster runs.',
|
|
248
|
+
];
|
|
249
|
+
case 'cluster-unreachable':
|
|
250
|
+
return [
|
|
251
|
+
'The apiserver did not answer. Check the cluster is up and the context points at it (`kubectl cluster-info`), then re-run `cat-factory k3s`.',
|
|
252
|
+
byHand,
|
|
253
|
+
];
|
|
254
|
+
case 'cluster-refused':
|
|
255
|
+
return [
|
|
256
|
+
`The cluster refused the read: ${readiness.probeFailure}. If that is an RBAC refusal, use a context allowed to list ingressclasses; then re-run \`cat-factory k3s\`.`,
|
|
257
|
+
byHand,
|
|
258
|
+
];
|
|
259
|
+
case 'unparseable':
|
|
260
|
+
return [`Read it by hand: \`kubectl get ingressclass -o json\`.`];
|
|
261
|
+
case 'host-port-filtered':
|
|
262
|
+
return [
|
|
263
|
+
`Nothing answered on host port ${readiness.port}, and a dropped packet is not a closed port, so this is undecided rather than missing. Check what is bound there (a firewall silently dropping the connection looks the same), then re-run \`cat-factory k3s\`.`,
|
|
264
|
+
byHand,
|
|
265
|
+
];
|
|
266
|
+
default:
|
|
267
|
+
return refuseUnknownCause(readiness.cause);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A cause outside {@link IngressProbeCause}. The `never` parameter keeps the switch above total at
|
|
272
|
+
* COMPILE time (a new cause with no remedy stops building) while still refusing a value the union
|
|
273
|
+
* never had, rather than falling off the end and rendering an empty remedy list.
|
|
274
|
+
*/
|
|
275
|
+
function refuseUnknownCause(cause) {
|
|
276
|
+
throw new Error(`Unhandled ingress probe cause '${String(cause)}'`);
|
|
277
|
+
}
|
|
278
|
+
export function ingressHostTemplate(readiness) {
|
|
279
|
+
return readiness.status === 'ready' ? INGRESS_HOST_TEMPLATE : null;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The `port` an `ingressTemplate` URL source should carry: the verified host port when it is not
|
|
283
|
+
* the scheme's default, else `null` (the derivation composes `scheme://host` with no port).
|
|
284
|
+
*/
|
|
285
|
+
export function ingressUrlPort(readiness) {
|
|
286
|
+
if (readiness.status !== 'ready')
|
|
287
|
+
return null;
|
|
288
|
+
return readiness.port === DEFAULT_INGRESS_PORT ? null : readiness.port;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The real, socket-backed probe.
|
|
292
|
+
*
|
|
293
|
+
* `ECONNREFUSED` is the one error that settles the negative: something answered the SYN with a
|
|
294
|
+
* reset, so nothing is listening. A timeout or any other error leaves it undecided (a firewall
|
|
295
|
+
* drops packets silently), and the reduction above keeps that distinct.
|
|
296
|
+
*/
|
|
297
|
+
export function createNodeTcpProbe() {
|
|
298
|
+
return {
|
|
299
|
+
probe(host, port, timeoutMs) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
let settled = false;
|
|
302
|
+
const finish = (state) => {
|
|
303
|
+
if (settled)
|
|
304
|
+
return;
|
|
305
|
+
settled = true;
|
|
306
|
+
socket.destroy();
|
|
307
|
+
resolve(state);
|
|
308
|
+
};
|
|
309
|
+
const socket = connect({ host, port });
|
|
310
|
+
socket.setTimeout(timeoutMs);
|
|
311
|
+
socket.on('connect', () => finish('open'));
|
|
312
|
+
socket.on('timeout', () => finish('unknown'));
|
|
313
|
+
socket.on('error', (err) => finish(err.code === 'ECONNREFUSED' ? 'closed' : 'unknown'));
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
/** How long a freshly created cluster is given to bring its bundled controller up. */
|
|
319
|
+
export const INGRESS_SETTLE_WAIT_MS = 90_000;
|
|
320
|
+
const ATTEMPT_INTERVAL_MS = 2_000;
|
|
321
|
+
const PORT_PROBE_TIMEOUT_MS = 2_000;
|
|
322
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
323
|
+
/**
|
|
324
|
+
* Probe every half and reduce, retrying while `waitMs` of WALL CLOCK remains.
|
|
325
|
+
*
|
|
326
|
+
* The wait exists because a k3d cluster's bundled Traefik is installed by a Job that completes
|
|
327
|
+
* ~20-30s AFTER `k3d cluster create` returns: probing once, immediately, would report a
|
|
328
|
+
* definitive `missing` for a cluster that is merely still starting, which is the same class of
|
|
329
|
+
* lie as the promise this replaces. A settled cluster answers on the first attempt, so the wait
|
|
330
|
+
* costs nothing on the reuse path, where callers pass 0.
|
|
331
|
+
*
|
|
332
|
+
* The budget covers the attempts as well as the gaps between them, so a run whose every probe is
|
|
333
|
+
* slow gives up on time rather than multiplying the deadline by the cost of an attempt.
|
|
334
|
+
*/
|
|
335
|
+
export async function probeIngress(deps, options) {
|
|
336
|
+
const sleep = deps.sleep ?? realSleep;
|
|
337
|
+
const now = deps.now ?? Date.now;
|
|
338
|
+
const deadline = now() + (options.waitMs ?? 0);
|
|
339
|
+
for (;;) {
|
|
340
|
+
const [classes, hostPort, publication] = await Promise.all([
|
|
341
|
+
runCommand(deps.shell, listIngressClassesCommand(options.context)),
|
|
342
|
+
deps.tcp.probe(INGRESS_PROBE_HOST, options.port, PORT_PROBE_TIMEOUT_MS),
|
|
343
|
+
readPortPublication(deps.shell, options.cluster),
|
|
344
|
+
]);
|
|
345
|
+
const last = classifyIngress({
|
|
346
|
+
port: options.port,
|
|
347
|
+
classes: readIngressClasses(classes),
|
|
348
|
+
hostPort,
|
|
349
|
+
publication,
|
|
350
|
+
});
|
|
351
|
+
if (last.status === 'ready' || now() + ATTEMPT_INTERVAL_MS > deadline)
|
|
352
|
+
return last;
|
|
353
|
+
await sleep(ATTEMPT_INTERVAL_MS);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Ask the container runtime which host ports the cluster forwards its controller entrypoint to.
|
|
358
|
+
*
|
|
359
|
+
* Any failure is `checked: false`, never "publishes nothing": the container may be named
|
|
360
|
+
* differently (a `--no-lb` k3d cluster has no load balancer at all), Docker may not be the
|
|
361
|
+
* runtime, or there may be no Docker. Reading a failure as a definitive negative would send an
|
|
362
|
+
* operator to rebuild a cluster whose port was fine.
|
|
363
|
+
*/
|
|
364
|
+
async function readPortPublication(shell, cluster) {
|
|
365
|
+
if (!cluster)
|
|
366
|
+
return { checked: false };
|
|
367
|
+
const result = await runCommand(shell, publishedPortsCommand(cluster.runtime, cluster.clusterName));
|
|
368
|
+
if (result.code !== 0)
|
|
369
|
+
return { checked: false };
|
|
370
|
+
return { checked: true, hostPorts: parsePublishedHostPorts(result.stdout) };
|
|
371
|
+
}
|
|
372
|
+
//# sourceMappingURL=k3s-ingress.js.map
|