Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
825
backend/internal/homeplugins/sync.go
Normal file
825
backend/internal/homeplugins/sync.go
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
package homeplugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Platform struct {
|
||||
GOOS string `json:"goos"`
|
||||
GOARCH string `json:"goarch"`
|
||||
}
|
||||
|
||||
type PluginRuntime interface {
|
||||
PluginBusy(id string) bool
|
||||
UnloadPlugin(id string) bool
|
||||
}
|
||||
|
||||
type PluginLoadInspector interface {
|
||||
PluginRegistered(id string) bool
|
||||
}
|
||||
|
||||
type contextualPluginUnloader interface {
|
||||
UnloadPluginContext(ctx context.Context, id string) bool
|
||||
}
|
||||
|
||||
type SyncReport struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
TaskID uint `json:"task_id,omitempty"`
|
||||
Task string `json:"task"`
|
||||
NodeID string `json:"node_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase"`
|
||||
OK bool `json:"ok"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Platform Platform `json:"platform"`
|
||||
Plugins []PluginInstallStatus `json:"plugins"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PluginInstallStatus struct {
|
||||
ID string `json:"id"`
|
||||
Version string `json:"version,omitempty"`
|
||||
ReleaseTag string `json:"release_tag,omitempty"`
|
||||
Repository string `json:"repository,omitempty"`
|
||||
InstallType string `json:"install_type,omitempty"`
|
||||
InstallStatus string `json:"install_status"`
|
||||
LoadStatus string `json:"load_status,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Overwritten bool `json:"overwritten,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
pluginTaskName = "plugin-sync"
|
||||
pluginDeleteTaskName = "plugin-delete"
|
||||
pluginTaskStatusOK = "success"
|
||||
pluginTaskStatusError = "failed"
|
||||
pluginTaskPhaseInstall = "install"
|
||||
pluginTaskPhaseLoad = "load"
|
||||
pluginTaskPhaseDelete = "delete"
|
||||
|
||||
pluginInstallStatusInstalled = "installed"
|
||||
pluginInstallStatusSkipped = "skipped"
|
||||
pluginInstallStatusFailed = "failed"
|
||||
pluginInstallStatusDeleted = "deleted"
|
||||
pluginInstallStatusMissing = "missing"
|
||||
pluginLoadStatusLoaded = "loaded"
|
||||
pluginLoadStatusFailed = "failed"
|
||||
)
|
||||
|
||||
// CurrentPlatform reports the platform used by pluginhost discovery.
|
||||
func CurrentPlatform() Platform {
|
||||
return Platform{
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePlatform(platform Platform) Platform {
|
||||
goos := strings.ToLower(strings.TrimSpace(platform.GOOS))
|
||||
switch goos {
|
||||
case "mac", "macos", "osx":
|
||||
goos = "darwin"
|
||||
}
|
||||
goarch := strings.ToLower(strings.TrimSpace(platform.GOARCH))
|
||||
switch goarch {
|
||||
case "x64", "x86_64":
|
||||
goarch = "amd64"
|
||||
case "aarch64":
|
||||
goarch = "arm64"
|
||||
}
|
||||
return Platform{GOOS: goos, GOARCH: goarch}
|
||||
}
|
||||
|
||||
func Sync(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) error {
|
||||
_, errSync := SyncPlatformWithReport(ctx, cfg, pluginRuntime, CurrentPlatform())
|
||||
return errSync
|
||||
}
|
||||
|
||||
func SyncPlatform(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, platform Platform) error {
|
||||
_, errSync := SyncPlatformWithReport(ctx, cfg, pluginRuntime, platform)
|
||||
return errSync
|
||||
}
|
||||
|
||||
func SyncWithReport(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime) (SyncReport, error) {
|
||||
return SyncPlatformWithReport(ctx, cfg, pluginRuntime, CurrentPlatform())
|
||||
}
|
||||
|
||||
func SyncPlatformWithReport(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, platform Platform) (SyncReport, error) {
|
||||
if cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled {
|
||||
return newSyncReport(platform), nil
|
||||
}
|
||||
platform = NormalizePlatform(platform)
|
||||
report := newSyncReport(platform)
|
||||
if platform.GOOS == "" {
|
||||
errPlatform := fmt.Errorf("home plugins: goos is required")
|
||||
finishReport(&report, errPlatform)
|
||||
return report, errPlatform
|
||||
}
|
||||
if platform.GOARCH == "" {
|
||||
errPlatform := fmt.Errorf("home plugins: goarch is required")
|
||||
finishReport(&report, errPlatform)
|
||||
return report, errPlatform
|
||||
}
|
||||
report.Platform = platform
|
||||
root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
|
||||
if errResolvePluginsDir != nil {
|
||||
errPluginsDir := fmt.Errorf("home plugins: %w", errResolvePluginsDir)
|
||||
finishReport(&report, errPluginsDir)
|
||||
return report, errPluginsDir
|
||||
}
|
||||
client := newPluginStoreClient(cfg)
|
||||
var syncErrors []error
|
||||
ids := make([]string, 0, len(cfg.Plugins.Configs))
|
||||
for id := range cfg.Plugins.Configs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
item := cfg.Plugins.Configs[id]
|
||||
if !pluginConfigEnabled(item) {
|
||||
continue
|
||||
}
|
||||
manifest, okManifest, errManifest := storeManifestFromPluginConfig(id, item)
|
||||
if errManifest != nil {
|
||||
status := PluginInstallStatus{
|
||||
ID: strings.TrimSpace(id),
|
||||
InstallStatus: pluginInstallStatusFailed,
|
||||
Error: errManifest.Error(),
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
syncErrors = append(syncErrors, errManifest)
|
||||
continue
|
||||
}
|
||||
if !okManifest {
|
||||
continue
|
||||
}
|
||||
status := pluginStatusFromManifest(manifest)
|
||||
result, errSync := installManifest(ctx, client, manifest, root, platform, pluginRuntime)
|
||||
if errSync != nil {
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errSync.Error()
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
syncErrors = append(syncErrors, errSync)
|
||||
continue
|
||||
}
|
||||
status.Path = strings.TrimSpace(result.Path)
|
||||
status.Skipped = result.Skipped
|
||||
status.Overwritten = result.Overwritten
|
||||
if result.Skipped {
|
||||
status.InstallStatus = pluginInstallStatusSkipped
|
||||
} else {
|
||||
status.InstallStatus = pluginInstallStatusInstalled
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
}
|
||||
errSync := errors.Join(syncErrors...)
|
||||
finishReport(&report, errSync)
|
||||
return report, errSync
|
||||
}
|
||||
|
||||
func SyncResolvedWithReport(ctx context.Context, cfg *config.Config, items []sdkpluginstore.PluginSyncItem, expiresAt time.Time, installedVersions map[string]string, pluginRuntime PluginRuntime) (SyncReport, error) {
|
||||
defer func() {
|
||||
for index := range items {
|
||||
items[index].Clear()
|
||||
}
|
||||
}()
|
||||
platform := NormalizePlatform(CurrentPlatform())
|
||||
report := newSyncReport(platform)
|
||||
if cfg == nil || !cfg.Home.Enabled || !cfg.Plugins.Enabled {
|
||||
finishReport(&report, nil)
|
||||
return report, nil
|
||||
}
|
||||
root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
|
||||
if errResolvePluginsDir != nil {
|
||||
errPluginsDir := fmt.Errorf("home plugins: %w", errResolvePluginsDir)
|
||||
finishReport(&report, errPluginsDir)
|
||||
return report, errPluginsDir
|
||||
}
|
||||
addInstalledVersionStatuses(&report, cfg, root, installedVersions)
|
||||
var syncErrors []error
|
||||
for index := range items {
|
||||
if !time.Now().UTC().Before(expiresAt) {
|
||||
errExpired := fmt.Errorf("home plugins: plugin sync response expired")
|
||||
syncErrors = append(syncErrors, errExpired)
|
||||
break
|
||||
}
|
||||
item := &items[index]
|
||||
manifest := item.Manifest
|
||||
status := pluginStatusFromManifest(manifest)
|
||||
result, errInstall := installResolvedManifest(ctx, cfg, manifest, item.Auth, expiresAt, root, platform, pluginRuntime)
|
||||
item.Clear()
|
||||
if errInstall != nil {
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errInstall.Error()
|
||||
upsertPluginInstallStatus(&report, status)
|
||||
syncErrors = append(syncErrors, errInstall)
|
||||
continue
|
||||
}
|
||||
status.Path = strings.TrimSpace(result.Path)
|
||||
status.Skipped = result.Skipped
|
||||
status.Overwritten = result.Overwritten
|
||||
if result.Skipped {
|
||||
status.InstallStatus = pluginInstallStatusSkipped
|
||||
} else {
|
||||
status.InstallStatus = pluginInstallStatusInstalled
|
||||
}
|
||||
upsertPluginInstallStatus(&report, status)
|
||||
}
|
||||
errSync := errors.Join(syncErrors...)
|
||||
finishReport(&report, errSync)
|
||||
return report, errSync
|
||||
}
|
||||
|
||||
func addInstalledVersionStatuses(report *SyncReport, cfg *config.Config, root string, installedVersions map[string]string) {
|
||||
if report == nil || cfg == nil || len(installedVersions) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(cfg.Plugins.Configs))
|
||||
for id := range cfg.Plugins.Configs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
item := cfg.Plugins.Configs[id]
|
||||
if !pluginConfigEnabled(item) {
|
||||
continue
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
version, okVersion := installedVersions[id]
|
||||
if !okVersion {
|
||||
continue
|
||||
}
|
||||
status := PluginInstallStatus{
|
||||
ID: id,
|
||||
Version: strings.TrimSpace(version),
|
||||
InstallStatus: pluginInstallStatusSkipped,
|
||||
Skipped: true,
|
||||
}
|
||||
files, errFiles := pluginFileInfos(root, id)
|
||||
if errFiles == nil {
|
||||
for _, file := range files {
|
||||
if strings.TrimSpace(file.Version) == status.Version {
|
||||
status.Path = strings.TrimSpace(file.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
manifest, okManifest, errManifest := storeManifestFromPluginConfig(id, item)
|
||||
if errManifest == nil && okManifest && pluginVersionsEqual(status.Version, manifest.Version) {
|
||||
status.ReleaseTag = strings.TrimSpace(manifest.ReleaseTag)
|
||||
status.Repository = strings.TrimSpace(manifest.Repository)
|
||||
status.InstallType = manifest.InstallType()
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
}
|
||||
}
|
||||
|
||||
func pluginVersionsEqual(left string, right string) bool {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return !sdkpluginstore.UpdateAvailable(left, right) && !sdkpluginstore.UpdateAvailable(right, left)
|
||||
}
|
||||
|
||||
func upsertPluginInstallStatus(report *SyncReport, status PluginInstallStatus) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(status.ID)
|
||||
for index := range report.Plugins {
|
||||
if strings.TrimSpace(report.Plugins[index].ID) == id {
|
||||
report.Plugins[index] = status
|
||||
return
|
||||
}
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
}
|
||||
|
||||
func installResolvedManifest(ctx context.Context, cfg *config.Config, manifest sdkpluginstore.Manifest, auth []sdkpluginstore.ResolvedAuthConfig, expiresAt time.Time, root string, platform Platform, pluginRuntime PluginRuntime) (sdkpluginstore.InstallResult, error) {
|
||||
client := newResolvedPluginStoreClient(cfg, auth, expiresAt)
|
||||
defer client.ClearAuth()
|
||||
return installManifest(ctx, client, manifest, root, platform, pluginRuntime)
|
||||
}
|
||||
|
||||
func InstalledVersions(cfg *config.Config) (map[string]string, error) {
|
||||
if cfg == nil {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
|
||||
if errResolvePluginsDir != nil {
|
||||
return nil, fmt.Errorf("home plugins: %w", errResolvePluginsDir)
|
||||
}
|
||||
versions := make(map[string]string, len(cfg.Plugins.Configs))
|
||||
for id := range cfg.Plugins.Configs {
|
||||
files, errFiles := pluginFileInfos(root, id)
|
||||
if errFiles != nil {
|
||||
return nil, fmt.Errorf("home plugins: discover installed plugin %s: %w", id, errFiles)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
continue
|
||||
}
|
||||
version := strings.TrimSpace(files[0].Version)
|
||||
if version != "" {
|
||||
versions[strings.TrimSpace(id)] = version
|
||||
}
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
func installManifest(ctx context.Context, client sdkpluginstore.Client, manifest sdkpluginstore.Manifest, root string, platform Platform, pluginRuntime PluginRuntime) (sdkpluginstore.InstallResult, error) {
|
||||
id := strings.TrimSpace(manifest.ID)
|
||||
if id == "" {
|
||||
return sdkpluginstore.InstallResult{}, fmt.Errorf("home plugins: manifest plugin id is empty")
|
||||
}
|
||||
pluginIsBusy := func() bool {
|
||||
return pluginRuntime != nil && pluginRuntime.PluginBusy(id)
|
||||
}
|
||||
result, errInstall := client.InstallManifest(ctx, manifest, sdkpluginstore.InstallOptions{
|
||||
PluginsDir: root,
|
||||
GOOS: platform.GOOS,
|
||||
GOARCH: platform.GOARCH,
|
||||
PluginLoaded: pluginIsBusy,
|
||||
})
|
||||
if errInstall != nil {
|
||||
return sdkpluginstore.InstallResult{}, fmt.Errorf("home plugins: install %s: %w", id, errInstall)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeleteWithReport(ctx context.Context, cfg *config.Config, pluginRuntime PluginRuntime, taskID uint, pluginID string) SyncReport {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
platform := CurrentPlatform()
|
||||
report := newSyncReport(platform)
|
||||
report.TaskID = taskID
|
||||
report.Task = pluginDeleteTaskName
|
||||
report.Phase = pluginTaskPhaseDelete
|
||||
pluginID = strings.TrimSpace(pluginID)
|
||||
status := PluginInstallStatus{ID: pluginID}
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errContext.Error()
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
finishReport(&report, errContext)
|
||||
return report
|
||||
}
|
||||
if cfg == nil {
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = "home plugins: config is nil"
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
finishReport(&report, errors.New(status.Error))
|
||||
return report
|
||||
}
|
||||
root, errResolvePluginsDir := config.ResolvePluginsDir(cfg.Plugins.Dir)
|
||||
if errResolvePluginsDir != nil {
|
||||
errPluginsDir := fmt.Errorf("home plugins: %w", errResolvePluginsDir)
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errPluginsDir.Error()
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
finishReport(&report, errPluginsDir)
|
||||
return report
|
||||
}
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errContext.Error()
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
finishReport(&report, errContext)
|
||||
return report
|
||||
}
|
||||
path, deleted, errDelete := deletePluginArtifact(ctx, root, pluginID, pluginRuntime)
|
||||
status.Path = strings.TrimSpace(path)
|
||||
switch {
|
||||
case errDelete != nil:
|
||||
status.InstallStatus = pluginInstallStatusFailed
|
||||
status.Error = errDelete.Error()
|
||||
case deleted:
|
||||
status.InstallStatus = pluginInstallStatusDeleted
|
||||
default:
|
||||
status.InstallStatus = pluginInstallStatusMissing
|
||||
}
|
||||
report.Plugins = append(report.Plugins, status)
|
||||
finishReport(&report, errDelete)
|
||||
return report
|
||||
}
|
||||
|
||||
func deletePluginArtifact(ctx context.Context, root string, id string, pluginRuntime PluginRuntime) (string, bool, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return "", false, errContext
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if !validPluginFileID(id) {
|
||||
return "", false, fmt.Errorf("invalid plugin id %q", id)
|
||||
}
|
||||
paths, errPaths := pluginFilePaths(root, id)
|
||||
if errPaths != nil {
|
||||
return "", false, errPaths
|
||||
}
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return "", false, errContext
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
if pluginRuntime != nil && pluginRuntime.PluginBusy(id) {
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return paths[0], false, errContext
|
||||
}
|
||||
unloaded := false
|
||||
if contextual, ok := pluginRuntime.(contextualPluginUnloader); ok {
|
||||
unloaded = contextual.UnloadPluginContext(ctx, id)
|
||||
} else {
|
||||
unloaded = pluginRuntime.UnloadPlugin(id)
|
||||
}
|
||||
if !unloaded && pluginRuntime.PluginBusy(id) {
|
||||
return paths[0], false, sdkpluginstore.ErrLoadedPluginLocked
|
||||
}
|
||||
}
|
||||
deleted := false
|
||||
for _, path := range paths {
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return paths[0], deleted, errContext
|
||||
}
|
||||
if errRemove := os.Remove(path); errRemove != nil {
|
||||
if errors.Is(errRemove, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return paths[0], deleted, errRemove
|
||||
}
|
||||
deleted = true
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return paths[0], deleted, errContext
|
||||
}
|
||||
}
|
||||
return paths[0], deleted, nil
|
||||
}
|
||||
|
||||
func currentPluginFilePath(root string, id string) (string, error) {
|
||||
paths, errPaths := pluginFilePaths(root, id)
|
||||
if errPaths != nil {
|
||||
return "", errPaths
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return paths[0], nil
|
||||
}
|
||||
|
||||
func pluginFilePaths(root string, id string) ([]string, error) {
|
||||
files, errFiles := pluginFileInfos(root, id)
|
||||
if errFiles != nil {
|
||||
return nil, errFiles
|
||||
}
|
||||
out := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
out = append(out, file.Path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func pluginFileInfos(root string, id string) ([]pluginFileInfo, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
root = "plugins"
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
platform := CurrentPlatform()
|
||||
extension := pluginExtension(platform.GOOS)
|
||||
candidates := make([]pluginFileInfo, 0)
|
||||
for _, dir := range pluginCandidateDirs(root, platform.GOOS, platform.GOARCH) {
|
||||
entries, errReadDir := os.ReadDir(dir)
|
||||
if errReadDir != nil {
|
||||
if errors.Is(errReadDir, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, errReadDir
|
||||
}
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry == nil || !entry.Type().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(entry.Name()), extension) {
|
||||
files = append(files, filepath.Join(dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, filePath := range files {
|
||||
file, okFile := pluginFileFromPath(filePath, extension)
|
||||
if !okFile || file.ID != id {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, file)
|
||||
}
|
||||
}
|
||||
if len(candidates) <= 1 {
|
||||
return candidates, nil
|
||||
}
|
||||
bestIndex := 0
|
||||
for index := 1; index < len(candidates); index++ {
|
||||
if pluginFilePreferred(candidates[index], candidates[bestIndex]) {
|
||||
bestIndex = index
|
||||
}
|
||||
}
|
||||
if bestIndex == 0 {
|
||||
return candidates, nil
|
||||
}
|
||||
out := make([]pluginFileInfo, 0, len(candidates))
|
||||
out = append(out, candidates[bestIndex])
|
||||
for index, candidate := range candidates {
|
||||
if index == bestIndex {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type pluginFileInfo struct {
|
||||
ID string
|
||||
Path string
|
||||
Version string
|
||||
}
|
||||
|
||||
func pluginCandidateDirs(root string, goos string, goarch string) []string {
|
||||
dirs := make([]string, 0, 2)
|
||||
dirs = append(dirs, filepath.Join(root, goos, goarch))
|
||||
dirs = append(dirs, root)
|
||||
return dirs
|
||||
}
|
||||
|
||||
func pluginIDFromPath(path string) string {
|
||||
file, ok := pluginFileFromPath(path, "")
|
||||
if ok {
|
||||
return file.ID
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
lowerBase := strings.ToLower(base)
|
||||
for _, extension := range []string{".so", ".dylib", ".dll"} {
|
||||
if strings.HasSuffix(lowerBase, extension) {
|
||||
return base[:len(base)-len(extension)]
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func pluginFileFromPath(filePath string, requiredExtension string) (pluginFileInfo, bool) {
|
||||
base := filepath.Base(filePath)
|
||||
lowerBase := strings.ToLower(base)
|
||||
extension := strings.TrimSpace(requiredExtension)
|
||||
if extension != "" {
|
||||
if !strings.HasSuffix(lowerBase, strings.ToLower(extension)) {
|
||||
return pluginFileInfo{}, false
|
||||
}
|
||||
} else {
|
||||
for _, candidateExtension := range []string{".so", ".dylib", ".dll"} {
|
||||
if strings.HasSuffix(lowerBase, candidateExtension) {
|
||||
extension = candidateExtension
|
||||
break
|
||||
}
|
||||
}
|
||||
if extension == "" {
|
||||
return pluginFileInfo{}, false
|
||||
}
|
||||
}
|
||||
name := base[:len(base)-len(extension)]
|
||||
id := name
|
||||
version := ""
|
||||
if versionIndex := strings.LastIndex(name, "-v"); versionIndex > 0 {
|
||||
candidateID := name[:versionIndex]
|
||||
candidateVersion := name[versionIndex+2:]
|
||||
if validPluginFileID(candidateID) && validPluginFileVersion(candidateVersion) {
|
||||
id = candidateID
|
||||
version = candidateVersion
|
||||
}
|
||||
}
|
||||
if !validPluginFileID(id) {
|
||||
return pluginFileInfo{}, false
|
||||
}
|
||||
return pluginFileInfo{ID: id, Path: filePath, Version: version}, true
|
||||
}
|
||||
|
||||
func pluginFilePreferred(candidate pluginFileInfo, current pluginFileInfo) bool {
|
||||
if strings.TrimSpace(current.Path) == "" {
|
||||
return true
|
||||
}
|
||||
if candidate.Version == "" {
|
||||
return false
|
||||
}
|
||||
if current.Version == "" {
|
||||
return true
|
||||
}
|
||||
return sdkpluginstore.UpdateAvailable(current.Version, candidate.Version)
|
||||
}
|
||||
|
||||
func pluginExtension(goos string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(goos)) {
|
||||
case "darwin", "mac", "macos", "osx":
|
||||
return ".dylib"
|
||||
case "windows":
|
||||
return ".dll"
|
||||
default:
|
||||
return ".so"
|
||||
}
|
||||
}
|
||||
|
||||
func validPluginFileID(id string) bool {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
|
||||
return false
|
||||
}
|
||||
for _, char := range id {
|
||||
switch {
|
||||
case char >= 'a' && char <= 'z':
|
||||
case char >= 'A' && char <= 'Z':
|
||||
case char >= '0' && char <= '9':
|
||||
case char == '-', char == '_', char == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validPluginFileVersion(version string) bool {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" || strings.HasPrefix(version, "v") {
|
||||
return false
|
||||
}
|
||||
first := version[0]
|
||||
return first >= '0' && first <= '9'
|
||||
}
|
||||
|
||||
func MarkLoadResults(report *SyncReport, inspector PluginLoadInspector) error {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
report.Phase = pluginTaskPhaseLoad
|
||||
var loadErrors []error
|
||||
preserveSyncError := !report.OK && strings.TrimSpace(report.Error) != ""
|
||||
if preserveSyncError {
|
||||
loadErrors = append(loadErrors, errors.New(report.Error))
|
||||
}
|
||||
for index := range report.Plugins {
|
||||
status := &report.Plugins[index]
|
||||
if status.InstallStatus == pluginInstallStatusFailed {
|
||||
if status.LoadStatus == "" {
|
||||
status.LoadStatus = pluginInstallStatusSkipped
|
||||
}
|
||||
if !preserveSyncError {
|
||||
if strings.TrimSpace(status.Error) != "" {
|
||||
loadErrors = append(loadErrors, errors.New(status.Error))
|
||||
} else {
|
||||
loadErrors = append(loadErrors, fmt.Errorf("home plugins: plugin %s install failed", status.ID))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inspector != nil && inspector.PluginRegistered(status.ID) {
|
||||
status.LoadStatus = pluginLoadStatusLoaded
|
||||
continue
|
||||
}
|
||||
status.LoadStatus = pluginLoadStatusFailed
|
||||
errLoad := fmt.Errorf("home plugins: plugin %s installed but not loaded", status.ID)
|
||||
if strings.TrimSpace(status.Error) == "" {
|
||||
status.Error = errLoad.Error()
|
||||
}
|
||||
loadErrors = append(loadErrors, errLoad)
|
||||
}
|
||||
errLoad := errors.Join(loadErrors...)
|
||||
finishReport(report, errLoad)
|
||||
return errLoad
|
||||
}
|
||||
|
||||
func newSyncReport(platform Platform) SyncReport {
|
||||
now := time.Now().UTC()
|
||||
return SyncReport{
|
||||
SchemaVersion: 1,
|
||||
Task: pluginTaskName,
|
||||
Status: pluginTaskStatusOK,
|
||||
Phase: pluginTaskPhaseInstall,
|
||||
OK: true,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
Platform: NormalizePlatform(platform),
|
||||
Plugins: []PluginInstallStatus{},
|
||||
}
|
||||
}
|
||||
|
||||
// CompletedSyncReport builds a completed report for outcomes before plugin installation starts.
|
||||
func CompletedSyncReport(platform Platform, errSync error) SyncReport {
|
||||
report := newSyncReport(platform)
|
||||
finishReport(&report, errSync)
|
||||
return report
|
||||
}
|
||||
|
||||
func finishReport(report *SyncReport, errTask error) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
report.FinishedAt = now
|
||||
report.UpdatedAt = now
|
||||
report.OK = errTask == nil
|
||||
if errTask != nil {
|
||||
report.Status = pluginTaskStatusError
|
||||
report.Error = errTask.Error()
|
||||
return
|
||||
}
|
||||
report.Status = pluginTaskStatusOK
|
||||
report.Error = ""
|
||||
}
|
||||
|
||||
func pluginStatusFromManifest(manifest sdkpluginstore.Manifest) PluginInstallStatus {
|
||||
return PluginInstallStatus{
|
||||
ID: strings.TrimSpace(manifest.ID),
|
||||
Version: strings.TrimSpace(manifest.Version),
|
||||
ReleaseTag: strings.TrimSpace(manifest.ReleaseTag),
|
||||
Repository: strings.TrimSpace(manifest.Repository),
|
||||
InstallType: manifest.InstallType(),
|
||||
InstallStatus: pluginInstallStatusFailed,
|
||||
}
|
||||
}
|
||||
|
||||
func storeManifestFromPluginConfig(id string, item config.PluginInstanceConfig) (sdkpluginstore.Manifest, bool, error) {
|
||||
if item.Raw.Kind == 0 {
|
||||
return sdkpluginstore.Manifest{}, false, nil
|
||||
}
|
||||
storeNode := yamlMappingValue(&item.Raw, "store")
|
||||
if storeNode == nil || storeNode.Kind == 0 {
|
||||
return sdkpluginstore.Manifest{}, false, nil
|
||||
}
|
||||
var manifest sdkpluginstore.Manifest
|
||||
if errDecode := storeNode.Decode(&manifest); errDecode != nil {
|
||||
return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: decode store manifest for %s: %w", id, errDecode)
|
||||
}
|
||||
if strings.TrimSpace(manifest.ID) == "" {
|
||||
manifest.ID = strings.TrimSpace(id)
|
||||
}
|
||||
if errValidate := manifest.Validate(); errValidate != nil {
|
||||
return sdkpluginstore.Manifest{}, false, fmt.Errorf("home plugins: invalid store manifest for %s: %w", id, errValidate)
|
||||
}
|
||||
return manifest, true, nil
|
||||
}
|
||||
|
||||
func yamlMappingValue(node *yaml.Node, key string) *yaml.Node {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||
keyNode := node.Content[i]
|
||||
if keyNode == nil || keyNode.Value != key {
|
||||
continue
|
||||
}
|
||||
return node.Content[i+1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
|
||||
client := &http.Client{}
|
||||
var storeAuth []sdkpluginstore.AuthConfig
|
||||
if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" {
|
||||
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client)
|
||||
}
|
||||
if cfg != nil {
|
||||
storeAuth = cfg.Plugins.StoreAuth
|
||||
}
|
||||
return sdkpluginstore.NewClientWithAuth(client, "", storeAuth)
|
||||
}
|
||||
|
||||
var newResolvedPluginStoreClient = func(cfg *config.Config, auth []sdkpluginstore.ResolvedAuthConfig, expiresAt time.Time) sdkpluginstore.Client {
|
||||
client := &http.Client{}
|
||||
if cfg != nil && strings.TrimSpace(cfg.ProxyURL) != "" {
|
||||
util.SetProxy(&sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(cfg.ProxyURL)}, client)
|
||||
}
|
||||
return sdkpluginstore.NewClientWithResolvedAuthExpiry(client, "", auth, expiresAt)
|
||||
}
|
||||
|
||||
func pluginConfigEnabled(item config.PluginInstanceConfig) bool {
|
||||
return item.Enabled != nil && *item.Enabled
|
||||
}
|
||||
814
backend/internal/homeplugins/sync_test.go
Normal file
814
backend/internal/homeplugins/sync_test.go
Normal file
|
|
@ -0,0 +1,814 @@
|
|||
package homeplugins
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type fakePluginRuntime struct {
|
||||
busy bool
|
||||
unloaded []string
|
||||
}
|
||||
|
||||
type fakePluginLoadInspector map[string]bool
|
||||
|
||||
func (r *fakePluginRuntime) PluginBusy(id string) bool {
|
||||
return r.busy
|
||||
}
|
||||
|
||||
func (r *fakePluginRuntime) UnloadPlugin(id string) bool {
|
||||
r.unloaded = append(r.unloaded, id)
|
||||
r.busy = false
|
||||
return true
|
||||
}
|
||||
|
||||
func (i fakePluginLoadInspector) PluginRegistered(id string) bool {
|
||||
return i[id]
|
||||
}
|
||||
|
||||
type contextPluginRuntime struct {
|
||||
fakePluginRuntime
|
||||
unloadContext context.Context
|
||||
}
|
||||
|
||||
func (r *contextPluginRuntime) UnloadPluginContext(ctx context.Context, id string) bool {
|
||||
r.unloadContext = ctx
|
||||
return r.UnloadPlugin(id)
|
||||
}
|
||||
|
||||
func TestSyncPlatformInstallsManifestArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
|
||||
archiveName := "sample_0.2.0_windows_amd64.zip"
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
httpClient := mapHTTPDoer{
|
||||
"https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []byte(`{
|
||||
"tag_name": "v0.2.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
|
||||
]
|
||||
}`),
|
||||
"https://downloads.example/" + archiveName: archiveData,
|
||||
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
|
||||
}
|
||||
restore := replacePluginStoreClientForTest(httpClient)
|
||||
defer restore()
|
||||
|
||||
if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil {
|
||||
t.Fatalf("SyncPlatform() error = %v", errSync)
|
||||
}
|
||||
target := pluginTestPath(root, "windows", "amd64", "sample", "0.2.0")
|
||||
got, errRead := os.ReadFile(target)
|
||||
if errRead != nil {
|
||||
t.Fatalf("read target: %v", errRead)
|
||||
}
|
||||
if string(got) != "library-data" {
|
||||
t.Fatalf("target data = %q, want library-data", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResolvedWithReportUsesTemporaryAuthAndClearsIt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
libraryName := "sample" + pluginExtension(runtime.GOOS)
|
||||
archiveData := makeZip(t, map[string]string{libraryName: "library-data"})
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
var authenticated bool
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer temporary-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
authenticated = true
|
||||
_, _ = w.Write(archiveData)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
response, errUnauthenticated := server.Client().Get(server.URL + "/private/sample.zip")
|
||||
if errUnauthenticated != nil {
|
||||
t.Fatalf("unauthenticated GET error = %v", errUnauthenticated)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
if response.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated status = %d, want 401", response.StatusCode)
|
||||
}
|
||||
|
||||
originalClient := newResolvedPluginStoreClient
|
||||
newResolvedPluginStoreClient = func(_ *config.Config, auth []sdkpluginstore.ResolvedAuthConfig, expiresAt time.Time) sdkpluginstore.Client {
|
||||
return sdkpluginstore.NewClientWithResolvedAuthExpiry(server.Client(), "", auth, expiresAt)
|
||||
}
|
||||
defer func() { newResolvedPluginStoreClient = originalClient }()
|
||||
token := sdkpluginstore.Secret("temporary-token")
|
||||
backing := token
|
||||
items := []sdkpluginstore.PluginSyncItem{{
|
||||
Manifest: sdkpluginstore.Manifest{
|
||||
SchemaVersion: sdkpluginstore.SchemaVersionV2,
|
||||
ID: "sample",
|
||||
Version: "1.0.0",
|
||||
Install: sdkpluginstore.InstallPlan{Type: sdkpluginstore.InstallTypeDirect, Artifacts: []sdkpluginstore.Artifact{{
|
||||
GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, URL: server.URL + "/private/sample.zip",
|
||||
SHA256: hex.EncodeToString(checksum[:]), Size: int64(len(archiveData)),
|
||||
}}},
|
||||
},
|
||||
Auth: []sdkpluginstore.ResolvedAuthConfig{{
|
||||
Match: server.URL + "/private/", ApplyTo: []string{sdkpluginstore.RequestKindArtifact}, Type: sdkpluginstore.AuthTypeBearer, Token: token,
|
||||
}},
|
||||
}}
|
||||
enabled := true
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{Enabled: true, Dir: root, Configs: map[string]config.PluginInstanceConfig{"sample": {Enabled: &enabled}}},
|
||||
}
|
||||
|
||||
report, errSync := SyncResolvedWithReport(context.Background(), cfg, items, time.Now().UTC().Add(time.Minute), map[string]string{"sample": "0.9.0"}, nil)
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncResolvedWithReport() error = %v", errSync)
|
||||
}
|
||||
if !authenticated || !report.OK || len(report.Plugins) != 1 || report.Plugins[0].Version != "1.0.0" {
|
||||
t.Fatalf("authenticated=%v report=%+v, want successful authenticated install", authenticated, report)
|
||||
}
|
||||
for index, value := range backing {
|
||||
if value != 0 {
|
||||
t.Fatalf("token byte %d = %d, want zero after sync", index, value)
|
||||
}
|
||||
}
|
||||
if items[0].Auth != nil {
|
||||
t.Fatalf("sync item retained auth references: %#v", items[0].Auth)
|
||||
}
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if got, errRead := os.ReadFile(target); errRead != nil || string(got) != "library-data" {
|
||||
t.Fatalf("installed plugin = %q, error = %v", got, errRead)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResolvedWithReportIncludesUnchangedInstalledPlugins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(target, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: root,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
name: Sample
|
||||
description: Adds sample support.
|
||||
author: owner
|
||||
version: 1.0.0
|
||||
release-tag: v1.0.0
|
||||
repository: https://github.com/owner/sample-plugin
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
report, errSync := SyncResolvedWithReport(
|
||||
context.Background(),
|
||||
cfg,
|
||||
nil,
|
||||
time.Now().UTC().Add(time.Minute),
|
||||
map[string]string{"sample": "1.0.0"},
|
||||
nil,
|
||||
)
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncResolvedWithReport() error = %v", errSync)
|
||||
}
|
||||
if len(report.Plugins) != 1 || report.Plugins[0].ID != "sample" || report.Plugins[0].InstallStatus != pluginInstallStatusSkipped {
|
||||
t.Fatalf("report plugins = %+v, want unchanged installed sample", report.Plugins)
|
||||
}
|
||||
status := report.Plugins[0]
|
||||
if status.Path != target || status.ReleaseTag != "v1.0.0" || status.Repository != "https://github.com/owner/sample-plugin" || status.InstallType != sdkpluginstore.InstallTypeGitHubRelease {
|
||||
t.Fatalf("unchanged plugin status = %+v, want preserved path and manifest metadata", status)
|
||||
}
|
||||
if errLoad := MarkLoadResults(&report, fakePluginLoadInspector{}); errLoad == nil {
|
||||
t.Fatal("MarkLoadResults() error = nil, want installed plugin load failure")
|
||||
}
|
||||
if report.Plugins[0].LoadStatus != pluginLoadStatusFailed {
|
||||
t.Fatalf("load status = %q, want failed", report.Plugins[0].LoadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncResolvedWithReportDoesNotMixInstalledAndConfiguredMetadata(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(target, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: root,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
name: Sample
|
||||
description: Adds sample support.
|
||||
author: owner
|
||||
version: 2.0.0
|
||||
release-tag: v2.0.0
|
||||
repository: https://github.com/owner/sample-plugin-v2
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
report, errSync := SyncResolvedWithReport(
|
||||
context.Background(),
|
||||
cfg,
|
||||
nil,
|
||||
time.Now().UTC().Add(time.Minute),
|
||||
map[string]string{"sample": "1.0.0"},
|
||||
nil,
|
||||
)
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncResolvedWithReport() error = %v", errSync)
|
||||
}
|
||||
if len(report.Plugins) != 1 {
|
||||
t.Fatalf("report plugins = %+v, want one installed sample", report.Plugins)
|
||||
}
|
||||
status := report.Plugins[0]
|
||||
if status.Version != "1.0.0" || status.Path != target {
|
||||
t.Fatalf("installed plugin status = %+v, want version 1.0.0 at %s", status, target)
|
||||
}
|
||||
if status.ReleaseTag != "" || status.Repository != "" || status.InstallType != "" {
|
||||
t.Fatalf("installed plugin status = %+v, want no metadata from configured version 2.0.0", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledVersionsUsesPluginFilesOnDisk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "2.3.4")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(target), 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(target, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
cfg := &config.Config{Plugins: config.PluginsConfig{Dir: root, Configs: map[string]config.PluginInstanceConfig{"sample": {}}}}
|
||||
|
||||
versions, errVersions := InstalledVersions(cfg)
|
||||
if errVersions != nil {
|
||||
t.Fatalf("InstalledVersions() error = %v", errVersions)
|
||||
}
|
||||
if versions["sample"] != "2.3.4" {
|
||||
t.Fatalf("InstalledVersions() = %#v, want sample 2.3.4", versions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformWithReportRecordsSuccessfulInstall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
|
||||
archiveName := "sample_0.2.0_windows_amd64.zip"
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
httpClient := mapHTTPDoer{
|
||||
"https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []byte(`{
|
||||
"tag_name": "v0.2.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
|
||||
]
|
||||
}`),
|
||||
"https://downloads.example/" + archiveName: archiveData,
|
||||
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
|
||||
}
|
||||
restore := replacePluginStoreClientForTest(httpClient)
|
||||
defer restore()
|
||||
|
||||
report, errSync := SyncPlatformWithReport(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"})
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncPlatformWithReport() error = %v", errSync)
|
||||
}
|
||||
if !report.OK || report.Status != pluginTaskStatusOK || report.Phase != pluginTaskPhaseInstall {
|
||||
t.Fatalf("report status = %+v, want successful install phase", report)
|
||||
}
|
||||
if len(report.Plugins) != 1 {
|
||||
t.Fatalf("report plugins len = %d, want 1", len(report.Plugins))
|
||||
}
|
||||
plugin := report.Plugins[0]
|
||||
if plugin.ID != "sample" || plugin.InstallStatus != pluginInstallStatusInstalled || plugin.Version != "0.2.0" {
|
||||
t.Fatalf("plugin report = %+v, want installed sample 0.2.0", plugin)
|
||||
}
|
||||
if wantPath := pluginTestPath(root, "windows", "amd64", "sample", "0.2.0"); plugin.Path != wantPath {
|
||||
t.Fatalf("plugin path = %q, want %q", plugin.Path, wantPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformWithReportRecordsSkippedIdenticalArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetDir := filepath.Join(root, "windows", "amd64")
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
target := filepath.Join(targetDir, "sample-v0.2.0.dll")
|
||||
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
|
||||
archiveName := "sample_0.2.0_windows_amd64.zip"
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
httpClient := mapHTTPDoer{
|
||||
"https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []byte(`{
|
||||
"tag_name": "v0.2.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
|
||||
]
|
||||
}`),
|
||||
"https://downloads.example/" + archiveName: archiveData,
|
||||
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
|
||||
}
|
||||
restore := replacePluginStoreClientForTest(httpClient)
|
||||
defer restore()
|
||||
|
||||
report, errSync := SyncPlatformWithReport(context.Background(), syncTestConfig(t, root), nil, Platform{GOOS: "windows", GOARCH: "amd64"})
|
||||
if errSync != nil {
|
||||
t.Fatalf("SyncPlatformWithReport() error = %v", errSync)
|
||||
}
|
||||
if !report.OK || len(report.Plugins) != 1 {
|
||||
t.Fatalf("report = %+v, want one successful skipped plugin", report)
|
||||
}
|
||||
plugin := report.Plugins[0]
|
||||
if plugin.ID != "sample" || plugin.InstallStatus != pluginInstallStatusSkipped || !plugin.Skipped {
|
||||
t.Fatalf("plugin report = %+v, want skipped identical sample", plugin)
|
||||
}
|
||||
if plugin.Path != target {
|
||||
t.Fatalf("plugin path = %q, want %q", plugin.Path, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformSkipsIdenticalBusyPlugin(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetDir := filepath.Join(root, "windows", "amd64")
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
target := filepath.Join(targetDir, "sample-v0.2.0.dll")
|
||||
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
archiveData := makeZip(t, map[string]string{"sample.dll": "library-data"})
|
||||
archiveName := "sample_0.2.0_windows_amd64.zip"
|
||||
checksum := sha256.Sum256(archiveData)
|
||||
httpClient := mapHTTPDoer{
|
||||
"https://api.github.com/repos/owner/sample-plugin/releases/tags/v0.2.0": []byte(`{
|
||||
"tag_name": "v0.2.0",
|
||||
"assets": [
|
||||
{"name": "` + archiveName + `", "browser_download_url": "https://downloads.example/` + archiveName + `"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://downloads.example/checksums.txt"}
|
||||
]
|
||||
}`),
|
||||
"https://downloads.example/" + archiveName: archiveData,
|
||||
"https://downloads.example/checksums.txt": []byte(hex.EncodeToString(checksum[:]) + " " + archiveName + "\n"),
|
||||
}
|
||||
restore := replacePluginStoreClientForTest(httpClient)
|
||||
defer restore()
|
||||
|
||||
runtime := &fakePluginRuntime{busy: true}
|
||||
if errSync := SyncPlatform(context.Background(), syncTestConfig(t, root), runtime, Platform{GOOS: "windows", GOARCH: "amd64"}); errSync != nil {
|
||||
t.Fatalf("SyncPlatform() error = %v", errSync)
|
||||
}
|
||||
if len(runtime.unloaded) != 0 {
|
||||
t.Fatalf("UnloadPlugin() calls = %v, want none", runtime.unloaded)
|
||||
}
|
||||
got, errRead := os.ReadFile(target)
|
||||
if errRead != nil {
|
||||
t.Fatalf("read target: %v", errRead)
|
||||
}
|
||||
if string(got) != "library-data" {
|
||||
t.Fatalf("target data = %q, want library-data", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformSkipsConfigWithoutManifest(t *testing.T) {
|
||||
restore := replacePluginStoreClientForTest(mapHTTPDoer{})
|
||||
defer restore()
|
||||
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `enabled: true`),
|
||||
},
|
||||
},
|
||||
}
|
||||
if errSync := SyncPlatform(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"}); errSync != nil {
|
||||
t.Fatalf("SyncPlatform() error = %v", errSync)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformRejectsInvalidManifest(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
if errSync := SyncPlatform(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"}); errSync == nil {
|
||||
t.Fatal("SyncPlatform() error = nil, want invalid manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPlatformWithReportRecordsInvalidManifest(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: t.TempDir(),
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
report, errSync := SyncPlatformWithReport(context.Background(), cfg, nil, Platform{GOOS: "linux", GOARCH: "amd64"})
|
||||
if errSync == nil {
|
||||
t.Fatal("SyncPlatformWithReport() error = nil, want invalid manifest")
|
||||
}
|
||||
if report.OK || report.Status != pluginTaskStatusError || len(report.Plugins) != 1 {
|
||||
t.Fatalf("report = %+v, want one failed plugin", report)
|
||||
}
|
||||
if report.Plugins[0].ID != "sample" || report.Plugins[0].InstallStatus != pluginInstallStatusFailed || !strings.Contains(report.Plugins[0].Error, "invalid store manifest") {
|
||||
t.Fatalf("plugin report = %+v, want invalid manifest failure", report.Plugins[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkLoadResultsFailsWhenInstalledPluginDidNotLoad(t *testing.T) {
|
||||
report := SyncReport{
|
||||
Status: pluginTaskStatusOK,
|
||||
OK: true,
|
||||
Phase: pluginTaskPhaseInstall,
|
||||
Plugins: []PluginInstallStatus{{ID: "sample", InstallStatus: pluginInstallStatusInstalled}},
|
||||
}
|
||||
|
||||
errLoad := MarkLoadResults(&report, fakePluginLoadInspector{})
|
||||
if errLoad == nil {
|
||||
t.Fatal("MarkLoadResults() error = nil, want load failure")
|
||||
}
|
||||
if report.OK || report.Status != pluginTaskStatusError || report.Phase != pluginTaskPhaseLoad {
|
||||
t.Fatalf("report = %+v, want failed load phase", report)
|
||||
}
|
||||
if report.Plugins[0].LoadStatus != pluginLoadStatusFailed || !strings.Contains(report.Plugins[0].Error, "installed but not loaded") {
|
||||
t.Fatalf("plugin report = %+v, want load failure", report.Plugins[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkLoadResultsPreservesInstallFailure(t *testing.T) {
|
||||
report := SyncReport{
|
||||
Status: pluginTaskStatusError,
|
||||
OK: false,
|
||||
Phase: pluginTaskPhaseInstall,
|
||||
Plugins: []PluginInstallStatus{{ID: "sample", InstallStatus: pluginInstallStatusFailed, Error: "install boom"}},
|
||||
}
|
||||
|
||||
errLoad := MarkLoadResults(&report, fakePluginLoadInspector{"sample": true})
|
||||
if errLoad == nil {
|
||||
t.Fatal("MarkLoadResults() error = nil, want install failure to remain fatal")
|
||||
}
|
||||
if report.OK || report.Status != pluginTaskStatusError {
|
||||
t.Fatalf("report = %+v, want failed status", report)
|
||||
}
|
||||
if report.Plugins[0].LoadStatus != pluginInstallStatusSkipped {
|
||||
t.Fatalf("load status = %q, want skipped", report.Plugins[0].LoadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkLoadResultsPreservesGlobalSyncFailure(t *testing.T) {
|
||||
report := newSyncReport(Platform{GOOS: "linux", GOARCH: "amd64"})
|
||||
report.Plugins = append(report.Plugins, PluginInstallStatus{
|
||||
ID: "installed", InstallStatus: pluginInstallStatusInstalled,
|
||||
})
|
||||
errExpired := errors.New("home plugins: plugin sync response expired")
|
||||
finishReport(&report, errExpired)
|
||||
|
||||
errLoad := MarkLoadResults(&report, fakePluginLoadInspector{"installed": true})
|
||||
if errLoad == nil || !strings.Contains(errLoad.Error(), "plugin sync response expired") {
|
||||
t.Fatalf("MarkLoadResults() error = %v, want preserved sync expiry", errLoad)
|
||||
}
|
||||
if report.OK || report.Status != pluginTaskStatusError || report.Phase != pluginTaskPhaseLoad {
|
||||
t.Fatalf("report = %+v, want failed load phase", report)
|
||||
}
|
||||
if !strings.Contains(report.Error, "plugin sync response expired") {
|
||||
t.Fatalf("report error = %q, want preserved sync expiry", report.Error)
|
||||
}
|
||||
if report.Plugins[0].LoadStatus != pluginLoadStatusLoaded {
|
||||
t.Fatalf("load status = %q, want loaded", report.Plugins[0].LoadStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletedSyncReport(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errSync error
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "success", wantOK: true},
|
||||
{name: "failure", errSync: errors.New("home plugins: inspect installed plugins: access denied")},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
report := CompletedSyncReport(Platform{GOOS: "linux", GOARCH: "amd64"}, tt.errSync)
|
||||
if report.OK != tt.wantOK || report.Task != pluginTaskName || report.FinishedAt.IsZero() {
|
||||
t.Fatalf("report = %+v, want completed plugin sync report with ok=%v", report, tt.wantOK)
|
||||
}
|
||||
if tt.errSync != nil && (report.Status != pluginTaskStatusError || report.Error != tt.errSync.Error()) {
|
||||
t.Fatalf("report = %+v, want error %q", report, tt.errSync.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportRejectsUnresolvedPluginsDir(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
t.Setenv("HOME", "")
|
||||
t.Setenv("USERPROFILE", "")
|
||||
t.Chdir(workspace)
|
||||
|
||||
literalPluginsDir := filepath.Join(workspace, "~", ".cli-proxy-api", "plugins")
|
||||
targetDir := filepath.Join(literalPluginsDir, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", targetDir, errMkdir)
|
||||
}
|
||||
target := filepath.Join(targetDir, "sample"+pluginExtension(runtime.GOOS))
|
||||
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", target, errWrite)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Dir: "~/.cli-proxy-api/plugins",
|
||||
},
|
||||
}
|
||||
|
||||
report := DeleteWithReport(context.Background(), cfg, nil, 41, "sample")
|
||||
|
||||
if report.OK || report.Status != pluginTaskStatusError {
|
||||
t.Fatalf("report = %+v, want failed delete task", report)
|
||||
}
|
||||
if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusFailed {
|
||||
t.Fatalf("plugin report = %+v, want failed status", report.Plugins)
|
||||
}
|
||||
if !strings.Contains(report.Plugins[0].Error, "resolve plugins directory") {
|
||||
t.Fatalf("plugin error = %q, want directory resolution error", report.Plugins[0].Error)
|
||||
}
|
||||
if _, errStat := os.Stat(target); errStat != nil {
|
||||
t.Fatalf("literal tilde target stat error = %v, want retained", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportRemovesCurrentPlatformPlugin(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
target := filepath.Join(targetDir, "sample"+pluginExtension(runtime.GOOS))
|
||||
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile() error = %v", errWrite)
|
||||
}
|
||||
runtimeHost := &fakePluginRuntime{busy: true}
|
||||
|
||||
report := DeleteWithReport(context.Background(), syncTestConfig(t, root), runtimeHost, 42, "sample")
|
||||
if !report.OK || report.TaskID != 42 || report.Task != pluginDeleteTaskName || report.Phase != pluginTaskPhaseDelete {
|
||||
t.Fatalf("report = %+v, want successful delete task", report)
|
||||
}
|
||||
if len(runtimeHost.unloaded) != 1 || runtimeHost.unloaded[0] != "sample" {
|
||||
t.Fatalf("UnloadPlugin calls = %v, want sample", runtimeHost.unloaded)
|
||||
}
|
||||
if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusDeleted || report.Plugins[0].Path != target {
|
||||
t.Fatalf("plugin report = %+v, want deleted target", report.Plugins)
|
||||
}
|
||||
if _, errStat := os.Stat(target); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("target stat error = %v, want not exist", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportRemovesAllCurrentPlatformPluginVersions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
targetDir := filepath.Join(root, runtime.GOOS, runtime.GOARCH)
|
||||
if errMkdir := os.MkdirAll(targetDir, 0o755); errMkdir != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", errMkdir)
|
||||
}
|
||||
extension := pluginExtension(runtime.GOOS)
|
||||
olderTarget := filepath.Join(targetDir, "sample-v0.2.0"+extension)
|
||||
newerTarget := filepath.Join(targetDir, "sample-v0.3.0"+extension)
|
||||
otherTarget := filepath.Join(targetDir, "other-v0.3.0"+extension)
|
||||
for _, target := range []string{olderTarget, newerTarget, otherTarget} {
|
||||
if errWrite := os.WriteFile(target, []byte("library-data"), 0o644); errWrite != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", target, errWrite)
|
||||
}
|
||||
}
|
||||
runtimeHost := &fakePluginRuntime{busy: true}
|
||||
|
||||
report := DeleteWithReport(context.Background(), syncTestConfig(t, root), runtimeHost, 43, "sample")
|
||||
if !report.OK {
|
||||
t.Fatalf("report = %+v, want successful delete task", report)
|
||||
}
|
||||
if len(runtimeHost.unloaded) != 1 || runtimeHost.unloaded[0] != "sample" {
|
||||
t.Fatalf("UnloadPlugin calls = %v, want sample", runtimeHost.unloaded)
|
||||
}
|
||||
if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusDeleted || report.Plugins[0].Path != newerTarget {
|
||||
t.Fatalf("plugin report = %+v, want deleted representative target %s", report.Plugins, newerTarget)
|
||||
}
|
||||
for _, target := range []string{olderTarget, newerTarget} {
|
||||
if _, errStat := os.Stat(target); !os.IsNotExist(errStat) {
|
||||
t.Fatalf("target %s stat error = %v, want not exist", target, errStat)
|
||||
}
|
||||
}
|
||||
if _, errStat := os.Stat(otherTarget); errStat != nil {
|
||||
t.Fatalf("other plugin stat error = %v, want retained", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportStopsBeforeUnloadWhenContextCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(path), 0o755); errMkdir != nil {
|
||||
t.Fatal(errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(path, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatal(errWrite)
|
||||
}
|
||||
runtimeHost := &contextPluginRuntime{fakePluginRuntime: fakePluginRuntime{busy: true}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
report := DeleteWithReport(ctx, syncTestConfig(t, root), runtimeHost, 44, "sample")
|
||||
|
||||
if report.OK || !strings.Contains(report.Error, context.Canceled.Error()) {
|
||||
t.Fatalf("canceled delete report = %+v, want context cancellation", report)
|
||||
}
|
||||
if runtimeHost.unloadContext != nil || len(runtimeHost.unloaded) != 0 {
|
||||
t.Fatalf("canceled delete unloaded plugin: context=%v unloads=%v", runtimeHost.unloadContext, runtimeHost.unloaded)
|
||||
}
|
||||
if _, errStat := os.Stat(path); errStat != nil {
|
||||
t.Fatalf("canceled delete removed plugin artifact: %v", errStat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportUsesContextualUnload(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := pluginTestPath(root, runtime.GOOS, runtime.GOARCH, "sample", "1.0.0")
|
||||
if errMkdir := os.MkdirAll(filepath.Dir(path), 0o755); errMkdir != nil {
|
||||
t.Fatal(errMkdir)
|
||||
}
|
||||
if errWrite := os.WriteFile(path, []byte("plugin"), 0o644); errWrite != nil {
|
||||
t.Fatal(errWrite)
|
||||
}
|
||||
runtimeHost := &contextPluginRuntime{fakePluginRuntime: fakePluginRuntime{busy: true}}
|
||||
ctx := context.WithValue(context.Background(), struct{}{}, "contextual")
|
||||
|
||||
report := DeleteWithReport(ctx, syncTestConfig(t, root), runtimeHost, 45, "sample")
|
||||
|
||||
if !report.OK {
|
||||
t.Fatalf("contextual delete report = %+v", report)
|
||||
}
|
||||
if runtimeHost.unloadContext != ctx || len(runtimeHost.unloaded) != 1 || runtimeHost.unloaded[0] != "sample" {
|
||||
t.Fatalf("contextual unload = context=%v unloads=%v", runtimeHost.unloadContext, runtimeHost.unloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWithReportMissingPluginIsSuccess(t *testing.T) {
|
||||
report := DeleteWithReport(context.Background(), syncTestConfig(t, t.TempDir()), nil, 7, "missing")
|
||||
if !report.OK || report.Status != pluginTaskStatusOK {
|
||||
t.Fatalf("report = %+v, want missing plugin delete success", report)
|
||||
}
|
||||
if len(report.Plugins) != 1 || report.Plugins[0].InstallStatus != pluginInstallStatusMissing {
|
||||
t.Fatalf("plugin report = %+v, want missing status", report.Plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func syncTestConfig(t *testing.T, root string) *config.Config {
|
||||
t.Helper()
|
||||
return &config.Config{
|
||||
Home: config.HomeConfig{Enabled: true},
|
||||
Plugins: config.PluginsConfig{
|
||||
Enabled: true,
|
||||
Dir: root,
|
||||
Configs: map[string]config.PluginInstanceConfig{
|
||||
"sample": pluginConfigFromYAML(t, `
|
||||
enabled: true
|
||||
store:
|
||||
id: sample
|
||||
name: Sample
|
||||
description: Adds sample support.
|
||||
author: owner
|
||||
version: 0.2.0
|
||||
release-tag: v0.2.0
|
||||
repository: https://github.com/owner/sample-plugin
|
||||
`),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pluginTestPath(root string, goos string, goarch string, id string, version string) string {
|
||||
name := strings.TrimSpace(id)
|
||||
version = strings.TrimSpace(version)
|
||||
if version != "" {
|
||||
name += "-v" + version
|
||||
}
|
||||
return filepath.Join(root, goos, goarch, name+pluginExtension(goos))
|
||||
}
|
||||
|
||||
func pluginConfigFromYAML(t *testing.T, text string) config.PluginInstanceConfig {
|
||||
t.Helper()
|
||||
var item config.PluginInstanceConfig
|
||||
if errUnmarshal := yaml.Unmarshal([]byte(text), &item); errUnmarshal != nil {
|
||||
t.Fatalf("unmarshal plugin config: %v", errUnmarshal)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func replacePluginStoreClientForTest(httpClient sdkpluginstore.HTTPDoer) func() {
|
||||
previous := newPluginStoreClient
|
||||
newPluginStoreClient = func(cfg *config.Config) sdkpluginstore.Client {
|
||||
return sdkpluginstore.NewClient(httpClient, "")
|
||||
}
|
||||
return func() {
|
||||
newPluginStoreClient = previous
|
||||
}
|
||||
}
|
||||
|
||||
func makeZip(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buffer bytes.Buffer
|
||||
writer := zip.NewWriter(&buffer)
|
||||
for name, content := range files {
|
||||
file, errCreate := writer.Create(name)
|
||||
if errCreate != nil {
|
||||
t.Fatalf("Create(%s) error = %v", name, errCreate)
|
||||
}
|
||||
if _, errWrite := file.Write([]byte(content)); errWrite != nil {
|
||||
t.Fatalf("Write(%s) error = %v", name, errWrite)
|
||||
}
|
||||
}
|
||||
if errClose := writer.Close(); errClose != nil {
|
||||
t.Fatalf("Close() error = %v", errClose)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
type mapHTTPDoer map[string][]byte
|
||||
|
||||
func (c mapHTTPDoer) Do(req *http.Request) (*http.Response, error) {
|
||||
body, ok := c[req.URL.String()]
|
||||
if !ok {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("not found")),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
Loading…
Reference in a new issue