@highstate/common 0.9.16 → 0.9.19

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.
Files changed (48) hide show
  1. package/dist/{chunk-HZBJ6LLS.js → chunk-WDYIUWYZ.js} +659 -267
  2. package/dist/chunk-WDYIUWYZ.js.map +1 -0
  3. package/dist/highstate.manifest.json +12 -8
  4. package/dist/index.js +1 -1
  5. package/dist/units/access-point/index.js +16 -0
  6. package/dist/units/access-point/index.js.map +1 -0
  7. package/dist/units/databases/existing-mariadb/index.js +17 -0
  8. package/dist/units/databases/existing-mariadb/index.js.map +1 -0
  9. package/dist/units/databases/existing-mongodb/index.js +17 -0
  10. package/dist/units/databases/existing-mongodb/index.js.map +1 -0
  11. package/dist/units/databases/existing-postgresql/index.js +17 -0
  12. package/dist/units/databases/existing-postgresql/index.js.map +1 -0
  13. package/dist/units/dns/record-set/index.js +22 -11
  14. package/dist/units/dns/record-set/index.js.map +1 -1
  15. package/dist/units/existing-server/index.js +12 -12
  16. package/dist/units/existing-server/index.js.map +1 -1
  17. package/dist/units/network/l3-endpoint/index.js +1 -1
  18. package/dist/units/network/l3-endpoint/index.js.map +1 -1
  19. package/dist/units/network/l4-endpoint/index.js +1 -1
  20. package/dist/units/network/l4-endpoint/index.js.map +1 -1
  21. package/dist/units/script/index.js +1 -1
  22. package/dist/units/script/index.js.map +1 -1
  23. package/dist/units/server-dns/index.js +1 -1
  24. package/dist/units/server-dns/index.js.map +1 -1
  25. package/dist/units/server-patch/index.js +1 -1
  26. package/dist/units/server-patch/index.js.map +1 -1
  27. package/dist/units/ssh/key-pair/index.js +6 -6
  28. package/dist/units/ssh/key-pair/index.js.map +1 -1
  29. package/package.json +61 -8
  30. package/src/shared/access-point.ts +110 -0
  31. package/src/shared/command.ts +310 -69
  32. package/src/shared/dns.ts +150 -90
  33. package/src/shared/files.ts +34 -34
  34. package/src/shared/gateway.ts +117 -0
  35. package/src/shared/impl-ref.ts +123 -0
  36. package/src/shared/index.ts +4 -0
  37. package/src/shared/network.ts +41 -27
  38. package/src/shared/passwords.ts +38 -2
  39. package/src/shared/ssh.ts +261 -126
  40. package/src/shared/tls.ts +123 -0
  41. package/src/units/access-point/index.ts +12 -0
  42. package/src/units/databases/existing-mariadb/index.ts +14 -0
  43. package/src/units/databases/existing-mongodb/index.ts +14 -0
  44. package/src/units/databases/existing-postgresql/index.ts +14 -0
  45. package/src/units/dns/record-set/index.ts +21 -11
  46. package/src/units/existing-server/index.ts +12 -17
  47. package/src/units/ssh/key-pair/index.ts +6 -6
  48. package/dist/chunk-HZBJ6LLS.js.map +0 -1
@@ -1,18 +1,28 @@
1
+ import { homedir } from "node:os"
1
2
  import { local, remote, type types } from "@pulumi/command"
2
3
  import {
3
4
  ComponentResource,
4
5
  interpolate,
5
6
  output,
7
+ toPromise,
6
8
  type ComponentResourceOptions,
7
9
  type Input,
8
10
  type InputOrArray,
9
11
  type Output,
10
12
  } from "@highstate/pulumi"
11
- import { common, ssh } from "@highstate/library"
13
+ import type { common, ssh } from "@highstate/library"
14
+ import { flat } from "remeda"
12
15
  import { l3EndpointToString } from "./network"
16
+ import { sha256 } from "@noble/hashes/sha2"
13
17
 
18
+ /**
19
+ * Creates a connection object for the given SSH credentials.
20
+ *
21
+ * @param ssh The SSH credentials.
22
+ * @returns An output connection object for Pulumi remote commands.
23
+ */
14
24
  export function getServerConnection(
15
- ssh: Input<ssh.Credentials>,
25
+ ssh: Input<ssh.Connection>,
16
26
  ): Output<types.input.remote.ConnectionArgs> {
17
27
  return output(ssh).apply(ssh => ({
18
28
  host: l3EndpointToString(ssh.endpoints[0]),
@@ -25,24 +35,133 @@ export function getServerConnection(
25
35
  }))
26
36
  }
27
37
 
28
- export type CommandHost = "local" | common.Server
38
+ export type CommandHost = "local" | Input<common.Server>
39
+ export type CommandRunMode = "auto" | "prefer-host"
29
40
 
30
41
  export type CommandArgs = {
31
- host: Input<CommandHost>
42
+ /**
43
+ * The host to run the command on.
44
+ *
45
+ * Can be "local" for local execution or a server object for remote execution.
46
+ */
47
+ host: CommandHost
48
+
49
+ /**
50
+ * The command to run when the resource is created.
51
+ *
52
+ * If an array is provided, it will be joined with spaces.
53
+ */
32
54
  create: InputOrArray<string>
55
+
56
+ /**
57
+ * The command to run when the resource is updated or one of the triggers changes.
58
+ *
59
+ * If not set, the `create` command will be used.
60
+ */
33
61
  update?: InputOrArray<string>
62
+
63
+ /**
64
+ * The command to run when the resource is deleted.
65
+ */
34
66
  delete?: InputOrArray<string>
35
- logging?: Input<remote.Logging>
67
+
68
+ /**
69
+ * The stdin content to pass to the command.
70
+ */
71
+ stdin?: Input<string>
72
+
73
+ /**
74
+ * The logging level for the command.
75
+ */
76
+ logging?: Input<local.Logging>
77
+
78
+ /**
79
+ * The triggers for the command.
80
+ *
81
+ * They will be captured in the command's state and will trigger the command to run again
82
+ * if they change.
83
+ */
36
84
  triggers?: InputOrArray<unknown>
85
+
86
+ /**
87
+ * The update triggers for the command.
88
+ *
89
+ * Unlike `triggers`, which replace the entire resource on change, these will only trigger the `update` command.
90
+ *
91
+ * Under the hood, it is implemented using a hash of the provided values and passing it as environment variable.
92
+ * It is recommended to pass only primitive values (strings, numbers, booleans) or small objects/arrays for proper serialization.
93
+ */
94
+ updateTriggers?: InputOrArray<unknown>
95
+
96
+ /**
97
+ * The working directory for the command.
98
+ *
99
+ * If not set, the command will run in the user's home directory (for both local and remote hosts).
100
+ */
37
101
  cwd?: Input<string>
102
+
103
+ /**
104
+ * The environment variables to set for the command.
105
+ */
106
+ environment?: Input<Record<string, Input<string>>>
107
+
108
+ /**
109
+ * The run mode for the command.
110
+ *
111
+ * - `auto` (default): if the `image` is set, it will always run in a container, never on the host;
112
+ * otherwise, it will run on the host.
113
+ *
114
+ * - `prefer-host`: it will try to run on the host if the executable is available;
115
+ * otherwise, it will run in a container or throw an error if the `image` is not set.
116
+ */
117
+ runMode?: CommandRunMode
118
+
119
+ /**
120
+ * The container image to use to run the command.
121
+ */
122
+ image?: Input<string>
123
+
124
+ /**
125
+ * The paths to mount if the command runs in a container.
126
+ *
127
+ * They will be mounted to the same paths in the container.
128
+ */
129
+ mounts?: InputOrArray<string>
38
130
  }
39
131
 
40
132
  export type TextFileArgs = {
133
+ /**
134
+ * The host to run the command on.
135
+ */
41
136
  host: CommandHost
137
+
138
+ /**
139
+ * The absolute path to the file on the host.
140
+ */
42
141
  path: Input<string>
142
+
143
+ /**
144
+ * The content to write to the file.
145
+ */
43
146
  content: Input<string>
44
147
  }
45
148
 
149
+ export type WaitForArgs = CommandArgs & {
150
+ /**
151
+ * The timeout in seconds to wait for the command to complete.
152
+ *
153
+ * Defaults to 5 minutes (300 seconds).
154
+ */
155
+ timeout?: Input<number>
156
+
157
+ /**
158
+ * The interval in seconds to wait between checks.
159
+ *
160
+ * Defaults to 5 seconds.
161
+ */
162
+ interval?: Input<number>
163
+ }
164
+
46
165
  function createCommand(command: string | string[]): string {
47
166
  if (Array.isArray(command)) {
48
167
  return command.join(" ")
@@ -51,92 +170,214 @@ function createCommand(command: string | string[]): string {
51
170
  return command
52
171
  }
53
172
 
54
- export class Command extends ComponentResource {
55
- public readonly command: Output<local.Command | remote.Command>
173
+ function wrapWithWorkDir(dir?: Input<string>) {
174
+ if (!dir) {
175
+ return (command: string) => output(command)
176
+ }
177
+
178
+ return (command: string) => interpolate`cd "${dir}" && ${command}`
179
+ }
180
+
181
+ function wrapWithEnvironment(environment?: Input<Record<string, Input<string>>>) {
182
+ if (!environment) {
183
+ return (command: string) => output(command)
184
+ }
185
+
186
+ return (command: string) =>
187
+ output({ command, environment }).apply(({ command, environment }) => {
188
+ if (!environment || Object.keys(environment).length === 0) {
189
+ return command
190
+ }
56
191
 
192
+ const envExport = Object.entries(environment)
193
+ .map(([key, value]) => `export ${key}="${value}"`)
194
+ .join(" && ")
195
+
196
+ return `${envExport} && ${command}`
197
+ })
198
+ }
199
+
200
+ function wrapWithWaitFor(timeout: Input<number> = 300, interval: Input<number> = 5) {
201
+ return (command: string | string[]) =>
202
+ // TOD: escape the command
203
+ interpolate`timeout ${timeout} bash -c 'while ! ${createCommand(command)}; do sleep ${interval}; done'`
204
+ }
205
+
206
+ function applyUpdateTriggers(
207
+ env: Input<Record<string, Input<string>>> | undefined,
208
+ triggers: InputOrArray<unknown> | undefined,
209
+ ) {
210
+ return output({ env, triggers }).apply(({ env, triggers }) => {
211
+ if (!triggers) {
212
+ return env
213
+ }
214
+
215
+ const hash = sha256(JSON.stringify(triggers))
216
+ const hashHex = Buffer.from(hash).toString("hex")
217
+
218
+ return {
219
+ ...env,
220
+ HIGHSTATE_UPDATE_TRIGGER_HASH: hashHex,
221
+ }
222
+ })
223
+ }
224
+
225
+ export class Command extends ComponentResource {
57
226
  public readonly stdout: Output<string>
58
227
  public readonly stderr: Output<string>
59
228
 
60
229
  constructor(name: string, args: CommandArgs, opts?: ComponentResourceOptions) {
61
230
  super("highstate:common:Command", name, args, opts)
62
231
 
63
- this.command = output(args).apply(args => {
64
- if (args.host === "local") {
65
- return new local.Command(
66
- name,
67
- {
68
- create: createCommand(args.create),
69
- update: args.update ? createCommand(args.update) : undefined,
70
- delete: args.delete ? createCommand(args.delete) : undefined,
71
- logging: args.logging,
72
- triggers: args.triggers,
73
- dir: args.cwd,
74
- },
75
- { ...opts, parent: this },
76
- )
77
- }
232
+ const environment = applyUpdateTriggers(
233
+ args.environment,
234
+ args.updateTriggers,
235
+ ) as local.CommandArgs["environment"]
78
236
 
79
- if (!args.host.ssh) {
80
- throw new Error(`The host "${args.host.hostname}" has no SSH credentials`)
81
- }
237
+ const command =
238
+ args.host === "local"
239
+ ? new local.Command(
240
+ name,
241
+ {
242
+ create: output(args.create).apply(createCommand),
243
+ update: args.update ? output(args.update).apply(createCommand) : undefined,
244
+ delete: args.delete ? output(args.delete).apply(createCommand) : undefined,
245
+ logging: args.logging,
246
+ triggers: args.triggers ? output(args.triggers).apply(flat) : undefined,
247
+ dir: args.cwd ?? homedir(),
248
+ environment,
249
+ stdin: args.stdin,
250
+ },
251
+ { ...opts, parent: this },
252
+ )
253
+ : new remote.Command(
254
+ name,
255
+ {
256
+ connection: output(args.host).apply(server => {
257
+ if (!server.ssh) {
258
+ throw new Error(`The server "${server.hostname}" has no SSH credentials`)
259
+ }
82
260
 
83
- return new remote.Command(
84
- name,
85
- {
86
- connection: getServerConnection(args.host.ssh),
87
- create: createCommand(args.create),
88
- update: args.update ? createCommand(args.update) : undefined,
89
- delete: args.delete ? createCommand(args.delete) : undefined,
90
- logging: args.logging,
91
- triggers: args.triggers,
92
- },
93
- { ...opts, parent: this },
94
- )
261
+ return getServerConnection(server.ssh)
262
+ }),
263
+
264
+ create: output(args.create)
265
+ .apply(createCommand)
266
+ .apply(wrapWithWorkDir(args.cwd))
267
+ .apply(wrapWithEnvironment(environment)),
268
+
269
+ update: args.update
270
+ ? output(args.update)
271
+ .apply(createCommand)
272
+ .apply(wrapWithWorkDir(args.cwd))
273
+ .apply(wrapWithEnvironment(environment))
274
+ : undefined,
275
+
276
+ delete: args.delete
277
+ ? output(args.delete)
278
+ .apply(createCommand)
279
+ .apply(wrapWithWorkDir(args.cwd))
280
+ .apply(wrapWithEnvironment(environment))
281
+ : undefined,
282
+
283
+ logging: args.logging,
284
+ triggers: args.triggers ? output(args.triggers).apply(flat) : undefined,
285
+ stdin: args.stdin,
286
+
287
+ addPreviousOutputInEnv: false,
288
+
289
+ // TODO: does not work if server do not define AcceptEnv
290
+ // environment,
291
+ },
292
+ { ...opts, parent: this },
293
+ )
294
+
295
+ this.stdout = command.stdout
296
+ this.stderr = command.stderr
297
+
298
+ this.registerOutputs({
299
+ stdout: this.stdout,
300
+ stderr: this.stderr,
95
301
  })
302
+ }
96
303
 
97
- this.stdout = this.command.stdout
98
- this.stderr = this.command.stderr
304
+ /**
305
+ * Waits for the command to complete and returns its output.
306
+ * The standard output will be returned.
307
+ */
308
+ async wait(): Promise<string> {
309
+ return await toPromise(this.stdout)
99
310
  }
100
311
 
312
+ /**
313
+ * Creates a command that writes the given content to a file on the host.
314
+ * The file will be created if it does not exist, and overwritten if it does.
315
+ *
316
+ * Use for small text files like configuration files.
317
+ */
101
318
  static createTextFile(
102
319
  name: string,
103
320
  options: TextFileArgs,
104
321
  opts?: ComponentResourceOptions,
105
- ): Output<Command> {
106
- return output(options).apply(options => {
107
- const escapedContent = options.content.replace(/"/g, '\\"')
108
-
109
- const command = new Command(
110
- name,
111
- {
112
- host: options.host,
113
- create: interpolate`mkdir -p $(dirname ${options.path}) && echo "${escapedContent}" > ${options.path}`,
114
- delete: interpolate`rm -rf ${options.path}`,
115
- },
116
- opts,
117
- )
118
-
119
- return command
120
- })
322
+ ): Command {
323
+ return new Command(
324
+ name,
325
+ {
326
+ host: options.host,
327
+ create: interpolate`mkdir -p $(dirname "${options.path}") && cat > ${options.path}`,
328
+ delete: interpolate`rm -rf ${options.path}`,
329
+ stdin: options.content,
330
+ },
331
+ opts,
332
+ )
121
333
  }
122
334
 
335
+ /**
336
+ * Creates a command that waits for a file to be created and then reads its content.
337
+ * This is useful for waiting for a file to be generated by another process.
338
+ *
339
+ * Use for small text files like configuration files.
340
+ */
123
341
  static receiveTextFile(
124
342
  name: string,
125
343
  options: Omit<TextFileArgs, "content">,
126
344
  opts?: ComponentResourceOptions,
127
- ): Output<Command> {
128
- return output(options).apply(options => {
129
- const command = new Command(
130
- name,
131
- {
132
- host: options.host,
133
- create: interpolate`while ! test -f ${options.path}; do sleep 1; done; cat ${options.path}`,
134
- logging: "stderr",
135
- },
136
- opts,
137
- )
138
-
139
- return command
140
- })
345
+ ): Command {
346
+ return new Command(
347
+ name,
348
+ {
349
+ host: options.host,
350
+ create: interpolate`while ! test -f "${options.path}"; do sleep 1; done; cat "${options.path}"`,
351
+ logging: "stderr",
352
+ },
353
+ opts,
354
+ )
355
+ }
356
+
357
+ /**
358
+ * Creates a command that waits for a condition to be met.
359
+ * The command will run until the condition is met or the timeout is reached.
360
+ *
361
+ * The condition is considered met if the command returns a zero exit code.
362
+ *
363
+ * @param name The name of the command resource.
364
+ * @param args The arguments for the command, including the condition to check.
365
+ * @param opts Optional resource options.
366
+ */
367
+ static waitFor(name: string, args: WaitForArgs, opts?: ComponentResourceOptions): Command {
368
+ return new Command(
369
+ name,
370
+ {
371
+ ...args,
372
+ create: output(args.create).apply(wrapWithWaitFor(args.timeout, args.interval)),
373
+ update: args.update
374
+ ? output(args.update).apply(wrapWithWaitFor(args.timeout, args.interval))
375
+ : undefined,
376
+ delete: args.delete
377
+ ? output(args.delete).apply(wrapWithWaitFor(args.timeout, args.interval))
378
+ : undefined,
379
+ },
380
+ opts,
381
+ )
141
382
  }
142
383
  }