Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 147 additions & 5 deletions cmd/wfctl/plugin_registry_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
Expand All @@ -27,9 +28,8 @@ import (
// 6. Fetches tagged plugin.json from upstream; syncs capabilities,
// minEngineVersion, iacProvider into registry manifest.
// 7. (--verify-capabilities) Downloads release tarball + spawns binary;
// diffs GetManifest's capabilities vs committed; with --fix rewrites.
// NOTE: deferred to a follow-up PR per plan I4 / I-P9 — initial impl
// stubs this with a clear "not implemented yet" message.
// reuses wfctl plugin verify-capabilities to diff runtime GetManifest
// against the registry manifest.
func runPluginRegistrySync(args []string) error {
if len(args) > 0 {
switch args[0] {
Expand Down Expand Up @@ -195,7 +195,16 @@ func syncDefault(registryDir string, fix bool, pluginFilter string, verifyCaps b
}

if verifyCaps {
fmt.Fprintf(os.Stderr, " NOTE %s — --verify-capabilities not yet implemented (workflow#762 follow-up)\n", pluginName)
verifyName, _ := raw["name"].(string)
if verifyName == "" {
verifyName = pluginName
}
if err := verifyRegistryPluginCapabilities(verifyName, manifestPath, ghRepo, targetTag); err != nil {
fmt.Fprintf(os.Stderr, " ERROR %s — verify capabilities: %v\n", pluginName, err)
mismatches++
continue
}
fmt.Printf(" OK %s capabilities verified against %s (%s/%s)\n", pluginName, targetTag, runtime.GOOS, runtime.GOARCH)
}
}

Expand Down Expand Up @@ -247,6 +256,7 @@ func releaseExists(ghRepo, tag string) bool {
}

type releaseAsset struct {
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
URL string `json:"url"`
Expand Down Expand Up @@ -288,11 +298,143 @@ func releaseDownloads(ghRepo, tag string) ([]releaseAsset, error) {
if !isKnownOS(os) || !isKnownArch(arch) {
continue
}
assets = append(assets, releaseAsset{OS: os, Arch: arch, URL: a.URL})
assets = append(assets, releaseAsset{Name: a.Name, OS: os, Arch: arch, URL: a.URL})
}
return assets, nil
}

var (
registrySyncReleaseDownloads = releaseDownloads
registrySyncDownloadReleaseAsset = downloadReleaseAsset
registrySyncVerifyManifest = verifyPluginManifestAgainstBinaryWithOptions
)

func verifyRegistryPluginCapabilities(pluginName, manifestPath, ghRepo, tag string) error {
assets, err := registrySyncReleaseDownloads(ghRepo, tag)
if err != nil {
return fmt.Errorf("list release downloads for %s %s: %w", ghRepo, tag, err)
}
Comment thread
intel352 marked this conversation as resolved.
asset, ok := selectPlatformReleaseAsset(assets, runtime.GOOS, runtime.GOARCH)
if !ok {
return fmt.Errorf("no %s/%s release asset found for %s %s", runtime.GOOS, runtime.GOARCH, ghRepo, tag)
}

tmpDir, err := os.MkdirTemp("", "wfctl-registry-sync-*")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)

assetPath, err := registrySyncDownloadReleaseAsset(ghRepo, tag, asset.Name, tmpDir)
if err != nil {
return err
}

searchDir := tmpDir
if isTarGz(assetPath) {
extractDir := filepath.Join(tmpDir, "extracted")
file, err := os.Open(assetPath) // #nosec G304 -- release asset downloaded to agent tempdir
if err != nil {
return err
}
if err := extractTarGzReader(file, extractDir); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
searchDir = extractDir
}
Comment thread
intel352 marked this conversation as resolved.

binaryPath, err := locateRegistrySyncBinary(searchDir, pluginName, assetBinaryName(asset.Name))
if err != nil {
return err
}
return registrySyncVerifyManifest(binaryPath, manifestPath, manifestCompareOptions{SkipName: true})
}

func selectPlatformReleaseAsset(assets []releaseAsset, goos, goarch string) (releaseAsset, bool) {
for _, asset := range assets {
if asset.OS == goos && asset.Arch == goarch {
return asset, true
}
}
return releaseAsset{}, false
}

func downloadReleaseAsset(ghRepo, tag, assetName, dir string) (string, error) {
if assetName == "" {
return "", fmt.Errorf("release asset name is empty")
}
cmd := exec.Command("gh", "release", "download", tag, "--repo", ghRepo, "--pattern", assetName, "--dir", dir, "--clobber") // #nosec G204 -- ghRepo+tag+assetName from trusted registry manifest/release metadata
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("gh release download %s %s %s: %w: %s", ghRepo, tag, assetName, err, strings.TrimSpace(string(out)))
}
return filepath.Join(dir, assetName), nil
}

func isTarGz(path string) bool {
return strings.HasSuffix(path, ".tar.gz") || strings.HasSuffix(path, ".tgz")
}

func assetBinaryName(assetName string) string {
name := strings.TrimSuffix(assetName, ".tar.gz")
name = strings.TrimSuffix(name, ".tgz")
for _, sep := range []string{"-", "_"} {
parts := strings.Split(name, sep)
if len(parts) >= 3 && isKnownOS(parts[len(parts)-2]) && isKnownArch(parts[len(parts)-1]) {
return strings.Join(parts[:len(parts)-2], sep)
}
}
return name
}

func locateRegistrySyncBinary(root string, names ...string) (string, error) {
wanted := map[string]bool{}
for _, name := range names {
if name == "" {
continue
}
wanted[name] = true
wanted[name+".exe"] = true
}
var candidates []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
base := filepath.Base(path)
if !wanted[base] {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode().IsRegular() && info.Mode()&0111 != 0 {
candidates = append(candidates, path)
}
return nil
})
if err != nil {
return "", err
}
sort.Strings(candidates)
if len(candidates) == 0 {
var requested []string
for name := range wanted {
requested = append(requested, name)
}
sort.Strings(requested)
return "", fmt.Errorf("no executable matching %v found under %s", requested, root)
}
return candidates[0], nil
}

func isKnownOS(s string) bool {
switch s {
case "linux", "darwin", "windows":
Expand Down
163 changes: 163 additions & 0 deletions cmd/wfctl/plugin_registry_sync_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package main

import (
"archive/tar"
"bytes"
"compress/gzip"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
Expand Down Expand Up @@ -140,3 +147,159 @@ func TestPluginRegistrySync_UsageHelp(t *testing.T) {
t.Logf("non-help error from --help (may be OK): %v", err)
}
}

func TestPluginRegistrySync_SelectPlatformReleaseAsset(t *testing.T) {
assets := []releaseAsset{
{Name: "workflow-plugin-foo-linux-amd64.tar.gz", OS: "linux", Arch: "amd64", URL: "linux-amd64"},
{Name: "workflow-plugin-foo-darwin-arm64.tar.gz", OS: "darwin", Arch: "arm64", URL: "darwin-arm64"},
{Name: "workflow-plugin-foo-linux-arm64.tar.gz", OS: "linux", Arch: "arm64", URL: "linux-arm64"},
}

got, ok := selectPlatformReleaseAsset(assets, "linux", "arm64")
if !ok {
t.Fatal("expected linux/arm64 asset to be selected")
}
if got.Name != "workflow-plugin-foo-linux-arm64.tar.gz" {
t.Fatalf("selected asset = %q, want linux arm64 tarball", got.Name)
}

if _, ok := selectPlatformReleaseAsset(assets, "windows", "amd64"); ok {
t.Fatal("unexpected asset for missing windows/amd64 platform")
}
}

func TestPluginRegistrySync_LocateExtractedBinary(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "workflow-plugin-foo")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
if runtime.GOOS == "windows" {
t.Skip("executable-bit lookup is POSIX-specific")
}

got, err := locateRegistrySyncBinary(dir, "foo", "workflow-plugin-foo")
if err != nil {
t.Fatalf("locateRegistrySyncBinary returned error: %v", err)
}
if got != bin {
t.Fatalf("binary path = %q, want %q", got, bin)
}

if _, err := locateRegistrySyncBinary(dir, "missing-plugin"); err == nil {
t.Fatal("expected missing binary error")
}
}

func TestPluginRegistrySync_AssetBinaryName(t *testing.T) {
cases := map[string]string{
"workflow-plugin-github-darwin-arm64.tar.gz": "workflow-plugin-github",
"workflow-plugin-foo_linux_amd64.tgz": "workflow-plugin-foo",
"custom-plugin": "custom-plugin",
}
for in, want := range cases {
if got := assetBinaryName(in); got != want {
t.Fatalf("assetBinaryName(%q) = %q, want %q", in, got, want)
}
}
}

func TestPluginRegistrySync_VerifyCapabilitiesDownloadsExtractsAndSkipsName(t *testing.T) {
restoreRegistrySyncTestHooks(t)

dir := t.TempDir()
assetName := "workflow-plugin-foo-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz"
assetPath := filepath.Join(dir, assetName)
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"name":"foo"}`), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(dir, "plugin.json")
if err := writeTestTarGz(assetPath, "archive/workflow-plugin-foo", []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}

registrySyncReleaseDownloads = func(ghRepo, tag string) ([]releaseAsset, error) {
if ghRepo != "owner/repo" || tag != "v1.2.3" {
t.Fatalf("releaseDownloads args = %q %q, want owner/repo v1.2.3", ghRepo, tag)
}
return []releaseAsset{{Name: assetName, OS: runtime.GOOS, Arch: runtime.GOARCH}}, nil
}
registrySyncDownloadReleaseAsset = func(ghRepo, tag, name, targetDir string) (string, error) {
if name != assetName {
t.Fatalf("download asset name = %q, want %q", name, assetName)
}
if targetDir == "" {
t.Fatal("download target dir is empty")
}
return assetPath, nil
}

var verifyCalled bool
registrySyncVerifyManifest = func(binary, manifest string, opts manifestCompareOptions) error {
verifyCalled = true
if filepath.Base(binary) != "workflow-plugin-foo" {
t.Fatalf("binary = %q, want extracted workflow-plugin-foo", binary)
}
if manifest != manifestPath {
t.Fatalf("manifest = %q, want %q", manifest, manifestPath)
}
if !opts.SkipName {
t.Fatal("registry verification must skip strict manifest name comparison")
}
return nil
}

if err := verifyRegistryPluginCapabilities("foo", manifestPath, "owner/repo", "v1.2.3"); err != nil {
t.Fatalf("verifyRegistryPluginCapabilities returned error: %v", err)
}
if !verifyCalled {
t.Fatal("expected registrySyncVerifyManifest to be called")
}
}

func TestPluginRegistrySync_VerifyCapabilitiesDownloadError(t *testing.T) {
restoreRegistrySyncTestHooks(t)

registrySyncReleaseDownloads = func(string, string) ([]releaseAsset, error) {
return []releaseAsset{{Name: "workflow-plugin-foo-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz", OS: runtime.GOOS, Arch: runtime.GOARCH}}, nil
}
registrySyncDownloadReleaseAsset = func(string, string, string, string) (string, error) {
return "", errors.New("download failed")
}

err := verifyRegistryPluginCapabilities("foo", filepath.Join(t.TempDir(), "plugin.json"), "owner/repo", "v1.2.3")
if err == nil || !strings.Contains(err.Error(), "download failed") {
t.Fatalf("error = %v, want download failure", err)
}
}

func restoreRegistrySyncTestHooks(t *testing.T) {
t.Helper()
oldReleaseDownloads := registrySyncReleaseDownloads
oldDownloadReleaseAsset := registrySyncDownloadReleaseAsset
oldVerifyManifest := registrySyncVerifyManifest
t.Cleanup(func() {
registrySyncReleaseDownloads = oldReleaseDownloads
registrySyncDownloadReleaseAsset = oldDownloadReleaseAsset
registrySyncVerifyManifest = oldVerifyManifest
})
}

func writeTestTarGz(path, name string, data []byte, mode int64) error {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(data))}); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
if err := gw.Close(); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}
Loading
Loading