diff --git a/internal/plugins/archive_cache.go b/internal/plugins/archive_cache.go index 72ec04d8..e5f1ac82 100644 --- a/internal/plugins/archive_cache.go +++ b/internal/plugins/archive_cache.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "log/slog" "os" "path/filepath" @@ -15,6 +16,7 @@ import ( type archiveStore interface { GetArchive(ctx context.Context, installationID int) (*InstallationArchive, error) + SaveArchive(ctx context.Context, installationID int, manifestJSON []byte, checksum string, archiveBytes []byte) error } type ArchiveCache struct { @@ -46,7 +48,10 @@ func (c *ArchiveCache) Ensure(ctx context.Context, installation *Installation) ( reader, manifestBytes, manifest, err := openPluginArchive(archive.Bytes) if err != nil { - return nil, fmt.Errorf("open stored plugin archive for installation %d: %w", installation.ID, err) + reader, manifestBytes, manifest, err = c.recoverLegacyBinaryArchive(ctx, installation.ID, archive, err) + if err != nil { + return nil, fmt.Errorf("open stored plugin archive for installation %d: %w", installation.ID, err) + } } if archive.Checksum != manifest.GetChecksum() { return nil, fmt.Errorf("stored plugin archive checksum mismatch for installation %d", installation.ID) @@ -88,6 +93,51 @@ func (c *ArchiveCache) Ensure(ctx context.Context, installation *Installation) ( return manifest, nil } +func (c *ArchiveCache) recoverLegacyBinaryArchive( + ctx context.Context, + installationID int, + archive *InstallationArchive, + openErr error, +) (*zip.Reader, []byte, *pluginv1.PluginManifest, error) { + if archive == nil || len(archive.ManifestJSON) == 0 || len(archive.Bytes) == 0 { + return nil, nil, nil, openErr + } + + manifest, err := LoadManifestBytes(archive.ManifestJSON) + if err != nil { + return nil, nil, nil, openErr + } + + checksum := sha256.Sum256(archive.Bytes) + actualChecksum := hex.EncodeToString(checksum[:]) + if actualChecksum != archive.Checksum || actualChecksum != manifest.GetChecksum() { + return nil, nil, nil, openErr + } + + archiveBytes, err := buildBinaryPluginArchive(archive.ManifestJSON, archive.Bytes) + if err != nil { + return nil, nil, nil, fmt.Errorf("%w; recover legacy raw binary archive: %v", openErr, err) + } + + reader, manifestBytes, manifest, err := openPluginArchive(archiveBytes) + if err != nil { + return nil, nil, nil, fmt.Errorf("%w; recover legacy raw binary archive: %v", openErr, err) + } + + // Persist the repaired archive so future preloads skip recovery, but don't + // fail startup over a write error — the in-memory archive is already valid + // and recovery will retry on the next preload. + if err := c.archives.SaveArchive(ctx, installationID, manifestBytes, manifest.GetChecksum(), archiveBytes); err != nil { + slog.Warn( + "failed to persist recovered legacy plugin archive; will retry on next preload", + "installation_id", installationID, + "error", err, + ) + } + + return reader, manifestBytes, manifest, nil +} + func openPluginArchive(data []byte) (*zip.Reader, []byte, *pluginv1.PluginManifest, error) { reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { @@ -135,6 +185,41 @@ func openPluginArchive(data []byte) (*zip.Reader, []byte, *pluginv1.PluginManife return reader, manifestBytes, manifest, nil } +func buildBinaryPluginArchive(manifestBytes []byte, binaryData []byte) ([]byte, error) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + + if err := writeArchiveEntry(writer, "manifest.json", manifestBytes); err != nil { + _ = writer.Close() + return nil, err + } + if err := writeArchiveEntry(writer, "plugin", binaryData); err != nil { + _ = writer.Close() + return nil, err + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("close plugin archive: %w", err) + } + + archiveBytes := buffer.Bytes() + if _, _, _, err := openPluginArchive(archiveBytes); err != nil { + return nil, fmt.Errorf("validate plugin archive: %w", err) + } + + return archiveBytes, nil +} + +func writeArchiveEntry(writer *zip.Writer, name string, data []byte) error { + entry, err := writer.Create(name) + if err != nil { + return fmt.Errorf("create plugin archive entry %q: %w", name, err) + } + if _, err := entry.Write(data); err != nil { + return fmt.Errorf("write plugin archive entry %q: %w", name, err) + } + return nil +} + func extractArchiveFiles(reader *zip.Reader, root string) error { for _, file := range reader.File { if err := extractZipFile(file, root); err != nil { diff --git a/internal/plugins/archive_cache_test.go b/internal/plugins/archive_cache_test.go new file mode 100644 index 00000000..b054471b --- /dev/null +++ b/internal/plugins/archive_cache_test.go @@ -0,0 +1,150 @@ +package plugins + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + + "google.golang.org/protobuf/encoding/protojson" +) + +func TestArchiveCacheEnsureRecoversLegacyBinaryArchive(t *testing.T) { + ctx := context.Background() + + binaryData := []byte("#!/bin/sh\nexit 0\n") + checksum := sha256.Sum256(binaryData) + + manifest := testPluginManifest(t, "silo.metadb", "0.0.19") + manifest.Checksum = hex.EncodeToString(checksum[:]) + manifestBytes, err := protojson.Marshal(manifest) + if err != nil { + t.Fatalf("protojson.Marshal() returned error: %v", err) + } + + store := &legacyArchiveStore{ + archive: &InstallationArchive{ + InstallationID: 42, + ManifestJSON: manifestBytes, + Checksum: manifest.GetChecksum(), + Bytes: binaryData, + }, + } + + installDir := filepath.Join(t.TempDir(), "plugins", "silo.metadb", "0.0.19") + installation := &Installation{ + ID: 42, + PluginID: manifest.GetPluginId(), + Version: manifest.GetVersion(), + InstallPath: filepath.Join(installDir, "plugin"), + } + + got, err := NewArchiveCache(store).Ensure(ctx, installation) + if err != nil { + t.Fatalf("Ensure() returned error: %v", err) + } + if got.GetPluginId() != manifest.GetPluginId() { + t.Fatalf("manifest plugin_id = %q, want %q", got.GetPluginId(), manifest.GetPluginId()) + } + + if store.savedInstallationID != installation.ID { + t.Fatalf("repaired archive installation id = %d, want %d", store.savedInstallationID, installation.ID) + } + if !bytes.Equal(store.savedManifestJSON, manifestBytes) { + t.Fatal("repaired archive manifest does not match stored manifest") + } + if _, _, savedManifest, err := openPluginArchive(store.savedArchiveBytes); err != nil { + t.Fatalf("repaired archive is not a valid plugin archive: %v", err) + } else if savedManifest.GetChecksum() != manifest.GetChecksum() { + t.Fatalf("repaired archive checksum = %q, want %q", savedManifest.GetChecksum(), manifest.GetChecksum()) + } + + installedBinary, err := os.ReadFile(installation.InstallPath) + if err != nil { + t.Fatalf("read rehydrated plugin binary: %v", err) + } + if !bytes.Equal(installedBinary, binaryData) { + t.Fatal("rehydrated plugin binary does not match legacy binary bytes") + } + if _, err := os.Stat(InstalledManifestPath(installation.InstallPath)); err != nil { + t.Fatalf("expected rehydrated manifest file: %v", err) + } +} + +func TestArchiveCacheEnsureRecoveryToleratesPersistFailure(t *testing.T) { + ctx := context.Background() + + binaryData := []byte("#!/bin/sh\nexit 0\n") + checksum := sha256.Sum256(binaryData) + + manifest := testPluginManifest(t, "silo.metadb", "0.0.19") + manifest.Checksum = hex.EncodeToString(checksum[:]) + manifestBytes, err := protojson.Marshal(manifest) + if err != nil { + t.Fatalf("protojson.Marshal() returned error: %v", err) + } + + store := &legacyArchiveStore{ + archive: &InstallationArchive{ + InstallationID: 42, + ManifestJSON: manifestBytes, + Checksum: manifest.GetChecksum(), + Bytes: binaryData, + }, + saveErr: errors.New("db unavailable"), + } + + installDir := filepath.Join(t.TempDir(), "plugins", "silo.metadb", "0.0.19") + installation := &Installation{ + ID: 42, + PluginID: manifest.GetPluginId(), + Version: manifest.GetVersion(), + InstallPath: filepath.Join(installDir, "plugin"), + } + + if _, err := NewArchiveCache(store).Ensure(ctx, installation); err != nil { + t.Fatalf("Ensure() returned error despite in-memory recovery: %v", err) + } + + installedBinary, err := os.ReadFile(installation.InstallPath) + if err != nil { + t.Fatalf("read rehydrated plugin binary: %v", err) + } + if !bytes.Equal(installedBinary, binaryData) { + t.Fatal("rehydrated plugin binary does not match legacy binary bytes") + } +} + +type legacyArchiveStore struct { + archive *InstallationArchive + saveErr error + savedInstallationID int + savedManifestJSON []byte + savedChecksum string + savedArchiveBytes []byte +} + +func (s *legacyArchiveStore) GetArchive(context.Context, int) (*InstallationArchive, error) { + return s.archive, nil +} + +func (s *legacyArchiveStore) SaveArchive( + _ context.Context, + installationID int, + manifestJSON []byte, + checksum string, + archiveBytes []byte, +) error { + if s.saveErr != nil { + return s.saveErr + } + s.savedInstallationID = installationID + s.savedManifestJSON = append([]byte(nil), manifestJSON...) + s.savedChecksum = checksum + s.savedArchiveBytes = append([]byte(nil), archiveBytes...) + return nil +} diff --git a/internal/plugins/installer.go b/internal/plugins/installer.go index 57377e11..72204f7e 100644 --- a/internal/plugins/installer.go +++ b/internal/plugins/installer.go @@ -256,6 +256,10 @@ func (i *Installer) replaceBinary( if err != nil { return nil, fmt.Errorf("serialize plugin manifest: %w", err) } + archiveBytes, err := buildBinaryPluginArchive(manifestBytes, binaryData) + if err != nil { + return nil, fmt.Errorf("package plugin archive: %w", err) + } installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion())) if err := os.MkdirAll(installRoot, 0755); err != nil { @@ -289,7 +293,7 @@ func (i *Installer) replaceBinary( return nil, err } - if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, checksum, binaryData); err != nil { + if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, checksum, archiveBytes); err != nil { return nil, fmt.Errorf("persist replacement plugin archive: %w", err) } @@ -412,6 +416,10 @@ func (i *Installer) installBinary(ctx context.Context, binaryData []byte, checks if err != nil { return nil, fmt.Errorf("serialize plugin manifest: %w", err) } + archiveBytes, err := buildBinaryPluginArchive(manifestBytes, binaryData) + if err != nil { + return nil, fmt.Errorf("package plugin archive: %w", err) + } installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion())) if err := os.MkdirAll(installRoot, 0755); err != nil { @@ -461,7 +469,7 @@ func (i *Installer) installBinary(ctx context.Context, binaryData []byte, checks return nil, fmt.Errorf("persist plugin installation: %w", err) } - if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, checksum, binaryData); err != nil { + if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, checksum, archiveBytes); err != nil { if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil { return nil, fmt.Errorf("persist plugin archive: %w (cleanup failed: %v)", err, deleteErr) } diff --git a/internal/plugins/installer_replace_test.go b/internal/plugins/installer_replace_test.go index c835b4b0..289f203b 100644 --- a/internal/plugins/installer_replace_test.go +++ b/internal/plugins/installer_replace_test.go @@ -1,6 +1,8 @@ package plugins import ( + "archive/zip" + "bytes" "context" "crypto/sha256" "encoding/hex" @@ -13,6 +15,31 @@ import ( pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" ) +func TestInstallerInstallBinaryPersistsPackagedArchive(t *testing.T) { + ctx := context.Background() + + store := newRecordingInstallationStore() + installer := NewInstaller(store, InstallerOptions{BaseDir: t.TempDir()}) + + manifest := testPluginManifest(t, "silo.metadb", "0.0.19") + binaryData := []byte("#!/bin/sh\nexit 0\n") + checksum := sha256.Sum256(binaryData) + manifest.Checksum = hex.EncodeToString(checksum[:]) + + result, err := installer.installBinary(ctx, binaryData, manifest.GetChecksum(), manifest, nil) + if err != nil { + t.Fatalf("installBinary() returned error: %v", err) + } + + if result.Installation.ID != 1 { + t.Fatalf("result installation id = %d, want 1", result.Installation.ID) + } + if len(store.saveArchiveIDs) != 1 || store.saveArchiveIDs[0] != 1 { + t.Fatalf("SaveArchive() ids = %#v, want [1]", store.saveArchiveIDs) + } + assertStoredPluginArchive(t, store.saveArchiveBytes, store.saveArchiveManifest, binaryData) +} + func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) { ctx := context.Background() @@ -28,6 +55,7 @@ func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) { manifest := testPluginManifest(t, "silo.metadb", "0.0.19") binaryData := []byte("#!/bin/sh\nexit 0\n") checksum := sha256.Sum256(binaryData) + manifest.Checksum = hex.EncodeToString(checksum[:]) result, err := installer.replaceBinary(ctx, &Installation{ ID: 15, @@ -46,6 +74,7 @@ func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) { if len(store.saveArchiveIDs) != 1 || store.saveArchiveIDs[0] != 15 { t.Fatalf("SaveArchive() ids = %#v, want [15]", store.saveArchiveIDs) } + assertStoredPluginArchive(t, store.saveArchiveBytes, store.saveArchiveManifest, binaryData) if len(store.updateIDs) != 1 || store.updateIDs[0] != 15 { t.Fatalf("Update() ids = %#v, want [15]", store.updateIDs) } @@ -86,6 +115,44 @@ func TestInstallerReplaceBinaryPreservesInstallationID(t *testing.T) { } } +func assertStoredPluginArchive(t *testing.T, archiveBytes, manifestBytes, binaryData []byte) { + t.Helper() + + reader, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes))) + if err != nil { + t.Fatalf("stored archive is not a zip: %v", err) + } + + files := make(map[string]*zip.File, len(reader.File)) + for _, file := range reader.File { + files[file.Name] = file + } + + manifestFile, ok := files["manifest.json"] + if !ok { + t.Fatal("stored archive is missing manifest.json") + } + gotManifest, err := readZipFile(manifestFile) + if err != nil { + t.Fatalf("read manifest.json from stored archive: %v", err) + } + if !bytes.Equal(gotManifest, manifestBytes) { + t.Fatal("stored archive manifest does not match saved manifest bytes") + } + + binaryFile, ok := files["plugin"] + if !ok { + t.Fatal("stored archive is missing plugin") + } + gotBinary, err := readZipFile(binaryFile) + if err != nil { + t.Fatalf("read plugin from stored archive: %v", err) + } + if !bytes.Equal(gotBinary, binaryData) { + t.Fatal("stored archive binary does not match installed binary") + } +} + func testPluginManifest(t *testing.T, pluginID, version string) *pluginv1.PluginManifest { t.Helper()