ninedeploy 0.16.8 → 0.16.10
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/README.md +17 -0
- package/dist/commands/autoscale.d.ts +38 -0
- package/dist/commands/autoscale.d.ts.map +1 -0
- package/dist/commands/autoscale.js +176 -0
- package/dist/commands/autoscale.js.map +1 -0
- package/dist/commands/canary.d.ts +37 -0
- package/dist/commands/canary.d.ts.map +1 -0
- package/dist/commands/canary.js +230 -0
- package/dist/commands/canary.js.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/commands/autoscale.ts +201 -0
- package/src/commands/canary.ts +245 -0
- package/src/index.ts +45 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CANARY_DEFAULT_STEPS,
|
|
3
|
+
canaryConfig,
|
|
4
|
+
type CanaryConfig,
|
|
5
|
+
type CanaryConfigInput,
|
|
6
|
+
type CanaryReleaseStats,
|
|
7
|
+
type CanaryStatusView,
|
|
8
|
+
} from '@ninedeploy/schemas';
|
|
9
|
+
import type { NineDeployClient } from '../client.js';
|
|
10
|
+
import { c, error, header, info, kv, spinner, success } from '../lib/format.js';
|
|
11
|
+
import { plain } from './sources.js';
|
|
12
|
+
import { resolveServiceRef } from './uptime.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 0.16.9 canary releases:
|
|
16
|
+
*
|
|
17
|
+
* ninedeploy canary show <service> [--json]
|
|
18
|
+
* ninedeploy canary set <service> [--steps 10,50] [--step-seconds 120] [--max-error-rate 5]
|
|
19
|
+
* [--max-p95 <ms>|off] [--min-requests 20] [--json]
|
|
20
|
+
* ninedeploy canary off <service>
|
|
21
|
+
* ninedeploy canary promote <service> [--deployment <id>]
|
|
22
|
+
*
|
|
23
|
+
* `<service>` is an id, a slug or a name. Stopping a running canary is
|
|
24
|
+
* `ninedeploy deploys cancel <serviceId> <deployId>`: all traffic goes back
|
|
25
|
+
* to the previous release.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const message = (err: unknown): string => plain(err instanceof Error ? err.message : String(err));
|
|
29
|
+
|
|
30
|
+
/** Skip the spinner for `--json` so stdout stays parseable. */
|
|
31
|
+
const load = <T>(json: boolean | undefined, msg: string, fn: () => Promise<T>): Promise<T> => (json ? fn() : spinner(msg, fn));
|
|
32
|
+
|
|
33
|
+
export interface CanarySetOptions {
|
|
34
|
+
steps?: string;
|
|
35
|
+
stepSeconds?: string;
|
|
36
|
+
maxErrorRate?: string;
|
|
37
|
+
maxP95?: string;
|
|
38
|
+
minRequests?: string;
|
|
39
|
+
json?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Flag names for the schema fields, so a validation failure names what the user typed. */
|
|
43
|
+
const FLAG: Record<string, string> = {
|
|
44
|
+
steps: '--steps',
|
|
45
|
+
stepSeconds: '--step-seconds',
|
|
46
|
+
maxErrorRate: '--max-error-rate',
|
|
47
|
+
maxP95Ms: '--max-p95',
|
|
48
|
+
minRequests: '--min-requests',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A number option; null after printing why not. */
|
|
52
|
+
function numberOption(raw: string, flag: string): number | null {
|
|
53
|
+
const n = Number(raw);
|
|
54
|
+
if (raw.trim() === '' || !Number.isFinite(n)) {
|
|
55
|
+
error(`${flag} must be a number`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The fields the user gave, as a partial canary configuration; null after
|
|
63
|
+
* printing why one is not valid. Ranges are left to the schema.
|
|
64
|
+
*/
|
|
65
|
+
export function canaryOptions(opts: CanarySetOptions): Partial<CanaryConfigInput> | null {
|
|
66
|
+
const out: Partial<CanaryConfigInput> = {};
|
|
67
|
+
if (opts.steps !== undefined) {
|
|
68
|
+
const parts = opts.steps.split(',').map((s) => s.trim());
|
|
69
|
+
const steps = parts.map(Number);
|
|
70
|
+
if (parts.some((p) => p === '') || steps.some((n) => !Number.isFinite(n))) {
|
|
71
|
+
error('--steps must be percentages separated by commas, for example 10,50');
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
out.steps = steps;
|
|
75
|
+
}
|
|
76
|
+
const numbers: Array<[keyof CanarySetOptions, 'stepSeconds' | 'maxErrorRate' | 'minRequests']> = [
|
|
77
|
+
['stepSeconds', 'stepSeconds'],
|
|
78
|
+
['maxErrorRate', 'maxErrorRate'],
|
|
79
|
+
['minRequests', 'minRequests'],
|
|
80
|
+
];
|
|
81
|
+
for (const [opt, field] of numbers) {
|
|
82
|
+
const raw = opts[opt];
|
|
83
|
+
if (typeof raw !== 'string') continue;
|
|
84
|
+
const n = numberOption(raw, FLAG[field]!);
|
|
85
|
+
if (n === null) return null;
|
|
86
|
+
out[field] = n;
|
|
87
|
+
}
|
|
88
|
+
if (opts.maxP95 !== undefined) {
|
|
89
|
+
if (opts.maxP95.trim().toLowerCase() === 'off') out.maxP95Ms = null;
|
|
90
|
+
else {
|
|
91
|
+
const n = numberOption(opts.maxP95, '--max-p95');
|
|
92
|
+
if (n === null) return null;
|
|
93
|
+
out.maxP95Ms = n;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What `canary set` sends. A service that has a configuration keeps the
|
|
101
|
+
* values not given (the PATCH replaces the whole object). Without one, only
|
|
102
|
+
* the given values go, and the server fills in the rest; `steps` has no
|
|
103
|
+
* server default, so the documented default (10, 50) is sent when not given.
|
|
104
|
+
*/
|
|
105
|
+
export function canaryBody(current: CanaryConfig | null | undefined, given: Partial<CanaryConfigInput>): CanaryConfigInput {
|
|
106
|
+
if (current) return { ...current, ...given };
|
|
107
|
+
return { ...given, steps: given.steps ?? [...CANARY_DEFAULT_STEPS] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A zod failure as one line, each issue led by the flag it is about. */
|
|
111
|
+
function validationMessage(err: { issues: Array<{ path: PropertyKey[]; message: string }> }): string {
|
|
112
|
+
return err.issues.map((i) => `${FLAG[String(i.path[0])] ?? 'canary'}: ${i.message}`).join('; ');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Seconds as `1m 20s`. */
|
|
116
|
+
export function fmtSeconds(total: number): string {
|
|
117
|
+
const s = Math.max(0, Math.round(total));
|
|
118
|
+
const m = Math.floor(s / 60);
|
|
119
|
+
return m > 0 ? `${m}m ${s % 60}s` : `${s}s`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printConfig(cfg: CanaryConfig | null | undefined): void {
|
|
123
|
+
if (!cfg) {
|
|
124
|
+
kv('Canary', c.gray('off: a deploy switches all traffic at once'));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
kv('Steps', cfg.steps.map((s) => `${s}%`).join(', '));
|
|
128
|
+
kv('Step length', fmtSeconds(cfg.stepSeconds));
|
|
129
|
+
kv('Max 5xx rate', `${cfg.maxErrorRate}%`);
|
|
130
|
+
kv('Max p95', cfg.maxP95Ms === null ? c.gray('not judged') : `${cfg.maxP95Ms} ms`);
|
|
131
|
+
kv('Min requests', cfg.minRequests);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stats(s: CanaryReleaseStats | undefined): string {
|
|
135
|
+
if (!s) return c.gray('—');
|
|
136
|
+
const rate = s.errorRate === null ? '—' : `${s.errorRate}%`;
|
|
137
|
+
const p95 = s.p95Ms === null ? '—' : `${s.p95Ms} ms`;
|
|
138
|
+
return `${s.requests} requests, 5xx ${rate}, p95 ${p95}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function printStatus(st: CanaryStatusView, now: number): void {
|
|
142
|
+
console.log();
|
|
143
|
+
if (!st.active) {
|
|
144
|
+
info('No canary is running.');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
console.log(` ${c.bold('Running canary')}`);
|
|
148
|
+
kv('Deployment', `#${st.deploymentId}`);
|
|
149
|
+
kv('Step', `${st.step}/${st.steps}`);
|
|
150
|
+
kv('Traffic', `${st.weight}% to the new release`);
|
|
151
|
+
if (st.stepEndsAt) kv('Step ends in', fmtSeconds((Date.parse(st.stepEndsAt) - now) / 1000));
|
|
152
|
+
kv('New release', stats(st.canary));
|
|
153
|
+
kv('Previous', stats(st.stable));
|
|
154
|
+
if (st.measured === false) {
|
|
155
|
+
info('Traffic analytics is off: steps are judged on the healthcheck only.');
|
|
156
|
+
}
|
|
157
|
+
if (st.promoting) info('Promote requested: all traffic is switching to the new release.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `ninedeploy canary show <service> [--json]` */
|
|
161
|
+
export async function canaryShow(client: NineDeployClient, ref: string, opts: { json?: boolean } = {}): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
const id = await resolveServiceRef(client, ref);
|
|
164
|
+
if (id === null) return;
|
|
165
|
+
const [svc, status] = await load(opts.json, 'Loading the canary', () => Promise.all([client.services.get(id), client.deploys.canary(id)]));
|
|
166
|
+
if (opts.json) {
|
|
167
|
+
console.log(JSON.stringify({ config: svc.canary ?? null, status }, null, 2));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
header(`Canary releases: ${plain(svc.name)}`);
|
|
171
|
+
printConfig(svc.canary);
|
|
172
|
+
printStatus(status, Date.now());
|
|
173
|
+
console.log();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
error(message(err));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `ninedeploy canary set <service> [...]` */
|
|
180
|
+
export async function canarySet(client: NineDeployClient, ref: string, opts: CanarySetOptions = {}): Promise<void> {
|
|
181
|
+
const given = canaryOptions(opts);
|
|
182
|
+
if (given === null) return;
|
|
183
|
+
try {
|
|
184
|
+
const id = await resolveServiceRef(client, ref);
|
|
185
|
+
if (id === null) return;
|
|
186
|
+
const svc = await client.services.get(id);
|
|
187
|
+
const body = canaryBody(svc.canary, given);
|
|
188
|
+
const parsed = canaryConfig.safeParse(body);
|
|
189
|
+
if (!parsed.success) {
|
|
190
|
+
error(validationMessage(parsed.error));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const updated = await load(opts.json, 'Saving the canary configuration', () => client.services.update(id, { canary: body }));
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
console.log(JSON.stringify(updated.canary ?? null, null, 2));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
success(`Canary releases on for ${plain(updated.name)}. The next deploy uses them.`);
|
|
199
|
+
printConfig(updated.canary);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
error(message(err));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** `ninedeploy canary off <service>` */
|
|
206
|
+
export async function canaryOff(client: NineDeployClient, ref: string): Promise<void> {
|
|
207
|
+
try {
|
|
208
|
+
const id = await resolveServiceRef(client, ref);
|
|
209
|
+
if (id === null) return;
|
|
210
|
+
const updated = await spinner('Turning canary releases off', () => client.services.update(id, { canary: null }));
|
|
211
|
+
success(`Canary releases off for ${plain(updated.name)}. A deploy switches all traffic at once.`);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
error(message(err));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `ninedeploy canary promote <service> [--deployment <id>]` */
|
|
218
|
+
export async function canaryPromote(client: NineDeployClient, ref: string, opts: { deployment?: string } = {}): Promise<void> {
|
|
219
|
+
let depId: number | undefined;
|
|
220
|
+
if (opts.deployment !== undefined) {
|
|
221
|
+
const n = Number(opts.deployment);
|
|
222
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
223
|
+
error('--deployment must be a positive integer');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
depId = n;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const id = await resolveServiceRef(client, ref);
|
|
230
|
+
if (id === null) return;
|
|
231
|
+
if (depId === undefined) {
|
|
232
|
+
const status = await client.deploys.canary(id);
|
|
233
|
+
if (!status.active || status.deploymentId === undefined) {
|
|
234
|
+
error('No canary is running on this service.');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
depId = status.deploymentId;
|
|
238
|
+
}
|
|
239
|
+
const target = depId;
|
|
240
|
+
await spinner('Promoting the canary', () => client.deploys.promoteCanary(id, target));
|
|
241
|
+
success(`Deployment #${target}: all traffic is switching to the new release.`);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
error(message(err));
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -143,6 +143,8 @@ import {
|
|
|
143
143
|
statusPagesAdd, statusPagesList, statusPagesNotice, statusPagesRemove, statusPagesSetPublished,
|
|
144
144
|
type MonitorAddOptions,
|
|
145
145
|
} from './commands/uptime.js';
|
|
146
|
+
import { canaryOff, canaryPromote, canarySet, canaryShow, type CanarySetOptions } from './commands/canary.js';
|
|
147
|
+
import { autoscaleOff, autoscaleSet, autoscaleShow, type AutoscaleSetOptions } from './commands/autoscale.js';
|
|
146
148
|
|
|
147
149
|
const program = new Command();
|
|
148
150
|
|
|
@@ -1209,6 +1211,49 @@ statusPagesCmd.command('rm <id>')
|
|
|
1209
1211
|
.action((id: string, opts: { yes?: boolean }) => statusPagesRemove(getClient(), id, opts));
|
|
1210
1212
|
/* v8 ignore stop */
|
|
1211
1213
|
|
|
1214
|
+
// ── 0.16.9: canary releases ──────────────────────────────────────────────
|
|
1215
|
+
const canaryCmd = program.command('canary').description('Canary releases: shift traffic to a new release in steps');
|
|
1216
|
+
/* v8 ignore start -- exercised by test/canary.test.ts. */
|
|
1217
|
+
canaryCmd.command('show <service>')
|
|
1218
|
+
.description('The canary configuration and the canary running now')
|
|
1219
|
+
.option('--json', 'Print raw JSON')
|
|
1220
|
+
.action((service: string, opts: { json?: boolean }) => canaryShow(getClient(), service, opts));
|
|
1221
|
+
canaryCmd.command('set <service>')
|
|
1222
|
+
.description('Turn canary releases on or change them (docker services); options not given keep their value')
|
|
1223
|
+
.option('--steps <list>', 'Percent of requests per step, rising, 1-99, up to 5 (default 10,50)')
|
|
1224
|
+
.option('--step-seconds <n>', 'Seconds per step (30-3600, default 120)')
|
|
1225
|
+
.option('--max-error-rate <percent>', 'Highest share of 5xx responses the new release may have (default 5)')
|
|
1226
|
+
.option('--max-p95 <ms>', 'Highest p95 response time in ms, or off (default off)')
|
|
1227
|
+
.option('--min-requests <n>', 'Fewer requests in a step are not judged (default 20)')
|
|
1228
|
+
.option('--json', 'Print the stored configuration as JSON')
|
|
1229
|
+
.action((service: string, opts: CanarySetOptions) => canarySet(getClient(), service, opts));
|
|
1230
|
+
canaryCmd.command('off <service>').description('Turn canary releases off').action((service: string) => canaryOff(getClient(), service));
|
|
1231
|
+
canaryCmd.command('promote <service>')
|
|
1232
|
+
.description('Skip the remaining steps and send all traffic to the new release now')
|
|
1233
|
+
.option('--deployment <id>', 'The deployment to promote (default: the one running)')
|
|
1234
|
+
.action((service: string, opts: { deployment?: string }) => canaryPromote(getClient(), service, opts));
|
|
1235
|
+
/* v8 ignore stop */
|
|
1236
|
+
|
|
1237
|
+
// ── 0.16.10: autoscaling ─────────────────────────────────────────────────
|
|
1238
|
+
const autoscaleCmd = program.command('autoscale').description('Autoscaling: run between a minimum and a maximum number of containers, set by load');
|
|
1239
|
+
/* v8 ignore start -- exercised by test/autoscale.test.ts. */
|
|
1240
|
+
autoscaleCmd.command('show <service>')
|
|
1241
|
+
.description('The autoscaling configuration and what it is doing now')
|
|
1242
|
+
.option('--json', 'Print raw JSON')
|
|
1243
|
+
.action((service: string, opts: { json?: boolean }) => autoscaleShow(getClient(), service, opts));
|
|
1244
|
+
autoscaleCmd.command('set <service>')
|
|
1245
|
+
.description('Turn autoscaling on or change it (docker services); options not given keep their value')
|
|
1246
|
+
.option('--min <n>', 'Containers it never goes below (1-10, default 1)')
|
|
1247
|
+
.option('--max <n>', 'Containers it never goes above (2-10, above --min; required to turn it on)')
|
|
1248
|
+
.option('--metric <metric>', 'cpu, memory or rps (default cpu)')
|
|
1249
|
+
.option('--target <value>', 'Value per container to keep near: percent for cpu/memory, requests per second for rps (required to turn it on)')
|
|
1250
|
+
.option('--up-seconds <n>', 'How long the need must last before adding containers (30-3600, default 60)')
|
|
1251
|
+
.option('--down-seconds <n>', 'How long the need must last before removing containers (60-3600, default 300)')
|
|
1252
|
+
.option('--json', 'Print the stored configuration as JSON')
|
|
1253
|
+
.action((service: string, opts: AutoscaleSetOptions) => autoscaleSet(getClient(), service, opts));
|
|
1254
|
+
autoscaleCmd.command('off <service>').description('Turn autoscaling off').action((service: string) => autoscaleOff(getClient(), service));
|
|
1255
|
+
/* v8 ignore stop */
|
|
1256
|
+
|
|
1212
1257
|
deploys.command('watch <serviceId> <deployId>').description('Stream a deployment\'s build logs live').action((svcId: string, depId: string) => deploysWatch(svcId, depId));
|
|
1213
1258
|
|
|
1214
1259
|
// ── Diagnostics ───────────────────────────────────────────────────────────
|