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

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()
}