@yadurajfleetos/cli 0.13.0 → 0.14.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/dist/archive.js CHANGED
@@ -82,12 +82,40 @@ export async function packContext(dir) {
82
82
  dir,
83
83
  // Ownership and timestamps vary per machine and would make two packs of the
84
84
  // same tree differ for no reason.
85
+ //
86
+ // Not sufficient on its own on macOS -- see COPYFILE_DISABLE below.
85
87
  '--no-xattrs',
86
88
  ...excludes.flatMap((p) => ['--exclude', p]),
87
89
  '.',
88
90
  ];
89
91
  return new Promise((resolve, reject) => {
90
- const child = spawn('tar', args, { stdio: ['ignore', 'pipe', 'pipe'] });
92
+ const child = spawn('tar', args, {
93
+ stdio: ['ignore', 'pipe', 'pipe'],
94
+ /**
95
+ * Keep macOS from smuggling AppleDouble files into the build context.
96
+ *
97
+ * bsdtar stores extended attributes as separate `._name` members, and
98
+ * `--no-xattrs` does not stop it -- that flag is a GNU-compatible alias
99
+ * bsdtar accepts for xattr *archiving*, while the AppleDouble members
100
+ * come from copyfile, which only this variable disables.
101
+ *
102
+ * The failure is invisible from a Mac, which is what makes it worth a
103
+ * comment this long. `tar tzf` on macOS does not list those members: it
104
+ * folds them back into xattrs on the file they belong to. GNU tar on the
105
+ * Linux control plane has no such notion and writes them out as real
106
+ * files, so a context packed here arrives there carrying `._Dockerfile`,
107
+ * `._Program.cs` and the rest.
108
+ *
109
+ * That is not merely untidy. Docker's COPY globs use Go's filepath.Match,
110
+ * where `*` matches a leading dot -- unlike a shell -- so a Dockerfile
111
+ * doing `COPY *.csproj .` copies both `Worker.csproj` and
112
+ * `._Worker.csproj`, and `dotnet restore` then refuses with "this folder
113
+ * contains more than one project or solution file". Found exactly that
114
+ * way, on a .NET service in Docker's own example voting app, having
115
+ * built five other services in the same deploy without complaint.
116
+ */
117
+ env: { ...process.env, COPYFILE_DISABLE: '1' },
118
+ });
91
119
  const chunks = [];
92
120
  let stderr = '';
93
121
  let settled = false;
@@ -0,0 +1,131 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { parseDocument } from 'yaml';
3
+ import { CliError, EXIT, request, requireFleet } from '../api.js';
4
+ import { c } from '../render.js';
5
+ import { glyph, rule } from '../ui.js';
6
+ import { confirm } from '../prompt.js';
7
+ import { awaitRunning } from '../progress.js';
8
+ /** How a manifest writes a value the model returned as a scalar. */
9
+ function shape(field, value) {
10
+ // `health: /healthz` is the obvious way to say it and the manifest wants
11
+ // `health: { path: /healthz }`. Same normalisation the server-side review
12
+ // does, for the same reason: a difference in spelling should not cost a fix.
13
+ if (field === 'health' && typeof value === 'string')
14
+ return { path: value };
15
+ return value;
16
+ }
17
+ /** Whether a service is running right now, as the fleet sees it. */
18
+ async function statusOf(fleetId, name) {
19
+ const { body } = await request('GET', `/fleets/${fleetId}/services`);
20
+ return body.services.find((s) => s.name === name)?.current?.status ?? 'not running';
21
+ }
22
+ export const fixCommand = {
23
+ async run(args, flags) {
24
+ const service = args[0];
25
+ if (!service)
26
+ throw new CliError('usage: fleet fix <service> [--yes]', EXIT.usage);
27
+ const fleetId = await requireFleet(typeof flags.fleet === 'string' ? flags.fleet : undefined);
28
+ console.log(`\n${rule(`fix · ${service}`)}`);
29
+ console.log(`${glyph.pending} looking…`);
30
+ const { body: found } = await request('POST', `/fleets/${fleetId}/diagnose`, {
31
+ body: { question: `Why is the "${service}" service not working as it should?` },
32
+ });
33
+ if (found.status !== 'ok') {
34
+ throw new CliError(found.status === 'disabled' ? found.reason : `inconclusive — ${found.reason}`, EXIT.failure);
35
+ }
36
+ console.log(`\n${found.summary}\n`);
37
+ for (const f of found.findings) {
38
+ console.log(` ${c.bold(f.claim)}`);
39
+ console.log(` ${c.dim(f.evidence)}`);
40
+ }
41
+ const fix = found.fix;
42
+ if (!fix) {
43
+ // Most investigations do not end in one exact manifest change, and this
44
+ // is not a failure. Saying what was found and stopping is the answer.
45
+ console.log(`\n ${c.dim('No single manifest change would fix this. What to do:')}`);
46
+ for (const n of found.next)
47
+ console.log(` ${c.dim('›')} ${n}`);
48
+ console.log();
49
+ return;
50
+ }
51
+ if (!fix.applicable) {
52
+ // Reported in words, never performed. A person clicking through a prompt
53
+ // is not a guardrail; not offering the button is.
54
+ console.log(`\n${glyph.warn} ${c.bold('This one has to be done by hand')}`);
55
+ console.log(` ${fix.service}.${fix.field} → ${JSON.stringify(fix.value)}`);
56
+ console.log(` ${c.dim(fix.why)}`);
57
+ console.log(` ${c.dim(fix.reason ?? '')}`);
58
+ console.log();
59
+ return;
60
+ }
61
+ console.log(`\n${glyph.info} ${c.bold('proposed')} ${fix.service}.${fix.field} → ${JSON.stringify(fix.value)}`);
62
+ console.log(` ${c.dim(fix.why)}\n`);
63
+ if (!flags.yes && !flags.y) {
64
+ const ok = await confirm(`Apply this to fleet.yaml and redeploy ${fix.service}?`);
65
+ if (!ok)
66
+ return console.log(` ${c.dim('left alone')}\n`);
67
+ }
68
+ // Read before writing, and keep the original in memory: this is what makes
69
+ // the change reversible without a second file on disk.
70
+ const path = 'fleet.yaml';
71
+ const before = await readFile(path, 'utf8').catch(() => {
72
+ throw new CliError(`No fleet.yaml here. Run this from the project directory.`, EXIT.usage);
73
+ });
74
+ const doc = parseDocument(before);
75
+ if (!doc.hasIn(['services', fix.service])) {
76
+ throw new CliError(`fleet.yaml has no service named "${fix.service}"`, EXIT.failure);
77
+ }
78
+ // Edited as a document, so the comments `fleet init` wrote survive. A
79
+ // round trip through parse and re-serialise would take them out, and they
80
+ // are the only explanation a generated manifest carries.
81
+ if (fix.value === null)
82
+ doc.deleteIn(['services', fix.service, fix.field]);
83
+ else
84
+ doc.setIn(['services', fix.service, fix.field], shape(fix.field, fix.value));
85
+ const wasRunning = (await statusOf(fleetId, fix.service)) === 'running';
86
+ await writeFile(path, String(doc));
87
+ console.log(`${glyph.ok} fleet.yaml updated`);
88
+ const restore = async (why) => {
89
+ await writeFile(path, before);
90
+ console.log(`${glyph.warn} ${why} — fleet.yaml put back`);
91
+ console.log(` ${c.dim(`Deploy the previous manifest with: fleet up ${fix.service}`)}\n`);
92
+ };
93
+ console.log(`${glyph.pending} deploying…`);
94
+ const { body: svc } = await request('GET', `/fleets/${fleetId}/services`);
95
+ const target = svc.services.find((s) => s.name === fix.service);
96
+ if (!target) {
97
+ await restore(`"${fix.service}" is not in this fleet`);
98
+ return;
99
+ }
100
+ // Verification, and the reason the whole thing is defensible at all: a
101
+ // change that did not help is undone rather than left behind.
102
+ //
103
+ // Followed to a conclusion rather than read once. The deploy request
104
+ // returns when a node has been chosen, and the container starting is the
105
+ // agent's job afterwards -- so reading the status here would find
106
+ // "deploying" and call that success, which is a verification step that
107
+ // verifies nothing.
108
+ //
109
+ // Judged against where it started. A service that was already down and is
110
+ // still down has not been made worse by this edit, and putting the
111
+ // manifest back would take away a change that may well be right while
112
+ // leaving the real problem in place.
113
+ try {
114
+ await request('POST', `/services/${target.id}/deploy`, { body: {} });
115
+ await awaitRunning(target, { timeoutMs: 240_000 });
116
+ }
117
+ catch (err) {
118
+ if (wasRunning) {
119
+ await restore(`${fix.service} was running before and did not come back: ${err.message}`);
120
+ return;
121
+ }
122
+ console.log(`${glyph.warn} still not running — ${err.message}`);
123
+ console.log(` ${c.dim('The edit was kept: it was already down, so this did not make it worse.')}`);
124
+ console.log(` ${c.dim(`undo it with: git checkout fleet.yaml && fleet up ${fix.service}`)}\n`);
125
+ return;
126
+ }
127
+ console.log(`${glyph.ok} ${fix.service} is running`);
128
+ console.log(`\n ${c.dim('check it settled:')} fleet deployments ${fix.service}`);
129
+ console.log(` ${c.dim('undo it:')} git checkout fleet.yaml && fleet up ${fix.service}\n`);
130
+ },
131
+ };
@@ -6,6 +6,7 @@ import { configCommand, useCommand } from './config.js';
6
6
  import { doctorCommand } from './doctor.js';
7
7
  import { upCommand } from './up.js';
8
8
  import { tuneCommand } from './tune.js';
9
+ import { fixCommand } from './fix.js';
9
10
  import { openCommand } from './open.js';
10
11
  import { downCommand } from './down.js';
11
12
  import { unpairCommand, agentCommand } from './unpair.js';
@@ -22,6 +23,7 @@ export const commands = {
22
23
  use: useCommand,
23
24
  doctor: doctorCommand,
24
25
  tune: tuneCommand,
26
+ fix: fixCommand,
25
27
  init: initCommand,
26
28
  diagnose: diagnoseCommand,
27
29
  import: importCommand,
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ const GROUPS = [
28
28
  ['deploy <service>', 'Build, schedule, and roll out'],
29
29
  ['explain <service>', 'Read a failed deploy and say what to do about it'],
30
30
  ['diagnose "<question>"', 'Investigate why something is wrong, and cite what it looked at'],
31
+ ['fix <service>', 'Diagnose, propose one manifest change, deploy it, and undo it if that was worse'],
31
32
  ],
32
33
  ],
33
34
  [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yadurajfleetos/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",