@@ -17,12 +17,15 @@ import { readProjectRuntime, writeProjectRuntime } from "./project-runtime.ts";
1717export interface ResourceRuntimeOptions extends DestructiveDecisionOptions {
1818 provider ?: string ;
1919 scope ?: ResourcePlanScope ;
20+ mode ?: ResourceSyncMode ;
2021 refresh ?: boolean ;
2122 refreshOnly ?: boolean ;
2223 quiet ?: boolean ;
2324 onFeedback ?: RuntimeFeedbackSink ;
2425}
2526
27+ export type ResourceSyncMode = "reconcile" | "create-only" ;
28+
2629export interface ResourcePlanScope {
2730 roots : ResourceAddress [ ] ;
2831 includeDependencies ?: boolean ;
@@ -51,6 +54,7 @@ export interface ResourcePlanResult {
5154 targetProviders ?: string [ ] ;
5255 destructiveActions : PlannedAction [ ] ;
5356 selectedAddresses ?: ResourceAddress [ ] ;
57+ mode ?: ResourceSyncMode ;
5458}
5559
5660export type DestructivePolicy = "block" | "prompt" | "force" ;
@@ -81,6 +85,10 @@ export async function syncProjectResourcesWithStateBackend(
8185 if ( options . refreshOnly ) {
8286 return { planned } ;
8387 }
88+ if ( options . mode === "create-only" ) {
89+ const errorDiagnostic = planned . plan . diagnostics . find ( ( diagnostic ) => diagnostic . severity === "error" ) ;
90+ if ( errorDiagnostic ) throw new UserError ( errorDiagnostic . message ) ;
91+ }
8492 return {
8593 planned,
8694 execution : await executePlannedProject ( planned , {
@@ -173,6 +181,9 @@ export async function planProjectContext(
173181 ctx : ProjectRuntimeContext ,
174182 options : ResourceRuntimeOptions = { } ,
175183) : Promise < ResourcePlanResult > {
184+ if ( options . mode === "create-only" && ! options . scope ) {
185+ throw new UserError ( "Resource create-only mode requires an explicit resource scope." ) ;
186+ }
176187 let targetProviders = resolveTargetProviders ( options . provider ) ;
177188 if ( ! targetProviders && options . scope ) {
178189 targetProviders = [ ...new Set ( options . scope . roots . map ( ( root ) => root . provider ) ) ] ;
@@ -190,11 +201,14 @@ export async function planProjectContext(
190201 } )
191202 : undefined ;
192203
193- const plan = await buildPlan ( ctx . config , ctx . state . getStateFile ( ) , {
204+ let plan = await buildPlan ( ctx . config , ctx . state . getStateFile ( ) , {
194205 providers : targetProviders ,
195206 configPath : ctx . configPath ,
196207 resourceAddresses : selectedAddresses ,
197208 } ) ;
209+ if ( options . mode === "create-only" && options . scope ) {
210+ plan = enforceCreateOnlyPlan ( plan , options . scope , toResourceRefreshResult ( refreshResult ) ) ;
211+ }
198212
199213 return {
200214 executionContext : ctx ,
@@ -203,6 +217,54 @@ export async function planProjectContext(
203217 targetProviders,
204218 destructiveActions : selectDestructive ( plan . actions ) ,
205219 selectedAddresses,
220+ mode : options . mode ,
221+ } ;
222+ }
223+
224+ function enforceCreateOnlyPlan (
225+ plan : ExecutionPlan ,
226+ scope : ResourcePlanScope ,
227+ refreshResult : ResourceRefreshResult | undefined ,
228+ ) : ExecutionPlan {
229+ const reasons : string [ ] = [ ] ;
230+ const rootKeys = new Set ( scope . roots . map ( addressKey ) ) ;
231+ const refreshError = refreshResult ?. errors [ 0 ] ;
232+ if ( refreshError ) {
233+ reasons . push (
234+ `Cannot verify scoped dependencies because refresh failed for ${ addressKey ( refreshError . resource . address ) } : ${ refreshError . error } ` ,
235+ ) ;
236+ }
237+
238+ for ( const root of scope . roots ) {
239+ const rootKey = addressKey ( root ) ;
240+ const rootAction = plan . actions . find ( ( action ) => addressKey ( action . address ) === rootKey ) ;
241+ if ( ! rootAction ) {
242+ reasons . push ( `Scoped plan did not contain target resource ${ rootKey } .` ) ;
243+ } else if ( rootAction . action !== "create" ) {
244+ reasons . push ( `Target resource ${ rootKey } must be new, but the scoped plan requires '${ rootAction . action } '.` ) ;
245+ }
246+ }
247+
248+ const dependencyChanges = plan . actions . filter (
249+ ( action ) => ! rootKeys . has ( addressKey ( action . address ) ) && action . action !== "no-op" ,
250+ ) ;
251+ if ( dependencyChanges . length > 0 ) {
252+ const labels = dependencyChanges . map ( ( action ) => `${ addressKey ( action . address ) } (${ action . action } )` ) . join ( ", " ) ;
253+ reasons . push ( `Create-only requires every scoped dependency to be up-to-date. Reconcile first: ${ labels } .` ) ;
254+ }
255+
256+ if ( reasons . length === 0 ) return plan ;
257+ return {
258+ ...plan ,
259+ diagnostics : [
260+ ...plan . diagnostics ,
261+ {
262+ severity : "error" ,
263+ code : "resource.create_only.blocked" ,
264+ message : reasons . join ( " " ) ,
265+ resource : scope . roots [ 0 ] ,
266+ } ,
267+ ] ,
206268 } ;
207269}
208270
0 commit comments