From ea408c22136fb171a687e66d8379d13dfd45955a Mon Sep 17 00:00:00 2001 From: Kosti Verigos Date: Wed, 24 Jun 2026 17:34:16 +0200 Subject: [PATCH 1/5] deploy: introduce PrepareDeployOptions planning mode Add a PrepareDeployMode (full deploy vs provision-only) threaded through the deploy planner. No behaviour change yet; the mode is consumed in later commits. Amp-Thread-ID: https://ampcode.com/threads/T-019ef9f5-4ba4-711c-b45c-370fd77b0c55 Co-authored-by: Amp --- internal/planning/deploy/deploy.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/planning/deploy/deploy.go b/internal/planning/deploy/deploy.go index 402565aa7..34ca68f45 100644 --- a/internal/planning/deploy/deploy.go +++ b/internal/planning/deploy/deploy.go @@ -87,7 +87,27 @@ func PrepareDeployServers(ctx context.Context, planner planning.Planner, focus . return PrepareDeployStack(ctx, planner, stack) } +// PrepareDeployMode selects what a deployment plan should act upon. +type PrepareDeployMode int + +const ( + // PrepareFullDeploy provisions resources and rolls out the requested servers. + PrepareFullDeploy PrepareDeployMode = iota + // PrepareProvisionOnly provisions resources (and the servers they require, e.g. + // a colocated database) but does not roll out the requested servers or ingress. + PrepareProvisionOnly +) + +// PrepareDeployOptions tunes how a deployment plan is assembled. +type PrepareDeployOptions struct { + Mode PrepareDeployMode +} + func PrepareDeployStack(ctx context.Context, planner planning.Planner, stack *planning.Stack, prepared ...compute.Computable[PreparedDeployable]) (compute.Computable[*Plan], error) { + return PrepareDeployStackOpts(ctx, planner, stack, PrepareDeployOptions{}, prepared...) +} + +func PrepareDeployStackOpts(ctx context.Context, planner planning.Planner, stack *planning.Stack, opts PrepareDeployOptions, prepared ...compute.Computable[PreparedDeployable]) (compute.Computable[*Plan], error) { def, err := prepareHandlerInvocations(ctx, planner, stack) if err != nil { return nil, err @@ -95,7 +115,7 @@ func PrepareDeployStack(ctx context.Context, planner planning.Planner, stack *pl ingressResult := computeIngressWithHandlerResult(planner, stack, ingressesFromHandlerResult(def)) - prepare, err := prepareBuildAndDeployment(ctx, planner, stack, def, ingressResult, prepared...) + prepare, err := prepareBuildAndDeployment(ctx, planner, stack, opts, def, ingressResult, prepared...) if err != nil { return nil, err } @@ -220,7 +240,7 @@ type prepareAndBuildResult struct { NamespaceReference string } -func prepareBuildAndDeployment(ctx context.Context, planner planning.Planner, stack *planning.Stack, stackDef compute.Computable[*handlerResult], ingress compute.Computable[*ComputeIngressResult], prepared ...compute.Computable[PreparedDeployable]) (compute.Computable[prepareAndBuildResult], error) { +func prepareBuildAndDeployment(ctx context.Context, planner planning.Planner, stack *planning.Stack, opts PrepareDeployOptions, stackDef compute.Computable[*handlerResult], ingress compute.Computable[*ComputeIngressResult], prepared ...compute.Computable[PreparedDeployable]) (compute.Computable[prepareAndBuildResult], error) { packages, images, err := computeStackAndImages(ctx, planner, stack, serverImagesOpts{ ProvisionResult: stackDef, IngressFragments: ingress, From 0fdee39ec484c5754df2267f88ef22fb23631a31 Mon Sep 17 00:00:00 2001 From: Kosti Verigos Date: Wed, 24 Jun 2026 17:37:25 +0200 Subject: [PATCH 2/5] deploy: add OpConsumeResourceOutputs no-op sink A no-op invocation that consumes resource instance outputs via RequiredOutput. Used by provision-only deployments to balance output accounting when the requested servers (the usual consumers) are not rolled out. Not wired up yet. Amp-Thread-ID: https://ampcode.com/threads/T-019ef9f5-4ba4-711c-b45c-370fd77b0c55 Co-authored-by: Amp --- .../deploy/op_consumeresourceoutputs.go | 23 +++++ internal/planning/deploy/resourceops.go | 1 + internal/resources/op.pb.go | 95 ++++++++++++++++--- internal/resources/op.proto | 8 ++ 4 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 internal/planning/deploy/op_consumeresourceoutputs.go diff --git a/internal/planning/deploy/op_consumeresourceoutputs.go b/internal/planning/deploy/op_consumeresourceoutputs.go new file mode 100644 index 000000000..8a9ac763a --- /dev/null +++ b/internal/planning/deploy/op_consumeresourceoutputs.go @@ -0,0 +1,23 @@ +// Copyright 2022 Namespace Labs Inc; All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package deploy + +import ( + "context" + + "namespacelabs.dev/foundation/internal/resources" + "namespacelabs.dev/foundation/schema" + "namespacelabs.dev/foundation/std/execution" +) + +// register_OpConsumeResourceOutputs registers a no-op handler that consumes +// resource instance outputs. The outputs are declared via RequiredOutput on the +// invocation, so the handler itself does nothing; it only exists to balance the +// plan's output accounting in provision-only deployments. +func register_OpConsumeResourceOutputs() { + execution.RegisterHandlerFunc(func(ctx context.Context, inv *schema.SerializedInvocation, op *resources.OpConsumeResourceOutputs) (*execution.HandleResult, error) { + return nil, nil + }) +} diff --git a/internal/planning/deploy/resourceops.go b/internal/planning/deploy/resourceops.go index 7f0568f16..90fd34632 100644 --- a/internal/planning/deploy/resourceops.go +++ b/internal/planning/deploy/resourceops.go @@ -7,4 +7,5 @@ package deploy func RegisterDeployOps() { register_OpWaitForProviderResults() register_OpCaptureServerConfig() + register_OpConsumeResourceOutputs() } diff --git a/internal/resources/op.pb.go b/internal/resources/op.pb.go index 751fdb060..69bf88a3c 100644 --- a/internal/resources/op.pb.go +++ b/internal/resources/op.pb.go @@ -98,6 +98,57 @@ func (x *OpWaitForProviderResults) GetInstanceTypeSource() *protos.FileDescripto return nil } +// OpConsumeResourceOutputs is a no-op that consumes resource instance outputs +// which would otherwise have no consumer. It is used in provision-only +// deployments, where the requested servers (the usual consumers) are not +// rolled out, to keep the plan's output accounting balanced. +type OpConsumeResourceOutputs struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ResourceInstanceId []string `protobuf:"bytes,1,rep,name=resource_instance_id,json=resourceInstanceId,proto3" json:"resource_instance_id,omitempty"` +} + +func (x *OpConsumeResourceOutputs) Reset() { + *x = OpConsumeResourceOutputs{} + if protoimpl.UnsafeEnabled { + mi := &file_internal_resources_op_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpConsumeResourceOutputs) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpConsumeResourceOutputs) ProtoMessage() {} + +func (x *OpConsumeResourceOutputs) ProtoReflect() protoreflect.Message { + mi := &file_internal_resources_op_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpConsumeResourceOutputs.ProtoReflect.Descriptor instead. +func (*OpConsumeResourceOutputs) Descriptor() ([]byte, []int) { + return file_internal_resources_op_proto_rawDescGZIP(), []int{1} +} + +func (x *OpConsumeResourceOutputs) GetResourceInstanceId() []string { + if x != nil { + return x.ResourceInstanceId + } + return nil +} + var File_internal_resources_op_proto protoreflect.FileDescriptor var file_internal_resources_op_proto_rawDesc = []byte{ @@ -131,11 +182,16 @@ var file_internal_resources_op_proto_rawDesc = []byte{ 0x61, 0x63, 0x65, 0x2e, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2e, 0x46, 0x69, 0x6c, 0x65, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x53, 0x65, 0x74, 0x41, 0x6e, 0x64, 0x44, 0x65, 0x70, 0x73, 0x52, 0x12, 0x69, 0x6e, 0x73, 0x74, - 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x42, 0x31, - 0x5a, 0x2f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, - 0x64, 0x65, 0x76, 0x2f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x69, - 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, - 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x61, 0x6e, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x22, 0x4c, + 0x0a, 0x18, 0x4f, 0x70, 0x43, 0x6f, 0x6e, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, + 0x72, 0x63, 0x65, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x73, 0x12, 0x30, 0x0a, 0x14, 0x72, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, + 0x63, 0x65, 0x49, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x42, 0x31, 0x5a, 0x2f, + 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x6c, 0x61, 0x62, 0x73, 0x2e, 0x64, 0x65, + 0x76, 0x2f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x69, 0x6e, 0x74, + 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -150,17 +206,18 @@ func file_internal_resources_op_proto_rawDescGZIP() []byte { return file_internal_resources_op_proto_rawDescData } -var file_internal_resources_op_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_internal_resources_op_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_internal_resources_op_proto_goTypes = []interface{}{ (*OpWaitForProviderResults)(nil), // 0: foundation.internal.resources.OpWaitForProviderResults - (*runtime.Deployable)(nil), // 1: foundation.schema.runtime.Deployable - (*schema.ResourceClass)(nil), // 2: foundation.schema.ResourceClass - (*protos.FileDescriptorSetAndDeps)(nil), // 3: foundation.workspace.source.protos.FileDescriptorSetAndDeps + (*OpConsumeResourceOutputs)(nil), // 1: foundation.internal.resources.OpConsumeResourceOutputs + (*runtime.Deployable)(nil), // 2: foundation.schema.runtime.Deployable + (*schema.ResourceClass)(nil), // 3: foundation.schema.ResourceClass + (*protos.FileDescriptorSetAndDeps)(nil), // 4: foundation.workspace.source.protos.FileDescriptorSetAndDeps } var file_internal_resources_op_proto_depIdxs = []int32{ - 1, // 0: foundation.internal.resources.OpWaitForProviderResults.deployable:type_name -> foundation.schema.runtime.Deployable - 2, // 1: foundation.internal.resources.OpWaitForProviderResults.resource_class:type_name -> foundation.schema.ResourceClass - 3, // 2: foundation.internal.resources.OpWaitForProviderResults.instance_type_source:type_name -> foundation.workspace.source.protos.FileDescriptorSetAndDeps + 2, // 0: foundation.internal.resources.OpWaitForProviderResults.deployable:type_name -> foundation.schema.runtime.Deployable + 3, // 1: foundation.internal.resources.OpWaitForProviderResults.resource_class:type_name -> foundation.schema.ResourceClass + 4, // 2: foundation.internal.resources.OpWaitForProviderResults.instance_type_source:type_name -> foundation.workspace.source.protos.FileDescriptorSetAndDeps 3, // [3:3] is the sub-list for method output_type 3, // [3:3] is the sub-list for method input_type 3, // [3:3] is the sub-list for extension type_name @@ -186,6 +243,18 @@ func file_internal_resources_op_proto_init() { return nil } } + file_internal_resources_op_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*OpConsumeResourceOutputs); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -193,7 +262,7 @@ func file_internal_resources_op_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_internal_resources_op_proto_rawDesc, NumEnums: 0, - NumMessages: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/resources/op.proto b/internal/resources/op.proto index 75efb7339..ce297c3ed 100644 --- a/internal/resources/op.proto +++ b/internal/resources/op.proto @@ -18,3 +18,11 @@ message OpWaitForProviderResults { foundation.schema.ResourceClass resource_class = 3; foundation.workspace.source.protos.FileDescriptorSetAndDeps instance_type_source = 4; } + +// OpConsumeResourceOutputs is a no-op that consumes resource instance outputs +// which would otherwise have no consumer. It is used in provision-only +// deployments, where the requested servers (the usual consumers) are not +// rolled out, to keep the plan's output accounting balanced. +message OpConsumeResourceOutputs { + repeated string resource_instance_id = 1; +} From ced975e918b012aad739949e229ad0b1aff5c8b4 Mon Sep 17 00:00:00 2001 From: Kosti Verigos Date: Wed, 24 Jun 2026 17:43:08 +0200 Subject: [PATCH 3/5] deploy: implement provision-only planning In provision-only mode, keep resource provisioning (including servers that resources require, e.g. a colocated database) but do not roll out the requested servers or runtime ingress. A no-op sink consumes resource outputs that the dropped servers would have consumed, keeping the plan's output accounting balanced. Amp-Thread-ID: https://ampcode.com/threads/T-019ef9f5-4ba4-711c-b45c-370fd77b0c55 Co-authored-by: Amp --- internal/planning/deploy/deploy.go | 23 ++++++++- internal/planning/deploy/provisiononly.go | 60 +++++++++++++++++++++++ internal/planning/deploy/resources.go | 13 +++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 internal/planning/deploy/provisiononly.go diff --git a/internal/planning/deploy/deploy.go b/internal/planning/deploy/deploy.go index 34ca68f45..fd286a20e 100644 --- a/internal/planning/deploy/deploy.go +++ b/internal/planning/deploy/deploy.go @@ -130,7 +130,7 @@ func PrepareDeployStackOpts(ctx context.Context, planner planning.Planner, stack ingressFragments: fragmentsOnly, } - if AlsoDeployIngress { + if AlsoDeployIngress && opts.Mode != PrepareProvisionOnly { g.ingressPlan = PlanIngressDeployment(planner.Runtime, ingressResult) } @@ -171,7 +171,10 @@ func (m *makeDeployGraph) Inputs() *compute.In { in := compute.Inputs().Computable("prepare", m.prepare).Indigestible("stack", m.stack) // TODO predeploy orchestration server already from here? if m.ingressFragments != nil { - in = in.Computable("ingress", m.ingressFragments).Computable("ingressPlan", m.ingressPlan) + in = in.Computable("ingress", m.ingressFragments) + } + if m.ingressPlan != nil { + in = in.Computable("ingressPlan", m.ingressPlan) } return in } @@ -343,6 +346,12 @@ func prepareBuildAndDeployment(ctx context.Context, planner planning.Planner, st deploymentSpec.Specs = append(deploymentSpec.Specs, spec) } + if opts.Mode == PrepareProvisionOnly { + // Provision resources without rolling out the requested servers; keep + // only the servers that resources require (e.g. a colocated database). + deploymentSpec.Specs = keepProvisionOnlyServers(deploymentSpec.Specs, resourcePlan.RequiredServers) + } + deploymentPlan, err := planner.Runtime.PlanDeployment(ctx, deploymentSpec) if err != nil { return prepareAndBuildResult{}, err @@ -355,6 +364,16 @@ func prepareBuildAndDeployment(ctx context.Context, planner planning.Planner, st ops = append(ops, resourcePlan.ExecutionInvocations...) ops = append(ops, deploymentPlan.Definitions...) + if opts.Mode == PrepareProvisionOnly { + sink, err := provisionOnlyOutputSink(resourcePlan.ProducedInstanceIDs) + if err != nil { + return prepareAndBuildResult{}, err + } + if sink != nil { + ops = append(ops, sink) + } + } + return prepareAndBuildResult{ HandlerResult: compute.MustGetDepValue(deps, stackDef, "stackAndDefs"), ResourcePlan: resourcePlan, diff --git a/internal/planning/deploy/provisiononly.go b/internal/planning/deploy/provisiononly.go new file mode 100644 index 000000000..ca3d47dfc --- /dev/null +++ b/internal/planning/deploy/provisiononly.go @@ -0,0 +1,60 @@ +// Copyright 2022 Namespace Labs Inc; All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package deploy + +import ( + "google.golang.org/protobuf/types/known/anypb" + internalresources "namespacelabs.dev/foundation/internal/resources" + "namespacelabs.dev/foundation/internal/runtime" + "namespacelabs.dev/foundation/schema" + stdresources "namespacelabs.dev/foundation/std/resources" +) + +// keepProvisionOnlyServers filters server deployables down to the ones required +// to provision resources (e.g. a colocated database). The requested servers are +// not rolled out in provision-only mode. +func keepProvisionOnlyServers(specs []runtime.DeployableSpec, required []schema.PackageName) []runtime.DeployableSpec { + keep := make(map[schema.PackageName]struct{}, len(required)) + for _, p := range required { + keep[p] = struct{}{} + } + + var out []runtime.DeployableSpec + for _, spec := range specs { + if spec.PackageRef == nil { + continue + } + if _, ok := keep[spec.PackageRef.AsPackageName()]; ok { + out = append(out, spec) + } + } + return out +} + +// provisionOnlyOutputSink returns a no-op invocation that consumes the given +// resource instance outputs, so the plan's output accounting stays balanced when +// the requested servers (the usual consumers) are not rolled out. +func provisionOnlyOutputSink(instanceIDs []string) (*schema.SerializedInvocation, error) { + if len(instanceIDs) == 0 { + return nil, nil + } + + wrapped, err := anypb.New(&internalresources.OpConsumeResourceOutputs{ResourceInstanceId: instanceIDs}) + if err != nil { + return nil, err + } + + after := make([]string, len(instanceIDs)) + for i, id := range instanceIDs { + after[i] = stdresources.ResourceInstanceCategory(id) + } + + return &schema.SerializedInvocation{ + Description: "Consume resource outputs (provision-only)", + Impl: wrapped, + RequiredOutput: instanceIDs, + Order: &schema.ScheduleOrder{SchedAfterCategory: after}, + }, nil +} diff --git a/internal/planning/deploy/resources.go b/internal/planning/deploy/resources.go index 7e17f9f6d..e8ded9ffa 100644 --- a/internal/planning/deploy/resources.go +++ b/internal/planning/deploy/resources.go @@ -46,6 +46,14 @@ type resourcePlan struct { PlannedResources []plannedResource ExecutionInvocations []*schema.SerializedInvocation Secrets []runtime.SecretResourceDependency + + // ProducedInstanceIDs are the resource instance ids that produce an execution + // output (resource providers and server-resource captures). Used to wire up the + // provision-only output sink. + ProducedInstanceIDs []string + // RequiredServers are servers referenced as resources (e.g. a colocated + // database) that must be deployed even in provision-only mode. + RequiredServers []schema.PackageName } type plannedResource struct { @@ -121,6 +129,9 @@ func planResources(ctx context.Context, planner planning.Planner, stack *plannin return nil, fnerrors.InternalError("%s: target server is not in the stack", serverIntent.PackageName) } + plan.ProducedInstanceIDs = append(plan.ProducedInstanceIDs, resource.ID) + plan.RequiredServers = append(plan.RequiredServers, target.PackageName()) + si := &schema.SerializedInvocation{ Description: "Capture Runtime Config", Order: &schema.ScheduleOrder{ @@ -218,6 +229,8 @@ func planResources(ctx context.Context, planner planning.Planner, stack *plannin p.BinaryConfig = config p.SerializedIntentJson = resource.JSONSerializedIntent + plan.ProducedInstanceIDs = append(plan.ProducedInstanceIDs, resource.ID) + executionInvocations = append(executionInvocations, p) default: From 308a11bb253290453e863845298992ed9b01016b Mon Sep 17 00:00:00 2001 From: Kosti Verigos Date: Wed, 24 Jun 2026 17:44:28 +0200 Subject: [PATCH 4/5] deploy: add --provision_only flag Provisions resources without rolling out the requested servers. Adjusts the completion output to point at "ns deploy" for the rollout. Amp-Thread-ID: https://ampcode.com/threads/T-019ef9f5-4ba4-711c-b45c-370fd77b0c55 Co-authored-by: Amp --- internal/cli/cmd/deploy.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/cli/cmd/deploy.go b/internal/cli/cmd/deploy.go index e417bd76e..24e2d5322 100644 --- a/internal/cli/cmd/deploy.go +++ b/internal/cli/cmd/deploy.go @@ -77,6 +77,7 @@ func NewDeployCmd() *cobra.Command { flags.StringVar(&deployOpts.manualReason, "reason", "", "Why was this deployment triggered.") flags.BoolVar(&forceApply, "force_apply", false, "Force apply resources, overriding field manager conflicts.") flags.MarkHidden("force_apply") + flags.BoolVar(&deployOpts.provisionOnly, "provision_only", false, "If set, provision resources (e.g. database schema) without rolling out the requested servers.") }). With( fncobra.ParseEnv(&env), @@ -121,7 +122,12 @@ func NewDeployCmd() *cobra.Command { } } - plan, err := deploy.PrepareDeployStack(ctx, p, stack) + var deployMode deploy.PrepareDeployMode + if deployOpts.provisionOnly { + deployMode = deploy.PrepareProvisionOnly + } + + plan, err := deploy.PrepareDeployStackOpts(ctx, p, stack, deploy.PrepareDeployOptions{Mode: deployMode}) if err != nil { return err } @@ -162,9 +168,10 @@ func NewDeployCmd() *cobra.Command { } type deployOpts struct { - alsoWait bool - outputPath string - manualReason string + alsoWait bool + outputPath string + manualReason string + provisionOnly bool } type Output struct { @@ -231,6 +238,12 @@ func completeDeployment(ctx context.Context, env cfg.Context, cluster runtime.Cl } } + if opts.provisionOnly { + fmt.Fprintf(out, "\n Resources provisioned. Run %s to roll out the servers.\n", + colors.Ctx(ctx).Highlight.Apply("ns deploy")) + return nil + } + envLabel := fmt.Sprintf("--env=%s", env.Environment().Name) fmt.Fprintf(out, "\n Next steps:\n\n") From a3ffa9968a22638a0f98d00b75b4f5b14c9d0488 Mon Sep 17 00:00:00 2001 From: Kosti Verigos Date: Wed, 24 Jun 2026 17:45:46 +0200 Subject: [PATCH 5/5] deploy: test provision-only server filter and output sink Cover the two correctness-critical helpers: keeping only resource-required servers, and building an output sink that consumes every produced resource instance. Amp-Thread-ID: https://ampcode.com/threads/T-019ef9f5-4ba4-711c-b45c-370fd77b0c55 Co-authored-by: Amp --- .../planning/deploy/provisiononly_test.go | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 internal/planning/deploy/provisiononly_test.go diff --git a/internal/planning/deploy/provisiononly_test.go b/internal/planning/deploy/provisiononly_test.go new file mode 100644 index 000000000..c7c5cb2bb --- /dev/null +++ b/internal/planning/deploy/provisiononly_test.go @@ -0,0 +1,59 @@ +// Copyright 2022 Namespace Labs Inc; All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package deploy + +import ( + "testing" + + "gotest.tools/assert" + internalresources "namespacelabs.dev/foundation/internal/resources" + "namespacelabs.dev/foundation/internal/runtime" + "namespacelabs.dev/foundation/schema" + stdresources "namespacelabs.dev/foundation/std/resources" +) + +func TestKeepProvisionOnlyServers(t *testing.T) { + app := schema.MakePackageSingleRef("example.com/app") + pg := schema.MakePackageSingleRef("example.com/postgres") + other := schema.MakePackageSingleRef("example.com/other") + + specs := []runtime.DeployableSpec{ + {PackageRef: app}, + {PackageRef: pg}, + {PackageRef: other}, + {PackageRef: nil}, + } + + kept := keepProvisionOnlyServers(specs, []schema.PackageName{pg.AsPackageName()}) + + assert.Equal(t, len(kept), 1) + assert.Equal(t, kept[0].PackageRef.AsPackageName(), pg.AsPackageName()) +} + +func TestProvisionOnlyOutputSinkEmpty(t *testing.T) { + sink, err := provisionOnlyOutputSink(nil) + assert.NilError(t, err) + assert.Assert(t, sink == nil) +} + +func TestProvisionOnlyOutputSink(t *testing.T) { + ids := []string{"res-a", "res-b"} + + sink, err := provisionOnlyOutputSink(ids) + assert.NilError(t, err) + assert.Assert(t, sink != nil) + + // Every produced output must be consumed to keep the plan's accounting balanced. + assert.DeepEqual(t, sink.RequiredOutput, ids) + + assert.DeepEqual(t, sink.Order.SchedAfterCategory, []string{ + stdresources.ResourceInstanceCategory("res-a"), + stdresources.ResourceInstanceCategory("res-b"), + }) + + msg := &internalresources.OpConsumeResourceOutputs{} + assert.NilError(t, sink.Impl.UnmarshalTo(msg)) + assert.DeepEqual(t, msg.ResourceInstanceId, ids) +}