@yadurajfleetos/cli 0.16.0 → 0.17.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.
@@ -3,6 +3,37 @@ import { loadProfile } from '../config.js';
3
3
  import { c, relativeTime } from '../render.js';
4
4
  import { glyph, rule, task } from '../ui.js';
5
5
  const icon = (state) => state === 'ok' ? glyph.ok : state === 'warn' ? glyph.warn : glyph.fail;
6
+ /**
7
+ * How full a node's disk is, from the right two numbers.
8
+ *
9
+ * It reported 615% used, which is arithmetic that cannot be right and quietly
10
+ * undermines every other line of the report. The denominator was `node.diskMb`
11
+ * — and the control plane says, in a comment directly above the field it sends
12
+ * instead:
13
+ *
14
+ * Capacity. node.diskMb is FREE space and is what the scheduler places
15
+ * against, so it is not the denominator for a "used of total" reading.
16
+ *
17
+ * Somebody wrote that warning and this divided by the wrong one anyway. Used
18
+ * over free exceeds 100% the moment a disk is more than half full, which is
19
+ * why the number looked wild rather than merely wrong.
20
+ *
21
+ * An agent too old to report a capacity gets no percentage at all. A missing
22
+ * figure is a gap somebody can fix; an invented one is a number people act on.
23
+ */
24
+ export function diskUse(usedMb, totalMb) {
25
+ if (!totalMb || usedMb === undefined) {
26
+ return { state: 'ok', detail: 'capacity not reported by this agent' };
27
+ }
28
+ const percent = Math.round((usedMb / totalMb) * 100);
29
+ return {
30
+ state: percent >= 90 ? 'fail' : percent >= 80 ? 'warn' : 'ok',
31
+ detail: `${percent}% used · ${Math.round(usedMb / 1024)}GB of ${Math.round(totalMb / 1024)}GB`,
32
+ remedy: percent >= 80
33
+ ? 'Free space from Docker images/volumes before the node becomes unschedulable.'
34
+ : undefined,
35
+ };
36
+ }
6
37
  /**
7
38
  * Services that answer on a health path but do not declare one.
8
39
  *
@@ -191,7 +222,7 @@ export const doctorCommand = {
191
222
  });
192
223
  for (const node of result.nodes) {
193
224
  const runtime = node.telemetry?.runtime;
194
- const diskPercent = node.diskMb ? Math.round(((node.telemetry?.diskUsedMb ?? 0) / node.diskMb) * 100) : 0;
225
+ const disk = diskUse(node.telemetry?.diskUsedMb, node.telemetry?.diskTotalMb);
195
226
  // Redis intentionally retains the last heartbeat briefly, but a node
196
227
  // that has stopped reporting must not have old host facts rendered as
197
228
  // current failures. The heartbeat check above is the only actionable
@@ -207,7 +238,7 @@ export const doctorCommand = {
207
238
  }
208
239
  checks.push({ state: runtime?.dockerAvailable ? 'ok' : 'fail', label: `Docker ${node.name}`, detail: runtime?.dockerAvailable ? `available${runtime.dockerVersion ? ` · ${runtime.dockerVersion}` : ''}` : runtime?.dockerError ?? 'No Docker runtime reported', remedy: runtime?.dockerAvailable ? undefined : 'Start Docker, then inspect the local fleet-agent log.' });
209
240
  checks.push({ state: runtime?.registryStatus === 'ok' ? 'ok' : runtime?.registryStatus === 'failed' ? 'fail' : 'warn', label: `registry ${node.name}`, detail: runtime?.registryStatus === 'ok' ? 'latest real image pull succeeded' : runtime?.registryError ?? 'not tested by a real image pull yet', remedy: runtime?.registryStatus === 'ok' ? undefined : 'Use a LAN-reachable REGISTRY_URL, then restart a service to run an authenticated pull.' });
210
- checks.push({ state: diskPercent >= 90 ? 'fail' : diskPercent >= 80 ? 'warn' : 'ok', label: `disk ${node.name}`, detail: `${diskPercent}% used`, remedy: diskPercent >= 80 ? 'Free space from Docker images/volumes before the node becomes unschedulable.' : undefined });
241
+ checks.push({ ...disk, label: `disk ${node.name}` });
211
242
  if (runtime?.lastReconcileError)
212
243
  checks.push({ state: 'fail', label: `reconcile ${node.name}`, detail: runtime.lastReconcileError, remedy: 'Run `fleet logs <service> --follow` and inspect the deployment history.' });
213
244
  }
@@ -332,6 +332,24 @@ export const rescheduleCommand = {
332
332
  console.log(`${c.green('moved')} ${service.name} → ${c.bold(body.movedTo.name)}`);
333
333
  },
334
334
  };
335
+ /**
336
+ * A building row, with what the builder is actually doing.
337
+ *
338
+ * Every field here has been travelling from buildx through Redis to the API for
339
+ * a while and stopping there, so this shows what already exists rather than
340
+ * measuring anything new. Emulation is called out because it is the usual
341
+ * answer to "why is this taking twenty minutes".
342
+ */
343
+ function buildStatus(status, progress) {
344
+ const parts = [statusColour(status)];
345
+ if (progress.step && progress.ofSteps)
346
+ parts.push(c.dim(`${progress.step}/${progress.ofSteps}`));
347
+ if (progress.platform)
348
+ parts.push(c.dim(progress.platform));
349
+ if (progress.emulated)
350
+ parts.push(c.yellow('emulated'));
351
+ return parts.join(' ');
352
+ }
335
353
  export const deploymentsCommand = {
336
354
  async run(args, flags) {
337
355
  const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
@@ -346,8 +364,10 @@ export const deploymentsCommand = {
346
364
  relativeTime(d.startedAt),
347
365
  d.gitSha?.slice(0, 7) ?? c.dim('—'),
348
366
  d.nodeName ?? c.dim('—'),
349
- statusColour(d.status),
350
- d.failureReason ?? '',
367
+ // "building" alone reads as stuck. The step counter is what tells a
368
+ // reader the difference between a slow build and a hung one.
369
+ d.progress ? buildStatus(d.status, d.progress) : statusColour(d.status),
370
+ d.failureReason ?? (d.progress?.detail ? c.dim(d.progress.detail) : ''),
351
371
  ])));
352
372
  },
353
373
  };
@@ -188,6 +188,27 @@ async function deployOne(service, opts) {
188
188
  // is measured in tens of minutes, not minutes.
189
189
  const deadline = Date.now() + 45 * 60_000;
190
190
  while (Date.now() < deadline) {
191
+ // What the builder is doing, rather than a hint about what it might
192
+ // be doing. Every field here has been reaching /progress since the
193
+ // phase writer was added and nothing asked for it, so this loop sat
194
+ // cycling generic advice past a reader who could have been told the
195
+ // step number. Failures are swallowed: this is a label, and losing it
196
+ // must not end a deploy that is going fine.
197
+ const line = await request('GET', `/services/${service.id}/progress`)
198
+ .then((r) => r.body)
199
+ .catch(() => null);
200
+ if (line && ['queued', 'building', 'pushing'].includes(line.status)) {
201
+ const parts = [line.status];
202
+ if (line.step && line.ofSteps)
203
+ parts.push(`${line.step}/${line.ofSteps}`);
204
+ if (line.platform)
205
+ parts.push(line.platform);
206
+ if (line.emulated)
207
+ parts.push('emulated');
208
+ s.update(`${c.bold(service.name)} · ${parts.join(' · ')}`);
209
+ if (line.detail)
210
+ s.hints([line.detail]);
211
+ }
191
212
  const { body } = await request('GET', `/fleets/${opts.fleetId}/services`);
192
213
  const current = body.services.find((s) => s.id === service.id)?.current;
193
214
  if (current?.status === 'running')
package/dist/detect.js CHANGED
@@ -45,11 +45,12 @@ const hasDep = (pkg, name) => {
45
45
  return Boolean(deps?.[name] || devDeps?.[name]);
46
46
  };
47
47
  // ── Dockerfile templates ────────────────────────────────────────────────
48
- const NEXTJS_DOCKERFILE = `# --- Build ---
48
+ const NEXTJS_DOCKERFILE = `# syntax=docker/dockerfile:1
49
+ # --- Build ---
49
50
  FROM node:22-alpine AS builder
50
51
  WORKDIR /app
51
52
  COPY package*.json ./
52
- RUN npm ci
53
+ RUN --mount=type=cache,target=/root/.npm npm ci
53
54
  COPY . .
54
55
  RUN npm run build
55
56
 
@@ -63,11 +64,12 @@ COPY --from=builder /app/public ./public
63
64
  EXPOSE 3000
64
65
  CMD ["node", "server.js"]
65
66
  `;
66
- const VITE_DOCKERFILE = `# --- Build ---
67
+ const VITE_DOCKERFILE = `# syntax=docker/dockerfile:1
68
+ # --- Build ---
67
69
  FROM node:22-alpine AS builder
68
70
  WORKDIR /app
69
71
  COPY package*.json ./
70
- RUN npm ci
72
+ RUN --mount=type=cache,target=/root/.npm npm ci
71
73
  COPY . .
72
74
  RUN npm run build
73
75
 
@@ -77,32 +79,57 @@ COPY --from=builder /app/dist /usr/share/nginx/html
77
79
  EXPOSE 80
78
80
  CMD ["nginx", "-g", "daemon off;"]
79
81
  `;
80
- const NODE_DOCKERFILE = `FROM node:22-alpine
82
+ const NODE_DOCKERFILE = `# syntax=docker/dockerfile:1
83
+ FROM node:22-alpine
81
84
  WORKDIR /app
82
85
  COPY package*.json ./
83
- RUN npm ci --omit=dev
86
+ RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
84
87
  COPY . .
85
88
  EXPOSE 3000
86
89
  CMD ["node", "src/index.js"]
87
90
  `;
88
- const PYTHON_DOCKERFILE = (entry, usesPoetry) => `FROM python:3.12-slim
91
+ /**
92
+ * `--mount=type=cache` rather than `--no-cache-dir`.
93
+ *
94
+ * The two look interchangeable and are opposites. `--no-cache-dir` tells pip to
95
+ * keep no wheel cache *inside the layer*, which keeps the image small and makes
96
+ * every rebuild download and recompile from nothing. A BuildKit cache mount
97
+ * lives outside the image entirely — it is not a layer, so it adds nothing to
98
+ * the final size — and survives between builds, so a rebuild that changes only
99
+ * application source reuses every wheel it already has.
100
+ *
101
+ * That is the difference between a two-minute rebuild and a twenty-minute one
102
+ * for anything with `cryptography` or `psycopg2` in it, and more again when the
103
+ * build is emulated.
104
+ *
105
+ * The syntax line is required: `RUN --mount` is a Dockerfile frontend feature,
106
+ * and without it an older builder fails on the flag rather than ignoring it.
107
+ */
108
+ const PYTHON_DOCKERFILE = (entry, usesPoetry) => `# syntax=docker/dockerfile:1
109
+ FROM python:3.12-slim
89
110
  WORKDIR /app
90
111
  ${usesPoetry
91
112
  ? `COPY pyproject.toml poetry.lock* ./
92
- RUN pip install --no-cache-dir poetry && poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-dev`
113
+ RUN --mount=type=cache,target=/root/.cache/pip \\
114
+ --mount=type=cache,target=/root/.cache/pypoetry \\
115
+ pip install poetry && poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi --no-dev`
93
116
  : `COPY requirements*.txt ./
94
- RUN pip install --no-cache-dir -r requirements.txt`}
117
+ RUN --mount=type=cache,target=/root/.cache/pip \\
118
+ pip install -r requirements.txt`}
95
119
  COPY . .
96
120
  EXPOSE 8000
97
121
  CMD ["python", "-m", "${entry}"]
98
122
  `;
99
- const GO_DOCKERFILE = (module) => `# --- Build ---
123
+ const GO_DOCKERFILE = (module) => `# syntax=docker/dockerfile:1
124
+ # --- Build ---
100
125
  FROM golang:1.24-alpine AS builder
101
126
  WORKDIR /app
102
127
  COPY go.mod go.sum* ./
103
- RUN go mod download
128
+ RUN --mount=type=cache,target=/go/pkg/mod go mod download
104
129
  COPY . .
105
- RUN CGO_ENABLED=0 go build -o /server .
130
+ RUN --mount=type=cache,target=/go/pkg/mod \\
131
+ --mount=type=cache,target=/root/.cache/go-build \\
132
+ CGO_ENABLED=0 go build -o /server .
106
133
 
107
134
  # --- Run ---
108
135
  FROM alpine:3.21
@@ -110,13 +137,21 @@ COPY --from=builder /server /server
110
137
  EXPOSE 8080
111
138
  CMD ["/server"]
112
139
  `;
113
- const RUST_DOCKERFILE = `# --- Build ---
140
+ const RUST_DOCKERFILE = `# syntax=docker/dockerfile:1
141
+ # --- Build ---
114
142
  FROM rust:1.87-slim AS builder
115
143
  WORKDIR /app
116
144
  COPY Cargo.toml Cargo.lock* ./
117
- RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
145
+ RUN --mount=type=cache,target=/usr/local/cargo/registry \\
146
+ mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
118
147
  COPY . .
119
- RUN cargo build --release
148
+ # Only the registry is cached, deliberately. A cache mount on /app/target would
149
+ # speed the compile up and put the binary somewhere the runtime stage cannot
150
+ # copy from: a mount is not part of the image, so "COPY --from=builder
151
+ # /app/target/release/*" would find an empty directory and the image would
152
+ # build cleanly and contain nothing.
153
+ RUN --mount=type=cache,target=/usr/local/cargo/registry \\
154
+ cargo build --release
120
155
 
121
156
  # --- Run ---
122
157
  FROM debian:bookworm-slim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Fleet OS command-line interface for deploying and orchestrating services on user-owned hardware",
5
5
  "type": "module",
6
6
  "license": "MIT",