The SC-core-side plugin runtime plus the shared plugin-side runtime library
(runtime/) every SC plugin builds on: proto definitions, go-plugin lifecycle
wrappers, MonitorProxy, pluginSession, SCCore callback server, PeerInvoke
plumbing, and the handler scaffolding (runtime.NewContext, runtime.Settle,
runtime.HarvestExports, runtime.ResourceInputForComputeProcess,
runtime.DecodeInputs). Together with the dispatch wiring in
pkg/clouds/pulumi/plugin_dispatch.go + plugin_preengine.go + plugin_sidecars.go,
this is the base layer of the
sc-plugin-architecture
design.
Read the design doc first — this README assumes you have the architecture context, especially §3 "The two gRPC conversations" and Addendum C "state-storage and secrets-provider as plugins". For the trap catalogue collected across the five-plugin extraction wave, read forge/docs/design/2026-11-01/plugin-extraction-playbook/ before you start extracting a new one.
pkg/plugin/
proto/v1/plugin.proto # SC plugin bus service definition (SCCloudPlugin + SCCore)
proto/v1/plugin.pb.go # generated — do not edit; regen with `make proto`
proto/v1/plugin_grpc.pb.go # ditto
proto/README.md # proto scope, buf config, regen command
goplugin.go # hashicorp/go-plugin HandshakeConfig + Plugin impl
client.go # SC-core-side: plugin.NewClient
server.go # plugin-side: plugin.Serve
monitor_proxy.go # loopback gRPC ResourceMonitor forwarding to the real engine
callback_server.go # SC-core-side SCCore server (ResolveSecret, PeerInvoke)
peer.go # plugin-side PeerInvoke client helper
compute.go # ComputeResult builder (env vars, tpl extensions)
vmhosts.go # VmHost builder
publisher.go # progress Publisher routed to LogStream
runtime/ # plugin-side handler scaffolding (new in 2026-11-01)
context.go # NewContext + Settle generic
wait.go # go:linkname (*Context).wait — the drain
engine.go # LocalEngineAddress for background ctx.Log calls
harvest.go # HarvestExports — Secret-preserving output harvest
compute.go # ResourceInputForComputeProcess parent-env swap
decode.go # DecodeInputs[T] via scPlugin.InputsToPlain
roundtrip_test.go # unit test: handshake + Schema roundtrip
provision_smoke_test.go # unit test: plugin dials MonitorAddress + RegisterResource
integration_test.go # (build tag `integration`) real Pulumi engine Stage A
vps_integration_test.go # (build tag `integration`) real VPS Stage B
testdata/
echo-plugin/ # minimal Schema-only fixture plugin
provision-plugin/ # plugin that registers a resource via monitor
vps-marker-plugin/ # plugin that SSHes to a VPS via command:remote:Command
state-store-plugin/ # plugin that provides a state-storage type (Step 4b)
Plugin advertises in SchemaResponse |
SC core wires it where |
|---|---|
resource_types |
Dispatch table in pApi.PluginResourcesByType. In-engine, plugin.Provision called from configureResource's fall-through. Plugin dials MonitorProxy and calls RegisterResource. |
state_store_types |
Pre-engine sidecar in pApi.InitStateStoreFuncByType. Plugin.InitStateStore called from pkg/clouds/pulumi/login.go to resolve backend_url + env vars. |
secrets_provider_types |
Proto reserved; wiring pending (see Addendum C / Step 4b follow-up). |
provider_types |
Proto reserved; wiring pending (plugin-owned pulumi:providers:<pkg> resources). |
compute_processor_types |
Proto reserved; wiring pending. |
registrar_types |
Proto reserved; wiring pending. |
pulumi_plugins |
SC core's auto-pin reads this for the explicit-provider mechanism (04 §3). |
Types with : are routed to plugins; bare names stay in-tree. The config-tier
fall-through in pkg/api/read.go triggers on any type: string containing :
and wraps the config in api.PluginFieldConfig.
The template at
simple-container-com/sc-plugin-echo
is the fastest start — clone it, swap in your cloud's config structs, keep the
runtime wiring. The author guide at
docs/docs/guides/plugin-authoring.md
walks the full path.
The minimum viable binary — using the runtime library every production plugin builds on:
package main
import (
"context"
"os"
hclog "github.com/hashicorp/go-hclog"
plugin "github.com/simple-container-com/api/pkg/plugin"
scpluginv1 "github.com/simple-container-com/api/pkg/plugin/proto/v1"
"github.com/simple-container-com/api/pkg/plugin/runtime"
)
type myPlugin struct {
scpluginv1.UnimplementedSCCloudPluginServer
progress *plugin.Publisher
}
func (p *myPlugin) Schema(_ context.Context, _ *scpluginv1.SchemaRequest) (*scpluginv1.SchemaResponse, error) {
return &scpluginv1.SchemaResponse{
PluginVersion: "my-plugin-0.1.0",
ResourceTypes: []string{"mycloud:storage:Bucket"}, // MUST contain `:` for routing
}, nil
}
func (p *myPlugin) Provision(stdctx context.Context, req *scpluginv1.ProvisionRequest) (resp *scpluginv1.ProvisionResponse, err error) {
// runtime.NewContext opens one sdk.Context against req.Header.MonitorAddress
// and defers a drain-then-close via runtime.Settle. WithLocalEngineFallback
// keeps background ctx.Log calls from panicking when the header has no
// engine address.
rctx, err := runtime.NewContext(stdctx, req.GetHeader(), runtime.WithLocalEngineFallback(p.progress))
if err != nil {
return nil, err
}
defer runtime.Settle(rctx, &resp, &err)
// ... register cloud resources against rctx.Pulumi ...
// ... rctx.Pulumi.Export(key, out) for every value the parent should see ...
outputs, err := runtime.HarvestExports(stdctx, rctx.Pulumi)
if err != nil {
return nil, err
}
return &scpluginv1.ProvisionResponse{Urn: "...", Outputs: outputs}, nil
}
func main() {
publisher := plugin.NewPublisher()
plugin.Serve(plugin.ServerConfig{
Impl: &myPlugin{progress: publisher},
Logger: hclog.New(&hclog.LoggerOptions{
Name: "my-plugin",
Level: hclog.Warn,
Output: os.Stderr,
}),
})
}Three invariants runtime.* enforces for you — each has caused a live
regression at least once (see the extraction playbook):
- Drain before you return.
runtime.Settleruns(*Context).waitviago:linknamebefore closing the SDK context. Returning without draining drops every pendingRegisterResourceand SC core plans a destroy of each state entry on the next apply. (Playbook trap 7, 2026-10-27.) - Preserve the Secret flag on exports.
runtime.HarvestExportscallsinternals.UnsafeAwaitOutputand copies.SecretintoInputValue.Secret. Dropping it lands the value plaintext in the parent stack root. (Playbook trap 11, 2026-10-31.) - Export cross-plugin bridge Outputs. If your plugin wraps a PeerInvoke
call in an
ApplyT, you MUSTrctx.Pulumi.Export(unique-key, urnOut)the resulting Output. Callers that discard the ResourceOutput let Pulumi dead-strip the orphan chain — the PeerInvoke never fires and the DNS record silently never registers. (Playbook trap 15, 2026-11-01.)
Build:
go build -o my-plugin ./cmd/my-pluginOperator configuration (server.yaml):
plugins:
my-plugin:
path: /opt/sc-plugins/my-plugin
sha256: abc123... # optional; verified at every spawn
resources:
resources:
staging:
resources:
my-bucket:
type: mycloud:storage:BucketThe point of all of this is to let a driver live in its own repo. Five drivers have come out this way so far (cloudflare, yandex, cloudru, mongodb, aws); the next milestone is gcp. The playbook at forge/docs/design/2026-11-01/plugin-extraction-playbook/ is the canonical trap-by-trap guide — read it alongside this checklist.
Steps:
- Decide the plugin's advertised type names. Add
:in each (required by the config-tier fall-through). Keep the type strings byte-identical to what the current in-tree driver passes to Pulumi RegisterResource — see 04-no-destroy-invariant.md §1.2. For existing drivers (migration path), use the explicit-provider mechanism from 04 §3 to keep provider URNs byte-identical. - Create a new repo named
simple-container-com/sc-plugin-<cloud>. Nested-module layout:/api(config structs, no Pulumi SDK) +/plugin(the binary — may vendor whatever typed Pulumi SDKs the driver needs). Pattern per 03 §2.2. - Author the plugin. Implement
Schema+ whatever RPCs match your advertised types. For resource plugins, Provision dials MonitorAddress and calls RegisterResource with the exact type token Pulumi expects (e.g.cloudflare:index/record:Record— NOTsc-cloudflare:record:Record). - Use
pkg/plugin/runtimefor the handler scaffolding — do NOT re-copy the per-plugindrain.go/pulumiContextFromHeader/ harvest triad from an older plugin. Every handler starts withrctx, err := runtime.NewContext(stdctx, req.GetHeader(), runtime.WithLocalEngineFallback(p.progress))+defer runtime.Settle(rctx, &resp, &err). Returning outputs?runtime.HarvestExports. ComputeProcess?runtime.ParentInfoFromRequest+runtime.ResourceInputForComputeProcess. Decoding typed inputs?runtime.DecodeInputs[T]. These helpers encode the invariants in playbook traps 7, 11, 12, 14 — rolling your own re-earns the bugs. - Lift the current in-tree driver code from
pkg/clouds/pulumi/<cloud>/into the plugin's/plugin/internal/. Rewrite the entry points from the pApi.Register*() init() pattern to SCCloudPluginServer method implementations. Preserve resource names and parent chains byte-identically. - Preserve the config-tier schema. Lift
pkg/clouds/<cloud>/structs into the plugin repo's/apimodule. SC core imports those forcmd/schema-genper D3. - Add a URN-contract test in the plugin's own repo, mirroring
pkg/clouds/pulumi/cloudflare/urn_contract_test.go(PR #479's seed) — asserts resource/provider names match a documented pattern. One failed byte-match blocks the plugin's release. - Add fixture-based migration test — a scratch stack deployed by pre-split
sc, then
pulumi preview --expect-no-changeswith the plugin-era sc. Required gate per 04 §4. - Cut v1.0.0 — GitHub release with signed binary + sha256sums.txt.
- Update SC core — remove
_ "github.com/simple-container-com/api/pkg/clouds/pulumi/<cloud>"blank import frompkg/clouds/pulumi/init.go; delete the subtree; updatego.mod; measure newmake buildlink RSS to verify shrinkage.
For composite drivers (kubernetes, k3s, mongodb that compose other clouds), add:
- PeerInvoke RPC (proto wiring pending — composite plugin asks SC core to invoke on another plugin, per D8).
| Surface | Plugin RPC | Shipped example |
|---|---|---|
| State-store | InitStateStore |
aws s3-bucket, yandex yc-object-storage |
| Secrets-provider | InitSecretsProvider |
aws aws-kms, yandex yc-kms |
| Provider | Provider |
aws, yandex, cloudru providers; mongodb Atlas provider |
| Resource / Template | Provision |
every resource/template token in supported-resources.md tagged "plugin" |
| Compute processor | ComputeProcess |
mongodb uses: fan-out (MONGO_URI/USER/PASSWORD), aws s3-bucket consumer env |
| Registrar | ProvisionRegistrar, NewDnsRecord, NewOverrideHeaderRule, ProvisionDomainForEndpoint |
cloudflare; aws delegates DNS to the operator-configured registrar via PeerInvoke |
| PreDestroy | PreDestroy |
mongodb drops databases named in -service-user outputs before Pulumi destroys the cluster |
| VM driver | VmHosts |
yandex yandex-vm, cloudru cloudru-evolution-vm |
| LogStream | LogStream (server-stream) |
every plugin's scPlugin.Publisher routed to the engine's progress channel |
| Peer invoke | SCCore.PeerInvoke (plugin → core → peer plugin) |
aws delegates NewDnsRecord, NewOverrideHeaderRule, ProvisionDomainForEndpoint to cloudflare without a Go-module import (D8) |
| What | Current state | Blocker |
|---|---|---|
| Fingerprint streamed health | Not yet in proto | Server-streaming RPC per Nomad pattern. Health inference today still happens inside SC core via pkg/clouds/pulumi/healthcheck.go. |
The two-phase plugin lifecycle (pre-engine + in-engine) + the SCCore callback server (ResolveSecret / ResolveCredential / PeerInvoke) cover everything the five extracted plugins needed.
# Unit tests — fast, no external deps.
go test ./pkg/plugin/...
# Stage A — real Pulumi engine, local-fs backend. Requires `pulumi` CLI on PATH.
go test -tags integration -run TestIntegration_PluginRegistersAgainstRealEngine ./pkg/plugin/...
# Stage B — real VPS deploy via command:remote:Command. Requires SC_PLUGIN_VPS_* env vars.
SC_PLUGIN_VPS_HOST=... SC_PLUGIN_VPS_USER=... SC_PLUGIN_VPS_PASSWORD=... \
go test -tags integration -run TestIntegration_PluginTouchesVPS ./pkg/plugin/...The integration tests are build-tagged so plain make check + go test ./... stay
fast; CI's dedicated integration job (which already has pulumi installed per
commit 20a04303) picks them up with -tags integration.