diff --git a/cmd/wfctl/deploy_providers.go b/cmd/wfctl/deploy_providers.go index 395c96a9..42f96eb1 100644 --- a/cmd/wfctl/deploy_providers.go +++ b/cmd/wfctl/deploy_providers.go @@ -86,8 +86,8 @@ var resolveIaCProvider = discoverAndLoadIaCProvider // double parse — and either may be empty without affecting the // other. type iacPluginManifest struct { - Name string `json:"name"` - Version string `json:"version"` + Name string `json:"name"` + Version string `json:"version"` Capabilities struct { IaCProvider struct { Name string `json:"name"` diff --git a/cmd/wfctl/plugin_conformance.go b/cmd/wfctl/plugin_conformance.go index 7cdfe6cf..392c6e19 100644 --- a/cmd/wfctl/plugin_conformance.go +++ b/cmd/wfctl/plugin_conformance.go @@ -226,15 +226,106 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili return PluginCompatibilityEvidence{}, err } binaryPath := filepath.Join(installDir, installName) - if info, statErr := os.Stat(filepath.Join(sourceDir, installName)); opts.ArtifactPath != "" && statErr == nil && !info.IsDir() && info.Mode()&0o111 != 0 { - if err := copyFile(filepath.Join(sourceDir, installName), binaryPath, info.Mode()); err != nil { - return PluginCompatibilityEvidence{}, err + var conformanceChecked bool + var conformanceStdout, conformanceStderr string + + if opts.ArtifactPath != "" { + candidates := discoverArtifactBinaryCandidates(sourceDir, manifest.Name, installName) + if len(candidates) > 0 { + var diagLines []string + diagLines = append(diagLines, fmt.Sprintf("artifact binary discovery: install=%q manifest=%q candidates=[%s]", + installName, manifest.Name, strings.Join(candidates, ", "))) + + var lastCheckErr error + for _, cand := range candidates { + srcPath := filepath.Join(sourceDir, cand) + srcInfo, statErr := os.Stat(srcPath) + if statErr != nil { + diagLines = append(diagLines, fmt.Sprintf(" [skip] %q: %v", cand, statErr)) + continue + } + if copyErr := copyFile(srcPath, binaryPath, srcInfo.Mode()); copyErr != nil { + diagLines = append(diagLines, fmt.Sprintf(" [fail] %q: copy error: %v", cand, copyErr)) + lastCheckErr = copyErr + continue + } + cstdout, cstderr, checkErr := checkTypedIaCPlugin(opts.Timeout, filepath.Join(tmp, "plugins"), installName) + conformanceStdout = cstdout + conformanceStderr = cstderr + if checkErr == nil { + diagLines = append(diagLines, fmt.Sprintf(" [pass] %q selected", cand)) + conformanceChecked = true + lastCheckErr = nil + break + } + lastCheckErr = checkErr + diagLines = append(diagLines, fmt.Sprintf(" [fail] %q: %v", cand, checkErr)) + } + + diagMsg := strings.Join(diagLines, "\n") + if conformanceStderr != "" { + conformanceStderr = diagMsg + "\n" + conformanceStderr + } else { + conformanceStderr = diagMsg + } + + if !conformanceChecked { + // All named candidates failed the handshake. + // If Go sources are present (go.mod exists in the archive), fall back to + // go build rather than declaring failure immediately. This supports + // source-in-archive tarballs that happen to contain a pre-built or + // unrelated executable alongside the Go sources. + if _, modErr := os.Stat(filepath.Join(sourceDir, "go.mod")); modErr != nil { + // No go.mod → binary-only artifact; emit fail evidence with diagnostics. + if lastCheckErr == nil { + lastCheckErr = fmt.Errorf("no executable artifact candidate could be staged from archive (candidates: %s)", strings.Join(candidates, ", ")) + } + manifestSHA, _ := hashFileSHA256(filepath.Join(installDir, "plugin.json")) + binarySHA := "" + if _, statErr := os.Stat(binaryPath); statErr == nil { + binarySHA, _ = hashFileSHA256(binaryPath) + } + ev := PluginCompatibilityEvidence{ + Plugin: manifest.Name, + Version: manifest.Version, + EngineVersion: opts.EngineVersion, + WfctlVersion: buildVersion(), + Mode: opts.Mode, + Status: PluginCompatibilityStatusFail, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + ArchiveSHA256: archiveSHA, + BinarySHA256: binarySHA, + PluginManifestSHA256: manifestSHA, + GeneratedBy: "wfctl plugin conformance", + StdoutTail: conformanceStdout, + StderrTail: conformanceStderr, + } + if normalized, normErr := ValidateCompatibilityEvidence(ev); normErr == nil { + ev = normalized + } + return ev, lastCheckErr + } + // go.mod found → fall through to go build below. + // Clear stdout/stderr from failed candidate attempts so the final + // evidence reflects the build result rather than handshake noise. + conformanceStdout, conformanceStderr = "", "" + } + // conformanceChecked=true: a candidate passed; binary is at binaryPath. } - } else { + // len(candidates)==0: no executables found in archive root; fall through to go build + // below (supports source-in-archive tarballs that include Go source). + } + + if !conformanceChecked { buildPackage := opts.BuildPackage if buildPackage == "" { buildPackage = "." } + // Remove any pre-existing file at binaryPath (e.g. a failed candidate that was + // copied there) so go build can write the output without refusing to overwrite + // a non-object file. + _ = os.Remove(binaryPath) cmd := exec.Command("go", "build", "-mod=mod", "-o", binaryPath, buildPackage) //nolint:gosec // command args are fixed; dir is staged source. cmd.Dir = sourceDir cmd.Env = append(os.Environ(), "GOWORK=off") @@ -253,7 +344,13 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili return PluginCompatibilityEvidence{}, err } - stdout, stderr, err := checkTypedIaCPlugin(opts.Timeout, filepath.Join(tmp, "plugins"), installName) + var stdout, stderr string + if conformanceChecked { + stdout = conformanceStdout + stderr = conformanceStderr + } else { + stdout, stderr, err = checkTypedIaCPlugin(opts.Timeout, filepath.Join(tmp, "plugins"), installName) + } ev := PluginCompatibilityEvidence{ Plugin: manifest.Name, Version: manifest.Version, @@ -284,6 +381,82 @@ func runPluginConformanceCheck(opts pluginConformanceOptions) (PluginCompatibili return ev, nil } +// discoverArtifactBinaryCandidates returns an ordered list of file names in sourceDir +// that are executable and should be tried as the plugin binary. Candidates are +// prioritised as follows: +// +// 1. installName (the normalised plugin name, e.g. "digitalocean") +// 2. manifestName when it differs from installName (e.g. "workflow-plugin-digitalocean") +// 3. On Windows: the above names with a ".exe" suffix +// 4. Any other executable in the archive root whose name starts with installName or +// manifestName (case-insensitive), e.g. "digitalocean_linux_amd64". +// This covers platform-suffixed GoReleaser binaries while avoiding the execution +// of arbitrary unrelated executables bundled in the archive. +func discoverArtifactBinaryCandidates(sourceDir, manifestName, installName string) []string { + seen := make(map[string]bool) + var out []string + + addIfExecutable := func(name string) { + if name == "" || seen[name] { + return + } + seen[name] = true + info, err := os.Stat(filepath.Join(sourceDir, name)) + if err != nil || info.IsDir() || info.Mode()&0o111 == 0 { + return + } + out = append(out, name) + } + + addIfExecutable(installName) + if manifestName != installName { + addIfExecutable(manifestName) + } + if runtime.GOOS == "windows" { + addIfExecutable(installName + ".exe") + if manifestName != installName { + addIfExecutable(manifestName + ".exe") + } + } + + // Scan the archive root for additional executables matching known plugin naming patterns. + // Only include names that start with installName or manifestName (case-insensitive) so + // that platform-suffixed GoReleaser binaries (e.g. "digitalocean_linux_amd64") are + // found without executing arbitrary unrelated executables from the archive. + entries, err := os.ReadDir(sourceDir) + if err != nil { + return out + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if seen[name] { + continue + } + seen[name] = true + info, statErr := entry.Info() + if statErr != nil { + continue + } + if info.Mode()&0o111 == 0 { + continue + } + // Restrict fallback to names that start with installName or manifestName to + // avoid executing arbitrary binaries (e.g. helper scripts, CLI tools) that + // happen to be bundled in the archive. + nameLower := strings.ToLower(name) + installLower := strings.ToLower(installName) + manifestLower := strings.ToLower(manifestName) + if !strings.HasPrefix(nameLower, installLower) && !strings.HasPrefix(nameLower, manifestLower) { + continue + } + out = append(out, name) + } + return out +} + func checkTypedIaCPlugin(timeout time.Duration, pluginsDir, name string) (string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go index 72e4fecc..fa2f2d0a 100644 --- a/cmd/wfctl/plugin_conformance_test.go +++ b/cmd/wfctl/plugin_conformance_test.go @@ -9,6 +9,7 @@ import ( "flag" "io" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -446,3 +447,335 @@ func writeTarGzFromDir(t *testing.T, srcDir, dest string) { t.Fatalf("write archive: %v", err) } } + +// writeTarGzFiles creates a tar.gz archive whose entries are the key→srcPath pairs in +// files. Each file is stored at the path "archive/" so that extractTarGzReader's +// stripTopDir leaves the file at "" in the destination directory. +func writeTarGzFiles(t *testing.T, dest string, files map[string]string) { + t.Helper() + f, err := os.Create(dest) + if err != nil { + t.Fatalf("create archive: %v", err) + } + defer f.Close() + gw := gzip.NewWriter(f) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + for name, srcPath := range files { + info, err := os.Stat(srcPath) + if err != nil { + t.Fatalf("stat %q: %v", srcPath, err) + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + t.Fatalf("tar header for %q: %v", srcPath, err) + } + // GoReleaser archives contain a top-level directory that stripTopDir removes. + hdr.Name = "archive/" + name + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("write header %q: %v", name, err) + } + in, err := os.Open(srcPath) //nolint:gosec + if err != nil { + t.Fatalf("open %q: %v", srcPath, err) + } + _, copyErr := io.Copy(tw, in) + in.Close() + if copyErr != nil { + t.Fatalf("copy %q: %v", name, copyErr) + } + } +} + +// buildFixtureBinary compiles the Go package at fixtureDir and writes the binary +// to a temp path with the given name. It returns the path to the compiled binary. +func buildFixtureBinary(t *testing.T, fixtureDir, binaryName string) string { + t.Helper() + binPath := filepath.Join(t.TempDir(), binaryName) + cmd := exec.Command("go", "build", "-mod=mod", "-o", binPath, ".") //nolint:gosec + cmd.Dir = fixtureDir + cmd.Env = append(os.Environ(), "GOWORK=off") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build fixture binary %q: %v: %s", binaryName, err, out) + } + return binPath +} + +// TestDiscoverArtifactBinaryCandidates verifies the priority ordering and filtering +// of discoverArtifactBinaryCandidates. +func TestDiscoverArtifactBinaryCandidates(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable-bit checks are not meaningful on Windows") + } + dir := t.TempDir() + + writeFile := func(name string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte("data"), mode); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + writeFile("digitalocean", 0o755) // installName – highest priority + writeFile("workflow-plugin-digitalocean", 0o755) // manifestName – second priority + writeFile("digitalocean_linux_amd64", 0o755) // platform-suffixed installName – scanned + writeFile("workflow-plugin-digitalocean_v1_linux", 0o755) // platform-suffixed manifestName – scanned + writeFile("plugin.json", 0o644) // not executable → excluded + writeFile("README.md", 0o755) // name doesn't start with install/manifest → excluded + writeFile("some-helper", 0o755) // name doesn't match → excluded + writeFile("non-exec", 0o644) // no executable bit → excluded + + candidates := discoverArtifactBinaryCandidates(dir, "workflow-plugin-digitalocean", "digitalocean") + + if len(candidates) < 4 { + t.Fatalf("expected at least 4 candidates, got %v", candidates) + } + if candidates[0] != "digitalocean" { + t.Fatalf("candidates[0] = %q, want %q", candidates[0], "digitalocean") + } + if candidates[1] != "workflow-plugin-digitalocean" { + t.Fatalf("candidates[1] = %q, want %q", candidates[1], "workflow-plugin-digitalocean") + } + // The scanned candidates should follow in some order. + scanFound := map[string]bool{} + for _, c := range candidates[2:] { + scanFound[c] = true + } + if !scanFound["digitalocean_linux_amd64"] { + t.Fatalf("expected digitalocean_linux_amd64 in candidates, got %v", candidates) + } + if !scanFound["workflow-plugin-digitalocean_v1_linux"] { + t.Fatalf("expected workflow-plugin-digitalocean_v1_linux in candidates, got %v", candidates) + } + // Non-matching and non-executable names must be excluded. + for _, c := range candidates { + switch c { + case "README.md", "plugin.json", "non-exec", "some-helper": + t.Fatalf("unexpected candidate %q in %v", c, candidates) + } + } +} + +// TestDiscoverArtifactBinaryCandidatesSameNames verifies that when installName and +// manifestName are identical, the candidate appears exactly once. +func TestDiscoverArtifactBinaryCandidatesSameNames(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("executable-bit checks are not meaningful on Windows") + } + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "myplugin"), []byte("data"), 0o755); err != nil { + t.Fatalf("write file: %v", err) + } + candidates := discoverArtifactBinaryCandidates(dir, "myplugin", "myplugin") + if len(candidates) != 1 || candidates[0] != "myplugin" { + t.Fatalf("candidates = %v, want [myplugin]", candidates) + } +} + +// TestPluginConformanceArtifactDiscoversByManifestName verifies that artifact mode +// discovers a plugin binary named after the full manifest name +// (e.g. "workflow-plugin-iac-pass") when the archive contains no binary matching the +// normalised install name (e.g. "iac-pass"). +func TestPluginConformanceArtifactDiscoversByManifestName(t *testing.T) { + fixture := prepareIACPassFixture(t) + + // Compile the iac-pass plugin binary. + binPath := buildFixtureBinary(t, fixture, "workflow-plugin-iac-pass") + + // Build plugin.json that uses the full "workflow-plugin-*" name so that + // normalizePluginName("workflow-plugin-iac-pass") == "iac-pass" (installName) + // differs from the binary name in the archive. + pluginJSONPath := filepath.Join(t.TempDir(), "plugin.json") + if err := os.WriteFile(pluginJSONPath, []byte(`{"name":"workflow-plugin-iac-pass","version":"0.1.0","author":"workflow","description":"manifest-name test"}`), 0o600); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + archive := filepath.Join(t.TempDir(), "workflow-plugin-iac-pass.tar.gz") + writeTarGzFiles(t, archive, map[string]string{ + "plugin.json": pluginJSONPath, + "workflow-plugin-iac-pass": binPath, + // Intentionally no file named "iac-pass" so discovery must use manifestName. + }) + + out := filepath.Join(t.TempDir(), "evidence.json") + if err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--artifact", archive, + "--engine-version", "v0.51.2", + "--output", out, + }); err != nil { + t.Fatalf("runPluginConformance: %v", err) + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusPass { + t.Fatalf("status = %q, want pass\nevidence: %#v", ev.Status, ev) + } + if ev.Plugin != "workflow-plugin-iac-pass" { + t.Fatalf("plugin = %q, want %q", ev.Plugin, "workflow-plugin-iac-pass") + } + // Diagnostics about candidate selection must appear in stderrTail. + if !strings.Contains(ev.StderrTail, "workflow-plugin-iac-pass") { + t.Fatalf("stderrTail missing candidate name:\n%s", ev.StderrTail) + } + if ev.ArchiveSHA256 == "" { + t.Fatalf("archiveSHA256 must be set for artifact conformance: %#v", ev) + } +} + +// TestPluginConformanceArtifactWrongBinaryEmitsEvidence verifies that when every +// discovered binary candidate fails the go-plugin handshake, artifact mode: +// - returns a non-nil error, +// - still writes conformance-evidence.json with status=fail, and +// - includes diagnostic lines that name the candidates considered. +func TestPluginConformanceArtifactWrongBinaryEmitsEvidence(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is Unix-specific") + } + + dir := t.TempDir() + + pluginJSONPath := filepath.Join(dir, "plugin.json") + if err := os.WriteFile(pluginJSONPath, []byte(`{"name":"iac-pass","version":"0.1.0","author":"workflow","description":"wrong binary test"}`), 0o600); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + // Create a non-plugin executable that has the correct install name but does + // not perform the go-plugin handshake. + wrongBinPath := filepath.Join(dir, "iac-pass") + if err := os.WriteFile(wrongBinPath, []byte("#!/bin/sh\necho 'not a plugin' >&2\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write wrong binary: %v", err) + } + + archive := filepath.Join(dir, "wrong-binary.tar.gz") + writeTarGzFiles(t, archive, map[string]string{ + "plugin.json": pluginJSONPath, + "iac-pass": wrongBinPath, + }) + + out := filepath.Join(dir, "evidence.json") + err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--artifact", archive, + "--engine-version", "v0.51.2", + "--output", out, + }) + if err == nil { + t.Fatal("expected error for non-plugin binary") + } + + // Evidence must still be written. + if _, statErr := os.Stat(out); os.IsNotExist(statErr) { + t.Fatalf("evidence file not written despite manifest being loadable: %v", statErr) + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusFail { + t.Fatalf("status = %q, want fail", ev.Status) + } + if ev.EvidenceDigest == "" { + t.Fatalf("failure evidence missing digest: %#v", ev) + } + // Diagnostics must name the candidate that was tried. + if !strings.Contains(ev.StderrTail, "iac-pass") { + t.Fatalf("stderrTail missing candidate name:\n%s", ev.StderrTail) + } + if !strings.Contains(ev.StderrTail, "artifact binary discovery") { + t.Fatalf("stderrTail missing discovery header:\n%s", ev.StderrTail) + } + // The returned error should name the candidate too. + if !strings.Contains(err.Error(), "iac-pass") { + t.Fatalf("error = %v, want candidate name in message", err) + } +} + +// TestPluginConformanceArtifactGoModFallback verifies that when a source-in-archive +// tarball contains a non-plugin executable matching the install-name prefix (which +// fails the handshake) AND a go.mod file, artifact mode falls back to go build rather +// than emitting fail evidence immediately. +func TestPluginConformanceArtifactGoModFallback(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is Unix-specific") + } + + // Prepare a full source fixture (go.mod + main.go). + fixture := prepareIACPassFixture(t) + + // Add a non-plugin executable whose name starts with the installName ("iac-pass") + // so discoverArtifactBinaryCandidates picks it up. It fails the handshake, but + // the presence of go.mod should trigger a go build fallback. + helperScript := filepath.Join(fixture, "iac-pass-info") + if err := os.WriteFile(helperScript, []byte("#!/bin/sh\necho 'info tool' >&2\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write helper script: %v", err) + } + + // Create archive from the source dir (includes go.mod, main.go, iac-pass-info). + archive := filepath.Join(t.TempDir(), "iac-pass-src.tar.gz") + writeTarGzFromDir(t, fixture, archive) + + out := filepath.Join(t.TempDir(), "evidence.json") + if err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--artifact", archive, + "--engine-version", "v0.51.2", + "--output", out, + }); err != nil { + t.Fatalf("expected go.mod fallback to succeed, got: %v", err) + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusPass { + t.Fatalf("status = %q, want pass\nevidence: %#v", ev.Status, ev) + } + if ev.BinarySHA256 == "" { + t.Fatalf("binarySHA256 must be set: %#v", ev) + } +} + +// TestPluginConformanceArtifactNoGoModFailsCleanly verifies that a binary-only archive +// (no go.mod) where every executable candidate fails the handshake emits fail evidence +// with diagnostics rather than a bare error. +func TestPluginConformanceArtifactNoGoModFailsCleanly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script fixture is Unix-specific") + } + + dir := t.TempDir() + + pluginJSONPath := filepath.Join(dir, "plugin.json") + if err := os.WriteFile(pluginJSONPath, []byte(`{"name":"iac-pass","version":"0.1.0","author":"workflow","description":"no gomod test"}`), 0o600); err != nil { + t.Fatalf("write plugin.json: %v", err) + } + + // Non-plugin executable – name starts with installName so it is discovered. + wrongBin := filepath.Join(dir, "iac-pass-cli") + if err := os.WriteFile(wrongBin, []byte("#!/bin/sh\necho 'cli tool'\n"), 0o755); err != nil { + t.Fatalf("write wrong binary: %v", err) + } + + // Archive has plugin.json + iac-pass-cli but NO go.mod → binary-only artifact. + archive := filepath.Join(dir, "nomod.tar.gz") + writeTarGzFiles(t, archive, map[string]string{ + "plugin.json": pluginJSONPath, + "iac-pass-cli": wrongBin, + }) + + out := filepath.Join(dir, "evidence.json") + err := runPluginConformance([]string{ + "--mode", "typed-iac", + "--artifact", archive, + "--engine-version", "v0.51.2", + "--output", out, + }) + if err == nil { + t.Fatal("expected error for non-plugin binary with no go.mod") + } + ev := readEvidence(t, out) + if ev.Status != PluginCompatibilityStatusFail { + t.Fatalf("status = %q, want fail", ev.Status) + } + if ev.EvidenceDigest == "" { + t.Fatalf("failure evidence missing digest: %#v", ev) + } + if !strings.Contains(ev.StderrTail, "iac-pass-cli") { + t.Fatalf("stderrTail missing candidate name:\n%s", ev.StderrTail) + } +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 1f47ea34..67c68840 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -566,6 +566,8 @@ wfctl plugin conformance --artifact [options] Local directory evidence is useful during development. Registry enforcement should use artifact evidence so `archiveSHA256` can be matched against the registry manifest download checksum. +In artifact mode, `wfctl` discovers the plugin binary from the extracted archive automatically. It first tries the normalised install name (e.g. `digitalocean`), then the full manifest name (e.g. `workflow-plugin-digitalocean`), and finally scans the archive root for any other executable. This supports GoReleaser archives where the binary retains the full project name. Discovery runs the go-plugin handshake on each candidate; a candidate that does not perform the typed-IaC handshake is skipped with a diagnostic logged in the evidence `stderrTail`. + ```bash wfctl plugin conformance --mode typed-iac --format json ./workflow-plugin-digitalocean wfctl plugin conformance --mode typed-iac --build-package ./cmd/plugin --format json ./workflow-plugin-digitalocean diff --git a/plugin/external/sdk/iacserver_test.go b/plugin/external/sdk/iacserver_test.go index d14a9ebd..43895d69 100644 --- a/plugin/external/sdk/iacserver_test.go +++ b/plugin/external/sdk/iacserver_test.go @@ -226,7 +226,9 @@ func TestRegisterAllIaCProviderServices_PluginServiceAlreadyRegistered_NoPanic(t // Pre-register PluginService (simulates a mixed sdk.Serve + IaC plugin). // Use an embedded-by-value stub so the pattern is idiomatic Go and not // a pointer-to-unimplemented (which the generated gRPC code warns against). - type minimalPluginSvc struct{ pb.UnimplementedPluginServiceServer } + type minimalPluginSvc struct { + pb.UnimplementedPluginServiceServer + } pb.RegisterPluginServiceServer(grpcSrv, &minimalPluginSvc{}) // RegisterAllIaCProviderServices must not panic on double-registration. if err := sdk.RegisterAllIaCProviderServices(grpcSrv, &fullProviderStub{}); err != nil {