Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions pkg/clouds/pulumi/plugin_inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down
32 changes: 31 additions & 1 deletion pkg/clouds/pulumi/plugin_sidecars.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down
43 changes: 39 additions & 4 deletions pkg/plugin/proto/v1/plugin.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions pkg/plugin/proto/v1/plugin.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 46 additions & 0 deletions pkg/plugin/runtime/compute.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading