Repository: pulumi/pulumi-yaml
Pull request #749: implement YAML plugin running
Author: login: tgummerer
Created: 2025-03-13T15:56:06Z | Merged: 2025-03-17T11:53:32Z

Allow YAML provider plugins to be run.  Note that this is slightly different than YAML programs in that it natively slurps in all yaml files in the directory, rather than just running the main file.  It merges all `*.yaml` files in the plugin directory instead.

Fixes https://github.com/pulumi/pulumi-yaml/issues/746
Depends on https://github.com/pulumi/pulumi-yaml/pull/748


Diff:
diff --git a/.changes/unreleased/Improvements-749.yaml b/.changes/unreleased/Improvements-749.yaml
new file mode 100644
index 00000000..f2e91773
--- /dev/null
+++ b/.changes/unreleased/Improvements-749.yaml
@@ -0,0 +1,6 @@
+component: runtime
+kind: Improvements
+body: Allow authoring components in YAML
+time: 2025-03-14T10:16:05.555834834+01:00
+custom:
+  PR: "749"
diff --git a/pkg/pulumiyaml/ast/template.go b/pkg/pulumiyaml/ast/template.go
index e8dc9b75..3b5d619f 100644
--- a/pkg/pulumiyaml/ast/template.go
+++ b/pkg/pulumiyaml/ast/template.go
@@ -596,6 +596,9 @@ func (d *ComponentListDecl) parse(name string, node syntax.Node) syntax.Diagnost
 		logname := fmt.Sprintf("%s.%s", name, kvp.Key.Value())
 		vdiags := parseField(logname, reflect.ValueOf(&v).Elem(), kvp.Value)
 		diags.Extend(vdiags...)
+		if diags.HasErrors() {
+			return diags
+		}
 
 		v.Name = String(kvp.Key.Value())
 		entries[i] = ComponentDecl{
@@ -699,6 +702,25 @@ func (d *TemplateDecl) NewDiagnosticWriter(w io.Writer, width uint, color bool)
 	return newDiagnosticWriter(w, fileMap, width, color)
 }
 
+func (d *TemplateDecl) Merge(other *TemplateDecl) error {
+	if other == nil {
+		return nil
+	}
+	if d.Name == nil {
+		d.Name = other.Name
+	} else if other.Name != nil {
+		return fmt.Errorf("cannot merge templates with different names")
+	}
+	if d.Description == nil {
+		d.Description = other.Description
+	} else if other.Description != nil {
+		return fmt.Errorf("cannot merge templates with different descriptions")
+	}
+	d.Config.Entries = append(d.Config.Entries, other.Config.Entries...)
+	d.Components.Entries = append(d.Components.Entries, other.Components.Entries...)
+	return nil
+}
+
 func parseTypeSpec(configDecl *ConfigParamDecl) (schema.TypeSpec, error) {
 	typeSpec := schema.TypeSpec{}
 	if configDecl.Type == nil {
diff --git a/pkg/pulumiyaml/run.go b/pkg/pulumiyaml/run.go
index ec1e58a1..301f5d6d 100644
--- a/pkg/pulumiyaml/run.go
+++ b/pkg/pulumiyaml/run.go
@@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
+	files, err := os.ReadDir(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	var template *ast.TemplateDecl
+	for _, file := range files {
+		if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" {
+			continue
+		}
+
+		f, err := os.Open(filepath.Join(directory, file.Name()))
+		if err != nil {
+			return nil, nil, err
+		}
+		t, diags, err := LoadYAML(file.Name(), f)
+		if err != nil {
+			return nil, diags, err
+		}
+		if diags.HasErrors() {
+			return nil, diags, diags
+		}
+		if template == nil {
+			template = t
+		} else {
+			if t == nil {
+				diags.Extend(syntax.Error(nil, "failed to load template", ""))
+				continue
+			}
+			if len(t.Configuration.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "PulumiPlugin.yaml: root-level `configuration` is not supported in plugins", ""))
+			}
+			if len(t.Config.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "PulumiPlugin.yaml: root-level `config` is not supported in plugins", ""))
+			}
+			if len(t.Variables.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "PulumiPlugin.yaml: root-level `variables` field is not supported in plugins.", ""))
+			}
+			if len(t.Resources.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "PulumiPlugin.yaml: root-level `resources` field is not supported in plugins.", ""))
+			}
+			if len(t.Outputs.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "PulumiPlugin.yaml: root-level `outputs` field is not supported in plugins.", ""))
+			}
+
+			err = template.Merge(t)
+			if err != nil {
+				return nil, nil, err
+			}
+		}
+	}
+
+	componentNames := make(map[string]bool)
+	for _, entry := range template.Components.Entries {
+		if _, ok := componentNames[entry.Value.Name.Value]; ok {
+			return nil, nil, fmt.Errorf("duplicate component name %s", entry.Value.Name.Value)
+		}
+		componentNames[entry.Value.Name.Value] = true
+	}
+	packages, err := packages.SearchPackageDecls(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	template.Packages = packages
+
+	return template, nil, nil
+}
+
 // Load a template from the current working directory
 func LoadFile(path string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	f, err := os.Open(path)
@@ -451,6 +521,114 @@ func RunTemplate(ctx *pulumi.Context, t *ast.TemplateDecl, config map[string]str
 	return nil
 }
 
+func RunComponentTemplate(ctx *pulumi.Context,
+	typ, name string, options pulumi.ResourceOption,
+	t *ast.TemplateDecl, inputs pulumi.Map, loader PackageLoader,
+) (pulumi.URNOutput, pulumi.Map, error) {
+	var templ ast.Template
+	typSplit := strings.Split(typ, ":")
+	if len(typSplit) != 3 {
+		return pulumi.URNOutput{}, nil, errors.New("invalid component type")
+	}
+	for _, comp := range t.Components.Entries {
+		if comp.Key.Value == typSplit[2] {
+			templ = comp.Value
+			break
+		}
+	}
+	runner := newRunner(templ, loader)
+
+	_, diags := TypeCheck(runner)
+	if diags.HasErrors() {
+		return pulumi.URNOutput{}, nil, diags
+	}
+
+	packageRefs, diags := findPackageRefs(ctx, runner)
+	if diags != nil {
+		return pulumi.URNOutput{}, nil, errors.New(diags.Error())
+	}
+
+	component := &componentEvaluator{
+		inputs:  inputs,
+		outputs: pulumi.Map{},
+		evaluator: &programEvaluator{
+			evalContext: runner.newContext(t),
+			pulumiCtx:   ctx,
+			packageRefs: packageRefs,
+		},
+	}
+
+	if err := ctx.RegisterComponentResource(typ, name, component, options); err != nil {
+		return pulumi.URNOutput{}, nil, err
+	}
+
+	diags.Extend(runner.Run(component)...)
+	if diags.HasErrors() {
+		return pulumi.URNOutput{}, nil, diags
+	}
+	if err := ctx.RegisterResourceOutputs(component, component.outputs); err != nil {
+		return pulumi.URNOutput{}, nil, err
+	}
+	return component.URN(), component.outputs, nil
+}
+
+type componentEvaluator struct {
+	pulumi.ResourceState
+
+	inputs  pulumi.Map
+	outputs pulumi.Map
+
+	evaluator *programEvaluator
+}
+
+func (m *componentEvaluator) EvalConfig(r *Runner, node configNode) bool {
+	k := node.key().Value
+	v, ok := m.inputs[k]
+	if ok {
+		r.config[k] = v
+		return true
+	}
+	return m.evaluator.EvalConfig(r, node)
+}
+
+func (m *componentEvaluator) EvalVariable(r *Runner, node variableNode) bool {
+	return m.evaluator.EvalVariable(r, node)
+}
+
+func (m *componentEvaluator) EvalResource(r *Runner, node resourceNode) bool {
+	ctx := r.newContext(node)
+	res, ok := m.evaluator.registerResourceWithParent(node, m)
+	if !ok {
+		msg := fmt.Sprintf("Error registering resource [%v]: %v", node.Key.Value, ctx.sdiags.Error())
+		err := m.evaluator.pulumiCtx.Log.Error(msg, &pulumi.LogArgs{})
+		if err != nil {
+			return false
+		}
+		return false
+	}
+	r.resources[node.Key.Value] = res
+	return true
+}
+
+func (m *componentEvaluator) EvalOutput(r *Runner, node ast.PropertyMapEntry) bool {
+	ctx := r.newContext(node)
+	out, ok := m.evaluator.registerOutput(node)
+	if !ok {
+		msg := fmt.Sprintf("Error registering output [%v]: %v", node.Key.Value, ctx.sdiags.Error())
+		err := m.evaluator.pulumiCtx.Log.Error(msg, &pulumi.LogArgs{})
+		if err != nil {
+			return false
+		}
+	} else {
+		m.outputs[node.Key.Value] = out
+	}
+	return true
+}
+
+func (m *componentEvaluator) EvalMissing(r *Runner, node missingNode) bool {
+	return m.evaluator.EvalMissing(r, node)
+}
+
 type syncDiags struct {
 	diags syntax.Diagnostics
 	mutex sync.Mutex
@@ -828,9 +1006,7 @@ func (e programEvaluator) EvalOutput(r *Runner, node ast.PropertyMapEntry) bool
 	return true
 }
 
-func (r *Runner) Evaluate(ctx *pulumi.Context) syntax.Diagnostics {
-	eCtx := r.newContext(nil)
-
+func findPackageRefs(ctx *pulumi.Context, r *Runner) (map[tokens.Package]string, syntax.Diagnostics) {
 	// Register package refs for all packages we know upfront
 	packageRefs := make(map[tokens.Package]string)
 	for key, pkg := range r.packageDescriptors {
@@ -860,10 +1036,20 @@ func (r *Runner) Evaluate(ctx *pulumi.Context) syntax.Diagnostics {
 		})
 		if err != nil {
 			err = fmt.Errorf("registering package %s: %w", name, err)
-			return syntax.Diagnostics{syntax.Error(nil, err.Error(), "")}
+			return nil, syntax.Diagnostics{syntax.Error(nil, err.Error(), "")}
 		}
 		packageRefs[key] = resp.Ref
 	}
+	return packageRefs, nil
+}
+
+func (r *Runner) Evaluate(ctx *pulumi.Context) syntax.Diagnostics {
+	eCtx := r.newContext(nil)
+
+	packageRefs, diags := findPackageRefs(ctx, r)
+	if diags != nil {
+		return diags
+	}
 
 	return r.Run(programEvaluator{
 		evalContext: eCtx,
@@ -1177,6 +1363,10 @@ func (e *programEvaluator) registerConfig(intm configNode) (interface{}, bool) {
 }
 
 func (e *programEvaluator) registerResource(kvp resourceNode) (lateboundResource, bool) {
+	return e.registerResourceWithParent(kvp, nil)
+}
+
+func (e *programEvaluator) registerResourceWithParent(kvp resourceNode, parent pulumi.Resource) (lateboundResource, bool) {
 	k, v := kvp.Key.Value, kvp.Value
 
 	// Read the properties and then evaluate them in case there are expressions contained inside.
@@ -1228,6 +1418,10 @@ func (e *programEvaluator) registerResource(kvp resourceNode) (lateboundResource
 		return p, isPoison
 	}
 
+	if v.Options.Parent == nil && parent != nil {
+		opts = append(opts, pulumi.Parent(parent))
+	}
+
 	if v.Options.Aliases != nil {
 		var aliases []pulumi.Alias
 		for _, s := range v.Options.Aliases.Elements {
diff --git a/pkg/server/component_provider.go b/pkg/server/component_provider.go
new file mode 100644
index 00000000..bc2366a6
--- /dev/null
+++ b/pkg/server/component_provider.go
@@ -0,0 +1,103 @@
+// Copyright 2025, Pulumi Corporation.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/provider"
+	pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/emptypb"
+)
+
+type componentProvider struct {
+	pulumirpc.UnimplementedResourceProviderServer
+
+	host      *grpc.ClientConn
+	name      string
+	schema    []byte
+	construct provider.ConstructFunc
+}
+
+// GetPluginInfo returns generic information about this plugin, like its version.
+func (p *componentProvider) GetPluginInfo(context.Context, *emptypb.Empty) (*pulumirpc.PluginInfo, error) {
+	// We fill in the version on the engine side for components.
+	return &pulumirpc.PluginInfo{
+		Version: "",
+	}, nil
+}
+
+// GetSchema returns the JSON-encoded schema for this provider's package.
+func (p *componentProvider) GetSchema(ctx context.Context,
+	req *pulumirpc.GetSchemaRequest,
+) (*pulumirpc.GetSchemaResponse, error) {
+	if v := req.GetVersion(); v != 0 {
+		return nil, fmt.Errorf("unsupported schema version %d", v)
+	}
+	return &pulumirpc.GetSchemaResponse{Schema: string(p.schema)}, nil
+}
+
+// Configure configures the resource provider with "globals" that control its behavior.
+func (p *componentProvider) Configure(ctx context.Context,
+	req *pulumirpc.ConfigureRequest,
+) (*pulumirpc.ConfigureResponse, error) {
+	return &pulumirpc.ConfigureResponse{
+		AcceptSecrets:   true,
+		SupportsPreview: true,
+		AcceptResources: true,
+		AcceptOutputs:   true,
+	}, nil
+}
+
+// Construct creates a new instance of the provided component resource and returns its state.
+func (p *componentProvider) Construct(ctx context.Context,
+	req *pulumirpc.ConstructRequest,
+) (*pulumirpc.ConstructResponse, error) {
+	return provider.Construct(ctx, req, p.host, p.construct)
+}
+
+// Call dynamically executes a method in the provider associated with a component resource.
+func (p *componentProvider) Call(ctx context.Context,
+	req *pulumirpc.CallRequest,
+) (*pulumirpc.CallResponse, error) {
+	return nil, status.Error(codes.Unimplemented, "Call is not yet implemented")
+}
+
+// Cancel signals the provider to gracefully shut down and abort any ongoing resource operations.
+// Operations aborted in this way will return an error (e.g., `Update` and `Create` will either a
+// creation error or an initialization error). Since Cancel is advisory and non-blocking, it is up
+// to the host to decide how long to wait after Cancel is called before (e.g.)
+// hard-closing any gRPC connection.
+func (p *componentProvider) Cancel(context.Context, *emptypb.Empty) (*emptypb.Empty, error) {
+	return &emptypb.Empty{}, nil
+}
+
+// Attach attaches to the engine for an already running provider.
+func (p *componentProvider) Attach(ctx context.Context,
+	req *pulumirpc.PluginAttach,
+) (*emptypb.Empty, error) {
+	return nil, status.Error(codes.Unimplemented, "Attach is not yet implemented")
+}
+
+// GetMapping fetches the conversion mapping (if any) for this resource provider.
+func (p *componentProvider) GetMapping(ctx context.Context,
+	req *pulumirpc.GetMappingRequest,
+) (*pulumirpc.GetMappingResponse, error) {
+	return &pulumirpc.GetMappingResponse{Provider: "", Data: nil}, nil
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 413dbfc2..edf79210 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -25,15 +25,21 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"time"
 
 	pbempty "github.com/golang/protobuf/ptypes/empty"
 	"github.com/pulumi/pulumi/pkg/v3/codegen/pcl"
 	"github.com/pulumi/pulumi/pkg/v3/codegen/schema"
+	"github.com/pulumi/pulumi/pkg/v3/resource/provider"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
+	"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
+	"github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil"
 	"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
 	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
+	providersdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi/provider"
 	pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
+	"google.golang.org/grpc"
 	"gopkg.in/yaml.v3"
 
 	"github.com/pulumi/pulumi-yaml/pkg/pulumiyaml"
@@ -73,6 +79,18 @@ func NewLanguageHost(engineAddress, tracing string, useRPCLoader bool) pulumirpc
 	}
 }
 
+func (host *yamlLanguageHost) loadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	template, diags, err := pulumiyaml.LoadPluginTemplate(directory)
+	if err != nil {
+		return nil, diags, err
+	}
+	if diags.HasErrors() {
+		return nil, diags, nil
+	}
+
+	return template, diags, nil
+}
+
 func (host *yamlLanguageHost) loadTemplate(compiler, directory string, compilerEnv []string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	// We can't cache comppiled templates because at the first point we call loadTemplate (in
 	// GetRequiredPackages) we don't have the compiler environment (with PULUMI_STACK etc) set.
@@ -289,6 +307,127 @@ func (host *yamlLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest
 	return &pulumirpc.RunResponse{}, nil
 }
 
+func (host *yamlLanguageHost) RunPlugin(
+	req *pulumirpc.RunPluginRequest, server pulumirpc.LanguageRuntime_RunPluginServer,
+) error {
+	logging.V(5).Infof("Attempting to run yaml plugin in %s", req.Info.ProgramDirectory)
+	ctx := context.Background()
+
+	closer, stdout, stderr, err := rpcutil.MakeRunPluginStreams(server, false)
+	if err != nil {
+		return err
+	}
+	defer closer.Close()
+
+	template, diags, err := host.loadPluginTemplate(req.Info.ProgramDirectory)
+	if err != nil {
+		return err
+	}
+
+	diagWriter := template.NewDiagnosticWriter(stderr, 0, true)
+	if len(diags) != 0 {
+		err := diagWriter.WriteDiagnostics(diags.HCL())
+		if err != nil {
+			return err
+		}
+	}
+	if diags.HasErrors() {
+		return errors.New("failed to load template")
+	}
+
+	// Use the Pulumi Go SDK to create an execution context and to interact with the engine.
+	// This encapsulates a fair bit of the boilerplate otherwise needed to do RPCs, etc.
+	pctx, err := pulumi.NewContext(ctx, pulumi.RunInfo{
+		EngineAddr: host.engineAddress,
+	})
+	if err != nil {
+		return err
+	}
+	defer pctx.Close()
+
+	// Because of async applies we may need the package loader to outlast the RunTemplate function. But by the
+	// time RunWithContext returns we should be done with all async work.
+	loader, err := pulumiyaml.NewPackageLoader(nil)
+	if err != nil {
+		return err
+	}
+	defer loader.Close()
+
+	schema, err := template.GenerateSchema()
+	if err != nil {
+		return err
+	}
+
+	jsonSchema, err := json.Marshal(schema)
+	if err != nil {
+		return err
+	}
+
+	var cancelChannel chan bool
+	providerHost, err := provider.NewHostClient(host.engineAddress)
+	if err != nil {
+		return fmt.Errorf("fatal: could not connect to host RPC: %w", err)
+	}
+
+	// If we have a host cancel our cancellation context if it fails the healthcheck
+	ctx, cancel := context.WithCancel(context.Background())
+	// map the context Done channel to the rpcutil boolean cancel channel
+	cancelChannel = make(chan bool)
+	go func() {
+		<-ctx.Done()
+		close(cancelChannel)
+	}()
+	err = rpcutil.Healthcheck(ctx, host.engineAddress, 5*time.Minute, cancel)
+	if err != nil {
+		return fmt.Errorf("could not start health check host RPC server: %w", err)
+	}
+
+	prov := &componentProvider{
+		host:   providerHost.EngineConn(),
+		name:   template.Name.Value,
+		schema: jsonSchema,
+		construct: func(ctx *pulumi.Context, typ, name string, inputs providersdk.ConstructInputs,
+			options pulumi.ResourceOption,
+		) (*providersdk.ConstructResult, error) {
+			m, err := inputs.Map()
+			if err != nil {
+				return nil, err
+			}
+			urn, state, err := pulumiyaml.RunComponentTemplate(ctx, typ, name, options, template, m, loader)
+			if err != nil {
+				return nil, err
+			}
+			return &providersdk.ConstructResult{
+				URN:   urn,
+				State: state,
+			}, nil
+		},
+	}
+
+	// Fire up a gRPC server, letting the kernel choose a free port for us.
+	handle, err := rpcutil.ServeWithOptions(rpcutil.ServeOptions{
+		Cancel: cancelChannel,
+		Init: func(srv *grpc.Server) error {
+			pulumirpc.RegisterResourceProviderServer(srv, prov)
+			return nil
+		},
+		Options: rpcutil.OpenTracingServerInterceptorOptions(nil),
+	})
+	if err != nil {
+		return fmt.Errorf("fatal: %w", err)
+	}
+
+	// The resource provider protocol requires that we now write out the port we have chosen to listen on.
+	fmt.Fprintf(stdout, "%d\n", handle.Port)
+
+	// Finally, wait for the server to stop serving.
+	if err := <-handle.Done; err != nil {
+		return fmt.Errorf("fatal: %w", err)
+	}
+
+	return nil
+}
+
 func (host *yamlLanguageHost) GetPluginInfo(ctx context.Context, req *pbempty.Empty) (*pulumirpc.PluginInfo, error) {
 	return &pulumirpc.PluginInfo{
 		Version: version.Version,
diff --git a/pkg/tests/integration_test.go b/pkg/tests/integration_test.go
index c5dfebe0..c6eb0e98 100644
--- a/pkg/tests/integration_test.go
+++ b/pkg/tests/integration_test.go
@@ -3,6 +3,7 @@
 package tests
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
@@ -198,3 +199,27 @@ func TestResourceSecret(t *testing.T) {
 		Dir: filepath.Join("testdata", "resource-secret"),
 	})
 }
+
+func TestAuthoredComponent(t *testing.T) {
+	t.Parallel()
+
+	e := ptesting.NewEnvironment(t)
+	defer e.DeleteIfNotFailed()
+
+	e.ImportDirectory(filepath.Join("testdata", "component"))
+
+	e.RunCommand("pulumi", "login", "--cloud-url", e.LocalURL())
+
+	e.CWD = filepath.Join(e.RootPath, "program")
+	e.RunCommand("pulumi", "stack", "init", "organization/component-consumer/test")
+	e.RunCommand("pulumi", "package", "add", "../provider")
+	e.RunCommand("pulumi", "up", "--non-interactive", "--skip-preview", "--yes")
+
+	stdout, _ := e.RunCommand("pulumi", "stack", "output", "randomPet")
+	// We expect 4 words separated by dashes.
+	require.Equal(t, 4, len(strings.Split(stdout, "-")))
+	require.Equal(t, "test-", stdout[:5])
+
+	stdout, _ = e.RunCommand("pulumi", "stack", "output", "randomString")
+	require.Len(t, strings.TrimSuffix(stdout, "\n"), 8, fmt.Sprintf("expected %s to have 8 characters", stdout))
+}
diff --git a/pkg/tests/testdata/component/program/Pulumi.yaml b/pkg/tests/testdata/component/program/Pulumi.yaml
new file mode 100644
index 00000000..ca379fdf
--- /dev/null
+++ b/pkg/tests/testdata/component/program/Pulumi.yaml
@@ -0,0 +1,19 @@
+name: component-consumer
+runtime: yaml
+plugins:
+  providers:
+    - name: random-plugin-component
+      path: ../provider
+resources:
+  randomPet:
+    type: random-plugin-component:index:randomPetGenerator
+    properties:
+      length: 3
+      prefix: "test"
+  randomString:
+    type: random-plugin-component:index:randomStringGenerator
+    properties:
+      length: 8
+outputs:
+  randomPet: ${randomPet.pet}
+  randomString: ${randomString.string}
diff --git a/pkg/tests/testdata/component/provider/PulumiPlugin.yaml b/pkg/tests/testdata/component/provider/PulumiPlugin.yaml
new file mode 100644
index 00000000..626be672
--- /dev/null
+++ b/pkg/tests/testdata/component/provider/PulumiPlugin.yaml
@@ -0,0 +1,18 @@
+name: random-plugin-component
+description: A random pulumi plugin
+runtime: yaml
+components:
+  randomPetGenerator:
+    inputs:
+      length:
+        type: integer
+      prefix:
+        type: string
+    resources:
+      randomPet:
+        type: random:RandomPet
+        properties:
+          length: ${length}
+          prefix: ${prefix}
+    outputs:
+      pet: ${randomPet.id}
diff --git a/pkg/tests/testdata/component/provider/anotherPlugin.yaml b/pkg/tests/testdata/component/provider/anotherPlugin.yaml
new file mode 100644
index 00000000..ddaed665
--- /dev/null
+++ b/pkg/tests/testdata/component/provider/anotherPlugin.yaml
@@ -0,0 +1,12 @@
+components:
+  randomStringGenerator:
+    inputs:
+      length:
+        type: integer
+    resources:
+      randomString:
+        type: random:RandomString
+        properties:
+          length: ${length}
+    outputs:
+      string: ${randomString.id}


Reviews:
user: login: Frassle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T08:22:24Z
user: login: tgummerer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T09:14:29Z
user: login: tgummerer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:08:05Z
user: login: mikhailshilkov
body: Looks neat! Any testing we could add in addition to this simple integration test?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:26:43Z
user: login: Frassle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T10:28:31Z
user: login: mikhailshilkov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:32:25Z
user: login: tgummerer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:33:02Z
user: login: tgummerer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T10:37:25Z
user: login: tgummerer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T12:53:54Z
user: login: mikhailshilkov
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T11:33:01Z

Review comments:
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
+	files, err := os.ReadDir(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	var template *ast.TemplateDecl
+	for _, file := range files {
+		if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" {
+			continue
+		}
+
+		f, err := os.Open(filepath.Join(directory, file.Name()))
+		if err != nil {
+			return nil, nil, err
+		}
+		t, diags, err := LoadYAML(file.Name(), f)
+		if err != nil {
+			return nil, diags, err
+		}
+		if diags.HasErrors() {
+			return nil, diags, diags
+		}
+		if template == nil {
+			template = t
+		} else {
+			if t == nil {
+				diags.Extend(syntax.Error(nil, "failed to load template", ""))
+				continue
+			}
+			if len(t.Configuration.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `configuration` is not supported in plugins", ""))
+			}
+			if len(t.Config.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `config` is not supported in plugins", ""))
+			}
path: pkg/pulumiyaml/run.go
user: login: Frassle
body: Why? A component provider is a provider it could have config, the engine will call Configure on it.
created_at: 2025-03-14T08:22:24Z
author_association: MEMBER
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
+	files, err := os.ReadDir(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	var template *ast.TemplateDecl
+	for _, file := range files {
+		if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" {
+			continue
+		}
+
+		f, err := os.Open(filepath.Join(directory, file.Name()))
+		if err != nil {
+			return nil, nil, err
+		}
+		t, diags, err := LoadYAML(file.Name(), f)
+		if err != nil {
+			return nil, diags, err
+		}
+		if diags.HasErrors() {
+			return nil, diags, diags
+		}
+		if template == nil {
+			template = t
+		} else {
+			if t == nil {
+				diags.Extend(syntax.Error(nil, "failed to load template", ""))
+				continue
+			}
+			if len(t.Configuration.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `configuration` is not supported in plugins", ""))
+			}
+			if len(t.Config.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `config` is not supported in plugins", ""))
+			}
path: pkg/pulumiyaml/run.go
user: login: tgummerer
body: I don't think we support configuring the component provider in other languages currently, and I was leaning on [`ComponentMain`](https://github.com/pulumi/pulumi/blob/master/pkg/resource/provider/component_provider.go#L69) when implementing this, which doesn't support config either, so I cut it out of scope for now.  I'll open an issue as a reminder for us to implement it later.
created_at: 2025-03-14T09:14:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
+	files, err := os.ReadDir(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	var template *ast.TemplateDecl
+	for _, file := range files {
+		if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" {
+			continue
+		}
+
+		f, err := os.Open(filepath.Join(directory, file.Name()))
+		if err != nil {
+			return nil, nil, err
+		}
+		t, diags, err := LoadYAML(file.Name(), f)
+		if err != nil {
+			return nil, diags, err
+		}
+		if diags.HasErrors() {
+			return nil, diags, diags
+		}
+		if template == nil {
+			template = t
+		} else {
+			if t == nil {
+				diags.Extend(syntax.Error(nil, "failed to load template", ""))
+				continue
+			}
+			if len(t.Configuration.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `configuration` is not supported in plugins", ""))
+			}
+			if len(t.Config.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `config` is not supported in plugins", ""))
+			}
path: pkg/pulumiyaml/run.go
user: login: tgummerer
body: Forgot to mention earlier, I opened https://github.com/pulumi/pulumi-yaml/issues/751 for this.
created_at: 2025-03-14T10:08:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
+	files, err := os.ReadDir(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	var template *ast.TemplateDecl
+	for _, file := range files {
+		if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" {
+			continue
+		}
+
+		f, err := os.Open(filepath.Join(directory, file.Name()))
+		if err != nil {
+			return nil, nil, err
+		}
+		t, diags, err := LoadYAML(file.Name(), f)
+		if err != nil {
+			return nil, diags, err
+		}
+		if diags.HasErrors() {
+			return nil, diags, diags
+		}
+		if template == nil {
+			template = t
+		} else {
+			if t == nil {
+				diags.Extend(syntax.Error(nil, "failed to load template", ""))
+				continue
+			}
+			if len(t.Configuration.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `configuration` is not supported in plugins", ""))
path: pkg/pulumiyaml/run.go
user: login: mikhailshilkov
body: PulumiPlugin.yaml?
created_at: 2025-03-14T10:19:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
path: pkg/pulumiyaml/run.go
user: login: mikhailshilkov
body: Are we sure we want this now? Are we sure we want to merge all YAML files? How will it work for YAML programs and their stack files, deployment setting files, arbitrary YAML files that users left in their folders, etc?

My preference is probably to keep it to a single file for now, and figure out merging in a separate PR later on the way it would work for any YAML program, not just plugins. I don't feel like we need multi-file for the launch.
created_at: 2025-03-14T10:21:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
path: pkg/pulumiyaml/run.go
user: login: Frassle
body: This is just merging for yaml plugins, that's all new so I'd like to have this. We can work out what we want to do for normal programs later, but it would be good to make them merge as well.
created_at: 2025-03-14T10:28:31Z
author_association: MEMBER
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
path: pkg/pulumiyaml/run.go
user: login: mikhailshilkov
body: Why do you want to have it? Are we going to recommend users create a file per component? I'd keep it simple and show just PulumiPlugin.yaml in all our docs.
created_at: 2025-03-14T10:32:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
path: pkg/pulumiyaml/run.go
user: login: tgummerer
body: Yeah, I think not merging actually sets us up for more headaches later if we don't do it now.  

> How will it work for YAML programs and their stack files, deployment setting files, arbitrary YAML files that users left in their folders, etc?

Since we're doing this only for plugins and that's new as Fraser mentioned, we don't have to worry about this, because nobody can expect this to work otherwise.  And I think merging yaml files is neat, I remember splitting up YAML files being requested multiple times.
created_at: 2025-03-14T10:33:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
path: pkg/pulumiyaml/run.go
user: login: tgummerer
body: Here's the issue requesting this: https://github.com/pulumi/pulumi-yaml/issues/227.  It's the most upvoted issue in this repo with a bunch of comments.  And since we can support it without having to worry about backwards compat now, I think we should just do it.
created_at: 2025-03-14T10:37:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -146,6 +146,76 @@ func LoadDir(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
 	return template, diags, nil
 }
 
+// Load a plugin template from the given directory.
+func LoadPluginTemplate(directory string) (*ast.TemplateDecl, syntax.Diagnostics, error) {
+	// Get all yaml files in the directory, load them and merge them into a single template.
+	files, err := os.ReadDir(directory)
+	if err != nil {
+		return nil, nil, err
+	}
+	var template *ast.TemplateDecl
+	for _, file := range files {
+		if file.IsDir() || filepath.Ext(file.Name()) != ".yaml" {
+			continue
+		}
+
+		f, err := os.Open(filepath.Join(directory, file.Name()))
+		if err != nil {
+			return nil, nil, err
+		}
+		t, diags, err := LoadYAML(file.Name(), f)
+		if err != nil {
+			return nil, diags, err
+		}
+		if diags.HasErrors() {
+			return nil, diags, diags
+		}
+		if template == nil {
+			template = t
+		} else {
+			if t == nil {
+				diags.Extend(syntax.Error(nil, "failed to load template", ""))
+				continue
+			}
+			if len(t.Configuration.Entries) > 0 {
+				diags.Extend(syntax.Error(nil, "Pulumi.yaml: root-level `configuration` is not supported in plugins", ""))
path: pkg/pulumiyaml/run.go
user: login: tgummerer
body: Yep, it should be.  Changed it.
created_at: 2025-03-14T12:53:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: tgummerer
created_at: 2025-03-14T10:35:07Z
body: > Any testing we could add in addition to this simple integration test?

Not sure?  The integration test pretty much covers everything here apart from specific error messages.  Open to suggestions here :)
author_association: CONTRIBUTOR
user: login: pulumi-bot
created_at: 2025-03-24T12:30:15Z
body: This PR has been shipped in [release v1.15.0](https://github.com/pulumi/pulumi-yaml/releases/tag/v1.15.0).
author_association: NONE