@localctl/cli 0.2.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/LICENSE +21 -0
- package/README.md +431 -0
- package/assets/manifests/namespace-addons.yaml +4 -0
- package/assets/manifests/tls-store.yaml +8 -0
- package/bin/localctl.js +2 -0
- package/package.json +54 -0
- package/schema/app.schema.json +127 -0
- package/src/commands/addons.js +56 -0
- package/src/commands/doctor.js +43 -0
- package/src/commands/down.js +35 -0
- package/src/commands/exec.js +15 -0
- package/src/commands/generateManifests.js +12 -0
- package/src/commands/hosts.js +23 -0
- package/src/commands/logs.js +45 -0
- package/src/commands/new.js +55 -0
- package/src/commands/profiles.js +180 -0
- package/src/commands/prune.js +49 -0
- package/src/commands/registryInfo.js +18 -0
- package/src/commands/reload.js +44 -0
- package/src/commands/secrets.js +75 -0
- package/src/commands/setup.js +36 -0
- package/src/commands/status.js +136 -0
- package/src/commands/uninstall.js +105 -0
- package/src/commands/up.js +123 -0
- package/src/index.js +36 -0
- package/src/lib/addonDirs.js +43 -0
- package/src/lib/addonLoader.js +190 -0
- package/src/lib/addons/cluster/logging/addon.yaml +46 -0
- package/src/lib/addons/cluster/logging/config/all-logs.json +21 -0
- package/src/lib/addons/cluster/monitoring/addon.yaml +33 -0
- package/src/lib/addons/mongo/addon.yaml +18 -0
- package/src/lib/addons/postgres/addon.yaml +39 -0
- package/src/lib/addons/redis/addon.yaml +29 -0
- package/src/lib/appSecrets.js +60 -0
- package/src/lib/clusterAddons.js +59 -0
- package/src/lib/clusterProvision.js +170 -0
- package/src/lib/configSchema.js +103 -0
- package/src/lib/constants.js +16 -0
- package/src/lib/engine.js +20 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/hostsFile.js +79 -0
- package/src/lib/loadConfig.js +51 -0
- package/src/lib/logger.js +9 -0
- package/src/lib/manifestGen.js +210 -0
- package/src/lib/paths.js +41 -0
- package/src/lib/podman.js +72 -0
- package/src/lib/profileStore.js +134 -0
- package/src/lib/projectContext.js +32 -0
- package/src/lib/prompt.js +97 -0
- package/src/lib/toolInstall.js +30 -0
- package/src/lib/ui.js +50 -0
- package/src/lib/workloadReadiness.js +35 -0
- package/templates/Tiltfile.tmpl +35 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Cluster-wide logging addon - deploys Grafana Labs' own official loki-stack chart (Loki +
|
|
2
|
+
# Promtail + Grafana, all in one). Enabled via `localctl addons enable logging`.
|
|
3
|
+
#
|
|
4
|
+
# `config` demonstrates the addon config/ folder feature: any file dropped in ./config/ becomes a
|
|
5
|
+
# ConfigMap ("<release>-config"), and {{configMapName}} below wires it in as a Grafana dashboard.
|
|
6
|
+
# See docs/adding-tools.md.
|
|
7
|
+
|
|
8
|
+
chart:
|
|
9
|
+
repo: https://grafana.github.io/helm-charts
|
|
10
|
+
name: loki-stack
|
|
11
|
+
version: "2.10.3"
|
|
12
|
+
|
|
13
|
+
config: true
|
|
14
|
+
|
|
15
|
+
values: |
|
|
16
|
+
loki:
|
|
17
|
+
enabled: true
|
|
18
|
+
promtail:
|
|
19
|
+
enabled: true
|
|
20
|
+
grafana:
|
|
21
|
+
enabled: true
|
|
22
|
+
adminUser: admin
|
|
23
|
+
dashboardProviders:
|
|
24
|
+
dashboardproviders.yaml:
|
|
25
|
+
apiVersion: 1
|
|
26
|
+
providers:
|
|
27
|
+
- name: default
|
|
28
|
+
orgId: 1
|
|
29
|
+
folder: ""
|
|
30
|
+
type: file
|
|
31
|
+
disableDeletion: false
|
|
32
|
+
editable: true
|
|
33
|
+
options:
|
|
34
|
+
path: /var/lib/grafana/dashboards/default
|
|
35
|
+
dashboardsConfigMaps:
|
|
36
|
+
default: "{{configMapName}}"
|
|
37
|
+
ingress:
|
|
38
|
+
enabled: true
|
|
39
|
+
ingressClassName: traefik
|
|
40
|
+
annotations:
|
|
41
|
+
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
|
42
|
+
hosts:
|
|
43
|
+
- "grafana.{{domain}}"
|
|
44
|
+
tls:
|
|
45
|
+
- hosts:
|
|
46
|
+
- "grafana.{{domain}}"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "All Logs",
|
|
3
|
+
"uid": "localctl-all-logs",
|
|
4
|
+
"schemaVersion": 39,
|
|
5
|
+
"timezone": "browser",
|
|
6
|
+
"time": { "from": "now-1h", "to": "now" },
|
|
7
|
+
"panels": [
|
|
8
|
+
{
|
|
9
|
+
"type": "logs",
|
|
10
|
+
"title": "Every pod, every namespace",
|
|
11
|
+
"gridPos": { "h": 20, "w": 24, "x": 0, "y": 0 },
|
|
12
|
+
"datasource": { "type": "loki", "uid": "Loki" },
|
|
13
|
+
"targets": [
|
|
14
|
+
{
|
|
15
|
+
"expr": "{namespace=~\".+\"}",
|
|
16
|
+
"refId": "A"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Cluster-wide metrics addon - deploys the real prometheus-community Prometheus chart (server +
|
|
2
|
+
# kube-state-metrics + node-exporter). Enabled via `localctl addons enable monitoring`.
|
|
3
|
+
#
|
|
4
|
+
# No bundled Grafana here (the `logging` addon already brings one) - point it at this Prometheus
|
|
5
|
+
# instance yourself as a datasource if you want dashboards
|
|
6
|
+
# (http://monitoring-prometheus-server.observability.svc.cluster.local - the chart's own Service
|
|
7
|
+
# name, check `kubectl get svc -n observability` if it ever renames itself in a future chart
|
|
8
|
+
# version rather than trusting this comment). Keeping the two addons independent means either
|
|
9
|
+
# works without the other.
|
|
10
|
+
|
|
11
|
+
chart:
|
|
12
|
+
repo: https://prometheus-community.github.io/helm-charts
|
|
13
|
+
name: prometheus
|
|
14
|
+
version: "29.34.0"
|
|
15
|
+
|
|
16
|
+
values: |
|
|
17
|
+
alertmanager:
|
|
18
|
+
enabled: false
|
|
19
|
+
prometheus-pushgateway:
|
|
20
|
+
enabled: false
|
|
21
|
+
server:
|
|
22
|
+
persistentVolume:
|
|
23
|
+
size: 2Gi
|
|
24
|
+
ingress:
|
|
25
|
+
enabled: true
|
|
26
|
+
ingressClassName: traefik
|
|
27
|
+
annotations:
|
|
28
|
+
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
|
29
|
+
hosts:
|
|
30
|
+
- "monitoring.{{domain}}"
|
|
31
|
+
tls:
|
|
32
|
+
- hosts:
|
|
33
|
+
- "monitoring.{{domain}}"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Mongo addon - deploys the real Bitnami MongoDB chart. Auth disabled by default, matching this
|
|
2
|
+
# addon's old behavior (local dev only, not meant to hold anything sensitive) - set auth.enabled
|
|
3
|
+
# and add a secrets/envFromSecret block here (see postgres/addon.yaml) if you want it.
|
|
4
|
+
|
|
5
|
+
chart:
|
|
6
|
+
repo: https://charts.bitnami.com/bitnami
|
|
7
|
+
name: mongodb
|
|
8
|
+
version: "19.3.1"
|
|
9
|
+
|
|
10
|
+
env:
|
|
11
|
+
MONGO_HOST: "{{serviceHost}}"
|
|
12
|
+
MONGO_PORT: "27017"
|
|
13
|
+
|
|
14
|
+
values: |
|
|
15
|
+
auth:
|
|
16
|
+
enabled: false
|
|
17
|
+
persistence:
|
|
18
|
+
size: "{{storage}}"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Postgres addon - deploys the real Bitnami PostgreSQL chart, configured for local dev.
|
|
2
|
+
# See docs/adding-tools.md for the full addon.yaml reference.
|
|
3
|
+
|
|
4
|
+
chart:
|
|
5
|
+
repo: https://charts.bitnami.com/bitnami
|
|
6
|
+
name: postgresql
|
|
7
|
+
version: "18.12.2"
|
|
8
|
+
|
|
9
|
+
# Generates a random value once per key, stored as a real k8s Secret named "<release>-secret" -
|
|
10
|
+
# never written to disk, never in this file. {{secretName}} below refers to that Secret's name.
|
|
11
|
+
secrets:
|
|
12
|
+
password:
|
|
13
|
+
length: 20
|
|
14
|
+
|
|
15
|
+
# Plain env vars injected into the app's own container automatically.
|
|
16
|
+
env:
|
|
17
|
+
POSTGRES_HOST: "{{serviceHost}}"
|
|
18
|
+
POSTGRES_PORT: "5432"
|
|
19
|
+
POSTGRES_DB: "{{database}}"
|
|
20
|
+
POSTGRES_USER: "localdev"
|
|
21
|
+
|
|
22
|
+
# Unlike `env`, these become a real secretKeyRef - the password itself never passes through a
|
|
23
|
+
# plaintext value anywhere in this pipeline.
|
|
24
|
+
envFromSecret:
|
|
25
|
+
POSTGRES_PASSWORD:
|
|
26
|
+
secret: "{{secretName}}"
|
|
27
|
+
key: password
|
|
28
|
+
|
|
29
|
+
# Passed to `helm template` as -f values.yaml. {{...}} is substituted first: {{database}}/
|
|
30
|
+
# {{storage}}/{{version}} come from the app's own .local/config.json dependency entry (with
|
|
31
|
+
# fallback defaults), {{secretName}} from the generated Secret above.
|
|
32
|
+
values: |
|
|
33
|
+
auth:
|
|
34
|
+
username: localdev
|
|
35
|
+
database: "{{database}}"
|
|
36
|
+
existingSecret: "{{secretName}}"
|
|
37
|
+
primary:
|
|
38
|
+
persistence:
|
|
39
|
+
size: "{{storage}}"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Redis addon - deploys the real Bitnami Redis chart, configured for local dev (standalone, no
|
|
2
|
+
# replicas). See docs/adding-tools.md for the full addon.yaml reference.
|
|
3
|
+
|
|
4
|
+
chart:
|
|
5
|
+
repo: https://charts.bitnami.com/bitnami
|
|
6
|
+
name: redis
|
|
7
|
+
version: "28.2.4"
|
|
8
|
+
|
|
9
|
+
secrets:
|
|
10
|
+
password:
|
|
11
|
+
length: 20
|
|
12
|
+
|
|
13
|
+
env:
|
|
14
|
+
REDIS_HOST: "{{serviceHost}}"
|
|
15
|
+
REDIS_PORT: "6379"
|
|
16
|
+
|
|
17
|
+
envFromSecret:
|
|
18
|
+
REDIS_PASSWORD:
|
|
19
|
+
secret: "{{secretName}}"
|
|
20
|
+
key: password
|
|
21
|
+
|
|
22
|
+
values: |
|
|
23
|
+
architecture: standalone
|
|
24
|
+
auth:
|
|
25
|
+
existingSecret: "{{secretName}}"
|
|
26
|
+
existingSecretPasswordKey: password
|
|
27
|
+
master:
|
|
28
|
+
persistence:
|
|
29
|
+
size: "{{storage}}"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const { run, capture } = require('./exec');
|
|
2
|
+
|
|
3
|
+
// One Secret per app, holding whatever keys `.local/config.json`'s "secrets" array declares.
|
|
4
|
+
// Unlike addon-generated secrets (random values, never seen by you), these are values *you*
|
|
5
|
+
// supply - an API key, a third-party token - so there's no "generate once" step: you set them
|
|
6
|
+
// with `localctl app secrets set`, and manifestGen.js just references them via secretKeyRef.
|
|
7
|
+
function secretNameFor(cfg) {
|
|
8
|
+
return `${cfg.name}-secrets`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ensureNamespace(namespace) {
|
|
12
|
+
if (capture('kubectl', ['get', 'namespace', namespace]).status !== 0) {
|
|
13
|
+
run('kubectl', ['create', 'namespace', namespace], { stdio: 'ignore', allowFail: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
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) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setSecret(cfg, key, value) {
|
|
39
|
+
const data = readAll(cfg);
|
|
40
|
+
data[key] = value;
|
|
41
|
+
writeAll(cfg, data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
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;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function listKeys(cfg) {
|
|
57
|
+
return Object.keys(readAll(cfg));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { secretNameFor, setSecret, unsetSecret, listKeys, readAll };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { run } = require('./exec');
|
|
2
|
+
const { loadAddon } = require('./addonLoader');
|
|
3
|
+
const { findAddonDir, listAddonNames } = require('./addonDirs');
|
|
4
|
+
const { getList, workloadsWithPrefix } = require('./workloadReadiness');
|
|
5
|
+
const { DOMAIN, OBSERVABILITY_NAMESPACE } = require('./constants');
|
|
6
|
+
const ui = require('./ui');
|
|
7
|
+
|
|
8
|
+
// Cluster-wide addons live at <addons-dir>/cluster/<name>/addon.yaml - your own addons dir
|
|
9
|
+
// (~/.localctl/addons, or $LOCALCTL_ADDONS_DIR) is searched before the ones bundled with this
|
|
10
|
+
// CLI, and a name there overrides a built-in one of the same name. See docs/adding-tools.md.
|
|
11
|
+
// Shared by `localctl addons` and `localctl profiles new`'s "carry over which addons?" wizard.
|
|
12
|
+
function addonDirFor(name) {
|
|
13
|
+
const dir = findAddonDir(name, 'cluster');
|
|
14
|
+
if (!dir) {
|
|
15
|
+
throw new Error(`Unknown addon "${name}". Known: ${listAddonNames('cluster').join(', ')}.`);
|
|
16
|
+
}
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderClusterAddon(name) {
|
|
21
|
+
const { manifestYaml } = loadAddon(addonDirFor(name), {
|
|
22
|
+
release: name,
|
|
23
|
+
namespace: OBSERVABILITY_NAMESPACE,
|
|
24
|
+
addonName: name,
|
|
25
|
+
extraVars: { domain: DOMAIN },
|
|
26
|
+
});
|
|
27
|
+
return manifestYaml;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function enableClusterAddon(name) {
|
|
31
|
+
run('kubectl', ['apply', '-f', '-'], { stdio: ['pipe', 'inherit', 'inherit'], input: renderClusterAddon(name) });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function disableClusterAddon(name) {
|
|
35
|
+
run('kubectl', ['delete', '-f', '-', '--ignore-not-found=true'], { stdio: ['pipe', 'inherit', 'inherit'], input: renderClusterAddon(name) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readyLabel(workloads) {
|
|
39
|
+
if (workloads.length === 0) return ui.dim('-');
|
|
40
|
+
const ready = workloads.reduce((sum, w) => sum + w.ready, 0);
|
|
41
|
+
const desired = workloads.reduce((sum, w) => sum + w.desired, 0);
|
|
42
|
+
return ui.readyLabel(ready, desired);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clusterAddonInstances() {
|
|
46
|
+
const records = getList(['get', 'configmap', '-A', '-l', 'localctl.dev/addon=true']);
|
|
47
|
+
return records.map((r) => {
|
|
48
|
+
const release = r.metadata.name.replace(/-localctl-addon$/, '');
|
|
49
|
+
return {
|
|
50
|
+
release,
|
|
51
|
+
owner: r.data.owner || null,
|
|
52
|
+
namespace: r.metadata.namespace,
|
|
53
|
+
chart: `${r.data.chart}@${r.data.version}`,
|
|
54
|
+
ready: readyLabel(workloadsWithPrefix(r.metadata.namespace, release)),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { renderClusterAddon, enableClusterAddon, disableClusterAddon, clusterAddonInstances };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const yaml = require('js-yaml');
|
|
5
|
+
const { run, capture, sleepSync, commandExists } = require('./exec');
|
|
6
|
+
const { detectEngine } = require('./engine');
|
|
7
|
+
const { installIfMissing } = require('./toolInstall');
|
|
8
|
+
const { ensureInsecureRegistry } = require('./podman');
|
|
9
|
+
const { DOMAIN } = require('./constants');
|
|
10
|
+
const logger = require('./logger');
|
|
11
|
+
|
|
12
|
+
// Cluster-level manifests: bundled under cli/assets/ in the npm package (copied there by
|
|
13
|
+
// scripts/sync-assets.js at pack time), or read straight from the repo root in a git checkout.
|
|
14
|
+
const BUNDLED_MANIFESTS_DIR = path.resolve(__dirname, '..', '..', 'assets', 'manifests');
|
|
15
|
+
const MANIFESTS_DIR = fs.existsSync(BUNDLED_MANIFESTS_DIR)
|
|
16
|
+
? BUNDLED_MANIFESTS_DIR
|
|
17
|
+
: path.resolve(__dirname, '..', '..', '..', 'cluster', 'manifests');
|
|
18
|
+
|
|
19
|
+
function registryNameFor(clusterName) {
|
|
20
|
+
return `${clusterName}-registry`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clusterExists(clusterName) {
|
|
24
|
+
return capture('k3d', ['cluster', 'list']).stdout
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => line.trim().split(/\s+/)[0])
|
|
27
|
+
.includes(clusterName);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Brings up (idempotently) one Main project's cluster: container engine + tool checks, the k3d
|
|
31
|
+
// cluster itself (own ports, since two clusters can't both bind host :80/:443), its own local
|
|
32
|
+
// registry container, and the shared *.local.test TLS cert wired into its Traefik. Used by both
|
|
33
|
+
// `localctl setup` (the "default" profile, unchanged ports/names from before multi-project
|
|
34
|
+
// support) and `localctl profiles new`/`switch` (any other Main project).
|
|
35
|
+
function provisionCluster({ clusterName, httpPort, httpsPort, registryPort }) {
|
|
36
|
+
logger.step('Detecting container engine...');
|
|
37
|
+
const detected = detectEngine();
|
|
38
|
+
if (!detected) {
|
|
39
|
+
throw new Error('No running container engine found. Start Docker Desktop, Podman, or Rancher Desktop and re-run.');
|
|
40
|
+
}
|
|
41
|
+
const { engine, bin: engineBin } = detected;
|
|
42
|
+
logger.success(`Using container engine: ${engine} (${engineBin} CLI)`);
|
|
43
|
+
|
|
44
|
+
// Podman-specific, and must happen before the cluster/registry exist - see lib/podman.js. The
|
|
45
|
+
// insecure-registry trust is VM-wide, so it only needs doing once no matter how many clusters
|
|
46
|
+
// exist, but re-running it per registry port is cheap and idempotent.
|
|
47
|
+
if (engine === 'podman') {
|
|
48
|
+
ensureInsecureRegistry(registryPort);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
installIfMissing('k3d', 'k3d', 'k3d-io.k3d');
|
|
52
|
+
installIfMissing('kubectl', 'kubernetes-cli', 'Kubernetes.kubectl');
|
|
53
|
+
installIfMissing('tilt', 'tilt', 'Tilt.dev.tilt');
|
|
54
|
+
installIfMissing('mkcert', 'mkcert', 'FiloSottile.mkcert');
|
|
55
|
+
installIfMissing('helm', 'helm', 'Helm.Helm');
|
|
56
|
+
|
|
57
|
+
const registryName = registryNameFor(clusterName);
|
|
58
|
+
|
|
59
|
+
if (clusterExists(clusterName)) {
|
|
60
|
+
logger.success(`k3d cluster '${clusterName}' already exists`);
|
|
61
|
+
} else {
|
|
62
|
+
logger.step(`Creating k3d cluster '${clusterName}'...`);
|
|
63
|
+
// Generated fresh per call, not a static file: every Main project needs its own cluster
|
|
64
|
+
// name, ports, and registry mirror name baked in.
|
|
65
|
+
const k3dConfig = {
|
|
66
|
+
apiVersion: 'k3d.io/v1alpha5',
|
|
67
|
+
kind: 'Simple',
|
|
68
|
+
metadata: { name: clusterName },
|
|
69
|
+
servers: 1,
|
|
70
|
+
agents: 0,
|
|
71
|
+
ports: [
|
|
72
|
+
{ port: `${httpPort}:80`, nodeFilters: ['loadbalancer'] },
|
|
73
|
+
{ port: `${httpsPort}:443`, nodeFilters: ['loadbalancer'] },
|
|
74
|
+
],
|
|
75
|
+
// Plain container + mirror config, not k3d's built-in `registries.create`: that hard-codes
|
|
76
|
+
// attaching to a network literally named "bridge", which Docker has by default but Podman
|
|
77
|
+
// does not, breaking cluster creation there.
|
|
78
|
+
registries: {
|
|
79
|
+
config: yaml.dump({ mirrors: { [`${registryName}:5000`]: { endpoint: [`http://${registryName}:5000`] } } }),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const tmpConfig = path.join(os.tmpdir(), `localctl-k3d-config-${clusterName}.yaml`);
|
|
83
|
+
fs.writeFileSync(tmpConfig, yaml.dump(k3dConfig));
|
|
84
|
+
run('k3d', ['cluster', 'create', '--config', tmpConfig, '--wait']);
|
|
85
|
+
fs.rmSync(tmpConfig, { force: true });
|
|
86
|
+
logger.success('Cluster created');
|
|
87
|
+
}
|
|
88
|
+
run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
|
|
89
|
+
|
|
90
|
+
const registryNetwork = `k3d-${clusterName}`;
|
|
91
|
+
const existingNames = capture(engineBin, ['ps', '-a', '--format', '{{.Names}}']).stdout.split('\n');
|
|
92
|
+
if (existingNames.includes(registryName)) {
|
|
93
|
+
logger.success('Registry container already exists');
|
|
94
|
+
run(engineBin, ['start', registryName], { stdio: 'ignore', allowFail: true });
|
|
95
|
+
} else {
|
|
96
|
+
logger.step('Starting local image registry...');
|
|
97
|
+
run(engineBin, [
|
|
98
|
+
'run', '-d',
|
|
99
|
+
'--name', registryName,
|
|
100
|
+
'--network', registryNetwork,
|
|
101
|
+
'-p', `0.0.0.0:${registryPort}:5000`,
|
|
102
|
+
'--restart=always',
|
|
103
|
+
'registry:2',
|
|
104
|
+
]);
|
|
105
|
+
logger.success(`Registry running at localhost:${registryPort} (in-cluster name: ${registryName}:5000)`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// TLS via mkcert - one *.local.test cert/key, shared across every cluster (it's the same
|
|
109
|
+
// domain everywhere; only the namespace/subdomain prefix or the cluster differs).
|
|
110
|
+
logger.step(`Setting up local TLS for *.${DOMAIN}...`);
|
|
111
|
+
run('mkcert', ['-install']);
|
|
112
|
+
const certDir = path.join(os.homedir(), '.localctl', 'certs');
|
|
113
|
+
fs.mkdirSync(certDir, { recursive: true });
|
|
114
|
+
const certFile = path.join(certDir, 'local-dev.pem');
|
|
115
|
+
const keyFile = path.join(certDir, 'local-dev-key.pem');
|
|
116
|
+
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
|
117
|
+
run('mkcert', ['-cert-file', certFile, '-key-file', keyFile, `*.${DOMAIN}`, DOMAIN]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const secretYaml = capture('kubectl', [
|
|
121
|
+
'create', 'secret', 'tls', 'local-dev-tls',
|
|
122
|
+
`--cert=${certFile}`, `--key=${keyFile}`,
|
|
123
|
+
'-n', 'kube-system', '--dry-run=client', '-o', 'yaml',
|
|
124
|
+
]).stdout;
|
|
125
|
+
run('kubectl', ['apply', '-f', '-'], { stdio: ['pipe', 'inherit', 'inherit'], input: secretYaml });
|
|
126
|
+
|
|
127
|
+
// k3d's --wait only waits for node readiness, not for Traefik's helm-install job to finish
|
|
128
|
+
// registering its CRDs, so applying TLSStore any earlier races and fails intermittently.
|
|
129
|
+
logger.step('Waiting for Traefik CRDs...');
|
|
130
|
+
for (let i = 0; i < 60; i += 1) {
|
|
131
|
+
if (capture('kubectl', ['get', 'crd', 'tlsstores.traefik.io']).status === 0) break;
|
|
132
|
+
sleepSync(2000);
|
|
133
|
+
}
|
|
134
|
+
run('kubectl', ['wait', '--for=condition=Established', 'crd/tlsstores.traefik.io', '--timeout=60s'], { stdio: 'ignore' });
|
|
135
|
+
|
|
136
|
+
run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'tls-store.yaml')]);
|
|
137
|
+
run('kubectl', ['apply', '-f', path.join(MANIFESTS_DIR, 'namespace-addons.yaml')]);
|
|
138
|
+
logger.success('TLS configured, default cert wired into Traefik');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Reverses provisionCluster() for one Main project: deletes its cluster, registry container,
|
|
142
|
+
// network, and image volume. Does NOT touch the Podman VM's insecure-registry trust (that's
|
|
143
|
+
// VM-wide, torn down once by `localctl uninstall`, not per-cluster) or *.local.test hosts/certs
|
|
144
|
+
// (shared across every cluster).
|
|
145
|
+
function teardownCluster(clusterName) {
|
|
146
|
+
if (commandExists('k3d')) {
|
|
147
|
+
logger.step(`Deleting k3d cluster '${clusterName}'...`);
|
|
148
|
+
run('k3d', ['cluster', 'delete', clusterName], { allowFail: true });
|
|
149
|
+
}
|
|
150
|
+
const detected = detectEngine();
|
|
151
|
+
if (detected) {
|
|
152
|
+
const { bin: engineBin } = detected;
|
|
153
|
+
run(engineBin, ['rm', '-f', '-v', registryNameFor(clusterName)], { stdio: 'ignore', allowFail: true });
|
|
154
|
+
run(engineBin, ['network', 'rm', `k3d-${clusterName}`], { stdio: 'ignore', allowFail: true });
|
|
155
|
+
run(engineBin, ['volume', 'rm', `k3d-${clusterName}-images`], { stdio: 'ignore', allowFail: true });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Throws with the exact fix command if `clusterName` isn't up yet, otherwise points kubectl at
|
|
160
|
+
// it. Called by every app-scoped command before it does anything else, so "cd in and run a
|
|
161
|
+
// command" fails with a clear instruction instead of a confusing kubectl error further down.
|
|
162
|
+
function ensureClusterUp(clusterName, profileName) {
|
|
163
|
+
if (!clusterExists(clusterName)) {
|
|
164
|
+
const fix = profileName === 'default' ? 'localctl setup' : `localctl profiles switch ${profileName}`;
|
|
165
|
+
throw new Error(`No cluster running for project "${profileName}". Run "${fix}" to start it.`);
|
|
166
|
+
}
|
|
167
|
+
run('kubectl', ['config', 'use-context', `k3d-${clusterName}`], { stdio: 'ignore' });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { provisionCluster, teardownCluster, clusterExists, registryNameFor, ensureClusterUp };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const { z } = require('zod');
|
|
2
|
+
const { knownAddonTypes } = require('./manifestGen');
|
|
3
|
+
|
|
4
|
+
// Dependency types are discovered from cli/src/lib/addons/<type>/addon.yaml (see
|
|
5
|
+
// docs/adding-tools.md) rather than hardcoded here - dropping in a new addon folder is enough to
|
|
6
|
+
// make it usable, no code change needed.
|
|
7
|
+
const dependencySchema = z.object({
|
|
8
|
+
type: z.enum(knownAddonTypes()),
|
|
9
|
+
version: z.string().optional(),
|
|
10
|
+
database: z.string().optional(),
|
|
11
|
+
storage: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const debugSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
enabled: z.boolean().default(false),
|
|
17
|
+
port: z.number().int().optional(),
|
|
18
|
+
type: z.enum(['node', 'python', 'go', 'java', 'dotnet']).optional(),
|
|
19
|
+
})
|
|
20
|
+
.default({ enabled: false });
|
|
21
|
+
|
|
22
|
+
const syncEntrySchema = z.object({
|
|
23
|
+
localPath: z.string(),
|
|
24
|
+
remotePath: z.string(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// An extra container in the same pod as your app - the classic k8s sidecar pattern (a DB admin
|
|
28
|
+
// UI next to a database, a log/metrics shipper, a background job runner next to a web app). It
|
|
29
|
+
// shares the pod's network namespace with your app automatically - no Service needed for your app
|
|
30
|
+
// 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({}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const resourceSpec = z
|
|
41
|
+
.object({
|
|
42
|
+
cpu: z.string().optional(),
|
|
43
|
+
memory: z.string().optional(),
|
|
44
|
+
})
|
|
45
|
+
.optional();
|
|
46
|
+
|
|
47
|
+
// A single probe - defaults to a plain TCP check on the app's own `port` if `path` isn't given
|
|
48
|
+
// (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
|
+
});
|
|
54
|
+
|
|
55
|
+
const probesSchema = z
|
|
56
|
+
.object({
|
|
57
|
+
readiness: probeSchema.optional(),
|
|
58
|
+
liveness: probeSchema.optional(),
|
|
59
|
+
})
|
|
60
|
+
.default({});
|
|
61
|
+
|
|
62
|
+
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'),
|
|
73
|
+
}),
|
|
74
|
+
env: z.record(z.string()).default({}),
|
|
75
|
+
// Names only - values never live here. Set with `localctl app secrets set <KEY>`, which
|
|
76
|
+
// stores the value once as a real k8s Secret and injects it via secretKeyRef, the same
|
|
77
|
+
// never-plaintext principle addon-provided credentials already follow.
|
|
78
|
+
secrets: z
|
|
79
|
+
.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be UPPER_SNAKE_CASE'))
|
|
80
|
+
.default([]),
|
|
81
|
+
replicas: z.number().int().min(1).default(1),
|
|
82
|
+
resources: z
|
|
83
|
+
.object({ requests: resourceSpec, limits: resourceSpec })
|
|
84
|
+
.optional(),
|
|
85
|
+
debug: debugSchema,
|
|
86
|
+
probes: probesSchema,
|
|
87
|
+
dependencies: z.array(dependencySchema).default([]),
|
|
88
|
+
sidecars: z.array(sidecarSchema).default([]),
|
|
89
|
+
sync: z.array(syncEntrySchema).default([]),
|
|
90
|
+
tls: z.boolean().default(true),
|
|
91
|
+
})
|
|
92
|
+
.superRefine((cfg, ctx) => {
|
|
93
|
+
const overlap = cfg.secrets.filter((key) => key in cfg.env);
|
|
94
|
+
if (overlap.length > 0) {
|
|
95
|
+
ctx.addIssue({
|
|
96
|
+
code: z.ZodIssueCode.custom,
|
|
97
|
+
path: ['secrets'],
|
|
98
|
+
message: `${overlap.join(', ')} can't be in both "env" and "secrets" - pick one.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
module.exports = { appConfigSchema };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
// The "default" profile's cluster name - the single cluster this whole tool managed before
|
|
3
|
+
// multi-project support existed. Other Main-level projects get their own `local-dev-<name>`
|
|
4
|
+
// cluster (see profileStore.js).
|
|
5
|
+
DEFAULT_CLUSTER_NAME: 'local-dev',
|
|
6
|
+
DEFAULT_HTTP_PORT: 80,
|
|
7
|
+
DEFAULT_HTTPS_PORT: 443,
|
|
8
|
+
// .test (RFC 2606) is reserved for testing and guaranteed to never resolve publicly or be
|
|
9
|
+
// HSTS-preloaded. Not .dev: it's a real, live gTLD (Google-operated) that's HSTS-preloaded at
|
|
10
|
+
// the TLD level - every browser force-HTTPS's *any* .dev domain, including made-up ones, with
|
|
11
|
+
// no way to click through a self-signed cert warning, and "local.dev" itself is registered by
|
|
12
|
+
// an unrelated third party.
|
|
13
|
+
DOMAIN: 'local.test',
|
|
14
|
+
REGISTRY_HOST_PORT: '5050',
|
|
15
|
+
OBSERVABILITY_NAMESPACE: 'observability',
|
|
16
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const { commandExists, capture } = require('./exec');
|
|
2
|
+
|
|
3
|
+
// Returns { engine, bin } - `engine` names what's actually running (docker/podman/rancher-desktop),
|
|
4
|
+
// `bin` is the CLI to drive it with (usually the same, except Rancher Desktop, which exposes
|
|
5
|
+
// docker or nerdctl depending on how it's configured). Returns null if nothing usable is found.
|
|
6
|
+
function detectEngine() {
|
|
7
|
+
if (commandExists('docker') && capture('docker', ['info']).status === 0) {
|
|
8
|
+
return { engine: 'docker', bin: 'docker' };
|
|
9
|
+
}
|
|
10
|
+
if (commandExists('podman') && capture('podman', ['info']).status === 0) {
|
|
11
|
+
return { engine: 'podman', bin: 'podman' };
|
|
12
|
+
}
|
|
13
|
+
if (commandExists('rdctl')) {
|
|
14
|
+
if (commandExists('docker')) return { engine: 'rancher-desktop', bin: 'docker' };
|
|
15
|
+
if (commandExists('nerdctl')) return { engine: 'rancher-desktop', bin: 'nerdctl' };
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { detectEngine };
|