@highstate/pulumi 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.
package/src/unit.ts CHANGED
@@ -1,140 +1,73 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
- /* eslint-disable @typescript-eslint/no-unsafe-member-access */
3
- /* eslint-disable @typescript-eslint/no-unsafe-argument */
4
- /* eslint-disable @typescript-eslint/no-unsafe-return */
5
- /* eslint-disable @typescript-eslint/no-explicit-any */
6
-
7
- import type { DeepInput, InputArray, InputMap } from "./utils"
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: здесь орать запрещено */
8
2
  import {
9
- type ArgumentValue,
10
- type ComponentInputSpec,
11
- type Entity,
12
- type Unit,
13
3
  type ComponentInput,
4
+ type ComponentInputSpec,
5
+ camelCaseToHumanReadable,
6
+ HighstateConfigKey,
14
7
  type InstanceInput,
8
+ type InstanceStatusField,
9
+ type InstanceStatusFieldValue,
10
+ type PartialKeys,
11
+ parseArgumentValue,
15
12
  parseInstanceId,
16
- type ArgumentValueSchema,
17
- getInstanceId,
18
- type ComponentArgumentSpec,
19
- type ComponentArgumentSpecToStatic,
20
- HighstateSignature,
21
- camelCaseToHumanReadable,
13
+ runtimeSchema,
14
+ type TriggerInvocation,
15
+ type Unit,
16
+ type UnitArtifact,
17
+ type UnitConfig,
18
+ type UnitPage,
19
+ type UnitTerminal,
20
+ type UnitTrigger,
21
+ type UnitWorker,
22
+ unitArtifactSchema,
23
+ unitConfigSchema,
24
+ z,
22
25
  } from "@highstate/contract"
23
- import { Type, type Static } from "@sinclair/typebox"
24
- import { mapValues, pickBy, pipe } from "remeda"
25
26
  import {
26
27
  Config,
27
- getStack,
28
- Output,
28
+ type Input,
29
+ type Output,
29
30
  output,
30
- secret,
31
+ secret as pulumiSecret,
31
32
  StackReference,
32
- type Input,
33
33
  type Unwrap,
34
34
  } from "@pulumi/pulumi"
35
- import { Ajv } from "ajv"
36
- import { createdSecrets } from "./secret"
37
-
38
- const ajv = new Ajv({ strict: false })
39
-
40
- export type ObjectMeta = {
41
- title?: Input<string | undefined>
42
- description?: Input<string | undefined>
43
- icon?: Input<string | undefined>
44
- iconColor?: Input<string | undefined>
45
- }
46
-
47
- export type InstanceFileMeta = {
48
- name: Input<string>
49
- contentType?: Input<string>
50
- size?: Input<number>
51
- isBinary?: Input<boolean>
52
- mode?: Input<number>
53
- }
54
-
55
- export type UnitArtifact = {
56
- hash: Input<string>
57
- meta?: Input<ObjectMeta>
58
- }
59
-
60
- export type InstanceFile = {
61
- meta: Input<InstanceFileMeta>
62
- content:
63
- | { type: "embedded"; value: Input<string> }
64
- | {
65
- type: "artifact"
66
- [HighstateSignature.Artifact]: UnitArtifact
67
- }
68
- }
69
-
70
- export type InstanceTerminalSpec = {
71
- image: Input<string>
72
- command: InputArray<string>
73
- cwd?: Input<string | undefined>
74
- env?: InputMap<string | undefined>
75
- files?: InputMap<InstanceFile | string | undefined>
76
- }
77
-
78
- export type InstanceTerminal = {
79
- name: Input<string>
80
- meta: Input<ObjectMeta>
81
- spec: Input<InstanceTerminalSpec>
82
- }
83
-
84
- export type StatusFieldValue = string | number | boolean | string[]
85
-
86
- export type StatusField<TArgName extends string = string> = {
87
- name: Input<string>
88
- meta?: Input<ObjectMeta>
89
- complementaryTo?: Input<TArgName | undefined>
90
- value?: Input<StatusFieldValue | undefined>
35
+ import { mapValues } from "remeda"
36
+ import { type DeepInput, toPromise } from "./utils"
37
+
38
+ type StatusField<TArgName extends string = string> = Omit<
39
+ InstanceStatusField,
40
+ "complementaryTo" | "meta"
41
+ > & {
42
+ meta?: PartialKeys<InstanceStatusField["meta"], "title">
43
+ complementaryTo?: TArgName
91
44
  }
92
45
 
93
- export type InstancePageBlock =
94
- | { type: "markdown"; content: Input<string> }
95
- | { type: "qr"; content: Input<string>; showContent?: boolean; language?: string }
96
- | ({ type: "file" } & InstanceFile)
97
-
98
- export type InstancePage = {
99
- name: Input<string>
100
- meta: Input<ObjectMeta>
101
- content: InputArray<InstancePageBlock>
102
- }
103
-
104
- export type InstanceTriggerSpec =
105
- | {
106
- type: "before-destroy"
107
- }
108
- | {
109
- type: "schedule"
110
- schedule: string
111
- }
112
-
113
- export type InstanceTrigger = {
114
- name: Input<string>
115
- title: Input<string>
116
- description?: Input<string>
117
- spec: Input<InstanceTriggerSpec>
118
- }
119
-
120
- export type ExtraOutputs<TArgName extends string = string> = {
46
+ type ExtraOutputs<TArgName extends string = string> = {
121
47
  $statusFields?:
122
- | InputMap<Omit<StatusField<TArgName>, "name"> | StatusFieldValue | undefined>
123
- | InputArray<StatusField<TArgName> | undefined>
48
+ | Input<
49
+ Record<
50
+ string,
51
+ DeepInput<Omit<StatusField<TArgName>, "name"> | InstanceStatusFieldValue | undefined>
52
+ >
53
+ >
54
+ | Input<DeepInput<StatusField<TArgName> | undefined>[]>
124
55
 
125
56
  $terminals?:
126
- | InputMap<Omit<InstanceTerminal, "name"> | undefined>
127
- | InputArray<InstanceTerminal | undefined>
57
+ | Input<Record<string, DeepInput<Omit<UnitTerminal, "name"> | undefined>>>
58
+ | Input<DeepInput<UnitTerminal | undefined>[]>
128
59
 
129
- $pages?: InputMap<Omit<InstancePage, "name"> | undefined> | InputArray<InstancePage | undefined>
60
+ $pages?:
61
+ | Input<Record<string, DeepInput<Omit<UnitPage, "name"> | undefined>>>
62
+ | Input<DeepInput<UnitPage | undefined>[]>
130
63
 
131
64
  $triggers?:
132
- | InputMap<Omit<InstanceTrigger, "name"> | undefined>
133
- | InputArray<InstanceTrigger | undefined>
134
- }
65
+ | Input<Record<string, DeepInput<Omit<UnitTrigger, "name"> | undefined>>>
66
+ | Input<DeepInput<UnitTrigger | undefined>[]>
135
67
 
136
- export type InstanceTriggerInvocation = {
137
- name: string
68
+ $workers?:
69
+ | Input<Record<string, DeepInput<Omit<UnitWorker, "name"> | undefined>>>
70
+ | Input<DeepInput<UnitWorker | undefined>[]>
138
71
  }
139
72
 
140
73
  type OutputMapToDeepInputMap<T extends Record<string, unknown>, TArgName extends string> =
@@ -142,27 +75,36 @@ type OutputMapToDeepInputMap<T extends Record<string, unknown>, TArgName extends
142
75
  ? ExtraOutputs
143
76
  : { [K in keyof T]: DeepInput<T[K]> } & ExtraOutputs<TArgName>
144
77
 
145
- export interface UnitContext<
146
- TArgs extends Record<string, ArgumentValue>,
78
+ interface UnitContext<
79
+ TArgs extends Record<string, unknown>,
147
80
  TInputs extends Record<string, unknown>,
148
81
  TOutputs extends Record<string, unknown>,
149
- TSecrets extends Record<string, ArgumentValue>,
82
+ TSecrets extends Record<string, unknown>,
150
83
  > {
151
84
  args: TArgs
152
85
  instanceId: string
153
86
  type: string
154
87
  name: string
155
- secrets: Output<TSecrets>
156
-
157
- inputs: TInputs extends Record<string, never>
158
- ? never
159
- : {
160
- [K in keyof TInputs]: undefined extends TInputs[K]
161
- ? Output<NonNullable<TInputs[K]>> | undefined
162
- : Output<TInputs[K]>
163
- }
164
88
 
165
- invokedTriggers: InstanceTriggerInvocation[]
89
+ secrets: {
90
+ [K in keyof TSecrets]: undefined extends TSecrets[K]
91
+ ? Output<NonNullable<TSecrets[K]>> | undefined
92
+ : Output<TSecrets[K]>
93
+ }
94
+
95
+ getSecret<K extends keyof TSecrets>(
96
+ this: void,
97
+ name: K,
98
+ ): Output<NonNullable<TSecrets[K]> | undefined>
99
+
100
+ getSecret<K extends keyof TSecrets>(
101
+ this: void,
102
+ name: K,
103
+ factory: () => Input<NonNullable<TSecrets[K]>>,
104
+ ): Output<NonNullable<TSecrets[K]>>
105
+
106
+ inputs: TInputs
107
+ invokedTriggers: TriggerInvocation[]
166
108
 
167
109
  outputs(
168
110
  this: void,
@@ -170,68 +112,102 @@ export interface UnitContext<
170
112
  ): Promise<unknown>
171
113
  }
172
114
 
173
- type InputSpecToValue<T extends ComponentInputSpec> = T[2] extends true
174
- ? Static<T[0]["schema"]>[]
115
+ // z.output since the values are validated/transformed and passed to the user
116
+ type InputSpecToWrappedValue<T extends ComponentInputSpec> = T[2] extends true
117
+ ? // we have to wrap the array in Output since we don't know how many items will be returned by each multiple input
118
+ Output<NonNullable<z.output<T[0]["schema"]>>[]>
175
119
  : T[1] extends true
176
- ? Static<T[0]["schema"]>
177
- : Static<T[0]["schema"]> | undefined
178
-
179
- type InputSpecMapToValueMap<T extends Record<string, ComponentInputSpec>> =
180
- T extends Record<string, never>
181
- ? Record<string, never>
182
- : { [K in keyof T]: InputSpecToValue<T[K]> }
120
+ ? Output<NonNullable<z.output<T[0]["schema"]>>>
121
+ : Output<NonNullable<z.output<T[0]["schema"]>>> | undefined
122
+
123
+ // z.input since the values are passed from the user and should be validated/transformed before returning from the unit
124
+ type OutputSpecToValue<T extends ComponentInputSpec> = T[2] extends true
125
+ ? T[1] extends true
126
+ ? NonNullable<z.input<T[0]["schema"]>>[]
127
+ : NonNullable<z.input<T[0]["schema"]>>[] | undefined
128
+ : T[1] extends true
129
+ ? NonNullable<z.input<T[0]["schema"]>>
130
+ : NonNullable<z.input<T[0]["schema"]>> | undefined
183
131
 
184
132
  const stackRefMap = new Map<string, StackReference>()
185
- const [projectId, instanceName] = getStack().split("_")
186
133
 
187
134
  let instanceId: string | undefined
135
+ let instanceName: string | undefined
188
136
 
137
+ /**
138
+ * Returns the current unit instance id.
139
+ *
140
+ * Only available after calling `forUnit` function.
141
+ */
189
142
  export function getUnitInstanceId(): string {
190
143
  if (!instanceId) {
191
- throw new Error("Instance id is not set. Did you call 'forUnit' function?")
144
+ throw new Error(`Instance id is not set. Did you call "forUnit" function?`)
192
145
  }
193
146
 
194
147
  return instanceId
195
148
  }
196
149
 
197
- export function getResourceComment(): string {
198
- return `Managed by Highstate Unit (${getUnitInstanceId()})`
199
- }
200
-
150
+ /**
151
+ * Returns the current unit instance name.
152
+ */
201
153
  export function getUnitInstanceName(): string {
154
+ if (!instanceName) {
155
+ throw new Error(`Instance name is not set. Did you call "forUnit" function?`)
156
+ }
157
+
202
158
  return instanceName
203
159
  }
204
160
 
205
- function getStackRef(input: InstanceInput) {
206
- const [instanceType, instanceName] = parseInstanceId(input.instanceId)
207
- const key = `organization/${instanceType}/${projectId}_${instanceName}`
161
+ /**
162
+ * Returns a comment that can be used in resources to indicate that they are managed by Highstate.
163
+ */
164
+ export function getResourceComment(): string {
165
+ return `Managed by Highstate (${getUnitInstanceId()})`
166
+ }
167
+
168
+ function getStackRef(config: UnitConfig, input: InstanceInput) {
169
+ const [instanceType] = parseInstanceId(input.instanceId)
170
+ const stateId = config.stateIdMap[input.instanceId]
171
+ if (!stateId) {
172
+ throw new Error(`State ID for instance "${input.instanceId}" not found in the unit config.`)
173
+ }
174
+
175
+ const key = `organization/${instanceType}/${stateId}`
176
+ let stackRef = stackRefMap.get(key)
208
177
 
209
- if (!stackRefMap.has(key)) {
210
- stackRefMap.set(key, new StackReference(key))
178
+ if (!stackRef) {
179
+ stackRef = new StackReference(key)
180
+ stackRefMap.set(key, stackRef)
211
181
  }
212
182
 
213
- return stackRefMap.get(key)!
183
+ return stackRef
214
184
  }
215
185
 
216
- function getOutput(unit: Unit, input: ComponentInput, refs: InstanceInput[]) {
186
+ function getOutput(config: UnitConfig, unit: Unit, input: ComponentInput, refs: InstanceInput[]) {
217
187
  const entity = unit.entities.get(input.type)
218
188
  if (!entity) {
219
- throw new Error(`Entity '${input.type}' not found in the unit '${unit.model.type}'.`)
189
+ throw new Error(`Entity "${input.type}" not found in the unit "${unit.model.type}".`)
220
190
  }
221
191
 
222
192
  const _getOutput = (ref: InstanceInput) => {
223
- const value = getStackRef(ref).requireOutput(ref.output)
193
+ const value = getStackRef(config, ref).requireOutput(ref.output)
224
194
 
225
195
  return value.apply(value => {
226
196
  if (Array.isArray(value)) {
227
197
  for (const [index, item] of value.entries()) {
228
- if (!ajv.validate(entity.schema, item)) {
229
- throw new Error(`Invalid output for '${input.type}[${index}]': ${ajv.errorsText()}`)
198
+ const result = entity.schema.safeParse(item)
199
+
200
+ if (!result.success) {
201
+ throw new Error(
202
+ `Invalid output for "${input.type}[${index}]": ${z.prettifyError(result.error)}`,
203
+ )
230
204
  }
231
205
  }
232
206
  } else {
233
- if (!ajv.validate(entity.schema, value)) {
234
- throw new Error(`Invalid output for '${input.type}': ${ajv.errorsText()}`)
207
+ const result = entity.schema.safeParse(value)
208
+
209
+ if (!result.success) {
210
+ throw new Error(`Invalid output for "${input.type}": ${z.prettifyError(result.error)}`)
235
211
  }
236
212
  }
237
213
 
@@ -252,183 +228,105 @@ function getOutput(unit: Unit, input: ComponentInput, refs: InstanceInput[]) {
252
228
  return values
253
229
  }
254
230
 
255
- function isAnyOfSchema(schema: ArgumentValueSchema, itemType: string): boolean {
256
- if (schema.anyOf) {
257
- return Object.values(schema.anyOf).every(schema =>
258
- isAnyOfSchema(schema as ArgumentValueSchema, itemType),
259
- )
260
- }
261
-
262
- return schema.type === itemType
263
- }
264
-
265
- function isStringSchema(schema: ArgumentValueSchema): boolean {
266
- if (schema.type === "string") {
267
- return true
268
- }
269
-
270
- if (isAnyOfSchema(schema, "string")) {
271
- return true
272
- }
273
-
274
- return false
275
- }
276
-
277
- function isNumberSchema(schema: ArgumentValueSchema): boolean {
278
- if (schema.type === "number") {
279
- return true
280
- }
281
-
282
- if (isAnyOfSchema(schema, "number")) {
283
- return true
284
- }
285
-
286
- return false
287
- }
288
-
289
- function isBooleanSchema(schema: ArgumentValueSchema): boolean {
290
- if (schema.type === "boolean") {
291
- return true
292
- }
293
-
294
- if (isAnyOfSchema(schema, "boolean")) {
295
- return true
296
- }
297
-
298
- return false
299
- }
300
-
301
231
  export function forUnit<
302
- TArgs extends Record<string, ComponentArgumentSpec>,
232
+ TArgs extends Record<string, z.ZodType>,
303
233
  TInputs extends Record<string, ComponentInputSpec>,
304
234
  TOutputs extends Record<string, ComponentInputSpec>,
305
- TSecrets extends Record<string, ComponentArgumentSpec>,
235
+ TSecrets extends Record<string, z.ZodType>,
306
236
  >(
307
237
  unit: Unit<TArgs, TInputs, TOutputs, TSecrets>,
308
238
  ): UnitContext<
309
- //
310
- ComponentArgumentSpecToStatic<TArgs>,
311
- InputSpecMapToValueMap<TInputs>,
312
- InputSpecMapToValueMap<TOutputs>,
313
- ComponentArgumentSpecToStatic<TSecrets>
239
+ { [K in keyof TArgs]: z.output<TArgs[K]> },
240
+ { [K in keyof TInputs]: InputSpecToWrappedValue<TInputs[K]> },
241
+ { [K in keyof TOutputs]: OutputSpecToValue<TOutputs[K]> },
242
+ { [K in keyof TSecrets]: z.output<TSecrets[K]> }
314
243
  > {
315
244
  const config = new Config()
245
+ const rawHSConfig = config.requireObject(HighstateConfigKey.Config)
246
+ const hsConfig = unitConfigSchema.parse(rawHSConfig)
316
247
 
317
- const args = mapValues(unit.model.args, (arg, argName) => {
318
- switch (true) {
319
- case isStringSchema(arg.schema): {
320
- if (arg.required) {
321
- return config.require(argName)
322
- }
323
-
324
- // handle empty strings as undefined
325
- return config.get(argName) || arg.schema.default
326
- }
327
- case isNumberSchema(arg.schema): {
328
- if (arg.required) {
329
- return config.requireNumber(argName)
330
- }
331
-
332
- // handle empty strings as undefined
333
- const value = config.get(argName)
334
- if (!value) {
335
- return arg.schema.default
336
- }
337
-
338
- return config.getNumber(argName) ?? arg.schema.default
339
- }
340
- case isBooleanSchema(arg.schema): {
341
- if (arg.required) {
342
- return config.requireBoolean(argName)
343
- }
248
+ const rawHsSecrets = config
249
+ .requireSecretObject(HighstateConfigKey.Secrets)
250
+ .apply(secrets => z.record(z.string(), z.unknown()).parse(secrets))
344
251
 
345
- // handle empty strings as undefined
346
- const value = config.get(argName)
347
- if (!value) {
348
- return arg.schema.default
349
- }
252
+ const args = mapValues(unit.model.args, (arg, argName) => {
253
+ const value = parseArgumentValue(hsConfig.args[argName])
254
+ // biome-ignore lint/style/noNonNullAssertion: runtime schema is there in runtime
255
+ const result = arg[runtimeSchema]!.safeParse(value)
350
256
 
351
- return config.getBoolean(argName) ?? arg.schema.default
352
- }
353
- default: {
354
- if (!arg.required) {
355
- const value = config.get(argName)
356
- // handle empty strings as undefined
357
- if (!value) {
358
- return arg.schema.default
359
- }
360
- }
257
+ if (!result.success) {
258
+ throw new Error(`Invalid argument "${argName}": ${z.prettifyError(result.error)}`)
259
+ }
361
260
 
362
- const value = arg.required ? config.requireObject(argName) : config.getObject(argName)
363
- if (value === undefined) return arg.schema.default
261
+ return result.data
262
+ })
364
263
 
365
- if (!ajv.validate(arg.schema, value)) {
366
- throw new Error(`Invalid config for '${argName}': ${ajv.errorsText()}`)
367
- }
264
+ const secrets = mapValues(unit.model.secrets, (secret, secretName) => {
265
+ const hasValue = hsConfig.secretNames.includes(secretName)
368
266
 
369
- return value
370
- }
267
+ if (!hasValue && !secret.required) {
268
+ return secret.schema.default ? pulumiSecret(secret.schema.default) : undefined
371
269
  }
372
- }) as ComponentArgumentSpecToStatic<TArgs>
373
270
 
374
- const secrets = output(
375
- mapValues(unit.model.secrets, (secret, secretName) => {
376
- switch (true) {
377
- case isStringSchema(secret.schema): {
378
- return secret.required ? config.requireSecret(secretName) : config.getSecret(secretName)
379
- }
380
- case isNumberSchema(secret.schema): {
381
- return secret.required
382
- ? config.requireSecretNumber(secretName)
383
- : config.getSecretNumber(secretName)
384
- }
385
- case isBooleanSchema(secret.schema): {
386
- return secret.required
387
- ? config.requireSecretBoolean(secretName)
388
- : config.getSecretBoolean(secretName)
389
- }
390
- default: {
391
- const value = secret.required
392
- ? config.requireSecretObject(secretName)
393
- : config.getSecretObject(secretName)
271
+ if (!hasValue && secret.required) {
272
+ throw new Error(`Secret "${secretName}" is required but not provided.`)
273
+ }
394
274
 
395
- if (!ajv.validate(secret.schema, value)) {
396
- throw new Error(`Invalid secret for '${secretName}': ${ajv.errorsText()}`)
397
- }
275
+ return rawHsSecrets[secretName].apply(rawValue => {
276
+ const value = parseArgumentValue(rawValue)
277
+ // biome-ignore lint/style/noNonNullAssertion: runtime schema is there in runtime
278
+ const result = secret[runtimeSchema]!.safeParse(value)
398
279
 
399
- return value
400
- }
280
+ if (!result.success) {
281
+ throw new Error(`Invalid secret "${secretName}": ${z.prettifyError(result.error)}`)
401
282
  }
402
- }),
403
- ) as unknown as Output<ComponentArgumentSpecToStatic<TSecrets>>
283
+
284
+ return pulumiSecret(result.data)
285
+ })
286
+ })
404
287
 
405
288
  const inputs = mapValues(unit.model.inputs, (input, inputName) => {
406
- const value = input.required
407
- ? config.requireObject<InstanceInput[]>(`input.${inputName}`)
408
- : config.getObject<InstanceInput[]>(`input.${inputName}`)
289
+ const value = hsConfig.inputs[inputName]
409
290
 
410
291
  if (!value) {
411
292
  if (input.multiple) {
412
- return output([])
293
+ return []
413
294
  }
414
295
 
415
296
  return undefined
416
297
  }
417
298
 
418
- return getOutput(unit as unknown as Unit, input, value)
299
+ return getOutput(hsConfig, unit as unknown as Unit, input, value)
419
300
  })
420
301
 
421
- const type = unit.model.type
422
- instanceId = getInstanceId(type, instanceName)
302
+ const [type, name] = parseInstanceId(hsConfig.instanceId)
303
+
304
+ instanceId = hsConfig.instanceId
305
+ instanceName = name
423
306
 
424
307
  return {
425
- args,
426
- instanceId,
308
+ instanceId: hsConfig.instanceId,
427
309
  type,
428
- name: instanceName,
429
- secrets,
310
+ name,
311
+
312
+ args: args as any,
313
+ secrets: secrets as any,
430
314
  inputs: inputs as any,
431
- invokedTriggers: config.getObject<InstanceTriggerInvocation[]>("$invokedTriggers") ?? [],
315
+ invokedTriggers: hsConfig.invokedTriggers,
316
+
317
+ getSecret: (<K extends keyof TSecrets>(
318
+ name: K,
319
+ factory?: () => Input<NonNullable<TSecrets[K]>>,
320
+ ) => {
321
+ if (!factory) {
322
+ return secrets[name as string]
323
+ }
324
+
325
+ const value = secrets[name as string] ?? pulumiSecret(factory())
326
+ secrets[name as string] = value
327
+
328
+ return value
329
+ }) as any,
432
330
 
433
331
  outputs: async (outputs: any = {}) => {
434
332
  const result: any = mapValues(outputs, (outputValue, outputName) => {
@@ -448,56 +346,54 @@ export function forUnit<
448
346
  return output(outputValue).apply(mapTriggers)
449
347
  }
450
348
 
349
+ if (outputName === "$workers") {
350
+ return output(outputValue).apply(mapWorkers)
351
+ }
352
+
451
353
  if (outputName.startsWith("$")) {
452
- throw new Error(`Unknown extra output '${outputName}'.`)
354
+ throw new Error(`Unknown extra output "${outputName}".`)
453
355
  }
454
356
 
455
357
  const outputModel = unit.model.outputs[outputName]
456
358
  if (!outputModel) {
457
- throw new Error(`Output '${outputName}' not found in the unit '${unit.model.type}'.`)
359
+ throw new Error(
360
+ `Output "${outputName}" not found in the unit "${unit.model.type}", but was passed to outputs(...).`,
361
+ )
458
362
  }
459
363
 
460
364
  const entity = unit.entities.get(outputModel.type)
461
365
  if (!entity) {
462
366
  throw new Error(
463
- `Entity '${outputModel.type}' not found in the unit '${unit.model.type}'.`,
367
+ `Entity "${outputModel.type}" not found in the unit "${unit.model.type}". It looks like a bug in the unit definition.`,
464
368
  )
465
369
  }
466
370
 
467
371
  return output(outputValue).apply(value => {
468
- if (value === undefined) {
469
- if (outputModel.required) {
470
- throw new Error(`Output '${outputName}' is required.`)
471
- }
472
-
473
- return undefined
474
- }
475
-
476
- const schema = outputModel.multiple ? Type.Array(entity.schema) : entity.schema
372
+ const schema = outputModel.multiple ? entity.schema.array() : entity.schema
373
+ const result = schema.safeParse(value)
477
374
 
478
- if (!ajv.validate(schema, value)) {
479
- throw new Error(`Invalid output for '${outputName}': ${ajv.errorsText()}`)
375
+ if (!result.success) {
376
+ throw new Error(
377
+ `Invalid output "${outputName}" of type "${outputModel.type}": ${z.prettifyError(result.error)}`,
378
+ )
480
379
  }
481
380
 
482
- return value
381
+ return result.data
483
382
  })
484
- }) as Record<string, Output<unknown>> & ExtraOutputs
383
+ })
485
384
 
486
- await Promise.all(Object.values(result).map(o => outputToPromise(o)))
385
+ // wait for all outputs to resolve before collecting secrets and artifacts
386
+ await Promise.all(Object.values(result).map(o => toPromise(o)))
487
387
 
488
- if (Object.keys(createdSecrets).length > 0) {
489
- result.$secrets = createdSecrets
490
- }
388
+ result.$secrets = secrets
491
389
 
492
390
  // collect artifacts from all outputs
493
391
  const artifactsMap: Record<string, UnitArtifact[]> = {}
494
392
  for (const [outputName, outputValue] of Object.entries(outputs)) {
495
- if (!outputName.startsWith("$")) {
496
- const resolvedValue = await outputToPromise(outputValue)
497
- const artifacts = extractArtifactsFromValue(resolvedValue)
498
- if (artifacts.length > 0) {
499
- artifactsMap[outputName] = artifacts
500
- }
393
+ const resolvedValue = await toPromise(outputValue)
394
+ const artifacts = extractObjectsFromValue(unitArtifactSchema, resolvedValue)
395
+ if (artifacts.length > 0) {
396
+ artifactsMap[outputName] = artifacts
501
397
  }
502
398
  }
503
399
 
@@ -510,29 +406,22 @@ export function forUnit<
510
406
  }
511
407
  }
512
408
 
513
- export type EntityValue<T extends Entity> = Static<T["schema"]>
514
- export type EntityInput<T extends Entity> = Output<EntityValue<T>>
515
-
516
- function outputToPromise(o: unknown): Promise<unknown> {
517
- return new Promise(resolve => (output(o) as Output<unknown>).apply(resolve))
518
- }
519
-
520
- function mapStatusFields(status: Unwrap<ExtraOutputs["$statusFields"]>): StatusField[] {
409
+ function mapStatusFields(status: Unwrap<ExtraOutputs["$statusFields"]>): InstanceStatusField[] {
521
410
  if (!status) {
522
411
  return []
523
412
  }
524
413
 
525
414
  if (Array.isArray(status)) {
526
415
  return status
527
- .filter(field => !!field?.value)
416
+ .filter((field): field is NonNullable<StatusField> => field?.value !== undefined)
528
417
  .map(field => {
529
418
  return {
530
- name: field!.name,
419
+ name: field.name,
531
420
  meta: {
532
- title: field!.meta?.title ?? camelCaseToHumanReadable(field!.name),
421
+ title: field.meta?.title ?? camelCaseToHumanReadable(field.name),
533
422
  },
534
- value: field!.value,
535
- } as StatusField
423
+ value: field.value,
424
+ }
536
425
  })
537
426
  }
538
427
 
@@ -558,7 +447,7 @@ function mapStatusFields(status: Unwrap<ExtraOutputs["$statusFields"]>): StatusF
558
447
  }
559
448
 
560
449
  return {
561
- ...(field as StatusField),
450
+ ...field,
562
451
  meta: {
563
452
  ...field.meta,
564
453
  title: field.meta?.title ?? camelCaseToHumanReadable(name),
@@ -566,65 +455,30 @@ function mapStatusFields(status: Unwrap<ExtraOutputs["$statusFields"]>): StatusF
566
455
  name,
567
456
  }
568
457
  })
569
- .filter(field => !!field?.value) as StatusField[]
458
+ .filter((field): field is InstanceStatusField => field?.value !== undefined)
570
459
  }
571
460
 
572
- function mapPages(pages: Unwrap<ExtraOutputs["$pages"]>): InstancePage[] {
461
+ function mapPages(pages: Unwrap<ExtraOutputs["$pages"]>): Output<UnitPage[]> {
573
462
  if (!pages) {
574
- return []
463
+ return output([])
575
464
  }
576
465
 
577
- if (Array.isArray(pages)) {
578
- return pages.filter(page => !!page)
579
- }
580
-
581
- return Object.entries(pages)
582
- .filter(([, page]) => !!page)
583
- .map(([name, page]) => ({ ...page!, name }))
584
- }
466
+ if (!Array.isArray(pages)) {
467
+ pages = Object.entries(pages).map(([name, page]) => {
468
+ if (!page) {
469
+ return undefined
470
+ }
585
471
 
586
- export function fileFromString(
587
- name: string,
588
- content: string,
589
- contentType = "text/plain",
590
- isSecret = false,
591
- ): InstanceFile {
592
- return {
593
- meta: {
594
- name,
595
- contentType,
596
- size: Buffer.byteLength(content, "utf8"),
597
- },
598
- content: {
599
- type: "embedded",
600
- value: isSecret ? secret(content) : content,
601
- },
472
+ return { ...page, name }
473
+ })
602
474
  }
603
- }
604
475
 
605
- export function fileFromBuffer(
606
- name: string,
607
- content: Buffer,
608
- contentType = "application/octet-stream",
609
- isSecret = false,
610
- ): InstanceFile {
611
- return {
612
- meta: {
613
- name,
614
- contentType,
615
- size: content.byteLength,
616
- isBinary: true,
617
- },
618
- content: {
619
- type: "embedded",
620
- value: isSecret ? secret(content.toString("base64")) : content.toString("base64"),
621
- },
622
- }
476
+ return output(pages.filter((page): page is NonNullable<UnitPage> => !!page))
623
477
  }
624
478
 
625
- function mapTerminals(terminals: Unwrap<ExtraOutputs["$terminals"]>): InstanceTerminal[] {
479
+ function mapTerminals(terminals: Unwrap<ExtraOutputs["$terminals"]>): Output<UnitTerminal[]> {
626
480
  if (!terminals) {
627
- return []
481
+ return output([])
628
482
  }
629
483
 
630
484
  if (!Array.isArray(terminals)) {
@@ -637,65 +491,53 @@ function mapTerminals(terminals: Unwrap<ExtraOutputs["$terminals"]>): InstanceTe
637
491
  })
638
492
  }
639
493
 
640
- return terminals
641
- .filter(terminal => !!terminal)
642
- .map(terminal => {
643
- if (!terminal.spec.files) {
644
- return terminal
645
- }
494
+ return output(terminals.filter((terminal): terminal is NonNullable<UnitTerminal> => !!terminal))
495
+ }
646
496
 
647
- return {
648
- ...terminal,
649
-
650
- spec: {
651
- ...terminal.spec,
652
-
653
- files: pipe(
654
- terminal.spec.files,
655
- mapValues(file => {
656
- if (typeof file === "string") {
657
- return {
658
- meta: {
659
- name: "content",
660
- contentType: "text/plain",
661
- size: Buffer.byteLength(file, "utf8"),
662
- },
663
- content: {
664
- type: "embedded" as const,
665
- value: file,
666
- },
667
- }
668
- }
669
-
670
- return file
671
- }),
672
- pickBy(value => !!value),
673
- ),
674
- },
497
+ function mapTriggers(triggers: Unwrap<ExtraOutputs["$triggers"]>): Output<UnitTrigger[]> {
498
+ if (!triggers) {
499
+ return output([])
500
+ }
501
+
502
+ if (!Array.isArray(triggers)) {
503
+ triggers = Object.entries(triggers).map(([name, trigger]) => {
504
+ if (!trigger) {
505
+ return undefined
675
506
  }
507
+
508
+ return { ...trigger, name }
676
509
  })
510
+ }
511
+
512
+ return output(triggers.filter((trigger): trigger is NonNullable<UnitTrigger> => !!trigger))
677
513
  }
678
514
 
679
- function mapTriggers(triggers: Unwrap<ExtraOutputs["$triggers"]>): InstanceTrigger[] {
680
- if (!triggers) {
681
- return []
515
+ function mapWorkers(workers: Unwrap<ExtraOutputs["$workers"]>): Output<Unwrap<UnitWorker>[]> {
516
+ if (!workers) {
517
+ return output([])
682
518
  }
683
519
 
684
- if (Array.isArray(triggers)) {
685
- return triggers.filter(trigger => !!trigger)
520
+ if (!Array.isArray(workers)) {
521
+ workers = Object.entries(workers).map(([name, worker]) => {
522
+ if (!worker) {
523
+ return undefined
524
+ }
525
+
526
+ return { ...worker, name }
527
+ })
686
528
  }
687
529
 
688
- return Object.entries(triggers)
689
- .filter(([, trigger]) => !!trigger)
690
- .map(([name, trigger]) => ({ ...(trigger as InstanceTrigger), name }))
530
+ return output(workers.filter((worker): worker is NonNullable<Unwrap<UnitWorker>> => !!worker))
691
531
  }
692
532
 
693
533
  /**
694
- * Extracts artifact objects from a value by traversing the object tree
695
- * looking for properties with HighstateSignature.Artifact.
534
+ * Extracts all objects with the specified schema from a value.
696
535
  */
697
- function extractArtifactsFromValue(data: unknown): UnitArtifact[] {
698
- const artifacts: UnitArtifact[] = []
536
+ function extractObjectsFromValue<TSchema extends z.ZodType>(
537
+ schema: TSchema,
538
+ data: unknown,
539
+ ): z.infer<TSchema>[] {
540
+ const result: z.infer<TSchema>[] = []
699
541
 
700
542
  function traverse(obj: unknown): void {
701
543
  if (obj === null || obj === undefined || typeof obj !== "object") {
@@ -709,24 +551,18 @@ function extractArtifactsFromValue(data: unknown): UnitArtifact[] {
709
551
  return
710
552
  }
711
553
 
712
- const record = obj as Record<string, unknown>
713
-
714
- // check if this object has an artifact signature
715
- if (HighstateSignature.Artifact in record) {
716
- const artifactData = record[HighstateSignature.Artifact] as UnitArtifact
717
- artifacts.push(artifactData)
718
-
719
- // strip all other properties except the artifact hash
720
- record[HighstateSignature.Artifact] = { hash: artifactData.hash }
554
+ const parseResult = schema.safeParse(obj)
555
+ if (parseResult.success) {
556
+ result.push(parseResult.data)
721
557
  return
722
558
  }
723
559
 
724
560
  // recursively traverse all properties
725
- for (const value of Object.values(record)) {
561
+ for (const value of Object.values(obj)) {
726
562
  traverse(value)
727
563
  }
728
564
  }
729
565
 
730
566
  traverse(data)
731
- return artifacts
567
+ return result
732
568
  }