519 lines
14 KiB
Go
519 lines
14 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
|
)
|
|
|
|
type PlatformBinary struct {
|
|
URL string `json:"url"`
|
|
Checksum string `json:"checksum"`
|
|
}
|
|
|
|
type CatalogPackage struct {
|
|
Manifest *pluginv1.PluginManifest `json:"manifest"`
|
|
ArchiveURL string `json:"archive_url,omitempty"`
|
|
ChecksumsURL string `json:"checksums_url,omitempty"`
|
|
Binaries map[string]PlatformBinary `json:"binaries,omitempty"`
|
|
}
|
|
|
|
type RepositoryIndex struct {
|
|
Plugins []CatalogPackage `json:"plugins"`
|
|
}
|
|
|
|
type CatalogEntry struct {
|
|
RepositoryID int
|
|
Manifest *pluginv1.PluginManifest
|
|
ArchiveURL string
|
|
Checksum string
|
|
}
|
|
|
|
type InstallCatalogRequest struct {
|
|
RepositoryID int
|
|
PluginID string
|
|
Version string
|
|
}
|
|
|
|
type ResolvedCatalogInstall struct {
|
|
RepositoryID int
|
|
ArchiveURL string
|
|
Checksum string
|
|
LegacyArchive bool
|
|
}
|
|
|
|
type CatalogServiceOptions struct {
|
|
HTTPClient *http.Client
|
|
SiloAPIVersion string
|
|
CurrentOS string
|
|
CurrentArch string
|
|
}
|
|
|
|
type CatalogService struct {
|
|
repositories *RepositoryStore
|
|
httpClient *http.Client
|
|
siloAPIVersion string
|
|
currentOS string
|
|
currentArch string
|
|
}
|
|
|
|
func NewCatalogService(repositories *RepositoryStore, opts CatalogServiceOptions) *CatalogService {
|
|
httpClient := opts.HTTPClient
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
|
|
apiVersion := opts.SiloAPIVersion
|
|
if apiVersion == "" {
|
|
apiVersion = DefaultSiloAPIVersion
|
|
}
|
|
|
|
currentOS := opts.CurrentOS
|
|
if currentOS == "" {
|
|
currentOS = runtime.GOOS
|
|
}
|
|
currentArch := opts.CurrentArch
|
|
if currentArch == "" {
|
|
currentArch = runtime.GOARCH
|
|
}
|
|
|
|
return &CatalogService{
|
|
repositories: repositories,
|
|
httpClient: httpClient,
|
|
siloAPIVersion: apiVersion,
|
|
currentOS: currentOS,
|
|
currentArch: currentArch,
|
|
}
|
|
}
|
|
|
|
func (s *CatalogService) Fetch(ctx context.Context) ([]CatalogEntry, error) {
|
|
repositories, err := s.repositories.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
catalogByVersion := make(map[string]CatalogEntry)
|
|
for _, repository := range repositories {
|
|
if !repository.Enabled {
|
|
continue
|
|
}
|
|
|
|
index, err := s.fetchRepositoryIndex(ctx, repository.URL)
|
|
if err != nil {
|
|
slog.Warn("skipping broken plugin repository",
|
|
"repository_id", repository.ID,
|
|
"repository_url", repository.URL,
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if err := s.repositories.Update(ctx, repository.ID, UpdateRepositoryInput{LastFetchedAt: &now}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, pkg := range index.Plugins {
|
|
entry, ok, err := s.catalogEntryFromPackage(repository, pkg)
|
|
if err != nil {
|
|
slog.Warn("skipping invalid plugin catalog entry",
|
|
"repository_id", repository.ID,
|
|
"repository_url", repository.URL,
|
|
"plugin_id", manifestPluginID(pkg.Manifest),
|
|
"version", manifestVersion(pkg.Manifest),
|
|
"error", err,
|
|
)
|
|
continue
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
key := capabilityKey(entry.Manifest.GetPluginId(), entry.Manifest.GetVersion())
|
|
if _, exists := catalogByVersion[key]; exists {
|
|
continue
|
|
}
|
|
catalogByVersion[key] = entry
|
|
}
|
|
}
|
|
|
|
entries := make([]CatalogEntry, 0, len(catalogByVersion))
|
|
for _, entry := range catalogByVersion {
|
|
entries = append(entries, entry)
|
|
}
|
|
slices.SortFunc(entries, func(left, right CatalogEntry) int {
|
|
if left.Manifest.GetPluginId() == right.Manifest.GetPluginId() {
|
|
switch {
|
|
case left.Manifest.GetVersion() < right.Manifest.GetVersion():
|
|
return -1
|
|
case left.Manifest.GetVersion() > right.Manifest.GetVersion():
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
switch {
|
|
case left.Manifest.GetPluginId() < right.Manifest.GetPluginId():
|
|
return -1
|
|
case left.Manifest.GetPluginId() > right.Manifest.GetPluginId():
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func (s *CatalogService) ResolveInstall(ctx context.Context, req InstallCatalogRequest) (*ResolvedCatalogInstall, error) {
|
|
if req.RepositoryID == 0 {
|
|
return nil, fmt.Errorf("repository_id is required")
|
|
}
|
|
if strings.TrimSpace(req.PluginID) == "" {
|
|
return nil, fmt.Errorf("plugin_id is required")
|
|
}
|
|
if strings.TrimSpace(req.Version) == "" {
|
|
return nil, fmt.Errorf("version is required")
|
|
}
|
|
|
|
repository, err := s.repositories.GetByID(ctx, req.RepositoryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !repository.Enabled {
|
|
return nil, fmt.Errorf("plugin repository %d is disabled", repository.ID)
|
|
}
|
|
|
|
index, err := s.fetchRepositoryIndex(ctx, repository.URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
if err := s.repositories.Update(ctx, repository.ID, UpdateRepositoryInput{LastFetchedAt: &now}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, pkg := range index.Plugins {
|
|
if pkg.Manifest == nil {
|
|
continue
|
|
}
|
|
if pkg.Manifest.GetPluginId() != req.PluginID || pkg.Manifest.GetVersion() != req.Version {
|
|
continue
|
|
}
|
|
|
|
target, err := s.installTargetFromPackage(ctx, repository, pkg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("plugin %s@%s not found in repository %d", req.PluginID, req.Version, req.RepositoryID)
|
|
}
|
|
|
|
func (s *CatalogService) fetchRepositoryIndex(ctx context.Context, repositoryURL string) (*RepositoryIndex, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, repositoryURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build repository request: %w", err)
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch repository index %q: %w", repositoryURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("fetch repository index %q: unexpected status %d", repositoryURL, resp.StatusCode)
|
|
}
|
|
|
|
var index RepositoryIndex
|
|
if err := json.NewDecoder(resp.Body).Decode(&index); err != nil {
|
|
return nil, fmt.Errorf("decode repository index %q: %w", repositoryURL, err)
|
|
}
|
|
return &index, nil
|
|
}
|
|
|
|
func supportsPlatform(manifest *pluginv1.PluginManifest, osName, arch string) bool {
|
|
for _, platform := range manifest.GetSupportedPlatforms() {
|
|
if platform.GetOs() == osName && platform.GetArch() == arch {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resolveRepositoryURL(repositoryURL, resourceURL string) (string, error) {
|
|
if resourceURL == "" {
|
|
return "", fmt.Errorf("plugin resource url is required")
|
|
}
|
|
baseURL, err := url.Parse(repositoryURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse repository url %q: %w", repositoryURL, err)
|
|
}
|
|
relativeURL, err := url.Parse(resourceURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse resource url %q: %w", resourceURL, err)
|
|
}
|
|
return baseURL.ResolveReference(relativeURL).String(), nil
|
|
}
|
|
|
|
func (s *CatalogService) catalogEntryFromPackage(repository *Repository, pkg CatalogPackage) (CatalogEntry, bool, error) {
|
|
if len(pkg.Binaries) > 0 {
|
|
if err := ValidateCatalogManifest(pkg.Manifest); err != nil {
|
|
return CatalogEntry{}, false, err
|
|
}
|
|
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
|
|
return CatalogEntry{}, false, nil
|
|
}
|
|
|
|
platformKey := s.currentOS + "/" + s.currentArch
|
|
binary, ok := pkg.Binaries[platformKey]
|
|
if !ok {
|
|
return CatalogEntry{}, false, nil
|
|
}
|
|
if strings.TrimSpace(binary.URL) == "" {
|
|
return CatalogEntry{}, false, fmt.Errorf("plugin binary url is required for platform %s", platformKey)
|
|
}
|
|
if strings.TrimSpace(binary.Checksum) == "" && strings.TrimSpace(pkg.ChecksumsURL) == "" {
|
|
return CatalogEntry{}, false, fmt.Errorf("plugin binary checksum is required for platform %s", platformKey)
|
|
}
|
|
|
|
resolvedURL, err := resolveRepositoryURL(repository.URL, binary.URL)
|
|
if err != nil {
|
|
return CatalogEntry{}, false, err
|
|
}
|
|
checksum := strings.TrimSpace(binary.Checksum)
|
|
if checksum != "" {
|
|
checksum, err = normalizeSHA256Checksum(checksum)
|
|
if err != nil {
|
|
return CatalogEntry{}, false, err
|
|
}
|
|
}
|
|
if strings.TrimSpace(pkg.ChecksumsURL) != "" {
|
|
if _, err := resolveRepositoryURL(repository.URL, pkg.ChecksumsURL); err != nil {
|
|
return CatalogEntry{}, false, err
|
|
}
|
|
}
|
|
|
|
return CatalogEntry{
|
|
RepositoryID: repository.ID,
|
|
Manifest: pkg.Manifest,
|
|
ArchiveURL: resolvedURL,
|
|
Checksum: checksum,
|
|
}, true, nil
|
|
}
|
|
|
|
if err := ValidateManifest(pkg.Manifest); err != nil {
|
|
return CatalogEntry{}, false, err
|
|
}
|
|
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
|
|
return CatalogEntry{}, false, nil
|
|
}
|
|
if !supportsPlatform(pkg.Manifest, s.currentOS, s.currentArch) {
|
|
return CatalogEntry{}, false, nil
|
|
}
|
|
|
|
resolvedURL, err := resolveRepositoryURL(repository.URL, pkg.ArchiveURL)
|
|
if err != nil {
|
|
return CatalogEntry{}, false, err
|
|
}
|
|
return CatalogEntry{
|
|
RepositoryID: repository.ID,
|
|
Manifest: pkg.Manifest,
|
|
ArchiveURL: resolvedURL,
|
|
}, true, nil
|
|
}
|
|
|
|
func (s *CatalogService) installTargetFromPackage(ctx context.Context, repository *Repository, pkg CatalogPackage) (*ResolvedCatalogInstall, error) {
|
|
if len(pkg.Binaries) > 0 {
|
|
if err := ValidateCatalogManifest(pkg.Manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
|
|
return nil, fmt.Errorf("plugin silo_api_version %q is not supported", pkg.Manifest.GetSiloApiVersion())
|
|
}
|
|
|
|
platformKey := s.currentOS + "/" + s.currentArch
|
|
binary, ok := pkg.Binaries[platformKey]
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugin %s@%s does not support platform %s", pkg.Manifest.GetPluginId(), pkg.Manifest.GetVersion(), platformKey)
|
|
}
|
|
if strings.TrimSpace(binary.URL) == "" {
|
|
return nil, fmt.Errorf("plugin binary url is required for platform %s", platformKey)
|
|
}
|
|
|
|
resolvedURL, err := resolveRepositoryURL(repository.URL, binary.URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
checksum := strings.TrimSpace(binary.Checksum)
|
|
if checksum != "" {
|
|
checksum, err = normalizeSHA256Checksum(checksum)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if strings.TrimSpace(pkg.ChecksumsURL) == "" {
|
|
return nil, fmt.Errorf("plugin binary checksum is required for platform %s", platformKey)
|
|
}
|
|
resolvedChecksumsURL, err := resolveRepositoryURL(repository.URL, pkg.ChecksumsURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
checksum, err = s.fetchChecksumForBinary(ctx, resolvedChecksumsURL, resolvedURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &ResolvedCatalogInstall{
|
|
RepositoryID: repository.ID,
|
|
ArchiveURL: resolvedURL,
|
|
Checksum: checksum,
|
|
LegacyArchive: false,
|
|
}, nil
|
|
}
|
|
|
|
if err := ValidateManifest(pkg.Manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
if pkg.Manifest.GetSiloApiVersion() != s.siloAPIVersion {
|
|
return nil, fmt.Errorf("plugin silo_api_version %q is not supported", pkg.Manifest.GetSiloApiVersion())
|
|
}
|
|
if !supportsPlatform(pkg.Manifest, s.currentOS, s.currentArch) {
|
|
return nil, fmt.Errorf("plugin %s@%s does not support platform %s/%s", pkg.Manifest.GetPluginId(), pkg.Manifest.GetVersion(), s.currentOS, s.currentArch)
|
|
}
|
|
|
|
resolvedURL, err := resolveRepositoryURL(repository.URL, pkg.ArchiveURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ResolvedCatalogInstall{
|
|
RepositoryID: repository.ID,
|
|
ArchiveURL: resolvedURL,
|
|
LegacyArchive: true,
|
|
}, nil
|
|
}
|
|
|
|
func (s *CatalogService) fetchChecksumForBinary(ctx context.Context, checksumsURL, binaryURL string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumsURL, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("build checksums request: %w", err)
|
|
}
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch checksums file %q: %w", checksumsURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("fetch checksums file %q: unexpected status %d", checksumsURL, resp.StatusCode)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read checksums file %q: %w", checksumsURL, err)
|
|
}
|
|
|
|
checksum, err := checksumForBinary(string(data), binaryURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return checksum, nil
|
|
}
|
|
|
|
func checksumForBinary(contents string, binaryURL string) (string, error) {
|
|
parsedURL, err := url.Parse(binaryURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse binary url %q: %w", binaryURL, err)
|
|
}
|
|
filename := path.Base(parsedURL.Path)
|
|
if filename == "." || filename == "/" || filename == "" {
|
|
return "", fmt.Errorf("resolve checksum target from binary url %q", binaryURL)
|
|
}
|
|
|
|
for _, line := range strings.Split(contents, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
checksum, entryFilename, ok, err := parseChecksumLine(line)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if path.Base(entryFilename) == filename {
|
|
return checksum, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("binary checksum for %q not found", filename)
|
|
}
|
|
|
|
func parseChecksumLine(line string) (checksum string, filename string, ok bool, err error) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return "", "", false, nil
|
|
}
|
|
if len(line) < 65 {
|
|
return "", "", false, fmt.Errorf("invalid checksum line %q", line)
|
|
}
|
|
|
|
checksum = strings.TrimSpace(line[:64])
|
|
checksum, err = normalizeSHA256Checksum(checksum)
|
|
if err != nil {
|
|
return "", "", false, err
|
|
}
|
|
|
|
rest := strings.TrimLeft(line[64:], " \t")
|
|
rest = strings.TrimPrefix(rest, "*")
|
|
rest = strings.TrimSpace(rest)
|
|
if rest == "" {
|
|
return "", "", false, fmt.Errorf("invalid checksum line %q", line)
|
|
}
|
|
return checksum, rest, true, nil
|
|
}
|
|
|
|
func normalizeSHA256Checksum(checksum string) (string, error) {
|
|
checksum = strings.ToLower(strings.TrimSpace(checksum))
|
|
if len(checksum) != 64 {
|
|
return "", fmt.Errorf("invalid sha256 checksum %q", checksum)
|
|
}
|
|
if _, err := hex.DecodeString(checksum); err != nil {
|
|
return "", fmt.Errorf("invalid sha256 checksum %q", checksum)
|
|
}
|
|
return checksum, nil
|
|
}
|
|
|
|
func manifestPluginID(manifest *pluginv1.PluginManifest) string {
|
|
if manifest == nil {
|
|
return ""
|
|
}
|
|
return manifest.GetPluginId()
|
|
}
|
|
|
|
func manifestVersion(manifest *pluginv1.PluginManifest) string {
|
|
if manifest == nil {
|
|
return ""
|
|
}
|
|
return manifest.GetVersion()
|
|
}
|