@highstate/common 0.9.16 → 0.9.18

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.
@@ -1,16 +1,26 @@
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,
10
+ type InputMap,
8
11
  type InputOrArray,
9
12
  type Output,
10
13
  } from "@highstate/pulumi"
11
14
  import { common, ssh } from "@highstate/library"
15
+ import { flat } from "remeda"
12
16
  import { l3EndpointToString } from "./network"
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
25
  ssh: Input<ssh.Credentials>,
16
26
  ): Output<types.input.remote.ConnectionArgs> {
@@ -25,24 +35,123 @@ export function getServerConnection(
25
35
  }))
26
36
  }
27
37
 
28
- export type CommandHost = "local" | common.Server
38
+ export type CommandHost = "local" | Input<common.Server> | Input<types.input.remote.ConnectionArgs>
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 working directory for the command.
88
+ *
89
+ * If not set, the command will run in the user's home directory (for both local and remote hosts).
90
+ */
37
91
  cwd?: Input<string>
92
+
93
+ /**
94
+ * The environment variables to set for the command.
95
+ */
96
+ environment?: Input<InputMap<string>>
97
+
98
+ /**
99
+ * The run mode for the command.
100
+ *
101
+ * - `auto` (default): if the `image` is set, it will always run in a container, never on the host;
102
+ * otherwise, it will run on the host.
103
+ *
104
+ * - `prefer-host`: it will try to run on the host if the executable is available;
105
+ * otherwise, it will run in a container or throw an error if the `image` is not set.
106
+ */
107
+ runMode?: CommandRunMode
108
+
109
+ /**
110
+ * The container image to use to run the command.
111
+ */
112
+ image?: Input<string>
113
+
114
+ /**
115
+ * The paths to mount if the command runs in a container.
116
+ *
117
+ * They will be mounted to the same paths in the container.
118
+ */
119
+ mounts?: InputOrArray<string>
38
120
  }
39
121
 
40
122
  export type TextFileArgs = {
123
+ /**
124
+ * The host to run the command on.
125
+ */
41
126
  host: CommandHost
127
+
128
+ /**
129
+ * The absolute path to the file on the host.
130
+ */
42
131
  path: Input<string>
132
+
133
+ /**
134
+ * The content to write to the file.
135
+ */
43
136
  content: Input<string>
44
137
  }
45
138
 
139
+ export type WaitForArgs = CommandArgs & {
140
+ /**
141
+ * The timeout in seconds to wait for the command to complete.
142
+ *
143
+ * Defaults to 5 minutes (300 seconds).
144
+ */
145
+ timeout?: Input<number>
146
+
147
+ /**
148
+ * The interval in seconds to wait between checks.
149
+ *
150
+ * Defaults to 5 seconds.
151
+ */
152
+ interval?: Input<number>
153
+ }
154
+
46
155
  function createCommand(command: string | string[]): string {
47
156
  if (Array.isArray(command)) {
48
157
  return command.join(" ")
@@ -51,92 +160,157 @@ function createCommand(command: string | string[]): string {
51
160
  return command
52
161
  }
53
162
 
54
- export class Command extends ComponentResource {
55
- public readonly command: Output<local.Command | remote.Command>
163
+ function wrapWithWorkDir(dir?: Input<string>) {
164
+ if (!dir) {
165
+ return (command: string) => output(command)
166
+ }
167
+
168
+ return (command: string) => interpolate`cd "${dir}" && ${command}`
169
+ }
170
+
171
+ function wrapWithWaitFor(timeout: Input<number> = 300, interval: Input<number> = 5) {
172
+ return (command: string | string[]) =>
173
+ // TOD: escape the command
174
+ interpolate`timeout ${timeout} bash -c 'while ! ${createCommand(command)}; do sleep ${interval}; done'`
175
+ }
56
176
 
177
+ export class Command extends ComponentResource {
57
178
  public readonly stdout: Output<string>
58
179
  public readonly stderr: Output<string>
59
180
 
60
181
  constructor(name: string, args: CommandArgs, opts?: ComponentResourceOptions) {
61
182
  super("highstate:common:Command", name, args, opts)
62
183
 
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
- }
78
-
79
- if (!args.host.ssh) {
80
- throw new Error(`The host "${args.host.hostname}" has no SSH credentials`)
81
- }
82
-
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
- )
95
- })
96
-
97
- this.stdout = this.command.stdout
98
- this.stderr = this.command.stderr
184
+ const command =
185
+ args.host === "local"
186
+ ? new local.Command(
187
+ name,
188
+ {
189
+ create: output(args.create).apply(createCommand),
190
+ update: args.update ? output(args.update).apply(createCommand) : undefined,
191
+ delete: args.delete ? output(args.delete).apply(createCommand) : undefined,
192
+ logging: args.logging,
193
+ triggers: args.triggers ? output(args.triggers).apply(flat) : undefined,
194
+ dir: args.cwd ?? homedir(),
195
+ environment: args.environment as local.CommandArgs["environment"],
196
+ stdin: args.stdin,
197
+ },
198
+ { ...opts, parent: this },
199
+ )
200
+ : new remote.Command(
201
+ name,
202
+ {
203
+ connection: output(args.host).apply(server => {
204
+ if ("host" in server) {
205
+ return output(server)
206
+ }
207
+
208
+ if (!server.ssh) {
209
+ throw new Error(`The server "${server.hostname}" has no SSH credentials`)
210
+ }
211
+
212
+ return getServerConnection(server.ssh)
213
+ }),
214
+
215
+ create: output(args.create).apply(createCommand).apply(wrapWithWorkDir(args.cwd)),
216
+
217
+ update: args.update
218
+ ? output(args.update).apply(createCommand).apply(wrapWithWorkDir(args.cwd))
219
+ : undefined,
220
+
221
+ delete: args.delete
222
+ ? output(args.delete).apply(createCommand).apply(wrapWithWorkDir(args.cwd))
223
+ : undefined,
224
+
225
+ logging: args.logging,
226
+ triggers: args.triggers ? output(args.triggers).apply(flat) : undefined,
227
+ stdin: args.stdin,
228
+ environment: args.environment as remote.CommandArgs["environment"],
229
+ },
230
+ { ...opts, parent: this },
231
+ )
232
+
233
+ this.stdout = command.stdout
234
+ this.stderr = command.stderr
99
235
  }
100
236
 
237
+ /**
238
+ * Waits for the command to complete and returns its output.
239
+ * The standard output will be returned.
240
+ */
241
+ async wait(): Promise<string> {
242
+ return await toPromise(this.stdout)
243
+ }
244
+
245
+ /**
246
+ * Creates a command that writes the given content to a file on the host.
247
+ * The file will be created if it does not exist, and overwritten if it does.
248
+ *
249
+ * Use for small text files like configuration files.
250
+ */
101
251
  static createTextFile(
102
252
  name: string,
103
253
  options: TextFileArgs,
104
254
  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
- })
255
+ ): Command {
256
+ return new Command(
257
+ name,
258
+ {
259
+ host: options.host,
260
+ create: interpolate`mkdir -p $(dirname "${options.path}") && cat > ${options.path}`,
261
+ delete: interpolate`rm -rf ${options.path}`,
262
+ stdin: options.content,
263
+ },
264
+ opts,
265
+ )
121
266
  }
122
267
 
268
+ /**
269
+ * Creates a command that waits for a file to be created and then reads its content.
270
+ * This is useful for waiting for a file to be generated by another process.
271
+ *
272
+ * Use for small text files like configuration files.
273
+ */
123
274
  static receiveTextFile(
124
275
  name: string,
125
276
  options: Omit<TextFileArgs, "content">,
126
277
  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
- })
278
+ ): Command {
279
+ return new Command(
280
+ name,
281
+ {
282
+ host: options.host,
283
+ create: interpolate`while ! test -f "${options.path}"; do sleep 1; done; cat "${options.path}"`,
284
+ logging: "stderr",
285
+ },
286
+ opts,
287
+ )
288
+ }
289
+
290
+ /**
291
+ * Creates a command that waits for a condition to be met.
292
+ * The command will run until the condition is met or the timeout is reached.
293
+ *
294
+ * The condition is considered met if the command returns a zero exit code.
295
+ *
296
+ * @param name The name of the command resource.
297
+ * @param args The arguments for the command, including the condition to check.
298
+ * @param opts Optional resource options.
299
+ */
300
+ static waitFor(name: string, args: WaitForArgs, opts?: ComponentResourceOptions): Command {
301
+ return new Command(
302
+ name,
303
+ {
304
+ ...args,
305
+ create: output(args.create).apply(wrapWithWaitFor(args.timeout, args.interval)),
306
+ update: args.update
307
+ ? output(args.update).apply(wrapWithWaitFor(args.timeout, args.interval))
308
+ : undefined,
309
+ delete: args.delete
310
+ ? output(args.delete).apply(wrapWithWaitFor(args.timeout, args.interval))
311
+ : undefined,
312
+ },
313
+ opts,
314
+ )
141
315
  }
142
316
  }
@@ -208,7 +208,7 @@ export class MaterializedFile implements AsyncDisposable {
208
208
  break
209
209
  }
210
210
  case "remote": {
211
- const response = await load(l7EndpointToString(this.entity.content.endpoint))
211
+ const response = await fetch(l7EndpointToString(this.entity.content.endpoint))
212
212
  if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`)
213
213
 
214
214
  const arrayBuffer = await response.arrayBuffer()
@@ -217,16 +217,14 @@ export class MaterializedFile implements AsyncDisposable {
217
217
  break
218
218
  }
219
219
  case "artifact": {
220
- const artifactData = this.entity.content[HighstateSignature.Artifact]
221
220
  const artifactPath = process.env.HIGHSTATE_ARTIFACT_READ_PATH
222
-
223
221
  if (!artifactPath) {
224
222
  throw new Error(
225
223
  "HIGHSTATE_ARTIFACT_READ_PATH environment variable is not set but required for artifact content",
226
224
  )
227
225
  }
228
226
 
229
- const tgzPath = join(artifactPath, `${artifactData.hash}.tgz`)
227
+ const tgzPath = join(artifactPath, `${this.entity.content.hash}.tgz`)
230
228
 
231
229
  // extract the tgz file directly to the target path
232
230
  const readStream = createReadStream(tgzPath)
@@ -311,10 +309,9 @@ export class MaterializedFile implements AsyncDisposable {
311
309
  meta: newMeta,
312
310
  content: {
313
311
  type: "artifact",
314
- [HighstateSignature.Artifact]: {
315
- hash: hashValue,
316
- meta: await toPromise(this.artifactMeta),
317
- },
312
+ [HighstateSignature.Artifact]: true,
313
+ hash: hashValue,
314
+ meta: await toPromise(this.artifactMeta),
318
315
  },
319
316
  }
320
317
  } finally {
@@ -451,7 +448,7 @@ export class MaterializedFolder implements AsyncDisposable {
451
448
  break
452
449
  }
453
450
  case "remote": {
454
- const response = await load(l7EndpointToString(this.entity.content.endpoint))
451
+ const response = await fetch(l7EndpointToString(this.entity.content.endpoint))
455
452
  if (!response.ok) throw new Error(`Failed to fetch: ${response.statusText}`)
456
453
  if (!response.body) throw new Error("Response body is empty")
457
454
 
@@ -491,7 +488,6 @@ export class MaterializedFolder implements AsyncDisposable {
491
488
  break
492
489
  }
493
490
  case "artifact": {
494
- const artifactData = this.entity.content[HighstateSignature.Artifact]
495
491
  const artifactPath = process.env.HIGHSTATE_ARTIFACT_READ_PATH
496
492
 
497
493
  if (!artifactPath) {
@@ -500,7 +496,7 @@ export class MaterializedFolder implements AsyncDisposable {
500
496
  )
501
497
  }
502
498
 
503
- const tgzPath = join(artifactPath, `${artifactData.hash}.tgz`)
499
+ const tgzPath = join(artifactPath, `${this.entity.content.hash}.tgz`)
504
500
 
505
501
  // extract the tgz file directly to the target path
506
502
  const readStream = createReadStream(tgzPath)
@@ -615,11 +611,10 @@ export class MaterializedFolder implements AsyncDisposable {
615
611
  return {
616
612
  meta: newMeta,
617
613
  content: {
614
+ [HighstateSignature.Artifact]: true,
618
615
  type: "artifact",
619
- [HighstateSignature.Artifact]: {
620
- hash: hashValue,
621
- meta: await toPromise(this.artifactMeta),
622
- },
616
+ hash: hashValue,
617
+ meta: await toPromise(this.artifactMeta),
623
618
  },
624
619
  }
625
620
  } finally {
@@ -701,7 +696,7 @@ export async function fetchFileSize(endpoint: network.L7Endpoint): Promise<numbe
701
696
  }
702
697
 
703
698
  const url = l7EndpointToString(endpoint)
704
- const response = await load(url, { method: "HEAD" })
699
+ const response = await fetch(url, { method: "HEAD" })
705
700
 
706
701
  if (!response.ok) {
707
702
  throw new Error(`Failed to fetch file size: ${response.statusText}`)
@@ -248,14 +248,14 @@ export async function requireInputL4Endpoint(
248
248
  }
249
249
 
250
250
  /**
251
- * Convers L3 endpoint to L4 endpoint by adding a port and protocol.
251
+ * Converts L3 endpoint to L4 endpoint by adding a port and protocol.
252
252
  *
253
253
  * @param l3Endpoint The L3 endpoint to convert.
254
254
  * @param port The port to add to the L3 endpoint.
255
255
  * @param protocol The protocol to add to the L3 endpoint. Defaults to "tcp".
256
256
  * @returns The L4 endpoint with the port and protocol added.
257
257
  */
258
- export function l3ToL4Endpoint(
258
+ export function l3EndpointToL4(
259
259
  l3Endpoint: InputL3Endpoint,
260
260
  port: number,
261
261
  protocol: network.L4Protocol = "tcp",
@@ -1,6 +1,42 @@
1
- import { randomBytes } from "@noble/hashes/utils"
1
+ import { bytesToHex, randomBytes } from "@noble/hashes/utils"
2
2
  import { secureMask } from "micro-key-producer/password.js"
3
3
 
4
- export function generatePassword() {
4
+ /**
5
+ * Generates a secure random password strong enough for online use.
6
+ *
7
+ * It uses "Safari Keychain Secure Password" format.
8
+ *
9
+ * The approximate entropy is 71 bits (https://support.apple.com/guide/security/automatic-strong-passwords-secc84c811c4/web).
10
+ */
11
+ export function generatePassword(): string {
5
12
  return secureMask.apply(randomBytes(32)).password
6
13
  }
14
+
15
+ type KeyFormatMap = {
16
+ raw: Uint8Array
17
+ hex: string
18
+ base64: string
19
+ }
20
+
21
+ /**
22
+ * Generates a secure random key strong enough for online use such as encryption.
23
+ *
24
+ * The strong entropy is 256 bits.
25
+ *
26
+ * @param format The format of the generated key. By default, it is "hex".
27
+ */
28
+ export function generateKey<TFormat extends keyof KeyFormatMap>(
29
+ format: TFormat = "hex" as TFormat,
30
+ ): KeyFormatMap[TFormat] {
31
+ const bytes = randomBytes(32)
32
+
33
+ if (format === "raw") {
34
+ return bytes as KeyFormatMap[TFormat]
35
+ }
36
+
37
+ if (format === "base64") {
38
+ return Buffer.from(bytes).toString("base64") as KeyFormatMap[TFormat]
39
+ }
40
+
41
+ return bytesToHex(bytes) as KeyFormatMap[TFormat]
42
+ }