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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Installed-plugin provenance is recorded on the instance** (spore-plugins#8).
A successful `spawn plugin install` now writes a `spore:plugin:<name>` EC2 tag
(version, content-digest + commit prefixes, and the verification tier reached —
`signature`/`manifest`/`none`) and persists the full resolved provenance into
spored's on-instance plugin state, so an audit can answer "which plugin bytes
are on this box, and how were they verified" from both the AWS control plane
and the instance itself — not just the controller's local record. `spawn plugin
status <name>` now shows a `Source:` line with the recorded provenance. Both
writes are best-effort and never fail an already-completed install.

### Fixed
- **Clear error on a cosign legacy-bundle signature.** A plugin release signed
without `--new-bundle-format` produces cosign's legacy bundle, which the
verifier can't parse; it now reports that explicitly instead of a cryptic
protobuf error (spore-plugins#8).

## [0.86.0] - 2026-07-20

### Added
Expand Down
71 changes: 66 additions & 5 deletions cmd/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,18 @@
}
}
fmt.Println("Running remote install steps on the instance...")
if err := remotePluginInstall(ctx, sshHost, spec, resolvedCfg, pushBuf.Values()); err != nil {
if err := remotePluginInstall(ctx, sshHost, spec, resolvedCfg, pushBuf.Values(), prov); err != nil {

Check warning on line 254 in cmd/plugin.go

View check run for this annotation

Codecov / codecov/patch

cmd/plugin.go#L254

Added line #L254 was not covered by tests
return fmt.Errorf("remote install: %w", err)
}
// Record provenance on the instance's EC2 tags so an audit can answer
// "which plugin bytes are on this box" from the AWS control plane, surviving
// loss of the controller-side local record. Done once the install request
// is accepted (the bytes are on the box and their provenance is known),
// independent of whether the plugin's service later reaches Running —
// matching spored's on-instance state, which also records on failure.
// Best-effort: a tagging failure never fails the install.
recordPluginProvenanceTag(ctx, instance, spec, prov)

Check warning on line 265 in cmd/plugin.go

View check run for this annotation

Codecov / codecov/patch

cmd/plugin.go#L264-L265

Added lines #L264 - L265 were not covered by tests
if err := waitForPluginReady(ctx, sshHost, spec.Name); err != nil {
return err
}
Expand All @@ -264,6 +273,53 @@
return nil
}

// pluginProvenanceTagValue builds the compact EC2-tag value recording a plugin's
// resolved provenance. It stays well under EC2's 256-char tag-value limit: a
// short content-digest prefix, the commit prefix (if pinned), the requested ref,
// and the verification tier reached.
func pluginProvenanceTagValue(spec *plugin.PluginSpec, prov *plugin.Provenance) string {
parts := []string{"version=" + spec.Version}
if prov != nil {
if prov.ContentSHA256 != "" {
parts = append(parts, "sha256="+shortHash(prov.ContentSHA256))
}
if prov.CommitSHA != "" {
parts = append(parts, "commit="+shortHash(prov.CommitSHA))
}
switch {
case prov.SignatureVerified:
parts = append(parts, "verify=signature")
case prov.ManifestVerified:
parts = append(parts, "verify=manifest")
default:
parts = append(parts, "verify=none")
}
}
return strings.Join(parts, ";")
}

// recordPluginProvenanceTag writes a spore:plugin:<name> EC2 tag recording the
// installed plugin's provenance. Best-effort: it resolves the instance to an EC2
// ID + region and skips silently (with a warning) if that's not possible (e.g. a
// bare hostname not backed by a resolvable spore instance).
func recordPluginProvenanceTag(ctx context.Context, instance string, spec *plugin.PluginSpec, prov *plugin.Provenance) {
inst := resolveInstanceViaSpawn(ctx, instance)
if inst == nil || inst.InstanceID == "" {
return // not an EC2-resolvable instance; the local record still holds provenance
}
client, err := aws.NewClientWithRegion(ctx, inst.Region)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: could not record plugin provenance tag: %v\n", err)
return
}
tags := map[string]string{
"spore:plugin:" + spec.Name: pluginProvenanceTagValue(spec, prov),
}
if err := client.UpdateInstanceTags(ctx, inst.Region, inst.InstanceID, tags); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not write plugin provenance tag for %s: %v\n", spec.Name, err)
}

Check warning on line 320 in cmd/plugin.go

View check run for this annotation

Codecov / codecov/patch

cmd/plugin.go#L305-L320

Added lines #L305 - L320 were not covered by tests
}

// saveLocalPluginRecord persists a controller-side record of an installed
// plugin so its local deprovision steps can be replayed later. Best-effort: a
// failure here is logged but does not fail the install (the plugin is already
Expand Down Expand Up @@ -470,16 +526,17 @@
// remotePluginInstall sends the resolved spec, config, and buffered pushed
// values to spored's install endpoint over an SSH tunnel. spored runs the
// install asynchronously and returns 202; the outcome is polled separately.
func remotePluginInstall(ctx context.Context, instance string, spec *plugin.PluginSpec, cfg, pushed map[string]string) error {
func remotePluginInstall(ctx context.Context, instance string, spec *plugin.PluginSpec, cfg, pushed map[string]string, prov *plugin.Provenance) error {

Check warning on line 529 in cmd/plugin.go

View check run for this annotation

Codecov / codecov/patch

cmd/plugin.go#L529

Added line #L529 was not covered by tests
specYAML, err := yaml.Marshal(spec)
if err != nil {
return fmt.Errorf("marshal spec: %w", err)
}

payload, err := json.Marshal(map[string]interface{}{
"spec": string(specYAML),
"config": cfg,
"pushed": pushed,
"spec": string(specYAML),
"config": cfg,
"pushed": pushed,
"provenance": prov,

Check warning on line 539 in cmd/plugin.go

View check run for this annotation

Codecov / codecov/patch

cmd/plugin.go#L536-L539

Added lines #L536 - L539 were not covered by tests
})
if err != nil {
return fmt.Errorf("encode install request: %w", err)
Expand Down Expand Up @@ -582,6 +639,9 @@
if st.LastHealth != nil {
fmt.Printf("Health: last OK %s\n", st.LastHealth.Format(time.RFC3339))
}
if p := st.Provenance; p != nil {
fmt.Printf("Source: %s\n", describeProvenance(p))
}

Check warning on line 644 in cmd/plugin.go

View check run for this annotation

Codecov / codecov/patch

cmd/plugin.go#L642-L644

Added lines #L642 - L644 were not covered by tests
return nil
},
}
Expand Down Expand Up @@ -740,6 +800,7 @@
UpdatedAt time.Time `json:"updated_at"`
LastHealth *time.Time `json:"last_health,omitempty"`
Error string `json:"error,omitempty"`
Provenance *plugin.Provenance `json:"provenance,omitempty"`
}

func remotePluginList(ctx context.Context, instance string) ([]*pluginStateResponse, error) {
Expand Down
58 changes: 58 additions & 0 deletions cmd/plugin_provenance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cmd

import (
"strings"
"testing"

"github.com/spore-host/spawn/pkg/plugin"
)

func TestPluginProvenanceTagValue(t *testing.T) {
spec := &plugin.PluginSpec{Name: "rstudio-server", Version: "v1.0.1"}

cases := []struct {
name string
prov *plugin.Provenance
want []string // substrings that must appear
}{
{
name: "signed official",
prov: &plugin.Provenance{
ContentSHA256: strings.Repeat("a", 64),
CommitSHA: strings.Repeat("b", 40),
ManifestVerified: true,
SignatureVerified: true,
},
want: []string{"version=v1.0.1", "sha256=aaaaaaaaaaaa", "commit=bbbbbbbbbbbb", "verify=signature"},
},
{
name: "manifest only (unsigned)",
prov: &plugin.Provenance{ContentSHA256: strings.Repeat("c", 64), ManifestVerified: true},
want: []string{"verify=manifest"},
},
{
name: "unverified",
prov: &plugin.Provenance{ContentSHA256: strings.Repeat("d", 64)},
want: []string{"verify=none"},
},
{
name: "nil provenance still records version",
prov: nil,
want: []string{"version=v1.0.1"},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := pluginProvenanceTagValue(spec, tc.prov)
if len(got) > 256 {
t.Errorf("tag value %d chars, exceeds EC2's 256-char limit: %q", len(got), got)
}
for _, sub := range tc.want {
if !strings.Contains(got, sub) {
t.Errorf("tag value %q missing %q", got, sub)
}
}
})
}
}
6 changes: 6 additions & 0 deletions pkg/plugin/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ type PluginState struct {
UpdatedAt time.Time `json:"updated_at"`
LastHealth *time.Time `json:"last_health,omitempty"`
Error string `json:"error,omitempty"`
// Provenance records where the installed spec came from and how it was
// verified (commit/digest/signature), so an on-instance audit — `spawn plugin
// status` or reading spored's state file — can answer "which bytes are here."
// nil for installs from a controller that didn't supply it (older spawn) or a
// local-file ref with no remote provenance.
Provenance *Provenance `json:"provenance,omitempty"`
}

// StateStore manages plugin state persistence.
Expand Down
36 changes: 36 additions & 0 deletions pkg/pluginruntime/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,42 @@ func TestInstallWithPushed_SeedsPushedBeforeConfigure(t *testing.T) {
}
}

// TestInstallWithProvenance_PersistsToState verifies that a resolved Provenance
// handed to the runtime is recorded in the plugin's on-instance state, so an
// audit can read what was installed and how it was verified.
func TestInstallWithProvenance_PersistsToState(t *testing.T) {
rt := newTestRuntime(t)
spec := &plugin.PluginSpec{
Name: "provrec",
Version: "v1.0.0",
Description: "d",
Remote: plugin.RemoteBlock{Install: []plugin.Step{{Type: "run", Run: "true"}}},
}
prov := &plugin.Provenance{
Host: "official",
Name: "provrec",
CommitSHA: "2dda4ab8dc6f734699112473f011e34fbf862f2b",
ContentSHA256: "0bffc628dd64b12920bef7561ff105c0f2ea23b2fe370db3be07cccda99561d0",
ManifestVerified: true,
SignatureVerified: true,
ReleaseTag: "provrec-v1.0.0",
}

if err := rt.InstallWithProvenance(context.Background(), spec, nil, nil, prov); err != nil {
t.Fatalf("InstallWithProvenance: %v", err)
}
st, err := rt.store.Load("provrec")
if err != nil {
t.Fatalf("load state: %v", err)
}
if st.Provenance == nil {
t.Fatal("state.Provenance = nil, want the recorded provenance")
}
if !st.Provenance.SignatureVerified || st.Provenance.CommitSHA != prov.CommitSHA {
t.Errorf("state.Provenance = %+v, want signature-verified with commit %s", st.Provenance, prov.CommitSHA)
}
}

// TestInstall_MissingPushedParksWaiting confirms the documented limitation: with
// no pushed value seeded, a configure step referencing {{ pushed.x }} parks the
// plugin at WaitingForPush rather than failing.
Expand Down
9 changes: 5 additions & 4 deletions pkg/pluginruntime/pushapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,10 @@ func (s *PushAPIServer) authMiddleware(next http.Handler) http.Handler {
// spored runs. pushed carries values captured/pushed by the controller's local
// provision steps so configure can resolve {{ pushed.<key> }} up front.
type installRequest struct {
Spec string `json:"spec"` // plugin.yaml contents
Config map[string]string `json:"config,omitempty"`
Pushed map[string]string `json:"pushed,omitempty"`
Spec string `json:"spec"` // plugin.yaml contents
Config map[string]string `json:"config,omitempty"`
Pushed map[string]string `json:"pushed,omitempty"`
Provenance *plugin.Provenance `json:"provenance,omitempty"` // resolved origin/verification, recorded in on-instance state
}

// handleInstall receives POST /v1/plugins/install and runs the remote half of a
Expand Down Expand Up @@ -154,7 +155,7 @@ func (s *PushAPIServer) handleInstall(w http.ResponseWriter, r *http.Request) {
baseCtx = context.Background()
}
go func() {
if err := s.rt.InstallWithPushed(baseCtx, spec, req.Config, req.Pushed); err != nil {
if err := s.rt.InstallWithProvenance(baseCtx, spec, req.Config, req.Pushed, req.Provenance); err != nil {
log.Printf("Install error for plugin %s: %v", spec.Name, err)
}
}()
Expand Down
9 changes: 9 additions & 0 deletions pkg/pluginruntime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ func (rt *Runtime) Install(ctx context.Context, spec *plugin.PluginSpec, cfg map
// ReceivePush. That path is unused by the current registry plugins and is left
// as a known limitation.
func (rt *Runtime) InstallWithPushed(ctx context.Context, spec *plugin.PluginSpec, cfg map[string]string, pushed map[string]string) error {
return rt.InstallWithProvenance(ctx, spec, cfg, pushed, nil)
}

// InstallWithProvenance is InstallWithPushed plus a resolved Provenance record
// (origin + verification tier) that is persisted into the plugin's on-instance
// state, so an audit can read what was installed and how it was verified. prov
// may be nil (local-file ref, or an older controller that didn't supply it).
func (rt *Runtime) InstallWithProvenance(ctx context.Context, spec *plugin.PluginSpec, cfg, pushed map[string]string, prov *plugin.Provenance) error {
resolvedCfg, err := spec.ResolvedConfig(cfg)
if err != nil {
return fmt.Errorf("resolve config: %w", err)
Expand All @@ -81,6 +89,7 @@ func (rt *Runtime) InstallWithPushed(ctx context.Context, spec *plugin.PluginSpe
Config: resolvedCfg,
Outputs: make(map[string]string),
Pushed: seededPushed,
Provenance: prov,
InstalledAt: time.Now(),
}
if err := rt.store.Save(state); err != nil {
Expand Down
Loading