@localctl/cli 0.5.0-beta.2 → 0.5.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 +32 -76
- package/package.json +3 -10
- package/schema/app.schema.json +678 -44
- package/src/commands/doctor.js +1 -16
- package/src/commands/down.js +3 -1
- package/src/commands/generateManifests.js +4 -0
- package/src/commands/logs.js +1 -0
- package/src/commands/new.js +42 -20
- package/src/commands/prune.js +4 -0
- package/src/commands/reload.js +11 -3
- package/src/commands/secrets.js +3 -3
- package/src/commands/setup.js +1 -11
- package/src/commands/tiltConfig.js +11 -0
- package/src/commands/uninstall.js +5 -6
- package/src/commands/up.js +9 -1
- package/src/commands/validate.js +48 -0
- package/src/index.js +2 -0
- package/src/lib/addonDirs.js +1 -2
- package/src/lib/addonLoader.js +38 -17
- package/src/lib/addons/mongo/addon.yaml +2 -0
- package/src/lib/addons/postgres/addon.yaml +5 -1
- package/src/lib/addons/redis/addon.yaml +2 -0
- package/src/lib/appSecrets.js +107 -36
- package/src/lib/clusterProvision.js +45 -211
- package/src/lib/configSchema.js +412 -41
- package/src/lib/engine.js +3 -13
- package/src/lib/envFile.js +58 -0
- package/src/lib/exec.js +6 -7
- package/src/lib/hostGuard.js +107 -0
- package/src/lib/hostsFile.js +9 -8
- package/src/lib/kubeErrors.js +22 -0
- package/src/lib/loadConfig.js +183 -9
- package/src/lib/manifestGen.js +96 -32
- package/src/lib/namespaces.js +83 -0
- package/src/lib/pathGuard.js +79 -0
- package/src/lib/paths.js +2 -6
- package/src/lib/podman.js +3 -93
- package/src/lib/projectContext.js +2 -2
- package/src/lib/quantities.js +20 -0
- package/src/lib/safeRead.js +41 -0
- package/src/lib/tiltConfig.js +25 -0
- package/src/lib/toolInstall.js +2 -141
- package/src/lib/yamlLoad.js +48 -0
- package/templates/Tiltfile.tmpl +22 -14
- package/src/lib/linuxDistro.js +0 -122
package/src/lib/configSchema.js
CHANGED
|
@@ -1,26 +1,193 @@
|
|
|
1
|
+
const path = require('path');
|
|
1
2
|
const { z } = require('zod');
|
|
2
3
|
const { knownAddonTypes } = require('./manifestGen');
|
|
4
|
+
const { isReservedNamespace } = require('./namespaces');
|
|
5
|
+
const { parseCpu, parseMem, checkResources } = require('./quantities');
|
|
3
6
|
|
|
4
7
|
// Dependency types are discovered from cli/src/lib/addons/<type>/addon.yaml (see
|
|
5
8
|
// docs/adding-tools.md) rather than hardcoded here - dropping in a new addon folder is enough to
|
|
6
9
|
// make it usable, no code change needed.
|
|
7
|
-
|
|
10
|
+
// YAML reads `version: 16.1` or `database: 2024` as numbers; for fields that are text by nature,
|
|
11
|
+
// accept numbers and booleans and turn them into strings. (yamlLoad.js already keeps lookalikes
|
|
12
|
+
// such as 1.10, 1e3 or 0x10 as text.)
|
|
13
|
+
const noControl = (v) => !/\u0000/.test(v);
|
|
14
|
+
const text = z.union([z.string(), z.number(), z.boolean()]).transform(String).refine(noControl, 'must not contain a NUL character');
|
|
15
|
+
|
|
16
|
+
// These three are substituted as raw text into an addon's Helm values (and `version` into a
|
|
17
|
+
// `helm --version` argument), so they are limited to what they legitimately look like. Anything
|
|
18
|
+
// with a quote, colon or newline could otherwise inject extra Helm values.
|
|
19
|
+
const restricted = (regex, hint) => text.pipe(z.string().regex(regex, hint));
|
|
20
|
+
|
|
21
|
+
// A Kubernetes DNS label: starts and ends with a letter or digit, so it can never be read as a
|
|
22
|
+
// command-line flag (`-x`) by kubectl or tilt.
|
|
23
|
+
const dnsLabel = z.string().max(63, 'must be at most 63 characters').regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'must be lowercase letters, digits and dashes, starting and ending with a letter or digit');
|
|
24
|
+
|
|
25
|
+
// Kubernetes quantities, parsed exactly (see quantities.js): cpu as 500m, 1 or 0.5; memory as
|
|
26
|
+
// 256Mi, 2Gi or 1G. YAML numbers (`cpu: 1`) are accepted. Zero and huge values are refused.
|
|
27
|
+
const quantityOf = (parse) => text.pipe(z.string().superRefine((v, ctx) => {
|
|
28
|
+
try { parse(v); } catch (e) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: e.message.includes(String(v)) ? e.message : `"${String(v).slice(0, 30)}": ${e.message}` }); }
|
|
29
|
+
}));
|
|
30
|
+
const resourceSpec = z
|
|
31
|
+
.strictObject({
|
|
32
|
+
cpu: quantityOf(parseCpu).optional(),
|
|
33
|
+
memory: quantityOf(parseMem).optional(),
|
|
34
|
+
})
|
|
35
|
+
.optional();
|
|
36
|
+
|
|
37
|
+
const dependencySchema = z.strictObject({
|
|
8
38
|
type: z.enum(knownAddonTypes()),
|
|
9
|
-
version:
|
|
10
|
-
database:
|
|
11
|
-
storage:
|
|
39
|
+
version: restricted(/^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/, 'must look like a chart version, e.g. 18.12.2').optional(),
|
|
40
|
+
database: restricted(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/, 'must be letters, digits and underscores, not starting with a digit').optional(),
|
|
41
|
+
storage: restricted(/^(?!0+(\.0+)?(Mi|Gi|Ti)$)[0-9]{1,6}(\.[0-9]{1,3})?(Mi|Gi|Ti)$/, 'must be a size like 512Mi or 2Gi').optional(),
|
|
42
|
+
// CPU/memory for the dependency's pod, e.g. { limits: { memory: 256Mi } }. Needs the addon to
|
|
43
|
+
// declare `resourcesPath` in its addon.yaml.
|
|
44
|
+
resources: z.strictObject({ requests: resourceSpec, limits: resourceSpec }).optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Build and sync paths are relative to the app folder (`..` is fine for monorepos). Absolute paths
|
|
48
|
+
// and `~` are refused so a config can't pull in files from elsewhere on the machine.
|
|
49
|
+
const relativePath = z.string().min(1).max(1000)
|
|
50
|
+
.refine((p) => !/[\u0000-\u001f\u007f]/.test(p), 'must not contain control characters')
|
|
51
|
+
.refine((p) => !/^([\\/]|[A-Za-z]:|~)/.test(p), 'must be a relative path, not absolute or starting with ~');
|
|
52
|
+
|
|
53
|
+
// An image reference can't start with `-` (it would be read as a flag) or contain whitespace.
|
|
54
|
+
const imageRef = z.string().min(1).max(300).regex(/^[A-Za-z0-9][^\s\u0000-\u001f\u007f]*$/, 'must be an image reference such as repo/name:tag');
|
|
55
|
+
|
|
56
|
+
const envKey = z.string().max(253).regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'must be a valid environment variable name (letters, digits, _)');
|
|
57
|
+
// Kubernetes accepts letters, digits, `_`, `-` and `.` in variable names (spring.profiles.active).
|
|
58
|
+
const envVarName = z.string().max(253).regex(/^[A-Za-z_][A-Za-z0-9._-]*$/, 'must be a valid environment variable name (letters, digits, _ . -)');
|
|
59
|
+
const envMap = z.record(envVarName, z.string().refine(noControl, 'must not contain a NUL character')).refine((o) => Object.keys(o).length <= 200, 'at most 200 variables');
|
|
60
|
+
|
|
61
|
+
const portNumber = z.number().int().min(1).max(65535);
|
|
62
|
+
|
|
63
|
+
// Kubernetes label/annotation keys and label values. `app` and the `localctl.dev/` prefix are
|
|
64
|
+
// reserved: the Deployment selector and localctl's own bookkeeping rely on them.
|
|
65
|
+
const k8sKey = z
|
|
66
|
+
.string()
|
|
67
|
+
.max(253)
|
|
68
|
+
.regex(/^([a-z0-9]([a-z0-9.-]*[a-z0-9])?\/)?[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/, 'is not a valid label/annotation key')
|
|
69
|
+
.refine((k) => k.split('/').pop().length <= 63, 'the part after "/" must be at most 63 characters');
|
|
70
|
+
const labelValue = text.pipe(z.string().max(63).regex(/^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)?$/, 'is not a valid label value'));
|
|
71
|
+
const isReservedKey = (key) => key === 'app' || key.startsWith('localctl.dev/');
|
|
72
|
+
|
|
73
|
+
// A URL path: starts with /, no empty segments (`//`), no `..`, no percent escapes, bounded size.
|
|
74
|
+
const urlPath = z
|
|
75
|
+
.string()
|
|
76
|
+
.max(200)
|
|
77
|
+
.regex(/^\/[A-Za-z0-9._~/-]*$/, 'must start with / and use only letters, digits and . _ ~ - /')
|
|
78
|
+
.refine((p) => !p.includes('//') && !p.split('/').includes('..'), 'cannot contain "//" or ".."');
|
|
79
|
+
|
|
80
|
+
// Extra container ports beyond `port` (a second HTTP port, gRPC, a UDP listener). They are added
|
|
81
|
+
// to the container and the in-cluster Service; `forward: true` also forwards it to localhost.
|
|
82
|
+
const extraPortSchema = z.strictObject({
|
|
83
|
+
port: portNumber,
|
|
84
|
+
name: z.string().regex(/^[a-z]([a-z0-9]|-(?!-)){0,13}[a-z0-9]$|^[a-z]$/, 'must be 1-15 lowercase letters, digits or single dashes, starting with a letter').optional(),
|
|
85
|
+
protocol: z.enum(['TCP', 'UDP']).default('TCP'),
|
|
86
|
+
forward: z.boolean().default(false),
|
|
12
87
|
});
|
|
13
88
|
|
|
89
|
+
// Traefik annotations a config may set. `router.priority` (could outrank another app's route),
|
|
90
|
+
// `router.middlewares` (can point into other namespaces) and `router.tls.*` are left out.
|
|
91
|
+
const INGRESS_ANNOTATIONS = [
|
|
92
|
+
'traefik.ingress.kubernetes.io/service.passhostheader',
|
|
93
|
+
'traefik.ingress.kubernetes.io/service.sticky.cookie',
|
|
94
|
+
'traefik.ingress.kubernetes.io/service.sticky.cookie.name',
|
|
95
|
+
'traefik.ingress.kubernetes.io/service.sticky.cookie.secure',
|
|
96
|
+
'traefik.ingress.kubernetes.io/service.serversscheme',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// What each allowed annotation may be set to; nothing here can carry a newline or `;`.
|
|
100
|
+
const INGRESS_ANNOTATION_VALUES = {
|
|
101
|
+
'traefik.ingress.kubernetes.io/service.passhostheader': /^(true|false)$/,
|
|
102
|
+
'traefik.ingress.kubernetes.io/service.sticky.cookie': /^(true|false)$/,
|
|
103
|
+
'traefik.ingress.kubernetes.io/service.sticky.cookie.name': /^[A-Za-z0-9._-]{1,64}$/,
|
|
104
|
+
'traefik.ingress.kubernetes.io/service.sticky.cookie.secure': /^(true|false)$/,
|
|
105
|
+
'traefik.ingress.kubernetes.io/service.serversscheme': /^(http|https|h2c)$/,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Pod annotations are read by container runtimes, CNIs and meshes, and every vendor invents its
|
|
109
|
+
// own keys, so a denylist keeps leaking. Annotations therefore use an allowlist: a plain key with
|
|
110
|
+
// no domain (`note`, `team`) or a key under a few well-known, harmless prefixes. Labels keep a
|
|
111
|
+
// denylist below because their keys are mostly free-form.
|
|
112
|
+
const POD_ANNOTATION_ALLOWED = /^(?:[A-Za-z0-9_-]+|(?:prometheus\.io|reloader\.stakater\.com|fluentbit\.io|ad\.datadoghq\.com|co\.elastic\.logs|logging\.banzaicloud\.io|backup\.velero\.io|checksum)\/[A-Za-z0-9][A-Za-z0-9._-]*)$/;
|
|
113
|
+
|
|
114
|
+
// Label keys that namespaces, runtimes or meshes act on.
|
|
115
|
+
const isDangerousLabelKey = (key) =>
|
|
116
|
+
// app.kubernetes.io/* are the standard descriptive labels (name, part-of, version...).
|
|
117
|
+
(!/^app\.kubernetes\.io\//.test(key) && /(^|[./])kubernetes\.io\//.test(key)) || /^io\.kubernetes\./.test(key) || /^k8s\./.test(key) ||
|
|
118
|
+
/apparmor|seccomp/i.test(key) || /\.cncf\.io\//.test(key) ||
|
|
119
|
+
// Runtime, mesh, CNI and cloud-identity hooks that act on pod annotations.
|
|
120
|
+
/^(io\.katacontainers|io\.containerd|runtime\.containerd|run\.oci|io\.podman|org\.systemd|openshift\.io|iam\.amazonaws\.com|vault\.hashicorp\.com|cni\.projectcalico\.org|v1\.multus-cni\.io|sidecar\.istio\.io|traffic\.sidecar\.istio\.io|istio\.io|linkerd\.io|config\.linkerd\.io|azure\.workload\.identity|cilium\.io|io\.cilium)([./]|$)/.test(key);
|
|
121
|
+
|
|
122
|
+
// Ingress extras. `hosts` are more subdomains of local.test that route to the same app; `paths`
|
|
123
|
+
// replaces the default "/" route; only Traefik's own annotations are accepted.
|
|
124
|
+
const ingressSchema = z
|
|
125
|
+
.strictObject({
|
|
126
|
+
hosts: z.array(dnsLabel).default([]),
|
|
127
|
+
paths: z
|
|
128
|
+
.array(z.strictObject({
|
|
129
|
+
path: urlPath,
|
|
130
|
+
port: portNumber.optional(),
|
|
131
|
+
}))
|
|
132
|
+
.default([]),
|
|
133
|
+
annotations: z
|
|
134
|
+
.record(z.enum(INGRESS_ANNOTATIONS, { errorMap: () => ({ message: `not an allowed annotation; allowed: ${INGRESS_ANNOTATIONS.join(', ')}` }) }), text)
|
|
135
|
+
.default({}),
|
|
136
|
+
})
|
|
137
|
+
.default({});
|
|
138
|
+
|
|
139
|
+
// Scratch space shared by the app, its sidecars and init containers. Always an emptyDir: it lives
|
|
140
|
+
// and dies with the pod. There is no hostPath on purpose, so a config can't mount node folders.
|
|
141
|
+
const MOUNT_DENYLIST = ['/proc', '/sys', '/dev', '/var/run/secrets', '/run/secrets'];
|
|
142
|
+
const MOUNT_EXACT_DENY = ['/run', '/var/run'];
|
|
143
|
+
|
|
144
|
+
const volumeSchema = z.strictObject({
|
|
145
|
+
name: dnsLabel,
|
|
146
|
+
mountPath: z
|
|
147
|
+
.string()
|
|
148
|
+
.max(200)
|
|
149
|
+
.regex(/^\/[A-Za-z0-9._/-]*$/, 'must be an absolute path (letters, digits, . _ - /)')
|
|
150
|
+
.refine((p) => p === path.posix.normalize(p) && p !== '/' && !p.endsWith('/'), 'must be written in its plain form: no "//", "/./", ".." or trailing "/", and not "/"')
|
|
151
|
+
.refine((p) => {
|
|
152
|
+
const lower = p.toLowerCase();
|
|
153
|
+
if (lower === '/dev/shm' || lower.startsWith('/dev/shm/')) return true; // shared memory for browsers, databases
|
|
154
|
+
return !MOUNT_EXACT_DENY.includes(lower) && !MOUNT_DENYLIST.some((d) => lower === d || lower.startsWith(`${d}/`));
|
|
155
|
+
}, `cannot be ${MOUNT_EXACT_DENY.join(' or ')} or inside ${MOUNT_DENYLIST.join(', ')}`),
|
|
156
|
+
sizeLimit: restricted(/^(?!0+(\.0+)?(Mi|Gi|Ti)$)[0-9]{1,6}(\.[0-9]{1,3})?(Mi|Gi|Ti)$/, 'must be a size like 256Mi or 1Gi').optional(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Runs to completion before the app starts, in the app's own image unless `image` is given: the
|
|
160
|
+
// place for database migrations or seed scripts.
|
|
161
|
+
const initContainerSchema = z.strictObject({
|
|
162
|
+
name: dnsLabel,
|
|
163
|
+
image: imageRef.optional(),
|
|
164
|
+
command: z.array(text).optional(),
|
|
165
|
+
args: z.array(text).optional(),
|
|
166
|
+
env: envMap.default({}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Only settings that tighten the container are accepted: no `privileged`, no added capabilities,
|
|
170
|
+
// and allowPrivilegeEscalation can only be turned off.
|
|
171
|
+
const securityContextSchema = z
|
|
172
|
+
.strictObject({
|
|
173
|
+
runAsUser: z.number().int().min(0).max(65535).optional(),
|
|
174
|
+
runAsGroup: z.number().int().min(0).max(65535).optional(),
|
|
175
|
+
runAsNonRoot: z.boolean().optional(),
|
|
176
|
+
readOnlyRootFilesystem: z.boolean().optional(),
|
|
177
|
+
allowPrivilegeEscalation: z.literal(false).optional(),
|
|
178
|
+
})
|
|
179
|
+
.refine((c) => !(c.runAsNonRoot === true && c.runAsUser === 0), { message: 'runAsNonRoot: true contradicts runAsUser: 0' });
|
|
180
|
+
|
|
14
181
|
const debugSchema = z
|
|
15
|
-
.
|
|
182
|
+
.strictObject({
|
|
16
183
|
enabled: z.boolean().default(false),
|
|
17
|
-
port:
|
|
184
|
+
port: portNumber.optional(),
|
|
18
185
|
type: z.enum(['node', 'python', 'go', 'java', 'dotnet']).optional(),
|
|
19
186
|
})
|
|
20
187
|
.default({ enabled: false });
|
|
21
188
|
|
|
22
|
-
const syncEntrySchema = z.
|
|
23
|
-
localPath:
|
|
189
|
+
const syncEntrySchema = z.strictObject({
|
|
190
|
+
localPath: relativePath,
|
|
24
191
|
remotePath: z.string(),
|
|
25
192
|
});
|
|
26
193
|
|
|
@@ -28,68 +195,199 @@ const syncEntrySchema = z.object({
|
|
|
28
195
|
// UI next to a database, a log/metrics shipper, a background job runner next to a web app). It
|
|
29
196
|
// shares the pod's network namespace with your app automatically - no Service needed for your app
|
|
30
197
|
// to reach it, just `localhost:<port>`. See docs/config-schema.md for a worked example.
|
|
31
|
-
const sidecarSchema = z.
|
|
32
|
-
name:
|
|
33
|
-
image:
|
|
34
|
-
port:
|
|
35
|
-
command: z.array(
|
|
36
|
-
args: z.array(
|
|
37
|
-
env:
|
|
198
|
+
const sidecarSchema = z.strictObject({
|
|
199
|
+
name: dnsLabel,
|
|
200
|
+
image: imageRef,
|
|
201
|
+
port: portNumber.optional(),
|
|
202
|
+
command: z.array(text).optional(),
|
|
203
|
+
args: z.array(text).optional(),
|
|
204
|
+
env: envMap.default({}),
|
|
38
205
|
});
|
|
39
206
|
|
|
40
|
-
const
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
.optional();
|
|
207
|
+
const runStepSchema = z.strictObject({
|
|
208
|
+
command: z.string().min(1),
|
|
209
|
+
trigger: z.array(z.string()).optional(),
|
|
210
|
+
});
|
|
46
211
|
|
|
47
212
|
// A single probe - defaults to a plain TCP check on the app's own `port` if `path` isn't given
|
|
48
213
|
// (see buildProbe() in manifestGen.js). Applies to the main app container only.
|
|
49
|
-
const probeSchema = z
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
214
|
+
const probeSchema = z
|
|
215
|
+
.strictObject({
|
|
216
|
+
path: urlPath.optional(),
|
|
217
|
+
// Runs inside the container; healthy when it exits 0. Alternative to `path`.
|
|
218
|
+
command: z.array(text.pipe(z.string().min(1))).min(1).optional(),
|
|
219
|
+
initialDelaySeconds: z.number().int().min(0).max(3600).optional(),
|
|
220
|
+
periodSeconds: z.number().int().min(1).max(3600).optional(),
|
|
221
|
+
timeoutSeconds: z.number().int().min(1).max(600).optional(),
|
|
222
|
+
failureThreshold: z.number().int().min(1).max(1000).optional(),
|
|
223
|
+
})
|
|
224
|
+
.refine((p) => !(p.path && p.command), { message: 'use either "path" (HTTP) or "command", not both' });
|
|
54
225
|
|
|
55
226
|
const probesSchema = z
|
|
56
|
-
.
|
|
227
|
+
.strictObject({
|
|
57
228
|
readiness: probeSchema.optional(),
|
|
58
229
|
liveness: probeSchema.optional(),
|
|
230
|
+
// Gives slow-starting apps time before liveness checks begin; absent unless set here.
|
|
231
|
+
startup: probeSchema.optional(),
|
|
59
232
|
})
|
|
60
233
|
.default({});
|
|
61
234
|
|
|
235
|
+
// Every object is strict: a typo like `replica: 3` is an error with a "did you mean" hint, not a
|
|
236
|
+
// silently ignored key. `$schema` is allowed so JSON-style files that carry one still load.
|
|
62
237
|
const appConfigSchema = z
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
238
|
+
.strictObject({
|
|
239
|
+
$schema: z.string().optional(),
|
|
240
|
+
name: dnsLabel,
|
|
241
|
+
subdomain: dnsLabel.optional(),
|
|
242
|
+
namespace: dnsLabel.optional(),
|
|
243
|
+
port: portNumber,
|
|
244
|
+
// Replaces the image's CMD/ENTRYPOINT arguments for the main container (like docker run
|
|
245
|
+
// args): `command` overrides ENTRYPOINT, `args` overrides CMD.
|
|
246
|
+
command: z.array(text).optional(),
|
|
247
|
+
args: z.array(text).optional(),
|
|
248
|
+
build: z.strictObject({
|
|
249
|
+
context: relativePath.default('.'),
|
|
250
|
+
dockerfile: relativePath.default('Dockerfile'),
|
|
251
|
+
// Multi-stage build target (docker build --target).
|
|
252
|
+
target: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'must be a build stage name').optional(),
|
|
253
|
+
// docker build --build-arg KEY=value
|
|
254
|
+
args: z.record(envKey, text).default({}),
|
|
255
|
+
// Paths Tilt leaves out of the build context and does not watch (like .dockerignore).
|
|
256
|
+
ignore: z.array(z.string()).default([]),
|
|
73
257
|
}),
|
|
74
|
-
env:
|
|
258
|
+
env: envMap.default({}),
|
|
259
|
+
// A dotenv-style file (KEY=value per line) read relative to the app folder and merged into
|
|
260
|
+
// `env`; keys written in `env` win. Meant for non-secret settings you don't want in the YAML.
|
|
261
|
+
envFile: z.string().optional(),
|
|
75
262
|
// Names only - values never live here. Set with `localctl app secrets set <KEY>`, which
|
|
76
263
|
// stores the value once as a real k8s Secret and injects it via secretKeyRef, the same
|
|
77
264
|
// never-plaintext principle addon-provided credentials already follow.
|
|
78
265
|
secrets: z
|
|
79
266
|
.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be UPPER_SNAKE_CASE'))
|
|
80
267
|
.default([]),
|
|
81
|
-
replicas: z.number().int().min(1).default(1),
|
|
268
|
+
replicas: z.number().int().min(1).max(20).default(1),
|
|
82
269
|
resources: z
|
|
83
|
-
.
|
|
270
|
+
.strictObject({ requests: resourceSpec, limits: resourceSpec })
|
|
84
271
|
.optional(),
|
|
85
272
|
debug: debugSchema,
|
|
86
273
|
probes: probesSchema,
|
|
87
274
|
dependencies: z.array(dependencySchema).default([]),
|
|
88
275
|
sidecars: z.array(sidecarSchema).default([]),
|
|
89
276
|
sync: z.array(syncEntrySchema).default([]),
|
|
277
|
+
// Commands run inside the container after files are synced; with `trigger`, only when one of
|
|
278
|
+
// those paths changed (for example `npm install` when package.json changes).
|
|
279
|
+
run: z.array(runStepSchema).default([]),
|
|
90
280
|
tls: z.boolean().default(true),
|
|
281
|
+
ports: z.array(extraPortSchema).default([]),
|
|
282
|
+
ingress: ingressSchema,
|
|
283
|
+
volumes: z.array(volumeSchema).default([]),
|
|
284
|
+
initContainers: z.array(initContainerSchema).default([]),
|
|
285
|
+
// Extra labels and (pod) annotations. `app` and `localctl.dev/*` are reserved.
|
|
286
|
+
labels: z.record(k8sKey, labelValue).default({}),
|
|
287
|
+
annotations: z.record(k8sKey, text).default({}),
|
|
288
|
+
imagePullPolicy: z.enum(['Always', 'IfNotPresent', 'Never']).optional(),
|
|
289
|
+
terminationGracePeriodSeconds: z.number().int().min(0).max(3600).optional(),
|
|
290
|
+
securityContext: securityContextSchema.optional(),
|
|
291
|
+
// Named overrides, merged over the rest of this file when selected with `localctl app up
|
|
292
|
+
// --env <name>` (objects merge, lists replace). `name` and `namespace` can't be overridden.
|
|
293
|
+
environments: z.record(dnsLabel, z.record(z.unknown())).optional(),
|
|
91
294
|
})
|
|
92
295
|
.superRefine((cfg, ctx) => {
|
|
296
|
+
const issue = (path, message) => ctx.addIssue({ code: z.ZodIssueCode.custom, path, message });
|
|
297
|
+
|
|
298
|
+
// Size limits: a config is a handful of settings; huge lists only make slow, giant manifests.
|
|
299
|
+
const limits = [
|
|
300
|
+
['ports', cfg.ports, 20], ['ingress.hosts', cfg.ingress.hosts, 20], ['ingress.paths', cfg.ingress.paths, 50],
|
|
301
|
+
['volumes', cfg.volumes, 20], ['initContainers', cfg.initContainers, 10], ['sidecars', cfg.sidecars, 10],
|
|
302
|
+
['dependencies', cfg.dependencies, 10], ['sync', cfg.sync, 50], ['run', cfg.run, 50], ['secrets', cfg.secrets, 100],
|
|
303
|
+
['build.ignore', cfg.build.ignore, 100], ['command', cfg.command || [], 100], ['args', cfg.args || [], 100],
|
|
304
|
+
['labels', Object.keys(cfg.labels), 50], ['annotations', Object.keys(cfg.annotations), 50],
|
|
305
|
+
['build.args', Object.keys(cfg.build.args), 100],
|
|
306
|
+
];
|
|
307
|
+
for (const [name, list, max] of limits) {
|
|
308
|
+
if (list.length > max) issue(name.split('.'), `at most ${max} entries are allowed (found ${list.length})`);
|
|
309
|
+
}
|
|
310
|
+
for (const [where, res] of [['resources', cfg.resources], ...cfg.dependencies.map((d, i) => [`dependencies.${i}.resources`, d.resources])]) {
|
|
311
|
+
if (!res) continue;
|
|
312
|
+
try { checkResources(res); } catch (e) { issue(where.split('.'), e.message); }
|
|
313
|
+
}
|
|
314
|
+
const types = cfg.dependencies.map((d) => d.type);
|
|
315
|
+
if (new Set(types).size !== types.length) issue(['dependencies'], 'each dependency type can appear once per app');
|
|
316
|
+
// Helm release names are limited to 53 characters and are built as "<name>-<type>".
|
|
317
|
+
cfg.dependencies.forEach((d, i) => {
|
|
318
|
+
if (`${cfg.name}-${d.type}`.length > 53) issue(['dependencies', i, 'type'], `"${cfg.name}-${d.type}" is longer than 53 characters (a Helm limit); use a shorter app name`);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const servicePorts = [cfg.port, ...cfg.ports.filter((p) => p.protocol === 'TCP').map((p) => p.port)];
|
|
322
|
+
// A port number can be used once per protocol (DNS on 53 uses both TCP and UDP).
|
|
323
|
+
const taken = new Set([`${cfg.port}/TCP`]);
|
|
324
|
+
if (cfg.debug.enabled && cfg.debug.port) {
|
|
325
|
+
if (cfg.debug.port === cfg.port) issue(['debug', 'port'], '"debug.port" must differ from "port"');
|
|
326
|
+
taken.add(`${cfg.debug.port}/TCP`);
|
|
327
|
+
}
|
|
328
|
+
const portNames = new Set(['http', ...(cfg.debug.enabled && cfg.debug.port ? ['debug'] : [])]);
|
|
329
|
+
cfg.ports.forEach((p, i) => {
|
|
330
|
+
const name = p.name || (p.protocol === 'UDP' ? `p${p.port}-udp` : `p${p.port}`);
|
|
331
|
+
if (portNames.has(name)) issue(['ports', i, 'name'], `port name "${name}" is already used (the app uses "http", and "debug" when debugging)`);
|
|
332
|
+
portNames.add(name);
|
|
333
|
+
});
|
|
334
|
+
cfg.ports.forEach((p, i) => {
|
|
335
|
+
if (taken.has(`${p.port}/${p.protocol}`)) issue(['ports', i, 'port'], `port ${p.port}/${p.protocol} is already used by "port", "debug.port" or another entry`);
|
|
336
|
+
taken.add(`${p.port}/${p.protocol}`);
|
|
337
|
+
});
|
|
338
|
+
cfg.sidecars.forEach((s, i) => {
|
|
339
|
+
if (s.port && taken.has(`${s.port}/TCP`)) issue(['sidecars', i, 'port'], `port ${s.port} is already used by the app or another entry; containers in a pod share one network`);
|
|
340
|
+
if (s.port) taken.add(`${s.port}/TCP`);
|
|
341
|
+
if (s.name.startsWith('wait-for-')) issue(['sidecars', i, 'name'], '"wait-for-*" is reserved for the automatic dependency wait');
|
|
342
|
+
});
|
|
343
|
+
cfg.ingress.paths.forEach((p, i) => {
|
|
344
|
+
if (p.port && !servicePorts.includes(p.port)) {
|
|
345
|
+
issue(['ingress', 'paths', i, 'port'], `port ${p.port} is not "port" or a TCP entry of "ports"`);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
for (const [key, value] of Object.entries(cfg.ingress.annotations)) {
|
|
349
|
+
const rule = INGRESS_ANNOTATION_VALUES[key];
|
|
350
|
+
if (rule && !rule.test(String(value))) issue(['ingress', 'annotations', key], `value "${String(value).slice(0, 40)}" is not valid for this annotation`);
|
|
351
|
+
}
|
|
352
|
+
const subdomain = cfg.subdomain || cfg.name;
|
|
353
|
+
if (new Set(cfg.ingress.hosts).size !== cfg.ingress.hosts.length || cfg.ingress.hosts.includes(subdomain)) {
|
|
354
|
+
issue(['ingress', 'hosts'], `hosts must be unique and different from the main subdomain "${subdomain}"`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const key of [...Object.keys(cfg.labels), ...Object.keys(cfg.annotations)]) {
|
|
358
|
+
if (isReservedKey(key)) issue(['labels'], `"${key}" is reserved (the "app" label and localctl.dev/* are managed by localctl)`);
|
|
359
|
+
}
|
|
360
|
+
for (const key of Object.keys(cfg.annotations)) {
|
|
361
|
+
if (!POD_ANNOTATION_ALLOWED.test(key)) issue(['annotations'], `"${key}" is not allowed: pod annotations may be a plain key (like "note") or sit under prometheus.io/, reloader.stakater.com/, fluentbit.io/, ad.datadoghq.com/, co.elastic.logs/, logging.banzaicloud.io/, backup.velero.io/ or checksum/`);
|
|
362
|
+
}
|
|
363
|
+
for (const key of Object.keys(cfg.labels)) {
|
|
364
|
+
if (isDangerousLabelKey(key) || /^(istio-injection|pod-security\.kubernetes\.io\/)/.test(key)) {
|
|
365
|
+
issue(['labels'], `"${key}" is not allowed: namespaces, meshes and the cluster act on this label`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const namespace = cfg.namespace || cfg.name;
|
|
369
|
+
if (isReservedNamespace(namespace)) {
|
|
370
|
+
issue([cfg.namespace ? 'namespace' : 'name'], `the namespace "${namespace}" is reserved for Kubernetes and localctl itself; choose another ${cfg.namespace ? '"namespace"' : '"name" or set "namespace"'}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Container names are shared by the app, sidecars and init containers in one pod.
|
|
374
|
+
const names = [cfg.name, ...cfg.sidecars.map((s) => s.name), ...cfg.initContainers.map((c) => c.name)];
|
|
375
|
+
const dup = names.find((n, i) => names.indexOf(n) !== i);
|
|
376
|
+
if (dup) issue(['initContainers'], `container name "${dup}" is used more than once (the app, sidecars and init containers share one namespace of names)`);
|
|
377
|
+
cfg.initContainers.forEach((c, i) => {
|
|
378
|
+
if (c.name.startsWith('wait-for-')) issue(['initContainers', i, 'name'], '"wait-for-*" is reserved for the automatic dependency wait');
|
|
379
|
+
});
|
|
380
|
+
const volumeNames = cfg.volumes.map((v) => v.name);
|
|
381
|
+
if (new Set(volumeNames).size !== volumeNames.length) issue(['volumes'], 'volume names must be unique');
|
|
382
|
+
if (new Set(cfg.volumes.map((v) => v.mountPath)).size !== cfg.volumes.length) issue(['volumes'], 'mountPath values must be unique');
|
|
383
|
+
|
|
384
|
+
if (cfg.run.length > 0 && cfg.sync.length === 0) {
|
|
385
|
+
ctx.addIssue({
|
|
386
|
+
code: z.ZodIssueCode.custom,
|
|
387
|
+
path: ['run'],
|
|
388
|
+
message: '"run" commands execute after files are synced into the container, so they need at least one "sync" entry.',
|
|
389
|
+
});
|
|
390
|
+
}
|
|
93
391
|
const overlap = cfg.secrets.filter((key) => key in cfg.env);
|
|
94
392
|
if (overlap.length > 0) {
|
|
95
393
|
ctx.addIssue({
|
|
@@ -100,4 +398,77 @@ const appConfigSchema = z
|
|
|
100
398
|
}
|
|
101
399
|
});
|
|
102
400
|
|
|
103
|
-
|
|
401
|
+
// Keys accepted at `path` (a list of object keys and array indexes) in the config, for "did you
|
|
402
|
+
// mean" hints. Walks through the wrappers zod puts around a schema (default, optional, effects,
|
|
403
|
+
// arrays) down to the object that owns the keys.
|
|
404
|
+
function knownKeysAt(schema, path) {
|
|
405
|
+
let node = schema;
|
|
406
|
+
const unwrap = (n) => {
|
|
407
|
+
for (;;) {
|
|
408
|
+
const def = n._def;
|
|
409
|
+
if (def.innerType) n = def.innerType;
|
|
410
|
+
else if (def.schema) n = def.schema;
|
|
411
|
+
else return n;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
for (const part of [...path, null]) {
|
|
415
|
+
node = unwrap(node);
|
|
416
|
+
if (node._def.typeName === 'ZodArray') {
|
|
417
|
+
node = unwrap(node._def.type);
|
|
418
|
+
}
|
|
419
|
+
if (part === null) break;
|
|
420
|
+
if (typeof part === 'number') continue;
|
|
421
|
+
if (!node.shape || !node.shape[part]) return [];
|
|
422
|
+
node = node.shape[part];
|
|
423
|
+
}
|
|
424
|
+
return node.shape ? Object.keys(node.shape).filter((k) => k !== '$schema') : [];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function editDistance(a, b) {
|
|
428
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
429
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
430
|
+
let diagonal = prev[0];
|
|
431
|
+
prev[0] = i;
|
|
432
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
433
|
+
const above = prev[j];
|
|
434
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
435
|
+
diagonal = above;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return prev[b.length];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function closestKey(key, candidates) {
|
|
442
|
+
let best = null;
|
|
443
|
+
let bestDistance = Infinity;
|
|
444
|
+
for (const c of candidates) {
|
|
445
|
+
const d = editDistance(key.toLowerCase(), c.toLowerCase());
|
|
446
|
+
if (d < bestDistance) { best = c; bestDistance = d; }
|
|
447
|
+
}
|
|
448
|
+
return best !== null && bestDistance <= Math.max(1, Math.floor(key.length / 3)) ? best : null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// One readable line per problem, with a "did you mean" hint for unknown fields.
|
|
452
|
+
function describeIssues(issues) {
|
|
453
|
+
const lines = [];
|
|
454
|
+
for (const issue of issues) {
|
|
455
|
+
const where = issue.path.join('.') || '(root)';
|
|
456
|
+
if (issue.code === 'unrecognized_keys') {
|
|
457
|
+
const known = knownKeysAt(appConfigSchema, issue.path);
|
|
458
|
+
for (const rawKey of issue.keys) {
|
|
459
|
+
// Keys can be huge (YAML allows anything as a key), so cap what is shown and compared.
|
|
460
|
+
const key = rawKey.length > 60 ? `${rawKey.slice(0, 57)}...` : rawKey;
|
|
461
|
+
const hint = rawKey.length > 64 ? null : closestKey(key, known);
|
|
462
|
+
const options = known.length ? ` Valid fields here: ${known.join(', ')}.` : '';
|
|
463
|
+
lines.push(` - ${where}: unknown field "${key}"${hint ? ` - did you mean "${hint}"?` : '.'}${hint ? '' : options}`);
|
|
464
|
+
}
|
|
465
|
+
} else if (issue.code === 'invalid_type' && issue.expected === 'number' && issue.received === 'string') {
|
|
466
|
+
lines.push(` - ${where}: expected a number; write it plainly, like 8080 (no quotes, decimals or +)`);
|
|
467
|
+
} else {
|
|
468
|
+
lines.push(` - ${where}: ${issue.message}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return lines.join('\n');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
module.exports = { appConfigSchema, describeIssues };
|
package/src/lib/engine.js
CHANGED
|
@@ -4,8 +4,8 @@ const logger = require('./logger');
|
|
|
4
4
|
// Support status per engine lives in cli/package.json ("localctl.containerEngines"), so marking an
|
|
5
5
|
// engine as tested is a one-word config change, not a code change. "tested" means verified end to
|
|
6
6
|
// end; anything else (or an engine missing from the map) gets the experimental warning.
|
|
7
|
-
const { localctl: { containerEngines = {}
|
|
8
|
-
const ENGINE_LABELS = { docker: 'Docker', podman: 'Podman', 'rancher-desktop': 'Rancher Desktop' };
|
|
7
|
+
const { localctl: { containerEngines = {} } = {} } = require('../../package.json');
|
|
8
|
+
const ENGINE_LABELS = { docker: 'Docker Desktop', podman: 'Podman', 'rancher-desktop': 'Rancher Desktop' };
|
|
9
9
|
const ISSUES_URL = 'https://github.com/psilvmoreira/localctl/issues';
|
|
10
10
|
|
|
11
11
|
// Returns { engine, bin } - `engine` names what's actually running (docker/podman/rancher-desktop),
|
|
@@ -33,14 +33,4 @@ function warnIfExperimental(engine) {
|
|
|
33
33
|
logger.warn(`If something breaks, please report it: ${ISSUES_URL}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// Same idea as warnIfExperimental(), for the operating system: platform status lives in
|
|
39
|
-
// cli/package.json ("localctl.platforms").
|
|
40
|
-
function warnIfExperimentalPlatform() {
|
|
41
|
-
if (!platforms[process.platform] || platforms[process.platform] === 'tested') return;
|
|
42
|
-
logger.warn(`${PLATFORM_LABELS[process.platform] || process.platform} support is experimental - it hasn't been through a full test cycle yet.`);
|
|
43
|
-
logger.warn(`If something breaks, please report it: ${ISSUES_URL}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
module.exports = { detectEngine, warnIfExperimental, warnIfExperimentalPlatform };
|
|
36
|
+
module.exports = { detectEngine, warnIfExperimental };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { readRegularFile } = require('./safeRead');
|
|
4
|
+
|
|
5
|
+
const MAX_ENV_FILE_BYTES = 256 * 1024;
|
|
6
|
+
|
|
7
|
+
const MAX_LINE_LENGTH = 8192;
|
|
8
|
+
|
|
9
|
+
// Drops a trailing ` # comment` from an unquoted value with a linear scan (a regex for this is
|
|
10
|
+
// quadratic on long runs of spaces).
|
|
11
|
+
function stripInlineComment(value) {
|
|
12
|
+
for (let i = 1; i < value.length; i += 1) {
|
|
13
|
+
if (value[i] === '#' && (value[i - 1] === ' ' || value[i - 1] === '\t')) return value.slice(0, i).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Minimal dotenv parser: KEY=value lines, `#` comments, optional `export ` prefix, values may be
|
|
19
|
+
// wrapped in single or double quotes (anything after the closing quote is ignored). No variable
|
|
20
|
+
// expansion on purpose: values are used exactly as written.
|
|
21
|
+
function parseEnvFile(text) {
|
|
22
|
+
const out = {};
|
|
23
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
24
|
+
if (line.length > MAX_LINE_LENGTH) throw new Error(`line ${index + 1} is longer than ${MAX_LINE_LENGTH} characters`);
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (!trimmed || trimmed.startsWith('#')) return;
|
|
27
|
+
const m = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
28
|
+
if (!m) throw new Error(`line ${index + 1} is not KEY=value`);
|
|
29
|
+
let value = m[2];
|
|
30
|
+
const quote = value[0];
|
|
31
|
+
const close = quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1;
|
|
32
|
+
value = close > 0 ? value.slice(1, close) : stripInlineComment(value);
|
|
33
|
+
if (m[1] === '__proto__') return;
|
|
34
|
+
out[m[1]] = value;
|
|
35
|
+
});
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reads `file` (relative to appDir). It has to stay inside the app folder, so a config from a
|
|
40
|
+
// repo you cloned can't point at ~/.aws/credentials or similar and pull its content into the
|
|
41
|
+
// container's environment.
|
|
42
|
+
function readEnvFile(appDir, file) {
|
|
43
|
+
const root = fs.realpathSync(appDir);
|
|
44
|
+
const resolved = path.resolve(root, file);
|
|
45
|
+
if (!fs.existsSync(resolved)) throw new Error(`envFile "${file}" not found (looked in ${resolved}).`);
|
|
46
|
+
const real = fs.realpathSync(resolved);
|
|
47
|
+
const inside = path.relative(root, real);
|
|
48
|
+
if (inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) {
|
|
49
|
+
throw new Error(`envFile "${file}" is outside the app folder; keep it inside ${root}.`);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return parseEnvFile(readRegularFile(real, MAX_ENV_FILE_BYTES, `envFile "${file}"`));
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new Error(e.message.startsWith('envFile') ? e.message : `envFile "${file}": ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { parseEnvFile, readEnvFile };
|
package/src/lib/exec.js
CHANGED
|
@@ -12,7 +12,9 @@ function run(cmd, args, opts = {}) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function capture(cmd, args, opts = {}) {
|
|
15
|
-
|
|
15
|
+
// 64 MB instead of Node's 1 MiB default: kubectl output (a Secret list, all Ingresses) can be big,
|
|
16
|
+
// and hitting the limit would look like a failed command.
|
|
17
|
+
const res = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
16
18
|
return {
|
|
17
19
|
status: res.status,
|
|
18
20
|
stdout: (res.stdout || '').trim(),
|
|
@@ -21,12 +23,9 @@ function capture(cmd, args, opts = {}) {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
function commandExists(cmd) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// `command -v` is a POSIX shell builtin; `which` is a separate program that minimal installs
|
|
28
|
-
// (Fedora/openSUSE server images, containers) don't ship.
|
|
29
|
-
return spawnSync('sh', ['-c', 'command -v "$0"', cmd], { stdio: 'ignore' }).status === 0;
|
|
26
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
27
|
+
const res = spawnSync(probe, [cmd], { stdio: 'ignore' });
|
|
28
|
+
return res.status === 0;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
// Blocking sleep with no native dependency - used for poll loops (e.g. waiting on a CRD to
|