@highstate/pulumi 0.19.1 → 0.20.0

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/src/unit.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  /** biome-ignore-all lint/suspicious/noExplicitAny: здесь орать запрещено */
2
2
 
3
3
  import type { IsEmptyObject } from "type-fest"
4
+ import { join } from "node:path"
4
5
  import { pathToFileURL } from "node:url"
5
6
  import {
6
7
  type ComponentInput,
7
8
  type ComponentInputSpec,
8
9
  camelCaseToHumanReadable,
9
- compact,
10
- decompact,
10
+ type EntityValue,
11
+ type EntityValueInput,
11
12
  HighstateConfigKey,
12
- type InstanceInput,
13
+ HighstateSignature,
13
14
  type InstanceStatusField,
14
15
  type InstanceStatusFieldValue,
15
16
  type PartialKeys,
@@ -19,8 +20,7 @@ import {
19
20
  type TriggerInvocation,
20
21
  type Unit,
21
22
  type UnitArtifact,
22
- type UnitConfig,
23
- type UnitInputReference,
23
+ type UnitInputValue,
24
24
  type UnitPage,
25
25
  type UnitTerminal,
26
26
  type UnitTrigger,
@@ -36,10 +36,10 @@ import {
36
36
  type Output,
37
37
  output,
38
38
  secret as pulumiSecret,
39
- StackReference,
40
39
  type Unwrap,
41
40
  } from "@pulumi/pulumi"
42
- import { mapValues } from "remeda"
41
+ import { isPlainObject, mapValues } from "remeda"
42
+ import { getHasResourceHooks } from "./resource-hooks"
43
43
  import { type DeepInput, toPromise } from "./utils"
44
44
 
45
45
  type StatusField<TArgName extends string = string> = Omit<
@@ -92,6 +92,7 @@ interface UnitContext<
92
92
  > {
93
93
  args: TArgs
94
94
  instanceId: string
95
+ stateId: string
95
96
  type: string
96
97
  name: string
97
98
 
@@ -112,6 +113,12 @@ interface UnitContext<
112
113
  factory: () => Input<NonNullable<TSecrets[K]>>,
113
114
  ): Output<NonNullable<TSecrets[K]>>
114
115
 
116
+ setSecret<K extends keyof TSecrets>(
117
+ this: void,
118
+ name: K,
119
+ value: Input<NonNullable<TSecrets[K]>>,
120
+ ): void
121
+
115
122
  inputs: TInputs
116
123
  invokedTriggers: TriggerInvocation[]
117
124
 
@@ -123,24 +130,22 @@ interface UnitContext<
123
130
 
124
131
  // z.output since the values are validated/transformed and passed to the user
125
132
  type InputSpecToWrappedValue<T extends ComponentInputSpec> = T[2] extends true
126
- ? // we have to wrap the array in Output since we don't know how many items will be returned by each multiple input
127
- Output<NonNullable<z.output<T[0]["schema"]>>[]>
133
+ ? NonNullable<EntityValue<T[0]>>[]
128
134
  : T[1] extends true
129
- ? Output<NonNullable<z.output<T[0]["schema"]>>>
130
- : Output<NonNullable<z.output<T[0]["schema"]>>> | undefined
135
+ ? NonNullable<EntityValue<T[0]>>
136
+ : NonNullable<EntityValue<T[0]>> | undefined
131
137
 
132
138
  // z.input since the values are passed from the user and should be validated/transformed before returning from the unit
133
139
  type OutputSpecToValue<T extends ComponentInputSpec> = T[2] extends true
134
140
  ? T[1] extends true
135
- ? NonNullable<z.input<T[0]["schema"]>>[]
136
- : NonNullable<z.input<T[0]["schema"]>>[] | undefined
141
+ ? NonNullable<EntityValueInput<T[0]>>[]
142
+ : NonNullable<EntityValueInput<T[0]>>[] | undefined
137
143
  : T[1] extends true
138
- ? NonNullable<z.input<T[0]["schema"]>>
139
- : NonNullable<z.input<T[0]["schema"]>> | undefined
140
-
141
- const stackRefMap = new Map<string, StackReference>()
144
+ ? NonNullable<EntityValueInput<T[0]>>
145
+ : NonNullable<EntityValueInput<T[0]>> | undefined
142
146
 
143
147
  let instanceId: string | undefined
148
+ let stateId: string | undefined
144
149
  let instanceName: string | undefined
145
150
  let importBaseUrl: URL | undefined
146
151
 
@@ -157,6 +162,20 @@ export function getUnitInstanceId(): string {
157
162
  return instanceId
158
163
  }
159
164
 
165
+ /**
166
+ * Returns the current unit instance state id.
167
+ *
168
+ * The state id is provided by the runner via Pulumi config.
169
+ * Only available after calling `forUnit` function.
170
+ */
171
+ export function getUnitStateId(): string {
172
+ if (!stateId) {
173
+ throw new Error(`State id is not set. Did you call "forUnit" function?`)
174
+ }
175
+
176
+ return stateId
177
+ }
178
+
160
179
  /**
161
180
  * Returns the current unit instance name.
162
181
  */
@@ -183,78 +202,38 @@ export function getImportBaseUrl(): URL {
183
202
  * Returns a comment that can be used in resources to indicate that they are managed by Highstate.
184
203
  */
185
204
  export function getResourceComment(): string {
186
- return `Managed by Highstate (${getUnitInstanceId()})`
187
- }
188
-
189
- function getStackRef(config: UnitConfig, input: InstanceInput) {
190
- const [instanceType] = parseInstanceId(input.instanceId)
191
- const stateId = config.stateIdMap[input.instanceId]
192
- if (!stateId) {
193
- throw new Error(`State ID for instance "${input.instanceId}" not found in the unit config.`)
194
- }
195
-
196
- const key = `organization/${instanceType}/${stateId}`
197
- let stackRef = stackRefMap.get(key)
198
-
199
- if (!stackRef) {
200
- stackRef = new StackReference(key)
201
- stackRefMap.set(key, stackRef)
202
- }
203
-
204
- return stackRef
205
+ return `Managed by Highstate [${getUnitStateId()}]`
205
206
  }
206
207
 
207
- function getOutput(
208
- config: UnitConfig,
208
+ function getInputValue(
209
209
  unit: Unit,
210
210
  inputName: string,
211
211
  input: ComponentInput,
212
- refs: UnitInputReference[],
212
+ entries: UnitInputValue[],
213
213
  ) {
214
214
  const entity = unit.entities.get(input.type)
215
215
  if (!entity) {
216
216
  throw new Error(`Entity "${input.type}" not found in the unit "${unit.model.type}".`)
217
217
  }
218
218
 
219
- const _getOutput = (ref: UnitInputReference) => {
220
- const value = getStackRef(config, ref).requireOutput(ref.output)
219
+ const values = entries.flatMap(entry => {
220
+ const value = parseArgumentValue(entry.value)
221
+ const schema = Array.isArray(value) ? entity.schema.array() : entity.schema
222
+ const result = schema.safeParse(value)
221
223
 
222
- return value.apply(value => {
223
- if (ref.inclusion) {
224
- if (value === null || value === undefined || typeof value !== "object") {
225
- throw new Error(
226
- `Cannot extract field "${ref.inclusion.field}" from non-object output "${ref.output}" of instance "${ref.instanceId}".`,
227
- )
228
- }
229
-
230
- value = (value as Record<string, unknown>)[ref.inclusion.field]
231
- }
232
-
233
- const schema = Array.isArray(value) ? entity.schema.array() : entity.schema
234
- const result = schema.safeParse(value)
235
-
236
- if (!result.success) {
237
- throw new Error(
238
- `Invalid output "${ref.output}" from "${ref.instanceId}" for input "${inputName}": ${result.error.message}`,
239
- )
240
- }
241
-
242
- if (Array.isArray(value)) {
243
- return value
244
- }
245
-
246
- return input.multiple ? [value] : value
247
- })
248
- }
224
+ if (!result.success) {
225
+ throw new Error(`Invalid value for input "${inputName}": ${z.prettifyError(result.error)}`)
226
+ }
249
227
 
250
- const _getDecompactedOutput = (ref: UnitInputReference) => {
251
- return _getOutput(ref).apply(decompact)
252
- }
228
+ if (Array.isArray(result.data)) {
229
+ return result.data
230
+ }
253
231
 
254
- const values = output(refs.map(_getDecompactedOutput)).apply(values => values.flat())
232
+ return input.multiple ? [result.data] : [result.data]
233
+ })
255
234
 
256
235
  if (!input.multiple) {
257
- return values.apply(values => values[0])
236
+ return values[0]
258
237
  }
259
238
 
260
239
  return values
@@ -278,10 +257,6 @@ export function forUnit<
278
257
  const rawHSConfig = config.requireObject(HighstateConfigKey.Config)
279
258
  const hsConfig = unitConfigSchema.parse(rawHSConfig)
280
259
 
281
- const rawHsSecrets = config
282
- .requireSecretObject(HighstateConfigKey.Secrets)
283
- .apply(secrets => z.record(z.string(), z.unknown()).parse(secrets))
284
-
285
260
  const args = mapValues(unit.model.args, (arg, argName) => {
286
261
  const value = parseArgumentValue(hsConfig.args[argName])
287
262
  const result = arg[runtimeSchema]!.safeParse(value)
@@ -294,7 +269,7 @@ export function forUnit<
294
269
  })
295
270
 
296
271
  const secrets = mapValues(unit.model.secrets, (secret, secretName) => {
297
- const hasValue = hsConfig.secretNames.includes(secretName)
272
+ const hasValue = secretName in hsConfig.secretValues
298
273
 
299
274
  if (!hasValue && !secret.required) {
300
275
  return secret.schema.default ? pulumiSecret(secret.schema.default) : undefined
@@ -304,16 +279,15 @@ export function forUnit<
304
279
  throw new Error(`Secret "${secretName}" is required but not provided.`)
305
280
  }
306
281
 
307
- return rawHsSecrets[secretName].apply(rawValue => {
308
- const value = parseArgumentValue(rawValue)
309
- const result = secret[runtimeSchema]!.safeParse(value)
282
+ const rawValue = hsConfig.secretValues[secretName]
283
+ const value = parseArgumentValue(rawValue)
284
+ const result = secret[runtimeSchema]!.safeParse(value)
310
285
 
311
- if (!result.success) {
312
- throw new Error(`Invalid secret "${secretName}": ${z.prettifyError(result.error)}`)
313
- }
286
+ if (!result.success) {
287
+ throw new Error(`Invalid secret "${secretName}": ${z.prettifyError(result.error)}`)
288
+ }
314
289
 
315
- return pulumiSecret(result.data)
316
- })
290
+ return pulumiSecret(result.data)
317
291
  })
318
292
 
319
293
  const inputs = mapValues(unit.model.inputs, (input, inputName) => {
@@ -321,23 +295,25 @@ export function forUnit<
321
295
 
322
296
  if (!value) {
323
297
  if (input.multiple) {
324
- return output([])
298
+ return []
325
299
  }
326
300
 
327
301
  return undefined
328
302
  }
329
303
 
330
- return getOutput(hsConfig, unit as unknown as Unit, inputName, input, value)
304
+ return getInputValue(unit as unknown as Unit, inputName, input, value)
331
305
  })
332
306
 
333
307
  const [type, name] = parseInstanceId(hsConfig.instanceId)
334
308
 
335
309
  instanceId = hsConfig.instanceId
310
+ stateId = hsConfig.stateId
336
311
  instanceName = name
337
312
  importBaseUrl = pathToFileURL(hsConfig.importBasePath)
338
313
 
339
314
  return {
340
315
  instanceId: hsConfig.instanceId,
316
+ stateId: hsConfig.stateId,
341
317
  type,
342
318
  name,
343
319
 
@@ -360,26 +336,32 @@ export function forUnit<
360
336
  return value
361
337
  }) as any,
362
338
 
339
+ setSecret: ((name: keyof TSecrets, value: Input<NonNullable<TSecrets[keyof TSecrets]>>) => {
340
+ secrets[name as string] = pulumiSecret(value)
341
+ }) as any,
342
+
363
343
  outputs: async (outputs: any = {}) => {
364
- const result: any = mapValues(outputs, (outputValue, outputName) => {
344
+ const resolvedOutputs = await toPromise(outputs)
345
+
346
+ const result: any = mapValues(resolvedOutputs, (outputValue, outputName) => {
365
347
  if (outputName === "$statusFields") {
366
- return output(outputValue).apply(mapStatusFields)
348
+ return mapStatusFields(outputValue)
367
349
  }
368
350
 
369
351
  if (outputName === "$pages") {
370
- return output(outputValue).apply(mapPages)
352
+ return mapPages(outputValue)
371
353
  }
372
354
 
373
355
  if (outputName === "$terminals") {
374
- return output(outputValue).apply(mapTerminals)
356
+ return mapTerminals(outputValue)
375
357
  }
376
358
 
377
359
  if (outputName === "$triggers") {
378
- return output(outputValue).apply(mapTriggers)
360
+ return mapTriggers(outputValue)
379
361
  }
380
362
 
381
363
  if (outputName === "$workers") {
382
- return output(outputValue).apply(mapWorkers)
364
+ return mapWorkers(outputValue)
383
365
  }
384
366
 
385
367
  if (outputName.startsWith("$")) {
@@ -400,25 +382,20 @@ export function forUnit<
400
382
  )
401
383
  }
402
384
 
403
- return output(outputValue).apply(value => {
404
- const schema = outputModel.multiple ? entity.schema.array() : entity.schema
405
- const result = schema.safeParse(value)
385
+ const schema = outputModel.multiple ? entity.schema.array() : entity.schema
386
+ const result = schema.safeParse(outputValue)
406
387
 
407
- if (!result.success) {
408
- throw new Error(
409
- `Invalid value for output "${outputName}" of type "${outputModel.type}": ${z.prettifyError(
410
- result.error,
411
- )}`,
412
- )
413
- }
388
+ if (!result.success) {
389
+ throw new Error(
390
+ `Invalid value for output "${outputName}" of type "${outputModel.type}": ${z.prettifyError(
391
+ result.error,
392
+ )}`,
393
+ )
394
+ }
414
395
 
415
- return compact(result.data)
416
- })
396
+ return result.data
417
397
  })
418
398
 
419
- // wait for all outputs to resolve before collecting secrets and artifacts
420
- await Promise.all(Object.values(result).map(o => toPromise(o)))
421
-
422
399
  result.$secrets = secrets
423
400
 
424
401
  // collect artifacts from all outputs
@@ -435,11 +412,64 @@ export function forUnit<
435
412
  result.$artifacts = artifactsMap
436
413
  }
437
414
 
438
- return result
415
+ result.$hasResourceHooks = getHasResourceHooks()
416
+
417
+ return wrapHighstateSecretValues(result)
439
418
  },
440
419
  }
441
420
  }
442
421
 
422
+ function wrapHighstateSecretValues<T>(data: T): T {
423
+ const cache = new WeakMap<object, unknown>()
424
+
425
+ const traverse = (value: unknown): unknown => {
426
+ if (value === null || value === undefined || typeof value !== "object") {
427
+ return value
428
+ }
429
+
430
+ if (Array.isArray(value)) {
431
+ const cached = cache.get(value)
432
+ if (cached) {
433
+ return cached
434
+ }
435
+
436
+ const mapped: unknown[] = []
437
+ cache.set(value, mapped)
438
+
439
+ for (const item of value) {
440
+ mapped.push(traverse(item))
441
+ }
442
+
443
+ return mapped
444
+ }
445
+
446
+ if (!isPlainObject(value)) {
447
+ return value
448
+ }
449
+
450
+ const cached = cache.get(value)
451
+ if (cached) {
452
+ return cached
453
+ }
454
+
455
+ const record = value as Record<string, unknown>
456
+ const mapped: Record<string, unknown> = {}
457
+ cache.set(value, mapped)
458
+
459
+ for (const [key, nestedValue] of Object.entries(record)) {
460
+ mapped[key] = traverse(nestedValue)
461
+ }
462
+
463
+ if (record[HighstateSignature.Secret] === true && "value" in record) {
464
+ return pulumiSecret(mapped)
465
+ }
466
+
467
+ return mapped
468
+ }
469
+
470
+ return traverse(data) as T
471
+ }
472
+
443
473
  function mapStatusFields(status: Unwrap<ExtraOutputs["$statusFields"]>): InstanceStatusField[] {
444
474
  if (!status) {
445
475
  return []
@@ -600,3 +630,13 @@ function extractObjectsFromValue<TSchema extends z.ZodType>(
600
630
  traverse(data)
601
631
  return result
602
632
  }
633
+
634
+ /**
635
+ * Returns a temporary file path for the current unit instance.
636
+ *
637
+ * The format is `/tmp/highstate/{stateId}`.
638
+ * This directory does not change between different runs of the same unit instance.
639
+ */
640
+ export function getUnitTempPath(): string {
641
+ return join("/tmp/highstate", getUnitStateId())
642
+ }