@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,6 +1,7 @@
1
- const { run, capture } = require('./exec');
1
+ const { run, capture, sleepSync } = require('./exec');
2
+ const { isNotFound, isConflict, firstLine } = require('./kubeErrors');
2
3
 
3
- // One Secret per app, holding whatever keys `.local/config.json`'s "secrets" array declares.
4
+ // One Secret per app, holding whatever keys `.local/config.yaml`'s "secrets" array declares.
4
5
  // Unlike addon-generated secrets (random values, never seen by you), these are values *you*
5
6
  // supply - an API key, a third-party token - so there's no "generate once" step: you set them
6
7
  // with `localctl app secrets set`, and manifestGen.js just references them via secretKeyRef.
@@ -8,53 +9,123 @@ function secretNameFor(cfg) {
8
9
  return `${cfg.name}-secrets`;
9
10
  }
10
11
 
11
- function ensureNamespace(namespace) {
12
- if (capture('kubectl', ['get', 'namespace', namespace]).status !== 0) {
13
- run('kubectl', ['create', 'namespace', namespace], { stdio: 'ignore', allowFail: true });
12
+ const { ensureManagedNamespace: ensureNamespace, assertNamespaceOwnership, MANAGED_LABEL } = require('./namespaces');
13
+
14
+ const MAX_VALUE_BYTES = 512 * 1024; // a Secret is limited to 1 MiB in total
15
+ const LAST_APPLIED = 'kubectl.kubernetes.io/last-applied-configuration';
16
+
17
+ // The Secret as stored, with what is needed to write it back safely: its data (key -> base64, kept
18
+ // in base64 end to end so binary values survive), its resourceVersion, and its labels/annotations.
19
+ // Anything unexpected in the answer is an error: a wrong guess here would overwrite real data.
20
+ function readSecret(cfg) {
21
+ assertNamespaceOwnership(cfg);
22
+ const name = secretNameFor(cfg);
23
+ const res = capture('kubectl', ['get', 'secret', name, '-n', cfg.namespace, '-o', 'json', '--request-timeout=20s']);
24
+ if (res.status !== 0) {
25
+ if (isNotFound(res.stderr, 'secrets', name)) return { exists: false, data: {} };
26
+ throw new Error(`Couldn't read the secrets of "${cfg.name}": ${firstLine(res.stderr || res.stdout || 'kubectl failed')}`);
27
+ }
28
+ let obj;
29
+ try {
30
+ obj = JSON.parse(res.stdout);
31
+ } catch (e) {
32
+ obj = null;
33
+ }
34
+ const meta = obj && typeof obj === 'object' && !Array.isArray(obj) ? obj.metadata : null;
35
+ const data = obj && obj.data === undefined ? {} : obj && obj.data;
36
+ const isMap = data !== null && typeof data === 'object' && !Array.isArray(data) && Object.values(data).every((v) => typeof v === 'string');
37
+ if (!meta || typeof meta.resourceVersion !== 'string' || !isMap) {
38
+ throw new Error(`The cluster returned the Secret "${name}" in an unexpected form, so it won't be changed.`);
14
39
  }
40
+ // Only a Secret localctl created is edited. Adopting one that happens to have the same name would
41
+ // mix its keys into the app's environment (and `replace` would drop its labels' meaning).
42
+ if ((meta.labels || {})[MANAGED_LABEL] !== 'true') {
43
+ throw new Error(
44
+ `A Secret named "${name}" already exists in namespace "${cfg.namespace}" and wasn't created by localctl. ` +
45
+ `Rename it, or if it is yours: kubectl label secret ${name} -n ${cfg.namespace} ${MANAGED_LABEL}=true`,
46
+ );
47
+ }
48
+ return { exists: true, data, resourceVersion: meta.resourceVersion, labels: meta.labels, annotations: meta.annotations };
49
+ }
50
+
51
+ function readRaw(cfg) {
52
+ return readSecret(cfg).data;
15
53
  }
16
54
 
17
55
  function readAll(cfg) {
18
- const res = capture('kubectl', ['get', 'secret', secretNameFor(cfg), '-n', cfg.namespace, '-o', 'json']);
19
- if (res.status !== 0) return {};
20
- const data = JSON.parse(res.stdout).data || {};
21
- return Object.fromEntries(Object.entries(data).map(([k, v]) => [k, Buffer.from(v, 'base64').toString('utf8')]));
22
- }
23
-
24
- // Rewrites the whole Secret from a plain key->value map. `apply` (not `create`) makes this
25
- // idempotent - a re-run with the same data is a no-op, a changed one updates in place.
26
- function writeAll(cfg, data) {
27
- ensureNamespace(cfg.namespace);
28
- const doc = {
29
- apiVersion: 'v1',
30
- kind: 'Secret',
31
- metadata: { name: secretNameFor(cfg), namespace: cfg.namespace },
32
- type: 'Opaque',
33
- stringData: data,
34
- };
35
- run('kubectl', ['apply', '-f', '-'], { stdio: ['pipe', 'inherit', 'inherit'], input: JSON.stringify(doc) });
56
+ return Object.fromEntries(Object.entries(readRaw(cfg)).map(([k, v]) => [k, Buffer.from(v, 'base64').toString('utf8')]));
57
+ }
58
+
59
+ // Writes the Secret with create/replace, not `apply`: apply keeps a copy of what it sent in the
60
+ // kubectl.kubernetes.io/last-applied-configuration annotation, which would leave every value
61
+ // readable in plain text next to the Secret, and it doesn't drop a key that is no longer in the
62
+ // new data. `replace` carries the resourceVersion that was read, so if someone changed the Secret
63
+ // in between, the write is refused (returns false) instead of silently overwriting their change.
64
+ function writeSecret(cfg, snapshot, data) {
65
+ const name = secretNameFor(cfg);
66
+ const annotations = { ...(snapshot.annotations || {}) };
67
+ delete annotations[LAST_APPLIED]; // a copy of old plaintext values from an earlier `kubectl apply`
68
+ const metadata = { name, namespace: cfg.namespace, labels: { ...(snapshot.labels || {}), [MANAGED_LABEL]: 'true' } };
69
+ if (Object.keys(annotations).length) metadata.annotations = annotations;
70
+ if (snapshot.exists) metadata.resourceVersion = snapshot.resourceVersion;
71
+ const doc = { apiVersion: 'v1', kind: 'Secret', metadata, type: 'Opaque', data };
72
+ const res = capture('kubectl', [snapshot.exists ? 'replace' : 'create', '-f', '-', '--request-timeout=30s'], { input: JSON.stringify(doc) });
73
+ if (res.status === 0) return true;
74
+ // Changed or created by someone else meanwhile, or deleted since we read it: read again and retry.
75
+ if (isConflict(res.stderr) || (snapshot.exists && isNotFound(res.stderr, 'secrets', name))) return false;
76
+ throw new Error(`Couldn't write the secrets of "${cfg.name}": ${firstLine(res.stderr || res.stdout || 'kubectl failed')}`);
77
+ }
78
+
79
+ // Read-modify-write that survives two processes changing the same Secret at once: on a conflict it
80
+ // reads again and re-applies the change. `change(data)` returns the new data, or null for "nothing
81
+ // to do".
82
+ function updateSecret(cfg, change) {
83
+ for (let attempt = 0; attempt < 8; attempt += 1) {
84
+ const snapshot = readSecret(cfg);
85
+ const next = change({ ...snapshot.data });
86
+ if (next === null) return false;
87
+ if (Object.keys(next).length === 0) {
88
+ // Empty the Secret under the same resourceVersion check first (so a concurrent writer is
89
+ // noticed), then remove it.
90
+ if (snapshot.exists && !writeSecret(cfg, snapshot, {})) {
91
+ sleepSync(100 + Math.floor(Math.random() * 300));
92
+ continue;
93
+ }
94
+ run('kubectl', ['delete', 'secret', secretNameFor(cfg), '-n', cfg.namespace, '--ignore-not-found=true'], { stdio: 'ignore' });
95
+ return true;
96
+ }
97
+ ensureNamespace(cfg.namespace, cfg.name);
98
+ if (writeSecret(cfg, snapshot, next)) return true;
99
+ sleepSync(100 + Math.floor(Math.random() * 300));
100
+ }
101
+ throw new Error(`The secrets of "${cfg.name}" kept changing while updating them; try again.`);
102
+ }
103
+
104
+ function assertKey(key) {
105
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || key.length > 253) throw new Error(`"${key}" is not a valid secret name: use UPPER_SNAKE_CASE.`);
36
106
  }
37
107
 
38
108
  function setSecret(cfg, key, value) {
39
- const data = readAll(cfg);
40
- data[key] = value;
41
- writeAll(cfg, data);
109
+ assertKey(key);
110
+ const bytes = Buffer.from(String(value), 'utf8');
111
+ if (bytes.length > MAX_VALUE_BYTES) throw new Error(`The value for "${key}" is ${Math.round(bytes.length / 1024)} KB; a secret value can be at most ${MAX_VALUE_BYTES / 1024} KB.`);
112
+ updateSecret(cfg, (data) => ({ ...data, [key]: bytes.toString('base64') }));
42
113
  }
43
114
 
44
115
  function unsetSecret(cfg, key) {
45
- const data = readAll(cfg);
46
- if (!(key in data)) return false;
47
- delete data[key];
48
- if (Object.keys(data).length === 0) {
49
- run('kubectl', ['delete', 'secret', secretNameFor(cfg), '-n', cfg.namespace, '--ignore-not-found=true'], { stdio: 'ignore' });
50
- } else {
51
- writeAll(cfg, data);
52
- }
53
- return true;
116
+ assertKey(key);
117
+ let found = false;
118
+ updateSecret(cfg, (data) => {
119
+ if (!(key in data)) return null;
120
+ found = true;
121
+ delete data[key];
122
+ return data;
123
+ });
124
+ return found;
54
125
  }
55
126
 
56
127
  function listKeys(cfg) {
57
- return Object.keys(readAll(cfg));
128
+ return Object.keys(readRaw(cfg));
58
129
  }
59
130
 
60
131
  module.exports = { secretNameFor, setSecret, unsetSecret, listKeys, readAll };
@@ -3,13 +3,11 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const yaml = require('js-yaml');
5
5
  const { run, capture, sleepSync, commandExists } = require('./exec');
6
- const { detectEngine, warnIfExperimental, warnIfExperimentalPlatform } = require('./engine');
7
- const { installIfMissing, installTiltWindows, nssInstallHint } = require('./toolInstall');
8
- const { ensureInsecureRegistry, ensurePodmanSocket, blockedPrivilegedPorts } = require('./podman');
6
+ const { detectEngine, warnIfExperimental } = require('./engine');
7
+ const { installIfMissing, installTiltWindows } = require('./toolInstall');
8
+ const { ensureInsecureRegistry } = require('./podman');
9
9
  const { DOMAIN } = require('./constants');
10
10
  const logger = require('./logger');
11
- const { CERTS_DIR } = require('./paths');
12
- const { detectDistro, dockerInstallSteps } = require('./linuxDistro');
13
11
 
14
12
  // Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
15
13
  // scripts/sync-assets.js at pack time), or read straight from the repo root in a git checkout.
@@ -18,15 +16,6 @@ const MANIFESTS_DIR = fs.existsSync(BUNDLED_MANIFESTS_DIR)
18
16
  ? BUNDLED_MANIFESTS_DIR
19
17
  : path.resolve(__dirname, '..', '..', '..', 'cluster', 'manifests');
20
18
 
21
- function isWsl() {
22
- if (process.platform !== 'linux') return false;
23
- try {
24
- return /microsoft/i.test(fs.readFileSync('/proc/version', 'utf8'));
25
- } catch (e) {
26
- return false;
27
- }
28
- }
29
-
30
19
  function registryNameFor(clusterName) {
31
20
  return `${clusterName}-registry`;
32
21
  }
@@ -38,163 +27,16 @@ function clusterExists(clusterName) {
38
27
  .includes(clusterName);
39
28
  }
40
29
 
41
- // k3d writes the API server as https://0.0.0.0:<port> into kubeconfig. Linux and Windows treat
42
- // 0.0.0.0 as "this host", but macOS refuses to connect to it ("can't assign requested address"),
43
- // so every kubectl call fails. Selects the cluster's context and points it at 127.0.0.1 instead,
44
- // which also repairs clusters created before this fix.
45
- function useClusterContext(clusterName) {
46
- const context = `k3d-${clusterName}`;
47
- // The cluster can exist while this kubeconfig knows nothing about it (a deleted or replaced
48
- // ~/.kube/config, a different KUBECONFIG): ask k3d to write it back instead of failing.
49
- const known = capture('kubectl', ['config', 'get-contexts', '-o', 'name']).stdout.split('\n');
50
- if (!known.includes(context)) {
51
- run('k3d', ['kubeconfig', 'merge', clusterName, '--kubeconfig-merge-default'], { stdio: 'ignore', allowFail: true });
52
- }
53
- run('kubectl', ['config', 'use-context', context], { stdio: 'ignore' });
54
- const server = capture('kubectl', [
55
- 'config', 'view', '--minify', '-o', 'jsonpath={.clusters[0].cluster.server}',
56
- ]).stdout;
57
- if (server.includes('//0.0.0.0:')) {
58
- run('kubectl', [
59
- 'config', 'set-cluster', context, `--server=${server.replace('//0.0.0.0:', '//127.0.0.1:')}`,
60
- ], { stdio: 'ignore' });
61
- }
62
- }
63
-
64
- // Generated fresh per call, not a static file: every Main project needs its own cluster name,
65
- // ports, and registry mirror name baked in.
66
- function buildK3dConfig({ clusterName, httpPort, httpsPort, registryName }) {
67
- return {
68
- apiVersion: 'k3d.io/v1alpha5',
69
- kind: 'Simple',
70
- metadata: { name: clusterName },
71
- servers: 1,
72
- agents: 0,
73
- ports: [
74
- { port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
75
- { port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
76
- ],
77
- options: {
78
- k3s: {
79
- // metrics-server only feeds `kubectl top` and HPAs, which a local dev loop doesn't use;
80
- // dropping it saves memory. servicelb must stay: it's what opens :80/:443 on the node for
81
- // Traefik's LoadBalancer Service, so disabling it would break every ingress.
82
- extraArgs: [{ arg: '--disable=metrics-server', nodeFilters: ['server:*'] }],
83
- },
84
- },
85
- // Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
86
- // attaching to a network literally named "bridge", which Docker has by default but Podman
87
- // does not, breaking cluster creation there.
88
- registries: {
89
- config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
90
- },
91
- };
92
- }
93
-
94
- // Right after a (re)start the load balancer can accept connections before the API server is
95
- // answering (EOF / connection refused), so wait for it before the first real kubectl call.
96
- // Also after an engine restart the server container can get a new IP while the load balancer
97
- // (nginx, resolves upstreams only at startup) keeps the old one, so the API never answers;
98
- // restarting the load balancer once re-resolves it. Returns true once the API is ready.
99
- function waitForClusterApi(engineBin, clusterName, { attempts = 60, quiet = false } = {}) {
100
- if (!quiet) logger.step('Waiting for the Kubernetes API...');
101
- let lbRestarted = false;
102
- for (let i = 0; i < attempts; i += 1) {
103
- if (capture('kubectl', ['get', '--raw', '/readyz']).status === 0) return true;
104
- if (i === 5 && !lbRestarted) {
105
- lbRestarted = true;
106
- run(engineBin, ['restart', `k3d-${clusterName}-serverlb`], { stdio: 'ignore', allowFail: true });
107
- }
108
- sleepSync(2000);
109
- }
110
- return false;
111
- }
112
-
113
- // k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
114
- // registering its CRDs, so applying TLSStore any earlier races and fails intermittently. On a slow
115
- // machine (first image pulls, a nested engine) that takes minutes, so poll for the CRD to be
116
- // *Established* instead of giving up after one fixed wait, and say why if it never is.
117
- function waitForTraefikCrds(attempts = 150) {
118
- logger.step('Waiting for Traefik CRDs...');
119
- let last = 'not found yet';
120
- for (let i = 0; i < attempts; i += 1) {
121
- const r = capture('kubectl', [
122
- 'get', 'crd', 'tlsstores.traefik.io',
123
- '-o', 'jsonpath={.status.conditions[?(@.type=="Established")].status}',
124
- ]);
125
- if (r.status === 0 && r.stdout === 'True') return;
126
- last = r.status === 0 ? `Established=${r.stdout || 'unknown'}` : (r.stderr.split('\n')[0] || 'not found yet');
127
- sleepSync(2000);
128
- }
129
- throw new Error(
130
- `Traefik's CRDs (tlsstores.traefik.io) were not ready after ${Math.round((attempts * 2) / 60)} minutes (${last}). ` +
131
- 'Check "kubectl -n kube-system get pods" - the traefik helm-install jobs are probably still pulling images.',
132
- );
133
- }
134
-
135
- // The CRDs existing does not mean Traefik is serving: on a slow machine its pod can still be
136
- // starting minutes later, and an app deployed in that window answers with a dropped connection.
137
- // Waiting here makes "cluster ready" mean the ingress actually accepts traffic.
138
- function waitForTraefik(attempts = 150) {
139
- logger.step('Waiting for Traefik to accept traffic...');
140
- for (let i = 0; i < attempts; i += 1) {
141
- if (capture('kubectl', ['get', 'deployment', 'traefik', '-n', 'kube-system']).status === 0) break;
142
- sleepSync(2000);
143
- }
144
- const res = capture('kubectl', ['rollout', 'status', 'deployment/traefik', '-n', 'kube-system', '--timeout=300s']);
145
- if (res.status !== 0) {
146
- throw new Error(
147
- `Traefik did not become ready in 5 minutes (${(res.stderr || res.stdout).split('\n')[0]}). ` +
148
- 'Check "kubectl -n kube-system get pods".',
149
- );
150
- }
151
- }
152
-
153
- // A CRD can be Established a moment before the API server's discovery serves it, which surfaces as
154
- // "no matches for kind". Retrying the apply covers that window.
155
- function applyWithRetry(file, attempts = 15) {
156
- let res;
157
- for (let i = 0; i < attempts; i += 1) {
158
- res = capture('kubectl', ['apply', '-f', file]);
159
- if (res.status === 0) {
160
- if (res.stdout) console.log(res.stdout);
161
- return;
162
- }
163
- sleepSync(2000);
164
- }
165
- throw new Error(`kubectl apply -f ${path.basename(file)} failed after ${attempts} attempts: ${res.stderr}`);
166
- }
167
-
168
30
  // Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
169
31
  // cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
170
32
  // registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
171
33
  // `localctl setup` (the "default" profile, unchanged ports/names from before multi-project
172
34
  // support) and `localctl profiles new`/`switch` (any other Main project).
173
35
  function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
174
- warnIfExperimentalPlatform();
175
- if (isWsl()) {
176
- logger.warn('WSL detected: browsers on Windows read C:\\Windows\\System32\\drivers\\etc\\hosts, not this distro\'s /etc/hosts, and WSL may regenerate /etc/hosts on restart. Add the *.local.test entries to the Windows hosts file too (localctl prints them), or run localctl from Windows instead.');
177
- }
178
36
  logger.step('Detecting container engine...');
179
37
  const detected = detectEngine();
180
38
  if (!detected) {
181
- let linuxHint = '';
182
- if (process.platform === 'linux') {
183
- if (commandExists('docker') || commandExists('podman')) {
184
- // Installed but not usable: reinstalling won't help, the daemon or permissions are the issue.
185
- linuxHint = '\nA container engine is installed but not responding. Start the service' +
186
- ' (`sudo systemctl enable --now docker`, or `systemctl --user enable --now podman.socket` for Podman;' +
187
- ' without systemd, start `dockerd` yourself) and make sure your user can reach it' +
188
- ' (member of the "docker" group, then log out and in).';
189
- } else {
190
- const { family, id } = detectDistro();
191
- const steps = dockerInstallSteps(family, id);
192
- linuxHint = steps
193
- ? `\nTo install Docker Engine, run "localctl setup --install-docker", or by hand:\n ${steps.join('\n ')}`
194
- : '\nInstall Docker Engine for your distro: https://docs.docker.com/engine/install/';
195
- }
196
- }
197
- throw new Error(`No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.${linuxHint}`);
39
+ throw new Error('No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.');
198
40
  }
199
41
  const { engine, bin: engineBin } = detected;
200
42
  logger.success(`Using container engine: ${engine} (${engineBin} CLI)`);
@@ -205,14 +47,6 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
205
47
  // exist, but re-running it per registry port is cheap and idempotent.
206
48
  if (engine === 'podman') {
207
49
  ensureInsecureRegistry(registryPort);
208
- if (process.platform === 'linux') {
209
- const socketProblem = ensurePodmanSocket();
210
- if (socketProblem) logger.warn(socketProblem);
211
- const blocked = blockedPrivilegedPorts([httpPort, httpsPort]);
212
- if (blocked.length) {
213
- logger.warn(`Rootless Podman can't bind host port(s) ${blocked.join(', ')}. Allow it with: sudo sysctl net.ipv4.ip_unprivileged_port_start=80 (add it to /etc/sysctl.d/ to persist).`);
214
- }
215
- }
216
50
  }
217
51
 
218
52
  installIfMissing('k3d', 'k3d', 'k3d.k3d');
@@ -220,53 +54,39 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
220
54
  installIfMissing('tilt', 'tilt', installTiltWindows);
221
55
  installIfMissing('mkcert', 'mkcert', 'FiloSottile.mkcert');
222
56
  installIfMissing('helm', 'helm', 'Helm.Helm');
223
- // mkcert can only add its CA to Firefox's trust store when NSS's certutil is present. It's
224
- // optional (Safari/Chrome don't need it), so a failed or impossible install - e.g. no Homebrew -
225
- // only warns. macOS installs it automatically; Linux needs sudo, so it only prints the command.
226
- if (!commandExists('certutil')) {
227
- if (process.platform === 'darwin') {
228
- if (commandExists('brew')) {
229
- try {
230
- installIfMissing('certutil', 'nss');
231
- } catch (e) {
232
- logger.warn(`Couldn't install nss (${e.message}). Firefox won't trust the local CA until you run "brew install nss && mkcert -install".`);
233
- }
234
- } else {
235
- logger.warn('Homebrew not found, skipping nss. Firefox won\'t trust the local CA until certutil is installed and "mkcert -install" is re-run.');
236
- logger.info('Tip: install Homebrew (https://brew.sh) so localctl can install missing tools for you, then run "brew install nss".');
237
- }
238
- } else if (process.platform === 'linux') {
239
- logger.warn(`certutil not found, so Firefox/Chromium won't trust the local CA. Install it with: ${nssInstallHint()} - then re-run "mkcert -install".`);
240
- }
241
- }
242
57
 
243
58
  const registryName = registryNameFor(clusterName);
244
59
 
245
60
  if (clusterExists(clusterName)) {
246
61
  logger.success(`k3d cluster '${clusterName}' already exists`);
247
- // A reboot or engine restart leaves the cluster stopped; `start` is a no-op when it's running.
248
- run('k3d', ['cluster', 'start', clusterName, '--wait'], { stdio: 'ignore', allowFail: true });
249
62
  } else {
250
63
  logger.step(`Creating k3d cluster '${clusterName}'...`);
251
- const k3dConfig = buildK3dConfig({ clusterName, httpPort, httpsPort, registryName });
64
+ // Generated fresh per call, not a static file: every Main project needs its own cluster
65
+ // name, ports, and registry mirror name baked in.
66
+ const k3dConfig = {
67
+ apiVersion: 'k3d.io/v1alpha5',
68
+ kind: 'Simple',
69
+ metadata: { name: clusterName },
70
+ servers: 1,
71
+ agents: 0,
72
+ ports: [
73
+ { port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
74
+ { port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
75
+ ],
76
+ // Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
77
+ // attaching to a network literally named "bridge", which Docker has by default but Podman
78
+ // does not, breaking cluster creation there.
79
+ registries: {
80
+ config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
81
+ },
82
+ };
252
83
  const tmpConfig = path.join(os.tmpdir(), `localctl-k3d-config-${clusterName}.yaml`);
253
84
  fs.writeFileSync(tmpConfig, yaml.dump(k3dConfig));
254
85
  run('k3d', ['cluster', 'create', '--config', tmpConfig, '--wait']);
255
86
  fs.rmSync(tmpConfig, { force: true });
256
87
  logger.success('Cluster created');
257
88
  }
258
- useClusterContext(clusterName);
259
-
260
- // Up to ~5 minutes: k3s can exit once during start-up (a known race between its cloud controller
261
- // and RBAC bootstrap) and is restarted by the engine, which a 2-minute wait would not outlast on
262
- // a slow machine. If it never answers, stop here with the reason instead of failing later on an
263
- // unrelated "EOF" from kubectl apply.
264
- if (!waitForClusterApi(engineBin, clusterName, { attempts: 150 })) {
265
- throw new Error(
266
- `The Kubernetes API of cluster '${clusterName}' did not become ready in 5 minutes. ` +
267
- `See why with: ${engineBin} logs --tail 50 k3d-${clusterName}-server-0`,
268
- );
269
- }
89
+ run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
270
90
 
271
91
  const registryNetwork = `k3d-${clusterName}`;
272
92
  const existingNames = capture(engineBin, ['ps', '-a', '--format', '{{.Names}}']).stdout.split('\n');
@@ -290,7 +110,7 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
290
110
  // domain everywhere; only the namespace/subdomain prefix or the cluster differs).
291
111
  logger.step(`Setting up local TLS for *.${DOMAIN}...`);
292
112
  run('mkcert', ['-install']);
293
- const certDir = CERTS_DIR;
113
+ const certDir = path.join(os.homedir(), '.localctl', 'certs');
294
114
  fs.mkdirSync(certDir, { recursive: true });
295
115
  const certFile = path.join(certDir, 'local-dev.pem');
296
116
  const keyFile = path.join(certDir, 'local-dev-key.pem');
@@ -307,10 +127,15 @@ function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
307
127
 
308
128
  // k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
309
129
  // registering its CRDs, so applying TLSStore any earlier races and fails intermittently.
310
- waitForTraefikCrds();
311
- applyWithRetry(path.join(MANIFESTS_DIR, 'tls-store.yaml'));
312
- applyWithRetry(path.join(MANIFESTS_DIR, 'namespace-addons.yaml'));
313
- waitForTraefik();
130
+ logger.step('Waiting for Traefik CRDs...');
131
+ for (let i = 0; i < 60; i += 1) {
132
+ if (capture('kubectl', ['get', 'crd', 'tlsstores.traefik.io']).status === 0) break;
133
+ sleepSync(2000);
134
+ }
135
+ run('kubectl', ['wait', '--for=condition=Established', 'crd/tlsstores.traefik.io', '--timeout=60s'], { stdio: 'ignore' });
136
+
137
+ run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'tls-store.yaml')]);
138
+ run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'namespace-addons.yaml')]);
314
139
  logger.success('TLS configured, default cert wired into Traefik');
315
140
  }
316
141
 
@@ -340,7 +165,16 @@ function ensureClusterUp(clusterName, profileName) {
340
165
  const fix = profileName === 'default' ? 'localctl setup' : `localctl profiles switch ${profileName}`;
341
166
  throw new Error(`No cluster running for project "${profileName}". Run "${fix}" to start it.`);
342
167
  }
343
- useClusterContext(clusterName);
168
+ // Switching context rewrites the kubeconfig under a file lock, which makes parallel localctl runs
169
+ // fail at random. Skip it when this context is already the current one, and retry the rest.
170
+ const wanted = `k3d-${clusterName}`;
171
+ if (capture('kubectl', ['config', 'current-context']).stdout === wanted) return;
172
+ for (let attempt = 0; ; attempt += 1) {
173
+ const res = capture('kubectl', ['config', 'use-context', wanted]);
174
+ if (res.status === 0) return;
175
+ if (attempt >= 4) throw new Error(`Couldn't switch kubectl to ${wanted}: ${String(res.stderr || res.stdout).split('\n')[0].slice(0, 200)}`);
176
+ sleepSync(150 + Math.floor(Math.random() * 250));
177
+ }
344
178
  }
345
179
 
346
- module.exports = { buildK3dConfig, useClusterContext, waitForClusterApi, provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
180
+ module.exports = { provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };