fix(plugins): package binary installs as zip archives and self-repair legacy raw-binary rows
Binary catalog install/update paths (installBinary/replaceBinary) saved raw plugin binaries into plugin_archives.archive_bytes, but startup preload opens that column with zip.NewReader expecting a package containing manifest.json and the plugin binary. Affected installs failed preload after restart with "zip: not a valid zip file". - Package manifest.json + binary into a validated zip before SaveArchive on both binary install paths. - Self-repair legacy rows in ArchiveCache.Ensure: when stored bytes are not a zip but hash-match both the stored checksum and the stored manifest's checksum, repackage and persist the repaired archive. A persist failure is logged, not fatal — the in-memory archive is already valid and recovery retries on the next preload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user