@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 +29 -1
- package/dist/commands/fix.js +131 -0
- package/dist/commands/index.js +2 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
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, {
|
|
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
|
+
};
|
package/dist/commands/index.js
CHANGED
|
@@ -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
|
[
|