Files
silo-server/internal/plugins/installer.go
T

665 lines
19 KiB
Go

package plugins
import (
"archive/zip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
publicmanifest "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginsdk/manifest"
"google.golang.org/protobuf/encoding/protojson"
)
type InstallArchiveRequest struct {
ArchivePath string
ArchiveURL string
RepositoryID *int
}
type InstallBinaryRequest struct {
BinaryURL string
Checksum string // expected SHA-256 hex checksum
Manifest *pluginv1.PluginManifest
RepositoryID *int
}
type InstallResult struct {
Installation *Installation
Manifest *pluginv1.PluginManifest
BinaryPath string
ManifestPath string
}
type InstallerOptions struct {
BaseDir string
HTTPClient *http.Client
}
type installationStore interface {
Create(ctx context.Context, input CreateInstallationInput) (*Installation, error)
SaveArchive(ctx context.Context, installationID int, manifestJSON []byte, checksum string, archiveBytes []byte) error
Update(ctx context.Context, id int, input UpdateInstallationInput) error
Delete(ctx context.Context, id int) error
}
type Installer struct {
installations installationStore
baseDir string
httpClient *http.Client
}
func NewInstaller(installations installationStore, opts InstallerOptions) *Installer {
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
baseDir := opts.BaseDir
if baseDir == "" {
baseDir = filepath.Join(os.TempDir(), "silo-plugins")
}
return &Installer{
installations: installations,
baseDir: baseDir,
httpClient: httpClient,
}
}
func (i *Installer) InstallLocal(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) {
if req.ArchivePath == "" {
return nil, fmt.Errorf("archive path is required")
}
data, err := os.ReadFile(req.ArchivePath)
if err != nil {
return nil, fmt.Errorf("read archive %q: %w", req.ArchivePath, err)
}
return i.installArchive(ctx, data, req.RepositoryID)
}
func (i *Installer) ReplaceLocal(ctx context.Context, existing *Installation, req InstallArchiveRequest) (*InstallResult, error) {
if existing == nil {
return nil, fmt.Errorf("existing installation is required")
}
if req.ArchivePath == "" {
return nil, fmt.Errorf("archive path is required")
}
data, err := os.ReadFile(req.ArchivePath)
if err != nil {
return nil, fmt.Errorf("read archive %q: %w", req.ArchivePath, err)
}
return i.replaceArchive(ctx, existing, data, req.RepositoryID)
}
func (i *Installer) InstallRemote(ctx context.Context, req InstallArchiveRequest) (*InstallResult, error) {
if req.ArchiveURL == "" {
return nil, fmt.Errorf("archive url is required")
}
data, err := i.downloadArchive(ctx, req.ArchiveURL)
if err != nil {
return nil, err
}
return i.installArchive(ctx, data, req.RepositoryID)
}
func (i *Installer) ReplaceRemote(ctx context.Context, existing *Installation, req InstallArchiveRequest) (*InstallResult, error) {
if req.ArchiveURL == "" {
return nil, fmt.Errorf("archive url is required")
}
data, err := i.downloadArchive(ctx, req.ArchiveURL)
if err != nil {
return nil, err
}
return i.replaceArchive(ctx, existing, data, req.RepositoryID)
}
func (i *Installer) downloadArchive(ctx context.Context, archiveURL string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL, nil)
if err != nil {
return nil, fmt.Errorf("build archive request: %w", err)
}
resp, err := i.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("download archive %q: %w", archiveURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download archive %q: unexpected status %d", archiveURL, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read archive response: %w", err)
}
return data, nil
}
func (i *Installer) InstallBinary(ctx context.Context, req InstallBinaryRequest) (*InstallResult, error) {
if req.BinaryURL == "" {
return nil, fmt.Errorf("binary url is required")
}
if req.Checksum == "" {
return nil, fmt.Errorf("binary checksum is required")
}
binaryData, manifest, actualChecksum, err := i.downloadBinary(ctx, req)
if err != nil {
return nil, err
}
return i.installBinary(ctx, binaryData, actualChecksum, manifest, req.RepositoryID)
}
func (i *Installer) ReplaceBinary(ctx context.Context, existing *Installation, req InstallBinaryRequest) (*InstallResult, error) {
if req.BinaryURL == "" {
return nil, fmt.Errorf("binary url is required")
}
if req.Checksum == "" {
return nil, fmt.Errorf("binary checksum is required")
}
binaryData, manifest, actualChecksum, err := i.downloadBinary(ctx, req)
if err != nil {
return nil, err
}
return i.replaceBinary(ctx, existing, binaryData, actualChecksum, manifest)
}
func (i *Installer) downloadBinary(
ctx context.Context,
req InstallBinaryRequest,
) ([]byte, *pluginv1.PluginManifest, string, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, req.BinaryURL, nil)
if err != nil {
return nil, nil, "", fmt.Errorf("build binary request: %w", err)
}
resp, err := i.httpClient.Do(request)
if err != nil {
return nil, nil, "", fmt.Errorf("download binary %q: %w", req.BinaryURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, "", fmt.Errorf("download binary %q: unexpected status %d", req.BinaryURL, resp.StatusCode)
}
binaryData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, "", fmt.Errorf("read binary response: %w", err)
}
checksum := sha256.Sum256(binaryData)
actualChecksum := hex.EncodeToString(checksum[:])
if actualChecksum != req.Checksum {
return nil, nil, "", fmt.Errorf("binary checksum mismatch: expected %s, got %s", req.Checksum, actualChecksum)
}
manifest := req.Manifest
if manifest == nil {
manifest, err = loadManifestFromBinary(ctx, binaryData)
if err != nil {
return nil, nil, "", err
}
} else if err := ValidateManifest(manifest); err != nil {
return nil, nil, "", err
}
return binaryData, manifest, actualChecksum, nil
}
func (i *Installer) InstallBinaryUpload(ctx context.Context, binaryData []byte) (*InstallResult, error) {
if len(binaryData) == 0 {
return nil, fmt.Errorf("binary data is required")
}
manifest, err := loadManifestFromBinary(ctx, binaryData)
if err != nil {
return nil, err
}
checksum := sha256.Sum256(binaryData)
actualChecksum := hex.EncodeToString(checksum[:])
return i.installBinary(ctx, binaryData, actualChecksum, manifest, nil)
}
func (i *Installer) replaceBinary(
ctx context.Context,
existing *Installation,
binaryData []byte,
checksum string,
manifest *pluginv1.PluginManifest,
) (*InstallResult, error) {
if existing == nil {
return nil, fmt.Errorf("existing installation is required")
}
if len(binaryData) == 0 {
return nil, fmt.Errorf("binary data is required")
}
if checksum == "" {
return nil, fmt.Errorf("binary checksum is required")
}
if manifest == nil {
return nil, fmt.Errorf("plugin manifest is required")
}
if err := ValidateManifest(manifest); err != nil {
return nil, err
}
manifestBytes, err := protojson.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("serialize plugin manifest: %w", err)
}
installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion()))
if err := os.MkdirAll(installRoot, 0755); err != nil {
return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err)
}
installDir, err := os.MkdirTemp(installRoot, "install-")
if err != nil {
return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err)
}
keepInstallDir := false
defer func() {
if keepInstallDir {
return
}
_ = os.RemoveAll(installDir)
}()
binaryPath := filepath.Join(installDir, "plugin")
manifestPath := filepath.Join(installDir, "manifest.json")
if err := os.WriteFile(binaryPath, binaryData, 0755); err != nil {
return nil, fmt.Errorf("write plugin binary %q: %w", binaryPath, err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil {
return nil, fmt.Errorf("write plugin manifest %q: %w", manifestPath, err)
}
capabilities, err := CapabilityRecordsFromManifest(manifest)
if err != nil {
return nil, err
}
if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, checksum, binaryData); err != nil {
return nil, fmt.Errorf("persist replacement plugin archive: %w", err)
}
version := manifest.GetVersion()
enabled := existing.Enabled
availableVersion := ""
if err := i.installations.Update(ctx, existing.ID, UpdateInstallationInput{
Version: &version,
InstallPath: &binaryPath,
Enabled: &enabled,
AvailableVersion: &availableVersion,
Capabilities: capabilities,
}); err != nil {
return nil, fmt.Errorf("update replacement plugin installation: %w", err)
}
oldInstallDir := filepath.Dir(existing.InstallPath)
if oldInstallDir != "" && filepath.Clean(oldInstallDir) != filepath.Clean(installDir) {
_ = os.RemoveAll(oldInstallDir)
}
keepInstallDir = true
updated := *existing
updated.Version = version
updated.InstallPath = binaryPath
updated.Enabled = enabled
return &InstallResult{
Installation: &updated,
Manifest: manifest,
BinaryPath: binaryPath,
ManifestPath: manifestPath,
}, nil
}
func (i *Installer) replaceArchive(
ctx context.Context,
existing *Installation,
data []byte,
repositoryID *int,
) (*InstallResult, error) {
if existing == nil {
return nil, fmt.Errorf("existing installation is required")
}
reader, manifestBytes, manifest, err := openPluginArchive(data)
if err != nil {
return nil, err
}
_ = repositoryID
installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion()))
if err := os.MkdirAll(installRoot, 0755); err != nil {
return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err)
}
installDir, err := os.MkdirTemp(installRoot, "install-")
if err != nil {
return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err)
}
keepInstallDir := false
defer func() {
if keepInstallDir {
return
}
_ = os.RemoveAll(installDir)
}()
binaryPath := filepath.Join(installDir, "plugin")
manifestPath := filepath.Join(installDir, "manifest.json")
capabilities, err := CapabilityRecordsFromManifest(manifest)
if err != nil {
return nil, err
}
if err := i.installations.SaveArchive(ctx, existing.ID, manifestBytes, manifest.GetChecksum(), data); err != nil {
return nil, fmt.Errorf("persist replacement plugin archive: %w", err)
}
if err := extractArchiveFiles(reader, installDir); err != nil {
return nil, err
}
version := manifest.GetVersion()
enabled := existing.Enabled
availableVersion := ""
if err := i.installations.Update(ctx, existing.ID, UpdateInstallationInput{
Version: &version,
InstallPath: &binaryPath,
Enabled: &enabled,
AvailableVersion: &availableVersion,
Capabilities: capabilities,
}); err != nil {
return nil, fmt.Errorf("update replacement plugin installation: %w", err)
}
oldInstallDir := filepath.Dir(existing.InstallPath)
if oldInstallDir != "" && filepath.Clean(oldInstallDir) != filepath.Clean(installDir) {
_ = os.RemoveAll(oldInstallDir)
}
keepInstallDir = true
updated := *existing
updated.Version = version
updated.InstallPath = binaryPath
updated.Enabled = enabled
return &InstallResult{
Installation: &updated,
Manifest: manifest,
BinaryPath: binaryPath,
ManifestPath: manifestPath,
}, nil
}
func (i *Installer) installBinary(ctx context.Context, binaryData []byte, checksum string, manifest *pluginv1.PluginManifest, repositoryID *int) (*InstallResult, error) {
manifestBytes, err := protojson.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("serialize plugin manifest: %w", err)
}
installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion()))
if err := os.MkdirAll(installRoot, 0755); err != nil {
return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err)
}
installDir, err := os.MkdirTemp(installRoot, "install-")
if err != nil {
return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err)
}
keepInstallDir := false
defer func() {
if keepInstallDir {
return
}
_ = os.RemoveAll(installDir)
}()
binaryPath := filepath.Join(installDir, "plugin")
manifestPath := filepath.Join(installDir, "manifest.json")
if err := os.WriteFile(binaryPath, binaryData, 0755); err != nil {
return nil, fmt.Errorf("write plugin binary %q: %w", binaryPath, err)
}
if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil {
return nil, fmt.Errorf("write plugin manifest %q: %w", manifestPath, err)
}
capabilities, err := CapabilityRecordsFromManifest(manifest)
if err != nil {
return nil, err
}
createInput := CreateInstallationInput{
PluginID: manifest.GetPluginId(),
Version: manifest.GetVersion(),
InstallPath: binaryPath,
Enabled: false,
Capabilities: capabilities,
}
if repositoryID != nil {
createInput.RepositoryID = *repositoryID
}
installation, err := i.installations.Create(ctx, createInput)
if err != nil {
return nil, fmt.Errorf("persist plugin installation: %w", err)
}
if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, checksum, binaryData); 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)
}
return nil, fmt.Errorf("persist plugin archive: %w", err)
}
enabled := true
if err := i.installations.Update(ctx, installation.ID, UpdateInstallationInput{
Enabled: &enabled,
}); err != nil {
if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil {
return nil, fmt.Errorf("enable plugin installation: %w (cleanup failed: %v)", err, deleteErr)
}
return nil, fmt.Errorf("enable plugin installation: %w", err)
}
installation.Enabled = true
keepInstallDir = true
return &InstallResult{
Installation: installation,
Manifest: manifest,
BinaryPath: binaryPath,
ManifestPath: manifestPath,
}, nil
}
func (i *Installer) installArchive(ctx context.Context, data []byte, repositoryID *int) (*InstallResult, error) {
reader, manifestBytes, manifest, err := openPluginArchive(data)
if err != nil {
return nil, err
}
installRoot := filepath.Join(i.baseDir, sanitizeFilesystemSegment(manifest.GetPluginId()), sanitizeFilesystemSegment(manifest.GetVersion()))
if err := os.MkdirAll(installRoot, 0755); err != nil {
return nil, fmt.Errorf("create plugin install root %q: %w", installRoot, err)
}
installDir, err := os.MkdirTemp(installRoot, "install-")
if err != nil {
return nil, fmt.Errorf("create plugin install dir in %q: %w", installRoot, err)
}
keepInstallDir := false
defer func() {
if keepInstallDir {
return
}
_ = os.RemoveAll(installDir)
}()
binaryPath := filepath.Join(installDir, "plugin")
manifestPath := filepath.Join(installDir, "manifest.json")
capabilities, err := CapabilityRecordsFromManifest(manifest)
if err != nil {
return nil, err
}
createInput := CreateInstallationInput{
PluginID: manifest.GetPluginId(),
Version: manifest.GetVersion(),
InstallPath: binaryPath,
Enabled: false,
Capabilities: capabilities,
}
if repositoryID != nil {
createInput.RepositoryID = *repositoryID
}
installation, err := i.installations.Create(ctx, createInput)
if err != nil {
return nil, fmt.Errorf("persist plugin installation: %w", err)
}
if err := i.installations.SaveArchive(ctx, installation.ID, manifestBytes, manifest.GetChecksum(), data); 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)
}
return nil, fmt.Errorf("persist plugin archive: %w", err)
}
if err := extractArchiveFiles(reader, installDir); err != nil {
if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil {
return nil, fmt.Errorf("extract plugin archive: %w (cleanup failed: %v)", err, deleteErr)
}
return nil, err
}
enabled := true
if err := i.installations.Update(ctx, installation.ID, UpdateInstallationInput{
Enabled: &enabled,
}); err != nil {
if deleteErr := i.installations.Delete(ctx, installation.ID); deleteErr != nil {
return nil, fmt.Errorf("enable plugin installation: %w (cleanup failed: %v)", err, deleteErr)
}
return nil, fmt.Errorf("enable plugin installation: %w", err)
}
installation.Enabled = true
keepInstallDir = true
return &InstallResult{
Installation: installation,
Manifest: manifest,
BinaryPath: binaryPath,
ManifestPath: manifestPath,
}, nil
}
func LoadManifestBytes(data []byte) (*pluginv1.PluginManifest, error) {
manifest, err := publicmanifest.Load(data)
if err != nil {
return nil, err
}
if err := ValidateManifest(manifest); err != nil {
return nil, err
}
return manifest, nil
}
func loadManifestFromBinary(ctx context.Context, binaryData []byte) (*pluginv1.PluginManifest, error) {
tmpDir, err := os.MkdirTemp("", "plugin-manifest-")
if err != nil {
return nil, fmt.Errorf("create temp dir for binary manifest: %w", err)
}
defer os.RemoveAll(tmpDir)
tmpBinary := filepath.Join(tmpDir, "plugin")
if err := os.WriteFile(tmpBinary, binaryData, 0755); err != nil {
return nil, fmt.Errorf("write temp binary for manifest: %w", err)
}
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
cmd := exec.CommandContext(execCtx, tmpBinary, "manifest")
manifestOut, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("execute plugin manifest command: %w", err)
}
var manifest pluginv1.PluginManifest
if err := protojson.Unmarshal(manifestOut, &manifest); err != nil {
return nil, fmt.Errorf("parse plugin manifest output: %w", err)
}
if err := ValidateManifest(&manifest); err != nil {
return nil, err
}
return &manifest, nil
}
func extractZipFile(file *zip.File, root string) error {
if err := validateRelativePath(file.Name, "plugin archive"); err != nil {
return err
}
targetPath := filepath.Join(root, filepath.Clean(file.Name))
if file.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return fmt.Errorf("create plugin directory %q: %w", targetPath, err)
}
return nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("create plugin parent dir %q: %w", targetPath, err)
}
data, err := readZipFile(file)
if err != nil {
return err
}
mode := os.FileMode(0644)
if filepath.Base(file.Name) == "plugin" {
mode = 0755
}
if err := os.WriteFile(targetPath, data, mode); err != nil {
return fmt.Errorf("write plugin file %q: %w", targetPath, err)
}
return nil
}
func readZipFile(file *zip.File) ([]byte, error) {
reader, err := file.Open()
if err != nil {
return nil, fmt.Errorf("open plugin archive entry %q: %w", file.Name, err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("read plugin archive entry %q: %w", file.Name, err)
}
return data, nil
}
func sanitizeFilesystemSegment(value string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", "@", "_")
return replacer.Replace(value)
}