@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.
Files changed (45) hide show
  1. package/README.md +32 -76
  2. package/package.json +3 -10
  3. package/schema/app.schema.json +678 -44
  4. package/src/commands/doctor.js +1 -16
  5. package/src/commands/down.js +3 -1
  6. package/src/commands/generateManifests.js +4 -0
  7. package/src/commands/logs.js +1 -0
  8. package/src/commands/new.js +42 -20
  9. package/src/commands/prune.js +4 -0
  10. package/src/commands/reload.js +11 -3
  11. package/src/commands/secrets.js +3 -3
  12. package/src/commands/setup.js +1 -11
  13. package/src/commands/tiltConfig.js +11 -0
  14. package/src/commands/uninstall.js +5 -6
  15. package/src/commands/up.js +9 -1
  16. package/src/commands/validate.js +48 -0
  17. package/src/index.js +2 -0
  18. package/src/lib/addonDirs.js +1 -2
  19. package/src/lib/addonLoader.js +38 -17
  20. package/src/lib/addons/mongo/addon.yaml +2 -0
  21. package/src/lib/addons/postgres/addon.yaml +5 -1
  22. package/src/lib/addons/redis/addon.yaml +2 -0
  23. package/src/lib/appSecrets.js +107 -36
  24. package/src/lib/clusterProvision.js +45 -211
  25. package/src/lib/configSchema.js +412 -41
  26. package/src/lib/engine.js +3 -13
  27. package/src/lib/envFile.js +58 -0
  28. package/src/lib/exec.js +6 -7
  29. package/src/lib/hostGuard.js +107 -0
  30. package/src/lib/hostsFile.js +9 -8
  31. package/src/lib/kubeErrors.js +22 -0
  32. package/src/lib/loadConfig.js +183 -9
  33. package/src/lib/manifestGen.js +96 -32
  34. package/src/lib/namespaces.js +83 -0
  35. package/src/lib/pathGuard.js +79 -0
  36. package/src/lib/paths.js +2 -6
  37. package/src/lib/podman.js +3 -93
  38. package/src/lib/projectContext.js +2 -2
  39. package/src/lib/quantities.js +20 -0
  40. package/src/lib/safeRead.js +41 -0
  41. package/src/lib/tiltConfig.js +25 -0
  42. package/src/lib/toolInstall.js +2 -141
  43. package/src/lib/yamlLoad.js +48 -0
  44. package/templates/Tiltfile.tmpl +22 -14
  45. package/src/lib/linuxDistro.js +0 -122
@@ -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
- const dependencySchema = z.object({
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: z.string().optional(),
10
- database: z.string().optional(),
11
- storage: z.string().optional(),
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
- .object({
182
+ .strictObject({
16
183
  enabled: z.boolean().default(false),
17
- port: z.number().int().optional(),
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.object({
23
- localPath: z.string(),
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.object({
32
- name: z.string().regex(/^[a-z0-9-]+$/, 'must be lowercase alphanumeric + dashes only'),
33
- image: z.string(),
34
- port: z.number().int().optional(),
35
- command: z.array(z.string()).optional(),
36
- args: z.array(z.string()).optional(),
37
- env: z.record(z.string()).default({}),
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 resourceSpec = z
41
- .object({
42
- cpu: z.string().optional(),
43
- memory: z.string().optional(),
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.object({
50
- path: z.string().optional(),
51
- initialDelaySeconds: z.number().int().optional(),
52
- periodSeconds: z.number().int().optional(),
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
- .object({
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
- .object({
64
- name: z
65
- .string()
66
- .regex(/^[a-z0-9-]+$/, 'must be lowercase alphanumeric + dashes only'),
67
- subdomain: z.string().regex(/^[a-z0-9-]+$/).optional(),
68
- namespace: z.string().optional(),
69
- port: z.number().int(),
70
- build: z.object({
71
- context: z.string().default('.'),
72
- dockerfile: z.string().default('Dockerfile'),
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: z.record(z.string()).default({}),
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
- .object({ requests: resourceSpec, limits: resourceSpec })
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
- module.exports = { appConfigSchema };
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 = {}, platforms = {} } = {} } = require('../../package.json');
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
- const PLATFORM_LABELS = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };
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
- const res = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
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
- if (process.platform === 'win32') {
25
- return spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
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