@travetto/cli 5.0.11 → 5.0.12

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 CHANGED
@@ -533,3 +533,62 @@ async validate(): Promise<CliValidationError | undefined> {
533
533
  }
534
534
  }
535
535
  ```
536
+
537
+ ## CLI - service
538
+ The module provides the ability to start/stop/restart services as [docker](https://www.docker.com/community-edition) containers. This is meant to be used for development purposes, to minimize the effort of getting an application up and running. Services can be targeted individually or handled as a group.
539
+
540
+ **Terminal: Command Service**
541
+ ```bash
542
+ $ trv service --help
543
+
544
+ Usage: service [options] <action:restart|start|status|stop> [services...:string]
545
+
546
+ Options:
547
+ -h, --help display help for command
548
+
549
+ Available Services
550
+ --------------------
551
+ * dynamodb@2.0.0
552
+ * elasticsearch@8.9.1
553
+ * firestore@latest
554
+ * mongodb@7.0
555
+ * mysql@8.0
556
+ * postgresql@15.4
557
+ * redis@7.2
558
+ * s3@3.1.0
559
+ ```
560
+
561
+ A sample of all services available to the entire framework:
562
+
563
+ **Terminal: All Services**
564
+ ```bash
565
+ $ trv service status
566
+
567
+ Service Version Status
568
+ -------------------------------------------------
569
+ dynamodb 2.0.0 Running 93af422e793a
570
+ elasticsearch 8.9.1 Running ed76ee063d13
571
+ firestore latest Running feec2e5e95b4
572
+ mongodb 7.0 Running 5513eba6734e
573
+ mysql 8.0 Running 307bc66d442a
574
+ postgresql 15.4 Running e78291e71040
575
+ redis 7.2 Running 77ba279b4e30
576
+ s3 3.1.0 Running fdacfc55b9e3
577
+ ```
578
+
579
+ ### Defining new Services
580
+ The services are defined as plain typescript files within the framework and can easily be extended:
581
+
582
+ **Code: Sample Service Definition**
583
+ ```typescript
584
+ import type { ServiceDescriptor } from '@travetto/cli';
585
+
586
+ const version = process.env.MONGO_VERSION ?? '7.0';
587
+
588
+ export const service: ServiceDescriptor = {
589
+ name: 'mongodb',
590
+ version,
591
+ port: 27017,
592
+ image: `mongo:${version}`
593
+ };
594
+ ```
package/__index__.ts CHANGED
@@ -10,4 +10,5 @@ export * from './src/color';
10
10
  export * from './src/module';
11
11
  export * from './src/scm';
12
12
  export * from './src/parse';
13
+ export * from './src/service';
13
14
  export * from './src/util';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/cli",
3
- "version": "5.0.11",
3
+ "version": "5.0.12",
4
4
  "description": "CLI infrastructure for Travetto framework",
5
5
  "keywords": [
6
6
  "cli",
package/src/service.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import rl from 'node:readline/promises';
4
+ import net from 'node:net';
5
+
6
+ import { ExecUtil, TimeUtil, Util } from '@travetto/runtime';
7
+
8
+ const ports = (val: number | `${number}:${number}`): [number, number] =>
9
+ typeof val === 'number' ?
10
+ [val, val] :
11
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
12
+ val.split(':').map(x => parseInt(x, 10)) as [number, number];
13
+
14
+ type BodyCheck = (body: string) => boolean;
15
+
16
+ /**
17
+ * This represents the schema for defined services
18
+ */
19
+ export interface ServiceDescriptor {
20
+ name: string;
21
+ version: string | number;
22
+ port?: number | `${number}:${number}`;
23
+ privileged?: boolean;
24
+ image: string;
25
+ args?: string[];
26
+ ready?: { url: string, test?: BodyCheck };
27
+ volumes?: Record<string, string>;
28
+ env?: Record<string, string>;
29
+ startupTimeout?: number;
30
+ }
31
+
32
+ export type ServiceAction = 'start' | 'stop' | 'status' | 'restart';
33
+
34
+ /**
35
+ * Service runner
36
+ */
37
+ export class ServiceRunner {
38
+
39
+ constructor(public svc: ServiceDescriptor) { }
40
+
41
+ async #isRunning(full = false): Promise<boolean> {
42
+ const port = ports(this.svc.port!)[0];
43
+ const start = Date.now();
44
+ const timeoutMs = TimeUtil.asMillis(full ? this.svc.startupTimeout ?? 5000 : 100);
45
+ while ((Date.now() - start) < timeoutMs) {
46
+ try {
47
+ const sock = net.createConnection(port, 'localhost');
48
+ await new Promise<void>((res, rej) =>
49
+ sock.on('connect', res).on('timeout', rej).on('error', rej)
50
+ ).finally(() => sock.destroy());
51
+
52
+ if (!this.svc.ready?.url || !full) {
53
+ return true;
54
+ } else {
55
+ const req = await fetch(this.svc.ready.url, { method: 'GET' });
56
+ const text = await req.text();
57
+ if (req.ok && (this.svc.ready.test?.(text) ?? true)) {
58
+ return true;
59
+ }
60
+ }
61
+ } catch {
62
+ await Util.blockingTimeout(50);
63
+ }
64
+ }
65
+
66
+ return false;
67
+ }
68
+
69
+ async #hasImage(): Promise<boolean> {
70
+ const result = await ExecUtil.getResult(spawn('docker', ['image', 'inspect', this.svc.image], { shell: false }), { catch: true });
71
+ return result.valid;
72
+ }
73
+
74
+ async * #pullImage(): AsyncIterable<string> {
75
+ const proc = spawn('docker', ['pull', this.svc.image], { stdio: [0, 'pipe', 'pipe'] });
76
+ yield* rl.createInterface(proc.stdout!);
77
+ await ExecUtil.getResult(proc);
78
+ }
79
+
80
+ async #startContainer(): Promise<string> {
81
+ const args = [
82
+ 'run',
83
+ '--rm',
84
+ '--detach',
85
+ ...this.svc.privileged ? ['--privileged'] : [],
86
+ '--label', `trv-${this.svc.name}`,
87
+ ...Object.entries(this.svc.env ?? {}).flatMap(([k, v]) => ['--env', `${k}=${v}`]),
88
+ ...this.svc.port ? ['-p', ports(this.svc.port).join(':')] : [],
89
+ ...Object.entries(this.svc.volumes ?? {}).flatMap(([k, v]) => ['--volume', `${k}:${v}`]),
90
+ this.svc.image,
91
+ ...this.svc.args ?? [],
92
+ ];
93
+
94
+ for (const item of Object.keys(this.svc.volumes ?? {})) {
95
+ await fs.mkdir(item, { recursive: true });
96
+ }
97
+
98
+ return (await ExecUtil.getResult(spawn('docker', args, { shell: false, stdio: [0, 'pipe', 2] }))).stdout;
99
+ }
100
+
101
+ async #getContainerId(): Promise<string | undefined> {
102
+ return (await ExecUtil.getResult(spawn('docker', ['ps', '-q', '--filter', `label=trv-${this.svc.name}`], { shell: false }))).stdout.trim();
103
+ }
104
+
105
+ async #killContainer(cid: string): Promise<void> {
106
+ await ExecUtil.getResult(spawn('docker', ['kill', cid], { shell: false }));
107
+ }
108
+
109
+ async * action(op: ServiceAction): AsyncIterable<['success' | 'failure' | 'message', string]> {
110
+ try {
111
+ const cid = await this.#getContainerId();
112
+ const port = this.svc.port ? ports(this.svc.port)[0] : 0;
113
+ const running = !!cid && (!port || await this.#isRunning());
114
+
115
+ if (running && !cid) { // We don't own
116
+ return yield [op === 'status' ? 'message' : 'failure', 'Running but not managed'];
117
+ }
118
+
119
+ if (op === 'status') {
120
+ return yield !cid ? ['message', 'Not running'] : ['success', `Running ${cid}`];
121
+ } else if (op === 'start' && running) {
122
+ return yield ['message', 'Skipping, already running'];
123
+ } else if (op === 'stop' && !running) {
124
+ return yield ['message', 'Skipping, already stopped'];
125
+ }
126
+
127
+ if (running && (op === 'restart' || op === 'stop')) {
128
+ yield ['message', 'Stopping'];
129
+ await this.#killContainer(cid);
130
+ yield ['success', 'Stopped'];
131
+ }
132
+
133
+ if (op === 'restart' || op === 'start') {
134
+ if (!await this.#hasImage()) {
135
+ yield ['message', 'Starting image download'];
136
+ for await (const line of await this.#pullImage()) {
137
+ yield ['message', `Downloading: ${line}`];
138
+ }
139
+ yield ['message', 'Image download complete'];
140
+ }
141
+
142
+ yield ['message', 'Starting'];
143
+ const out = await this.#startContainer();
144
+
145
+ if (port) {
146
+ yield ['message', `Waiting for ${this.svc.ready?.url ?? 'container'}...`];
147
+ if (!await this.#isRunning(true)) {
148
+ yield ['failure', 'Failed to start service correctly'];
149
+ }
150
+ }
151
+ yield ['success', `Started ${out.substring(0, 12)}`];
152
+ }
153
+ } catch {
154
+ yield ['failure', 'Failed to start'];
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,71 @@
1
+ import { CliCommandShape, CliCommand, cliTpl, CliValidationError } from '@travetto/cli';
2
+ import { Terminal } from '@travetto/terminal';
3
+ import { AsyncQueue, Runtime, RuntimeIndex, Util } from '@travetto/runtime';
4
+
5
+ import { ServiceRunner, ServiceDescriptor, ServiceAction } from '../src/service';
6
+
7
+ /**
8
+ * Allows for running services
9
+ */
10
+ @CliCommand()
11
+ export class CliServiceCommand implements CliCommandShape {
12
+
13
+ async #getServices(services: string[]): Promise<ServiceDescriptor[]> {
14
+ return (await Promise.all(
15
+ RuntimeIndex.find({
16
+ module: m => m.roles.includes('std'),
17
+ folder: f => f === 'support',
18
+ file: f => /support\/service[.]/.test(f.sourceFile)
19
+ })
20
+ .map(x => Runtime.importFrom<{ service: ServiceDescriptor }>(x.import).then(v => v.service))
21
+ ))
22
+ .filter(x => !!x)
23
+ .filter(x => services?.length ? services.includes(x.name) : true)
24
+ .sort((a, b) => a.name.localeCompare(b.name));
25
+ }
26
+
27
+ async validate(action: ServiceAction, services: string[]): Promise<CliValidationError | undefined> {
28
+ const all = await this.#getServices(services);
29
+
30
+ if (!all.length) {
31
+ return { message: 'No services found' };
32
+ }
33
+ }
34
+
35
+ async help(): Promise<string[]> {
36
+ const all = await this.#getServices([]);
37
+ return [
38
+ cliTpl`${{ title: 'Available Services' }}`,
39
+ '-'.repeat(20),
40
+ ...all.map(x => cliTpl` * ${{ identifier: x.name }}@${{ type: x.version }}`)
41
+ ];
42
+ }
43
+
44
+ async main(action: ServiceAction, services: string[] = []): Promise<void> {
45
+ const all = await this.#getServices(services);
46
+ const maxName = Math.max(...all.map(x => x.name.length), 'Service'.length) + 3;
47
+ const maxVersion = Math.max(...all.map(x => `${x.version}`.length), 'Version'.length) + 3;
48
+ const maxStatus = 20;
49
+ const q = new AsyncQueue<{ idx: number, text: string, done?: boolean }>();
50
+
51
+ const jobs = all.map(async (v, i) => {
52
+ const identifier = v.name.padEnd(maxName);
53
+ const type = `${v.version}`.padStart(maxVersion - 3).padEnd(maxVersion);
54
+ for await (const [valueType, value] of new ServiceRunner(v).action(action)) {
55
+ const details = { [valueType === 'message' ? 'subtitle' : valueType]: value };
56
+ q.add({ idx: i, text: cliTpl`${{ identifier }} ${{ type }} ${details}` });
57
+ }
58
+ });
59
+
60
+ Promise.all(jobs).then(() => Util.queueMacroTask()).then(() => q.close());
61
+
62
+ const term = new Terminal();
63
+ await term.writer.writeLines([
64
+ '',
65
+ cliTpl`${{ title: 'Service'.padEnd(maxName) }} ${{ title: 'Version'.padEnd(maxVersion) }} ${{ title: 'Status' }}`,
66
+ ''.padEnd(maxName + maxVersion + maxStatus + 3, '-'),
67
+ ]).commit();
68
+
69
+ await term.streamList(q);
70
+ }
71
+ }