diff --git a/pkg/clouds/pulumi/plugin_inputs_test.go b/pkg/clouds/pulumi/plugin_inputs_test.go index fda26f4d..d9317605 100644 --- a/pkg/clouds/pulumi/plugin_inputs_test.go +++ b/pkg/clouds/pulumi/plugin_inputs_test.go @@ -5,21 +5,21 @@ package pulumi import ( "context" + "encoding/json" "testing" "google.golang.org/grpc" "gopkg.in/yaml.v3" . "github.com/onsi/gomega" - "github.com/samber/lo" - sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + "github.com/samber/lo" + ycApi "github.com/simple-container-com/sc-plugin-yandex/api" "github.com/simple-container-com/api/pkg/api" pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" scPlugin "github.com/simple-container-com/api/pkg/plugin" scpluginv1 "github.com/simple-container-com/api/pkg/plugin/proto/v1" - ycApi "github.com/simple-container-com/sc-plugin-yandex/api" ) type nestedSite struct { @@ -212,6 +212,100 @@ func TestResourceSidecar_ForwardsConfigAndComputeEnv(t *testing.T) { Expect(env[1].GetStackName()).To(Equal("backend")) } +// TestResourceSidecar_PacksDependencyOutputs — a sibling resource provisioned +// earlier in the same stack publishes outputs onto its `api.ResourceOutput.Ref` +// as `*api.PluginResourceOutput` (post-#569). When THIS resource's sidecar +// fires with `ProvisionParams.ResourceOutputs` carrying such Refs, the +// `DependencyOutputsJson` field on the outbound `ProvisionRequest` must be +// populated — `runtime.DependencyOutputsFromProvisionRequest` decodes it on +// the plugin side and the body reads `params.ResourceOutputs[dep].Ref` as +// `*api.PluginResourceOutput`, which is exactly the shape in-tree bodies +// already type-assert on. +// +// Live-caught 2026-11-08: `gcp-docker-image-push` read +// `params.ResourceOutputs[registryResource].Ref` and errored with +// `expected registry resource %q to be present in outputs for %q in %q` +// because this sidecar previously left `DependencyOutputsJson` empty. See +// forge/docs/roadmap/items/sc-plugin-dependency-outputs-not-populated.md. +func TestResourceSidecar_PacksDependencyOutputs(t *testing.T) { + RegisterTestingT(t) + withActiveSession(t) + + stub := &stubProvisionClient{} + sidecar := (&preEnginePluginBundle{}).makeResourceSidecar( + scPlugin.NewClientWithStub(stub), "sc-plugin-yandex", ycApi.TemplateTypeYandexServerlessContainer, + ) + cfg := &ycApi.ServerlessContainerInput{ + AccountConfig: ycApi.AccountConfig{FolderID: "b1g"}, + StackConfig: api.StackConfigSingleImage{ + Image: &api.ContainerImage{Dockerfile: "Dockerfile"}, + }, + } + // Mix: one plugin-owned sibling (packable), one in-tree Ref (string — NOT + // packable, silently dropped since the plugin can't unmarshal a Go struct). + resourceOutputs := map[string]*api.ResourceOutput{ + "my-registry": {Ref: &api.PluginResourceOutput{ + PluginName: "yc", + Urn: "urn:pulumi:parent::stack::yandex:index:ContainerRegistry::my-registry", + Outputs: map[string]api.PluginOutputValue{ + "url": {Value: "cr.yandex/my-registry", Secret: false}, + }, + }}, + "legacy-intree": {Ref: "plain-string-ref"}, + } + + err := sdk.RunErr(func(ctx *sdk.Context) error { + _, err := sidecar(ctx, api.Stack{Name: "backend"}, api.ResourceInput{ + Descriptor: &api.ResourceDescriptor{Type: ycApi.TemplateTypeYandexServerlessContainer, Name: "backend", Config: api.Config{Config: cfg}}, + StackParams: &api.StackParams{StackName: "backend", Environment: "staging-ru"}, + }, pApi.ProvisionParams{ResourceOutputs: resourceOutputs}) + return err + }, sdk.WithMocks("project", "stack", &refMocks{})) + Expect(err).ToNot(HaveOccurred()) + + Expect(stub.gotReq).ToNot(BeNil()) + raw := stub.gotReq.GetDependencyOutputsJson() + Expect(raw).ToNot(BeEmpty(), "sibling-published outputs must cross the bus as DependencyOutputsJson") + + // Decode and verify shape — same path the plugin side uses. + var decoded map[string]*api.PluginResourceOutput + Expect(json.Unmarshal(raw, &decoded)).To(Succeed()) + Expect(decoded).To(HaveKey("my-registry")) + Expect(decoded).ToNot(HaveKey("legacy-intree"), "in-tree Refs are dropped — plugin can't unmarshal unknown Go struct shapes") + Expect(decoded["my-registry"].PluginName).To(Equal("yc")) + Expect(decoded["my-registry"].Outputs["url"].Value).To(Equal("cr.yandex/my-registry")) +} + +// TestResourceSidecar_EmptyResourceOutputs — the common case: a resource with +// no sibling deps. DependencyOutputsJson stays empty (older-plugin-compatible), +// and the plugin side's `params.ResourceOutputs` is nil — matching the +// pre-field behaviour exactly. +func TestResourceSidecar_EmptyResourceOutputs(t *testing.T) { + RegisterTestingT(t) + withActiveSession(t) + + stub := &stubProvisionClient{} + sidecar := (&preEnginePluginBundle{}).makeResourceSidecar( + scPlugin.NewClientWithStub(stub), "sc-plugin-yandex", ycApi.TemplateTypeYandexServerlessContainer, + ) + cfg := &ycApi.ServerlessContainerInput{ + AccountConfig: ycApi.AccountConfig{FolderID: "b1g"}, + StackConfig: api.StackConfigSingleImage{ + Image: &api.ContainerImage{Dockerfile: "Dockerfile"}, + }, + } + + err := sdk.RunErr(func(ctx *sdk.Context) error { + _, err := sidecar(ctx, api.Stack{Name: "backend"}, api.ResourceInput{ + Descriptor: &api.ResourceDescriptor{Type: ycApi.TemplateTypeYandexServerlessContainer, Name: "backend", Config: api.Config{Config: cfg}}, + StackParams: &api.StackParams{StackName: "backend", Environment: "staging-ru"}, + }, pApi.ProvisionParams{}) + return err + }, sdk.WithMocks("project", "stack", &refMocks{})) + Expect(err).ToNot(HaveOccurred()) + Expect(stub.gotReq.GetDependencyOutputsJson()).To(BeEmpty()) +} + func TestComputeContextToProto_Nil(t *testing.T) { RegisterTestingT(t) Expect(computeContextToProto(nil)).To(BeNil()) diff --git a/pkg/clouds/pulumi/plugin_sidecars.go b/pkg/clouds/pulumi/plugin_sidecars.go index 6d7066a0..86ef20d7 100644 --- a/pkg/clouds/pulumi/plugin_sidecars.go +++ b/pkg/clouds/pulumi/plugin_sidecars.go @@ -11,7 +11,6 @@ import ( "time" "github.com/pkg/errors" - "github.com/pulumi/pulumi/sdk/v3/go/auto" sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" @@ -171,6 +170,37 @@ func (b *preEnginePluginBundle) makeResourceSidecar(cli *scPlugin.Client, plugin } req.ParentInfoJson = b } + // Dependency outputs for resources whose config implements + // api.WithParentDependencies: pack the sibling-published outputs so the + // plugin's Provision body can read them. The flat `dependency_outputs` + // proto field (:683 on ProvisionRequest) can't carry the per-value + // secret flag or nested shape #569 added; the JSON blob does. + // + // Only plugin-owned sibling Refs (*api.PluginResourceOutput, produced + // by harvestPluginOutputs) are packable — in-tree Refs are typed Go + // structs whose shape the receiving plugin doesn't know how to unmarshal. + // An in-tree dep without a PluginResourceOutput Ref is dropped silently + // here; the plugin body sees it as absent and errors with its own + // resource-not-in-outputs message. That matches the pre-#569 silent + // behaviour and keeps the plugin bus free of in-tree Go struct shapes. + if len(params.ResourceOutputs) > 0 { + depOuts := map[string]*api.PluginResourceOutput{} + for name, rOut := range params.ResourceOutputs { + if rOut == nil { + continue + } + if plOut, ok := rOut.Ref.(*api.PluginResourceOutput); ok && plOut != nil { + depOuts[name] = plOut + } + } + if len(depOuts) > 0 { + b, err := json.Marshal(depOuts) + if err != nil { + return nil, errors.Wrapf(err, "plugin %q Provision(%q): marshal dependency outputs", pluginName, typeToken) + } + req.DependencyOutputsJson = b + } + } resp, err := cli.Provision(ctx.Context(), req) if err != nil { return nil, errors.Wrapf(err, "plugin %q Provision(%q, %q)", pluginName, typeToken, input.Descriptor.Name) diff --git a/pkg/plugin/proto/v1/plugin.pb.go b/pkg/plugin/proto/v1/plugin.pb.go index 1032169c..86ddf750 100644 --- a/pkg/plugin/proto/v1/plugin.pb.go +++ b/pkg/plugin/proto/v1/plugin.pb.go @@ -2924,8 +2924,35 @@ type ProvisionRequest struct { // forge/docs/roadmap/items/sc-plugin-provisionrequest-missing-parent-info.md // for the two-guardrails-ship-together framing. ParentInfoJson []byte `protobuf:"bytes,11,opt,name=parent_info_json,json=parentInfoJson,proto3" json:"parent_info_json,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Dependency outputs for a resource whose config implements + // `api.WithParentDependencies`: the values its sibling resources (provisioned + // earlier in the same stack) published, keyed by sibling resource name. Each + // value is a JSON-marshalled `api.PluginResourceOutput` with `PluginName`, + // `Urn`, and the per-key `Outputs` map (the same values that get re-exported + // onto the parent stack root — see api PR #569 for why the per-value secret + // flag travels with each output). + // + // Populated in `pkg/clouds/pulumi/plugin_sidecars.go`'s + // `makeResourceSidecar` from `params.ResourceOutputs`, decoded on the plugin + // side by `runtime.DependencyOutputsFromProvisionRequest` into + // `map[string]*api.ResourceOutput` with `Ref: &api.PluginResourceOutput`. + // + // The flat `dependency_outputs = 5` field was declared on the proto but never + // wired by any sidecar (its string-only shape couldn't carry the per-value + // secret flag or the nested map structure a sibling consumer needs). This + // JSON blob carries the richer shape instead; the flat field is left in place + // for wire-compat and may be reused for simpler use-cases later. + // + // Older plugins ignore the field: the fallback is an empty + // `params.ResourceOutputs` map, which is the same silent-drop shape that + // broke `gcp-docker-image-push` (reading + // `params.ResourceOutputs[registryResource].Ref` as `*ArtifactRegistryOut` + // and erroring with `expected registry resource %q to be present in + // outputs`). See + // forge/docs/roadmap/items/sc-plugin-dependency-outputs-not-populated.md. + DependencyOutputsJson []byte `protobuf:"bytes,12,opt,name=dependency_outputs_json,json=dependencyOutputsJson,proto3" json:"dependency_outputs_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ProvisionRequest) Reset() { @@ -3035,6 +3062,13 @@ func (x *ProvisionRequest) GetParentInfoJson() []byte { return nil } +func (x *ProvisionRequest) GetDependencyOutputsJson() []byte { + if x != nil { + return x.DependencyOutputsJson + } + return nil +} + type ProvisionResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The URN of the created resource. Must be byte-identical to the pre-split URN @@ -4414,7 +4448,7 @@ const file_v1_plugin_proto_rawDesc = "" + "\x11StackOutputsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\x14\n" + - "\x12PreDestroyResponse\"\xfa\x05\n" + + "\x12PreDestroyResponse\"\xb2\x06\n" + "\x10ProvisionRequest\x123\n" + "\x06header\x18\x01 \x01(\v2\x1b.sc.plugin.v1.RequestHeaderR\x06header\x12#\n" + "\rresource_type\x18\x02 \x01(\tR\fresourceType\x12#\n" + @@ -4428,7 +4462,8 @@ const file_v1_plugin_proto_rawDesc = "" + "\x0eregistrar_type\x18\t \x01(\tR\rregistrarType\x122\n" + "\x15registrar_main_domain\x18\n" + " \x01(\tR\x13registrarMainDomain\x12(\n" + - "\x10parent_info_json\x18\v \x01(\fR\x0eparentInfoJson\x1aS\n" + + "\x10parent_info_json\x18\v \x01(\fR\x0eparentInfoJson\x126\n" + + "\x17dependency_outputs_json\x18\f \x01(\fR\x15dependencyOutputsJson\x1aS\n" + "\vInputsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12.\n" + "\x05value\x18\x02 \x01(\v2\x18.sc.plugin.v1.InputValueR\x05value:\x028\x01\x1aD\n" + diff --git a/pkg/plugin/proto/v1/plugin.proto b/pkg/plugin/proto/v1/plugin.proto index ab744c18..3a8e8b60 100644 --- a/pkg/plugin/proto/v1/plugin.proto +++ b/pkg/plugin/proto/v1/plugin.proto @@ -713,6 +713,34 @@ message ProvisionRequest { // forge/docs/roadmap/items/sc-plugin-provisionrequest-missing-parent-info.md // for the two-guardrails-ship-together framing. bytes parent_info_json = 11; + + // Dependency outputs for a resource whose config implements + // `api.WithParentDependencies`: the values its sibling resources (provisioned + // earlier in the same stack) published, keyed by sibling resource name. Each + // value is a JSON-marshalled `api.PluginResourceOutput` with `PluginName`, + // `Urn`, and the per-key `Outputs` map (the same values that get re-exported + // onto the parent stack root — see api PR #569 for why the per-value secret + // flag travels with each output). + // + // Populated in `pkg/clouds/pulumi/plugin_sidecars.go`'s + // `makeResourceSidecar` from `params.ResourceOutputs`, decoded on the plugin + // side by `runtime.DependencyOutputsFromProvisionRequest` into + // `map[string]*api.ResourceOutput` with `Ref: &api.PluginResourceOutput`. + // + // The flat `dependency_outputs = 5` field was declared on the proto but never + // wired by any sidecar (its string-only shape couldn't carry the per-value + // secret flag or the nested map structure a sibling consumer needs). This + // JSON blob carries the richer shape instead; the flat field is left in place + // for wire-compat and may be reused for simpler use-cases later. + // + // Older plugins ignore the field: the fallback is an empty + // `params.ResourceOutputs` map, which is the same silent-drop shape that + // broke `gcp-docker-image-push` (reading + // `params.ResourceOutputs[registryResource].Ref` as `*ArtifactRegistryOut` + // and erroring with `expected registry resource %q to be present in + // outputs`). See + // forge/docs/roadmap/items/sc-plugin-dependency-outputs-not-populated.md. + bytes dependency_outputs_json = 12; } message ProvisionResponse { diff --git a/pkg/plugin/runtime/compute.go b/pkg/plugin/runtime/compute.go index 02413bf2..c40611ba 100644 --- a/pkg/plugin/runtime/compute.go +++ b/pkg/plugin/runtime/compute.go @@ -61,6 +61,52 @@ func ParentInfoFromProvisionRequest(req *scpluginv1.ProvisionRequest) (*pApi.Par return parent, nil } +// DependencyOutputsFromProvisionRequest unmarshals the +// DependencyOutputsJson field a provision request carries into the shape a +// plugin's Provision body reads via ProvisionParams.ResourceOutputs. +// Returns a nil map when the request carries no dependencies (every +// resource whose config does NOT implement api.WithParentDependencies, or +// an older SC core that pre-dates the field). +// +// Shape contract (api PR adding this helper): SC core's +// plugin_sidecars.go packs the map[resourceName]*api.PluginResourceOutput +// from params.ResourceOutputs via json.Marshal. Plugin side decodes into +// the same shape and lifts each entry onto a `*api.ResourceOutput` with +// `Ref: *api.PluginResourceOutput`, which is exactly what sibling +// resources' Provision bodies already type-assert on post-#569. An +// in-tree dep whose Ref was a typed Go struct is NOT packed on the +// sidecar side (the receiving plugin can't unmarshal unknown struct +// shapes); the plugin body reads that as absent and errors normally. +// +// Live-caught 2026-11-08 on sc-plugin-gcp tier-2 smoke: `gcp-docker-image-push` +// read `params.ResourceOutputs[registryResource].Ref.(*ArtifactRegistryOut)` +// and failed with `expected registry resource %q to be present in outputs` +// because this field was declared on the proto (`dependency_outputs = 5`) +// but never populated by any sidecar. The flat string map was too narrow +// to carry PluginResourceOutput; a JSON bytes field matches the shape #569 +// introduced. +// +// See forge/docs/roadmap/items/sc-plugin-dependency-outputs-not-populated.md. +func DependencyOutputsFromProvisionRequest(req *scpluginv1.ProvisionRequest) (map[string]*api.ResourceOutput, error) { + raw := req.GetDependencyOutputsJson() + if len(raw) == 0 { + return nil, nil + } + var decoded map[string]*api.PluginResourceOutput + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, errors.Wrap(err, "decode DependencyOutputs from provision request") + } + result := make(map[string]*api.ResourceOutput, len(decoded)) + for name, plOut := range decoded { + if plOut == nil { + continue + } + plOutCopy := *plOut + result[name] = &api.ResourceOutput{Ref: &plOutCopy} + } + return result, nil +} + // ResourceInputForComputeProcess builds the api.ResourceInput a plugin // compute-process handler hands to the driver. Critically, it swaps the // child environment for the parent's when ParentInfo.ParentEnv is non-empty: diff --git a/pkg/plugin/runtime/compute_test.go b/pkg/plugin/runtime/compute_test.go index 1e43cb9b..a040fb21 100644 --- a/pkg/plugin/runtime/compute_test.go +++ b/pkg/plugin/runtime/compute_test.go @@ -9,6 +9,7 @@ import ( . "github.com/onsi/gomega" + "github.com/simple-container-com/api/pkg/api" pApi "github.com/simple-container-com/api/pkg/clouds/pulumi/api" scpluginv1 "github.com/simple-container-com/api/pkg/plugin/proto/v1" ) @@ -72,3 +73,79 @@ func TestParentInfoFromProvisionRequest_RejectsMalformedJson(t *testing.T) { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("provision request")) } + +// TestDependencyOutputsFromProvisionRequest_RoundTrips — a +// map[string]*api.PluginResourceOutput marshalled by SC core's +// plugin_sidecars.go into ProvisionRequest.DependencyOutputsJson arrives +// on the plugin side as map[string]*api.ResourceOutput with the Ref +// pointing at a copied PluginResourceOutput. The sibling-consumer's +// type assertion on `Ref.(*api.PluginResourceOutput)` matches what +// in-tree callers do post-#569. +func TestDependencyOutputsFromProvisionRequest_RoundTrips(t *testing.T) { + RegisterTestingT(t) + + want := map[string]*api.PluginResourceOutput{ + "my-registry": { + PluginName: "gcp", + Urn: "urn:pulumi:parent::stack::gcp:artifactregistry:Repository::my-registry", + Outputs: map[string]api.PluginOutputValue{ + "url": { + Value: "us-central1-docker.pkg.dev/project/my-registry", + Secret: false, + }, + "admin-sa-key": { + Value: "secret-bytes", + Secret: true, + }, + }, + }, + } + b, err := json.Marshal(want) + Expect(err).ToNot(HaveOccurred()) + + req := &scpluginv1.ProvisionRequest{ + DependencyOutputsJson: b, + } + got, err := DependencyOutputsFromProvisionRequest(req) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + Expect(got["my-registry"]).ToNot(BeNil()) + + plOut, ok := got["my-registry"].Ref.(*api.PluginResourceOutput) + Expect(ok).To(BeTrue(), "Ref should type-assert to *api.PluginResourceOutput for sibling consumers") + Expect(plOut.PluginName).To(Equal("gcp")) + Expect(plOut.Urn).To(Equal(want["my-registry"].Urn)) + Expect(plOut.Outputs).To(HaveLen(2)) + Expect(plOut.Outputs["url"].Value).To(Equal("us-central1-docker.pkg.dev/project/my-registry")) + Expect(plOut.Outputs["url"].Secret).To(BeFalse()) + Expect(plOut.Outputs["admin-sa-key"].Secret).To(BeTrue()) +} + +// TestDependencyOutputsFromProvisionRequest_EmptyMeansNil — a request +// that carries no dependencies (either the resource's config doesn't +// implement api.WithParentDependencies, or an older SC core pre-dates +// this field) returns (nil, nil). The plugin-side `params.ResourceOutputs` +// map stays unset and sibling-reading bodies error normally with their +// own resource-not-in-outputs sentence. +func TestDependencyOutputsFromProvisionRequest_EmptyMeansNil(t *testing.T) { + RegisterTestingT(t) + + req := &scpluginv1.ProvisionRequest{} + got, err := DependencyOutputsFromProvisionRequest(req) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeNil()) +} + +// TestDependencyOutputsFromProvisionRequest_RejectsMalformedJson — a +// corrupted field (should never happen, but a proto-handling bug could +// land here) returns a wrapped error naming the provision request. +func TestDependencyOutputsFromProvisionRequest_RejectsMalformedJson(t *testing.T) { + RegisterTestingT(t) + + req := &scpluginv1.ProvisionRequest{ + DependencyOutputsJson: []byte("not json"), + } + _, err := DependencyOutputsFromProvisionRequest(req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("provision request")) +}