@highstate/backend 0.9.31 → 0.9.33

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,80 @@
1
+ import { describe } from "vitest"
2
+ import { createOperationPlan } from "./operation-plan"
3
+ import { operationPlanTest } from "./operation-plan.fixtures"
4
+
5
+ describe("OperationPlan - Preview Operations", () => {
6
+ operationPlanTest(
7
+ "1. returns single preview phase for requested unit",
8
+ async ({ testBuilder, expect }) => {
9
+ const { context, operation } = await testBuilder()
10
+ .unit("App")
11
+ .request("preview", "App")
12
+ .build()
13
+
14
+ const plan = createOperationPlan(
15
+ context,
16
+ operation.type,
17
+ operation.requestedInstanceIds,
18
+ operation.options,
19
+ )
20
+
21
+ expect(plan).toMatchInlineSnapshot(`
22
+ [
23
+ {
24
+ "instances": [
25
+ {
26
+ "id": "component.v1:App",
27
+ "message": "explicitly requested",
28
+ "parentId": undefined,
29
+ },
30
+ ],
31
+ "type": "preview",
32
+ },
33
+ ]
34
+ `)
35
+ },
36
+ )
37
+
38
+ operationPlanTest(
39
+ "2. throws when multiple instances are requested",
40
+ async ({ testBuilder, expect }) => {
41
+ const { context, operation } = await testBuilder()
42
+ .unit("A")
43
+ .unit("B")
44
+ .request("preview", "A", "B")
45
+ .build()
46
+
47
+ expect(() =>
48
+ createOperationPlan(
49
+ context,
50
+ operation.type,
51
+ operation.requestedInstanceIds,
52
+ operation.options,
53
+ ),
54
+ ).toThrowErrorMatchingInlineSnapshot(
55
+ "[Error: Preview operations can only target a single instance]",
56
+ )
57
+ },
58
+ )
59
+
60
+ operationPlanTest(
61
+ "3. throws when previewing composite instance",
62
+ async ({ testBuilder, expect }) => {
63
+ const { context, operation } = await testBuilder()
64
+ .composite("Group")
65
+ .request("preview", "Group")
66
+ .build()
67
+
68
+ expect(() =>
69
+ createOperationPlan(
70
+ context,
71
+ operation.type,
72
+ operation.requestedInstanceIds,
73
+ operation.options,
74
+ ),
75
+ ).toThrowErrorMatchingInlineSnapshot(
76
+ '[Error: Preview is not supported for composite instance "composite.v1:Group"]',
77
+ )
78
+ },
79
+ )
80
+ })
@@ -35,6 +35,38 @@ export function createOperationPlan(
35
35
  requestedInstanceIds: string[],
36
36
  options: OperationOptions,
37
37
  ): OperationPhase[] {
38
+ if (options.forceUpdateDependencies && options.ignoreDependencies) {
39
+ throw new Error(
40
+ "Operation options are invalid: forceUpdateDependencies and ignoreDependencies cannot both be enabled.",
41
+ )
42
+ }
43
+
44
+ if (type === "preview") {
45
+ if (requestedInstanceIds.length !== 1) {
46
+ throw new Error("Preview operations can only target a single instance")
47
+ }
48
+
49
+ const instanceId = requestedInstanceIds[0] as InstanceId
50
+ const instance = context.getInstance(instanceId)
51
+
52
+ if (instance.kind !== "unit") {
53
+ throw new Error(`Preview is not supported for composite instance "${instanceId}"`)
54
+ }
55
+
56
+ return [
57
+ {
58
+ type: "preview",
59
+ instances: [
60
+ {
61
+ id: instanceId,
62
+ parentId: instance.parentId,
63
+ message: "explicitly requested",
64
+ },
65
+ ],
66
+ },
67
+ ]
68
+ }
69
+
38
70
  // initialize work state
39
71
  const workState: WorkState = {
40
72
  included: new Map(),
@@ -195,6 +227,10 @@ function processUpdateInclusions(
195
227
  if (workState.included.has(instance.id)) {
196
228
  const dependencies = context.getDependencies(instance.id)
197
229
  for (const depInstance of dependencies) {
230
+ if (options.ignoreDependencies) {
231
+ continue
232
+ }
233
+
198
234
  const shouldInclude = options.forceUpdateDependencies || isOutdated(depInstance, context)
199
235
 
200
236
  if (shouldInclude && !workState.included.has(depInstance.id)) {
@@ -237,6 +273,10 @@ function processRefreshInclusions(
237
273
  if (workState.included.has(instance.id)) {
238
274
  const dependencies = context.getDependencies(instance.id)
239
275
  for (const depInstance of dependencies) {
276
+ if (options.ignoreDependencies) {
277
+ continue
278
+ }
279
+
240
280
  const shouldInclude = options.forceUpdateDependencies
241
281
 
242
282
  if (shouldInclude && !workState.included.has(depInstance.id)) {
@@ -48,6 +48,47 @@ describe("OperationPlan - Update Operations", () => {
48
48
  },
49
49
  )
50
50
 
51
+ operationPlanTest(
52
+ "1a. should ignore dependencies when option enabled",
53
+ async ({ testBuilder, expect }) => {
54
+ // arrange
55
+ const { context, operation } = await testBuilder()
56
+ .unit("A")
57
+ .unit("B")
58
+ .unit("C")
59
+ .depends("C", "B")
60
+ .depends("B", "A")
61
+ .states({ A: "upToDate", B: "changed", C: "upToDate" })
62
+ .options({ ignoreDependencies: true })
63
+ .request("update", "C")
64
+ .build()
65
+
66
+ // act
67
+ const plan = createOperationPlan(
68
+ context,
69
+ operation.type,
70
+ operation.requestedInstanceIds,
71
+ operation.options,
72
+ )
73
+
74
+ // assert
75
+ expect(plan).toMatchInlineSnapshot(`
76
+ [
77
+ {
78
+ "instances": [
79
+ {
80
+ "id": "component.v1:C",
81
+ "message": "explicitly requested",
82
+ "parentId": undefined,
83
+ },
84
+ ],
85
+ "type": "update",
86
+ },
87
+ ]
88
+ `)
89
+ },
90
+ )
91
+
51
92
  operationPlanTest(
52
93
  "2. should not propagate beyond compositional inclusion",
53
94
  async ({ testBuilder, expect }) => {
@@ -158,6 +199,35 @@ describe("OperationPlan - Update Operations", () => {
158
199
  },
159
200
  )
160
201
 
202
+ operationPlanTest(
203
+ "3a. should reject conflicting dependency options",
204
+ async ({ testBuilder, expect }) => {
205
+ // arrange
206
+ const { context, operation } = await testBuilder()
207
+ .unit("A")
208
+ .unit("B")
209
+ .unit("C")
210
+ .depends("C", "B")
211
+ .depends("B", "A")
212
+ .states({ A: "upToDate", B: "upToDate", C: "upToDate" })
213
+ .options({ forceUpdateDependencies: true, ignoreDependencies: true })
214
+ .request("update", "C")
215
+ .build()
216
+
217
+ // act & assert
218
+ expect(() =>
219
+ createOperationPlan(
220
+ context,
221
+ operation.type,
222
+ operation.requestedInstanceIds,
223
+ operation.options,
224
+ ),
225
+ ).toThrowErrorMatchingInlineSnapshot(
226
+ "[Error: Operation options are invalid: forceUpdateDependencies and ignoreDependencies cannot both be enabled.]",
227
+ )
228
+ },
229
+ )
230
+
161
231
  operationPlanTest(
162
232
  "4. should include outdated children of substantive composite",
163
233
  async ({ testBuilder, expect }) => {
@@ -120,18 +120,21 @@ export class OperationWorkset {
120
120
  inputs => inputs.map(input => input.input),
121
121
  )
122
122
 
123
+ const serializedResolvedInputs = this.context.serializeResolvedInputs(resolvedInputs)
124
+
123
125
  return [
124
126
  {
125
127
  stateId: state.id,
126
128
  operationId: this.operationId,
127
129
  status: "pending",
128
130
  model: instance,
129
- resolvedInputs,
131
+ resolvedInputs: serializedResolvedInputs,
130
132
  },
131
133
  {
134
+ // preview runs still provision a pulumi stack; keep "attempted" so a later destroy removes it
132
135
  status: state.status === "undeployed" ? "attempted" : state.status,
133
136
  model: instance,
134
- resolvedInputs,
137
+ resolvedInputs: serializedResolvedInputs,
135
138
  },
136
139
  ]
137
140
  }),
@@ -153,7 +156,7 @@ export class OperationWorkset {
153
156
  options,
154
157
  )
155
158
 
156
- if (state.parentInstanceId) {
159
+ if (state.parentInstanceId && this.currentPhase !== "preview") {
157
160
  // TODO: update all updates in single transaction
158
161
  await this.recalculateCompositeInstanceState(state.parentInstanceId)
159
162
  }
@@ -231,6 +234,8 @@ export class OperationWorkset {
231
234
 
232
235
  getTransientStatusByOperationPhase(): InstanceOperationStatus {
233
236
  switch (this.currentPhase) {
237
+ case "preview":
238
+ return "previewing"
234
239
  case "update":
235
240
  return "updating"
236
241
  case "destroy":
@@ -242,6 +247,8 @@ export class OperationWorkset {
242
247
 
243
248
  getStableStatusByOperationPhase(): InstanceOperationStatus {
244
249
  switch (this.currentPhase) {
250
+ case "preview":
251
+ return "previewed"
245
252
  case "update":
246
253
  return "updated"
247
254
  case "destroy":
@@ -255,6 +262,8 @@ export class OperationWorkset {
255
262
  const state = this.context.getState(instanceId)
256
263
 
257
264
  switch (this.currentPhase) {
265
+ case "preview":
266
+ return state.status // do not change instance status when previewing
258
267
  case "update":
259
268
  return "deployed"
260
269
  case "destroy":
@@ -252,9 +252,60 @@ export class RuntimeOperation {
252
252
  case "refresh": {
253
253
  return this.refreshUnit(instance, state)
254
254
  }
255
+ case "preview": {
256
+ return this.previewUnit(instance, state)
257
+ }
255
258
  }
256
259
  }
257
260
 
261
+ private previewUnit(instance: InstanceModel, state: InstanceState): Promise<void> {
262
+ return this.getInstancePromise(instance.id, async (logger, signal, forceSignal) => {
263
+ signal.throwIfAborted()
264
+
265
+ if (this.operation.status === "failing") {
266
+ throw new AbortError("The operation is failing, aborting preview branch")
267
+ }
268
+
269
+ logger.info("previewing unit")
270
+
271
+ await this.workset.updateState(instance.id, {
272
+ operationState: {
273
+ status: "previewing",
274
+ startedAt: new Date(),
275
+ },
276
+ })
277
+
278
+ signal.throwIfAborted()
279
+
280
+ const secrets = await this.secretService.getInstanceSecretValues(this.project.id, state.id)
281
+ signal.throwIfAborted()
282
+
283
+ const config = this.prepareUnitConfig(instance, secrets)
284
+ const artifactIds = this.collectArtifactIdsForInstance(instance)
285
+ const artifacts = await this.artifactService.getArtifactsByIds(this.project.id, artifactIds)
286
+
287
+ logger.debug({ count: artifactIds.length }, "artifact ids collected for preview")
288
+
289
+ await this.runnerBackend.preview({
290
+ projectId: this.project.id,
291
+ libraryId: this.project.libraryId,
292
+ stateId: state.id,
293
+ instanceType: instance.type,
294
+ instanceName: instance.name,
295
+ config,
296
+ refresh: this.operation.options.refresh,
297
+ secrets,
298
+ artifacts,
299
+ signal,
300
+ forceSignal,
301
+ debug: this.operation.options.debug,
302
+ })
303
+
304
+ await this.watchStateStream(state, instance.type, instance.name, logger)
305
+ logger.info("unit preview completed")
306
+ })
307
+ }
308
+
258
309
  private getCompositePromise(instance: InstanceModel): Promise<void> {
259
310
  return this.getInstancePromise(instance.id, async logger => {
260
311
  let instanceState: InstanceStatePatch | undefined
@@ -353,7 +404,7 @@ export class RuntimeOperation {
353
404
 
354
405
  logger.debug({ count: artifactIds.length }, "artifact ids collected from dependencies")
355
406
 
356
- await this.runnerBackend[this.operation.type === "preview" ? "preview" : "update"]({
407
+ await this.runnerBackend.update({
357
408
  projectId: this.project.id,
358
409
  libraryId: this.project.libraryId,
359
410
  stateId: state.id,
@@ -669,7 +720,6 @@ export class RuntimeOperation {
669
720
  state: InstanceState,
670
721
  ): Promise<void> {
671
722
  if (this.operation.type === "preview") {
672
- // do not change instance status in preview mode
673
723
  await this.workset.updateState(update.unitId, {
674
724
  operationState: {
675
725
  status: this.workset.getStableStatusByOperationPhase(),
@@ -209,14 +209,6 @@ export class PlanTestBuilder {
209
209
  const instances = Array.from(this.instances.values())
210
210
  const states = Array.from(this.stateMap.values())
211
211
 
212
- // Copy resolvedInputs from instances to states for dependency tracking
213
- states.forEach(state => {
214
- const instance = instances.find(i => i.id === state.instanceId)
215
- if (instance?.resolvedInputs) {
216
- state.resolvedInputs = instance.resolvedInputs
217
- }
218
- })
219
-
220
212
  // get requested instance IDs
221
213
  const requestedInstanceIds = this.requestedInstanceNames.map(name => {
222
214
  const instance = this.instances.get(name)
@@ -234,6 +226,14 @@ export class PlanTestBuilder {
234
226
  // create context with instances and initial states
235
227
  const context = await this.createContext(instances, states)
236
228
 
229
+ // copy resolvedInputs from instances to states for dependency tracking
230
+ states.forEach(state => {
231
+ const instance = instances.find(i => i.id === state.instanceId)
232
+ if (instance?.resolvedInputs) {
233
+ state.resolvedInputs = context.serializeResolvedInputs(instance.resolvedInputs)
234
+ }
235
+ })
236
+
237
237
  // update "upToDate" states with correct input hashes from context
238
238
  const updatedStates = states.map(state => {
239
239
  const stateEntry = Array.from(this.stateMap.entries()).find(
@@ -208,7 +208,7 @@ export class LocalRunnerBackend implements RunnerBackend {
208
208
  const outputs = await stack.outputs()
209
209
  const completionUpdate = this.createCompletionStateUpdate("update", unitId, outputs)
210
210
 
211
- if (outputs["$artifacts"]) {
211
+ if (!preview && outputs["$artifacts"]) {
212
212
  const artifacts = z
213
213
  .record(z.string(), unitArtifactSchema.array())
214
214
  .parse(outputs["$artifacts"].value)
@@ -221,6 +221,8 @@ export class LocalRunnerBackend implements RunnerBackend {
221
221
  Object.values(artifacts).flat(),
222
222
  childLogger,
223
223
  )
224
+ } else if (preview && outputs["$artifacts"]) {
225
+ childLogger.debug({ msg: "skipping artifact persistence for preview" })
224
226
  }
225
227
 
226
228
  this.emitStateUpdate(completionUpdate)
@@ -111,7 +111,12 @@ export class LocalPulumiHost {
111
111
  {
112
112
  projectSettings: {
113
113
  name: pulumiProjectName,
114
- runtime: "nodejs",
114
+ runtime: {
115
+ name: "nodejs",
116
+ options: {
117
+ nodeargs: "--no-deprecation",
118
+ },
119
+ },
115
120
  main: "index.js",
116
121
  },
117
122
  stackSettings: stackConfig
@@ -24,7 +24,7 @@ declare global {
24
24
  type InstanceModel = contract.InstanceModel
25
25
 
26
26
  type InstanceArgs = Record<string, unknown>
27
- type InstanceResolvedInputs = Record<string, contract.InstanceInput[]>
27
+ type InstanceResolvedInputs = Record<string, shared.StableInstanceInput[]>
28
28
  type UnlockMethodMeta = shared.UnlockMethodMeta
29
29
  type ProjectUnlockSuite = shared.ProjectUnlockSuite
30
30
 
@@ -4,7 +4,7 @@ import { instanceIdSchema, objectMetaSchema, z } from "@highstate/contract"
4
4
  /**
5
5
  * Phase type for operation execution.
6
6
  */
7
- export const operationPhaseTypeSchema = z.enum(["destroy", "update", "refresh"])
7
+ export const operationPhaseTypeSchema = z.enum(["destroy", "preview", "update", "refresh"])
8
8
 
9
9
  /**
10
10
  * Instance information for operation phase.
@@ -61,6 +61,20 @@ export const operationOptionsSchema = z
61
61
  */
62
62
  forceUpdateDependencies: z.boolean().default(false),
63
63
 
64
+ /**
65
+ * Ignore dependencies and operate only on explicitly requested instances.
66
+ *
67
+ * **Operation Behavior Impact:**
68
+ * - skips dependency inclusion even when dependencies are failed or undeployed;
69
+ * - caller must explicitly include every prerequisite instance to avoid failures;
70
+ * - complements on-demand or targeted updates where dependency safety is managed externally.
71
+ *
72
+ * **Usage with other options:**
73
+ * - mutually exclusive with `forceUpdateDependencies`;
74
+ * - independent of child/composite inclusion options.
75
+ */
76
+ ignoreDependencies: z.boolean().default(false),
77
+
64
78
  /**
65
79
  * Force update all children of composite instances regardless of their state.
66
80
  *
@@ -8,6 +8,18 @@ import type {
8
8
  } from "../../../database"
9
9
  import { z } from "zod"
10
10
 
11
+ export const stableInstanceInputSchema = z.object({
12
+ stateId: z.string(),
13
+ output: z.string(),
14
+ })
15
+
16
+ /**
17
+ * The instance input that references state IDs instead of instance IDs.
18
+ *
19
+ * This provides a stable reference to an instance output that is not affected by instance ID changes.
20
+ */
21
+ export type StableInstanceInput = z.infer<typeof stableInstanceInputSchema>
22
+
11
23
  /**
12
24
  * The instance state aggregate including all related states.
13
25
  */
@@ -109,6 +121,7 @@ export type {
109
121
  } from "../../../database"
110
122
 
111
123
  export const finalInstanceOperationStatuses: InstanceOperationStatus[] = [
124
+ "previewed",
112
125
  "destroyed",
113
126
  "updated",
114
127
  "cancelled",
@@ -45,6 +45,9 @@ export class InputHashResolver extends GraphResolver<InputHashNode, InputHashOut
45
45
  }: InputHashNode): InputHashOutput {
46
46
  const inputHashSink: Uint8Array[] = []
47
47
 
48
+ // 0. include the instance id to reflect renames
49
+ inputHashSink.push(Buffer.from(instance.id))
50
+
48
51
  // 1. include the component definition hash
49
52
  inputHashSink.push(int32ToBytes(component.definitionHash))
50
53
 
@@ -1,9 +1,7 @@
1
1
  import type { Logger } from "pino"
2
- import type { InstanceState } from "../models/project"
3
2
  import type { DependentSetHandler, GraphResolver, ResolverOutputHandler } from "./graph-resolver"
4
3
  import { InputResolver, type InputResolverNode, type InputResolverOutput } from "./input"
5
4
  import { type InputHashNode, type InputHashOutput, InputHashResolver } from "./input-hash"
6
- import { StateResolver } from "./state"
7
5
  import { type ValidationNode, type ValidationOutput, ValidationResolver } from "./validation"
8
6
 
9
7
  export type GraphResolverType = keyof GraphResolverMap
@@ -12,15 +10,12 @@ export type GraphResolverMap = {
12
10
  InputResolver: [InputResolverNode, InputResolverOutput]
13
11
  InputHashResolver: [InputHashNode, InputHashOutput]
14
12
  ValidationResolver: [ValidationNode, ValidationOutput]
15
- // biome-ignore lint/suspicious/noConfusingVoidType: it is return type
16
- StateResolver: [InstanceState, void]
17
13
  }
18
14
 
19
15
  export const resolverFactories = {
20
16
  InputResolver,
21
17
  InputHashResolver,
22
18
  ValidationResolver,
23
- StateResolver,
24
19
  } as Record<
25
20
  GraphResolverType,
26
21
  new (