flightdeck 0.0.2 → 0.0.3

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.
@@ -0,0 +1,343 @@
1
+ import { execSync, spawn } from "node:child_process"
2
+ import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs"
3
+ import type { Server } from "node:http"
4
+ import { createServer } from "node:http"
5
+ import { homedir } from "node:os"
6
+ import { resolve } from "node:path"
7
+
8
+ import { Future } from "atom.io/internal"
9
+ import { fromEntries, toEntries } from "atom.io/json"
10
+ import { ChildSocket } from "atom.io/realtime-server"
11
+
12
+ export type FlightDeckOptions<S extends string = string> = {
13
+ secret: string
14
+ packageName: string
15
+ services: { [service in S]: { run: string[]; waitFor: boolean } }
16
+ downloadPackageToUpdatesCmd: string[]
17
+ flightdeckRootDir?: string | undefined
18
+ }
19
+
20
+ const PORT = process.env.PORT ?? 8080
21
+ const ORIGIN = `http://localhost:${PORT}`
22
+ export class FlightDeck<S extends string = string> {
23
+ protected safety = 0
24
+
25
+ protected webhookServer: Server
26
+ protected services: {
27
+ [service in S]: ChildSocket<
28
+ { updatesReady: [] },
29
+ { readyToUpdate: []; alive: [] }
30
+ > | null
31
+ }
32
+ protected serviceIdx: { readonly [service in S]: number }
33
+ public defaultServicesReadyToUpdate: { readonly [service in S]: boolean }
34
+ public servicesReadyToUpdate: { [service in S]: boolean }
35
+ public servicesShouldRestart: boolean
36
+
37
+ protected restartTimes: number[] = []
38
+
39
+ public servicesLive: Future<void>[]
40
+ public servicesDead: Future<void>[]
41
+ public live = new Future(() => {})
42
+ public dead = new Future(() => {})
43
+
44
+ public readonly currentServiceDir: string
45
+ public readonly updateServiceDir: string
46
+ public readonly backupServiceDir: string
47
+
48
+ public constructor(public readonly options: FlightDeckOptions<S>) {
49
+ const { secret, flightdeckRootDir = resolve(homedir(), `services`) } =
50
+ options
51
+
52
+ const servicesEntries = toEntries(options.services)
53
+ this.services = fromEntries(
54
+ servicesEntries.map(([serviceName]) => [serviceName, null]),
55
+ )
56
+ this.serviceIdx = fromEntries(
57
+ servicesEntries.map(([serviceName], idx) => [serviceName, idx]),
58
+ )
59
+ this.defaultServicesReadyToUpdate = fromEntries(
60
+ servicesEntries.map(([serviceName, { waitFor }]) => [
61
+ serviceName,
62
+ !waitFor,
63
+ ]),
64
+ )
65
+ this.servicesReadyToUpdate = { ...this.defaultServicesReadyToUpdate }
66
+ this.servicesShouldRestart = true
67
+ this.servicesLive = servicesEntries.map(() => new Future(() => {}))
68
+ this.servicesDead = servicesEntries.map(() => new Future(() => {}))
69
+ this.live.use(Promise.all(this.servicesLive))
70
+ this.dead.use(Promise.all(this.servicesDead))
71
+
72
+ this.currentServiceDir = resolve(
73
+ flightdeckRootDir,
74
+ options.packageName,
75
+ `current`,
76
+ )
77
+ this.backupServiceDir = resolve(
78
+ flightdeckRootDir,
79
+ options.packageName,
80
+ `backup`,
81
+ )
82
+ this.updateServiceDir = resolve(
83
+ flightdeckRootDir,
84
+ options.packageName,
85
+ `update`,
86
+ )
87
+
88
+ createServer((req, res) => {
89
+ let data: Uint8Array[] = []
90
+ req
91
+ .on(`data`, (chunk) => {
92
+ data.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk))
93
+ })
94
+ .on(`end`, () => {
95
+ console.log(req.headers)
96
+ const authHeader = req.headers.authorization
97
+ try {
98
+ if (typeof req.url === `undefined`) throw 400
99
+ if (authHeader !== `Bearer ${secret}`) throw 401
100
+ const url = new URL(req.url, ORIGIN)
101
+ console.log(req.method, url.pathname)
102
+ switch (req.method) {
103
+ case `POST`:
104
+ {
105
+ console.log(`received post, url is ${url.pathname}`)
106
+ switch (url.pathname) {
107
+ case `/`:
108
+ {
109
+ res.writeHead(200)
110
+ res.end()
111
+ this.fetchLatestRelease()
112
+ if (
113
+ toEntries(this.servicesReadyToUpdate).every(
114
+ ([, isReady]) => isReady,
115
+ )
116
+ ) {
117
+ console.log(`All services are ready to update!`)
118
+ this.stopAllServices()
119
+ return
120
+ }
121
+ for (const entry of toEntries(this.services)) {
122
+ const [serviceName, service] = entry
123
+ if (service) {
124
+ if (this.options.services[serviceName].waitFor) {
125
+ service.emit(`updatesReady`)
126
+ }
127
+ } else {
128
+ this.startService(serviceName)
129
+ }
130
+ }
131
+ }
132
+ break
133
+
134
+ default:
135
+ throw 404
136
+ }
137
+ }
138
+ break
139
+
140
+ default:
141
+ throw 405
142
+ }
143
+ } catch (thrown) {
144
+ console.error(thrown, req.url)
145
+ if (typeof thrown === `number`) {
146
+ res.writeHead(thrown)
147
+ res.end()
148
+ }
149
+ } finally {
150
+ data = []
151
+ }
152
+ })
153
+ }).listen(PORT, () => {
154
+ console.log(`Server started on port ${PORT}`)
155
+ })
156
+
157
+ this.startAllServices()
158
+ }
159
+
160
+ protected startAllServices(): void {
161
+ console.log(`Starting all services...`)
162
+ for (const [serviceName] of toEntries(this.services)) {
163
+ this.startService(serviceName)
164
+ }
165
+ }
166
+
167
+ protected startService(serviceName: S): void {
168
+ console.log(
169
+ `Starting service ${this.options.packageName}::${serviceName}, try ${this.safety}/2...`,
170
+ )
171
+ if (this.safety > 2) {
172
+ throw new Error(`Out of tries...`)
173
+ }
174
+ this.safety++
175
+ if (!existsSync(this.currentServiceDir)) {
176
+ console.log(
177
+ `Tried to start service but failed: Service ${this.options.packageName} is not yet installed.`,
178
+ )
179
+ this.fetchLatestRelease()
180
+ this.applyUpdate()
181
+ this.startService(serviceName)
182
+
183
+ return
184
+ }
185
+
186
+ const [executable, ...args] = this.options.services[serviceName].run
187
+ const program = executable.startsWith(`./`)
188
+ ? resolve(this.currentServiceDir, executable)
189
+ : executable
190
+ const serviceProcess = spawn(program, args, {
191
+ cwd: this.currentServiceDir,
192
+ env: import.meta.env,
193
+ })
194
+ this.services[serviceName] = new ChildSocket(
195
+ serviceProcess,
196
+ `${this.options.packageName}::${serviceName}`,
197
+ console,
198
+ )
199
+ this.services[serviceName].onAny((...messages) => {
200
+ console.log(`${this.options.packageName}::${serviceName} 💬`, ...messages)
201
+ })
202
+ this.services[serviceName].on(`readyToUpdate`, () => {
203
+ console.log(
204
+ `Service ${this.options.packageName}::${serviceName} is ready to update.`,
205
+ )
206
+ this.servicesReadyToUpdate[serviceName] = true
207
+ if (
208
+ toEntries(this.servicesReadyToUpdate).every(([, isReady]) => isReady)
209
+ ) {
210
+ console.log(`All services are ready to update!`)
211
+ this.stopAllServices()
212
+ }
213
+ })
214
+ this.services[serviceName].on(`alive`, () => {
215
+ this.servicesLive[this.serviceIdx[serviceName]].use(Promise.resolve())
216
+ this.servicesDead[this.serviceIdx[serviceName]] = new Future(() => {})
217
+ if (this.dead.done) {
218
+ this.dead = new Future(() => {})
219
+ }
220
+ this.dead.use(Promise.all(this.servicesDead))
221
+ })
222
+ this.services[serviceName].process.on(`close`, (exitCode) => {
223
+ console.log(
224
+ `${this.options.packageName}::${serviceName} exited with code ${exitCode}`,
225
+ )
226
+ this.services[serviceName] = null
227
+ if (!this.servicesShouldRestart) {
228
+ console.log(
229
+ `Service ${this.options.packageName}::${serviceName} will not be restarted.`,
230
+ )
231
+ return
232
+ }
233
+ const updatesAreReady = existsSync(this.updateServiceDir)
234
+ if (updatesAreReady) {
235
+ console.log(
236
+ `${this.options.packageName}::${serviceName} will be updated before startup...`,
237
+ )
238
+ this.restartTimes = []
239
+ this.applyUpdate()
240
+ this.startService(serviceName)
241
+ } else {
242
+ const now = Date.now()
243
+ const fiveMinutesAgo = now - 5 * 60 * 1000
244
+ this.restartTimes = this.restartTimes.filter(
245
+ (time) => time > fiveMinutesAgo,
246
+ )
247
+ this.restartTimes.push(now)
248
+
249
+ if (this.restartTimes.length < 5) {
250
+ console.log(
251
+ `Service ${this.options.packageName}::${serviceName} crashed. Restarting...`,
252
+ )
253
+ this.startService(serviceName)
254
+ } else {
255
+ console.log(
256
+ `Service ${this.options.packageName}::${serviceName} crashed too many times. Not restarting.`,
257
+ )
258
+ }
259
+ }
260
+ })
261
+ this.safety = 0
262
+ }
263
+
264
+ protected applyUpdate(): void {
265
+ console.log(
266
+ `Installing latest version of service ${this.options.packageName}...`,
267
+ )
268
+
269
+ if (existsSync(this.updateServiceDir)) {
270
+ const runningServices = toEntries(this.services).filter(
271
+ ([, service]) => service,
272
+ )
273
+ if (runningServices.length > 0) {
274
+ console.log(
275
+ `Tried to apply update to ${this.options.packageName} but failed. The following services are currently running: [${runningServices.map(([serviceName]) => serviceName).join(`, `)}]`,
276
+ )
277
+ return
278
+ }
279
+
280
+ if (existsSync(this.currentServiceDir)) {
281
+ if (!existsSync(this.backupServiceDir)) {
282
+ mkdirSync(this.backupServiceDir, { recursive: true })
283
+ } else {
284
+ rmSync(this.backupServiceDir, { recursive: true })
285
+ }
286
+ renameSync(this.currentServiceDir, this.backupServiceDir)
287
+ }
288
+
289
+ renameSync(this.updateServiceDir, this.currentServiceDir)
290
+ this.restartTimes = []
291
+ this.servicesReadyToUpdate = { ...this.defaultServicesReadyToUpdate }
292
+ } else {
293
+ console.log(`Service ${this.options.packageName} is already up to date.`)
294
+ }
295
+ }
296
+
297
+ protected fetchLatestRelease(): void {
298
+ console.log(
299
+ `Downloading latest version of service ${this.options.packageName}...`,
300
+ )
301
+
302
+ try {
303
+ execSync(this.options.downloadPackageToUpdatesCmd.join(` `))
304
+ } catch (thrown) {
305
+ if (thrown instanceof Error) {
306
+ console.error(`Failed to fetch the latest release: ${thrown.message}`)
307
+ }
308
+ return
309
+ }
310
+ }
311
+
312
+ public stopAllServices(): void {
313
+ console.log(`Stopping all services...`)
314
+ for (const [serviceName] of toEntries(this.services)) {
315
+ this.stopService(serviceName)
316
+ }
317
+ }
318
+
319
+ public stopService(serviceName: S): void {
320
+ if (this.services[serviceName]) {
321
+ console.log(
322
+ `Stopping service ${this.options.packageName}::${serviceName}...`,
323
+ )
324
+ this.services[serviceName].process.kill()
325
+ this.services[serviceName] = null
326
+ this.servicesDead[this.serviceIdx[serviceName]].use(Promise.resolve())
327
+ this.servicesLive[this.serviceIdx[serviceName]] = new Future(() => {})
328
+ if (this.live.done) {
329
+ this.live = new Future(() => {})
330
+ }
331
+ this.live.use(Promise.all(this.servicesLive))
332
+ } else {
333
+ console.error(
334
+ `Failed to stop service ${this.options.packageName}::${serviceName}: Service is not running.`,
335
+ )
336
+ }
337
+ }
338
+
339
+ public shutdown(): void {
340
+ this.servicesShouldRestart = false
341
+ this.stopAllServices()
342
+ }
343
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cli, required } from "comline"
4
+ import { z } from "zod"
5
+
6
+ import * as Klaxon from "./klaxon.lib"
7
+
8
+ const changesetsPublishedPackagesSchema: z.ZodSchema<Klaxon.ScrambleOptions> =
9
+ z.object({
10
+ packageConfig: z.record(z.string(), z.object({ endpoint: z.string() })),
11
+ secretsConfig: z.record(z.string(), z.string()),
12
+ publishedPackages: z.array(
13
+ z.object({
14
+ name: z.string(),
15
+ version: z.string(),
16
+ }),
17
+ ),
18
+ })
19
+
20
+ const klaxon = cli({
21
+ cliName: `klaxon`,
22
+ routes: required({
23
+ scramble: null,
24
+ }),
25
+ routeOptions: {
26
+ scramble: {
27
+ options: {
28
+ packageConfig: {
29
+ description: `Maps the names of your packages to the endpoints that klaxon will POST to.`,
30
+ example: `--packageConfig="{\\"my-app\\":{\\"endpoint\\":\\"https://my-app.com\\"}}"`,
31
+ flag: `c`,
32
+ parse: JSON.parse,
33
+ required: true,
34
+ },
35
+ secretsConfig: {
36
+ description: `Maps the names of your packages to the secrets that klaxon will use to authenticate with their respective endpoints.`,
37
+ example: `--secretsConfig="{\\"my-app\\":\\"XXXX-XXXX-XXXX\\"}"`,
38
+ flag: `s`,
39
+ parse: JSON.parse,
40
+ required: true,
41
+ },
42
+ publishedPackages: {
43
+ description: `The output of the "Publish" step in Changesets.`,
44
+ example: `--publishedPackages="[{\\"name\\":\\"my-app\\",\\"version\\":\\"0.0.0\\"}]"`,
45
+ flag: `p`,
46
+ parse: JSON.parse,
47
+ required: true,
48
+ },
49
+ },
50
+ optionsSchema: changesetsPublishedPackagesSchema,
51
+ },
52
+ },
53
+ })
54
+
55
+ const { inputs } = klaxon(process.argv)
56
+ await Klaxon.scramble(inputs.opts).then((scrambleResult) => {
57
+ console.log(scrambleResult)
58
+ })
@@ -0,0 +1,78 @@
1
+ export type AlertOptions = {
2
+ secret: string
3
+ endpoint: string
4
+ }
5
+
6
+ export async function alert({
7
+ secret,
8
+ endpoint,
9
+ }: AlertOptions): Promise<Response> {
10
+ const response = await fetch(endpoint, {
11
+ method: `POST`,
12
+ headers: {
13
+ "Content-Type": `application/json`,
14
+ Authorization: `Bearer ${secret}`,
15
+ },
16
+ })
17
+
18
+ return response
19
+ }
20
+
21
+ /**
22
+ * @see https://github.com/changesets/action/blob/main/src/run.ts
23
+ */
24
+ export type ChangesetsPublishedPackage = {
25
+ name: string
26
+ version: string
27
+ }
28
+
29
+ /**
30
+ * @see https://github.com/changesets/action/blob/main/src/run.ts
31
+ */
32
+ export type ChangesetsPublishResult =
33
+ | {
34
+ published: true
35
+ publishedPackages: ChangesetsPublishedPackage[]
36
+ }
37
+ | { published: false }
38
+
39
+ export type PackageConfig<K extends string> = {
40
+ [key in K]: { endpoint: string }
41
+ }
42
+ export type SecretsConfig<K extends string> = {
43
+ [key in K]: string
44
+ }
45
+
46
+ export type ScrambleOptions<K extends string = string> = {
47
+ packageConfig: PackageConfig<K>
48
+ secretsConfig: SecretsConfig<K>
49
+ publishedPackages: ChangesetsPublishedPackage[]
50
+ }
51
+
52
+ export type ScrambleResult<K extends string = string> = {
53
+ [key in K]: Response
54
+ }
55
+
56
+ export async function scramble<K extends string = string>({
57
+ packageConfig,
58
+ secretsConfig,
59
+ publishedPackages,
60
+ }: ScrambleOptions<K>): Promise<ScrambleResult<K>> {
61
+ const alertResults: Promise<readonly [K, Response]>[] = []
62
+ for (const publishedPackage of publishedPackages) {
63
+ if (publishedPackage.name in packageConfig) {
64
+ const name = publishedPackage.name as K
65
+ const { endpoint } = packageConfig[name]
66
+ const secret = secretsConfig[name]
67
+ const alertResultPromise = alert({ secret, endpoint }).then(
68
+ (alertResult) => [name, alertResult] as const,
69
+ )
70
+ alertResults.push(alertResultPromise)
71
+ }
72
+ }
73
+ const alertResultsResolved = await Promise.all(alertResults)
74
+ const scrambleResult = Object.fromEntries(
75
+ alertResultsResolved,
76
+ ) as ScrambleResult<K>
77
+ return scrambleResult
78
+ }
package/src/lib.ts CHANGED
@@ -1 +1,4 @@
1
- export * from "./flightdeck"
1
+ export * from "./flightdeck.lib"
2
+ import * as Klaxon from "./klaxon.lib"
3
+
4
+ export { Klaxon }
package/dist/bin.js DELETED
@@ -1,102 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/bin.ts
4
- import * as path from "node:path";
5
- import {cli, optional, parseArrayOption} from "comline";
6
- import {FlightDeck} from "flightdeck";
7
- import {z} from "zod";
8
- var FLIGHTDECK_MANUAL = {
9
- optionsSchema: z.object({
10
- secret: z.string(),
11
- repo: z.string(),
12
- app: z.string(),
13
- runCmd: z.array(z.string()),
14
- serviceDir: z.string(),
15
- updateCmd: z.array(z.string())
16
- }),
17
- options: {
18
- secret: {
19
- flag: `s`,
20
- required: true,
21
- description: `Secret used to authenticate with the service.`,
22
- example: `--secret=\"secret\"`
23
- },
24
- repo: {
25
- flag: `r`,
26
- required: true,
27
- description: `Name of the repository.`,
28
- example: `--repo=\"sample/repo\"`
29
- },
30
- app: {
31
- flag: `a`,
32
- required: true,
33
- description: `Name of the application.`,
34
- example: `--app=\"my-app\"`
35
- },
36
- runCmd: {
37
- flag: `r`,
38
- required: true,
39
- description: `Command to run the application.`,
40
- example: `--runCmd=\"./app\"`,
41
- parse: parseArrayOption
42
- },
43
- serviceDir: {
44
- flag: `d`,
45
- required: true,
46
- description: `Directory where the service is stored.`,
47
- example: `--serviceDir=\"./services/sample/repo/my-app/current\"`
48
- },
49
- updateCmd: {
50
- flag: `u`,
51
- required: true,
52
- description: `Command to update the service.`,
53
- example: `--updateCmd=\"./app\"`,
54
- parse: parseArrayOption
55
- }
56
- }
57
- };
58
- var SCHEMA_MANUAL = {
59
- optionsSchema: z.object({
60
- outdir: z.string().optional()
61
- }),
62
- options: {
63
- outdir: {
64
- flag: `o`,
65
- required: false,
66
- description: `Directory to write the schema to.`,
67
- example: `--outdir=./dist`
68
- }
69
- }
70
- };
71
- var parse = cli({
72
- cliName: `flightdeck`,
73
- routes: optional({ schema: null, $configPath: null }),
74
- routeOptions: {
75
- "": FLIGHTDECK_MANUAL,
76
- $configPath: FLIGHTDECK_MANUAL,
77
- schema: SCHEMA_MANUAL
78
- },
79
- discoverConfigPath: (args) => {
80
- if (args[0] === `schema`) {
81
- return;
82
- }
83
- const configPath = args[0] ?? path.join(process.cwd(), `flightdeck.config.json`);
84
- return configPath;
85
- }
86
- }, console);
87
- var { inputs, writeJsonSchema } = parse(process.argv);
88
- switch (inputs.case) {
89
- case `schema`:
90
- {
91
- const { outdir } = inputs.opts;
92
- writeJsonSchema(outdir ?? `.`);
93
- }
94
- break;
95
- default: {
96
- const flightDeck = new FlightDeck(inputs.opts);
97
- process.on(`close`, async () => {
98
- flightDeck.stopService();
99
- await flightDeck.dead;
100
- });
101
- }
102
- }