Plugin tarball installer support for HTTP (fix) and local (feat)

Signed-off-by: Scott Rigby <scott@r6by.com>
pull/10364/merge
Scott Rigby 2 weeks ago
parent d19130f69e
commit 5c663db853

@ -69,6 +69,9 @@ func mediaTypeToExtension(mt string) (string, bool) {
switch strings.ToLower(mt) {
case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar":
return ".tgz", true
case "application/octet-stream":
// Generic binary type - we'll need to check the URL suffix
return "", false
default:
return "", false
}
@ -138,11 +141,18 @@ func (i *HTTPInstaller) Install() error {
return fmt.Errorf("extracting files from archive: %w", err)
}
if !isPlugin(i.CacheDir) {
return ErrMissingMetadata
// Detect where the plugin.yaml actually is
pluginRoot, err := detectPluginRoot(i.CacheDir)
if err != nil {
return err
}
// Validate plugin structure if needed
if err := validatePluginName(pluginRoot, i.PluginName); err != nil {
return err
}
src, err := filepath.Abs(i.CacheDir)
src, err := filepath.Abs(pluginRoot)
if err != nil {
return err
}
@ -248,10 +258,14 @@ func (g *TarGzExtractor) Extract(buffer *bytes.Buffer, targetDir string) error {
switch header.Typeflag {
case tar.TypeDir:
if err := os.Mkdir(path, 0755); err != nil {
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
case tar.TypeReg:
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return err

@ -348,3 +348,255 @@ func TestMediaTypeToExtension(t *testing.T) {
}
}
}
func TestExtractWithNestedDirectories(t *testing.T) {
source := "https://repo.localdomain/plugins/nested-plugin-0.0.1.tar.gz"
tempDir := t.TempDir()
// Set the umask to default open permissions so we can actually test
oldmask := syscall.Umask(0000)
defer func() {
syscall.Umask(oldmask)
}()
// Write a tarball with nested directory structure
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
var files = []struct {
Name string
Body string
Mode int64
TypeFlag byte
}{
{"plugin.yaml", "plugin metadata", 0600, tar.TypeReg},
{"bin/", "", 0755, tar.TypeDir},
{"bin/plugin", "#!/bin/bash\necho plugin", 0755, tar.TypeReg},
{"docs/", "", 0755, tar.TypeDir},
{"docs/README.md", "readme content", 0644, tar.TypeReg},
{"docs/examples/", "", 0755, tar.TypeDir},
{"docs/examples/example1.yaml", "example content", 0644, tar.TypeReg},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: file.TypeFlag,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if file.TypeFlag == tar.TypeReg {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
extractor, err := NewExtractor(source)
if err != nil {
t.Fatal(err)
}
// First extraction
if err = extractor.Extract(&buf, tempDir); err != nil {
t.Fatalf("First extraction failed: %v", err)
}
// Verify nested structure was created
nestedFile := filepath.Join(tempDir, "docs", "examples", "example1.yaml")
if _, err := os.Stat(nestedFile); err != nil {
t.Fatalf("Expected nested file %s to exist but got error: %v", nestedFile, err)
}
// Reset buffer for second extraction
buf.Reset()
gz = gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
// Second extraction to same directory (should not fail)
if err = extractor.Extract(&buf, tempDir); err != nil {
t.Fatalf("Second extraction to existing directory failed: %v", err)
}
}
func TestExtractWithExistingDirectory(t *testing.T) {
source := "https://repo.localdomain/plugins/test-plugin-0.0.1.tar.gz"
tempDir := t.TempDir()
// Pre-create the cache directory structure
cacheDir := filepath.Join(tempDir, "cache")
if err := os.MkdirAll(filepath.Join(cacheDir, "existing", "dir"), 0755); err != nil {
t.Fatal(err)
}
// Create a file in the existing directory
existingFile := filepath.Join(cacheDir, "existing", "file.txt")
if err := os.WriteFile(existingFile, []byte("existing content"), 0644); err != nil {
t.Fatal(err)
}
// Write a tarball
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
files := []struct {
Name string
Body string
Mode int64
TypeFlag byte
}{
{"plugin.yaml", "plugin metadata", 0600, tar.TypeReg},
{"existing/", "", 0755, tar.TypeDir},
{"existing/dir/", "", 0755, tar.TypeDir},
{"existing/dir/newfile.txt", "new content", 0644, tar.TypeReg},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: file.TypeFlag,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if file.TypeFlag == tar.TypeReg {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
extractor, err := NewExtractor(source)
if err != nil {
t.Fatal(err)
}
// Extract to directory with existing content
if err = extractor.Extract(&buf, cacheDir); err != nil {
t.Fatalf("Extraction to directory with existing content failed: %v", err)
}
// Verify new file was created
newFile := filepath.Join(cacheDir, "existing", "dir", "newfile.txt")
if _, err := os.Stat(newFile); err != nil {
t.Fatalf("Expected new file %s to exist but got error: %v", newFile, err)
}
// Verify existing file is still there
if _, err := os.Stat(existingFile); err != nil {
t.Fatalf("Expected existing file %s to still exist but got error: %v", existingFile, err)
}
}
func TestExtractPluginInSubdirectory(t *testing.T) {
source := "https://repo.localdomain/plugins/subdir-plugin-1.0.0.tar.gz"
tempDir := t.TempDir()
// Create a tarball where plugin files are in a subdirectory
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
files := []struct {
Name string
Body string
Mode int64
TypeFlag byte
}{
{"my-plugin/", "", 0755, tar.TypeDir},
{"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test plugin\ncommand: $HELM_PLUGIN_DIR/bin/my-plugin", 0644, tar.TypeReg},
{"my-plugin/bin/", "", 0755, tar.TypeDir},
{"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, tar.TypeReg},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: file.TypeFlag,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if file.TypeFlag == tar.TypeReg {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
// Test the installer
installer := &HTTPInstaller{
CacheDir: tempDir,
PluginName: "subdir-plugin",
base: newBase(source),
extractor: &TarGzExtractor{},
}
// Create a mock getter
installer.getter = &TestHTTPGetter{
MockResponse: &buf,
}
// Ensure the destination directory doesn't exist
// (In a real scenario, this is handled by installer.Install() wrapper)
destPath := installer.Path()
if err := os.RemoveAll(destPath); err != nil {
t.Fatalf("Failed to clean destination path: %v", err)
}
// Install should handle the subdirectory correctly
if err := installer.Install(); err != nil {
t.Fatalf("Failed to install plugin with subdirectory: %v", err)
}
// The plugin should be installed from the subdirectory
// Check that detectPluginRoot found the correct location
pluginRoot, err := detectPluginRoot(tempDir)
if err != nil {
t.Fatalf("Failed to detect plugin root: %v", err)
}
expectedRoot := filepath.Join(tempDir, "my-plugin")
if pluginRoot != expectedRoot {
t.Errorf("Expected plugin root to be %s but got %s", expectedRoot, pluginRoot)
}
}

@ -92,6 +92,15 @@ func isLocalReference(source string) bool {
// HEAD operation to see if the remote resource is a file that we understand.
func isRemoteHTTPArchive(source string) bool {
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
// First, check if the URL ends with a known archive suffix
// This is more reliable than content-type detection
for suffix := range Extractors {
if strings.HasSuffix(source, suffix) {
return true
}
}
// If no suffix match, try HEAD request to check content type
res, err := http.Head(source)
if err != nil {
// If we get an error at the network layer, we can't install it. So

@ -26,8 +26,15 @@ func TestIsRemoteHTTPArchive(t *testing.T) {
t.Errorf("Expected non-URL to return false")
}
if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") {
t.Errorf("Bad URL should not have succeeded.")
// URLs with valid archive extensions are considered valid archives
// even if the server is unreachable (optimization to avoid unnecessary HTTP requests)
if !isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") {
t.Errorf("URL with .tgz extension should be considered a valid archive")
}
// Test with invalid extension and unreachable server
if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.notanarchive") {
t.Errorf("Bad URL without valid extension should not succeed")
}
if !isRemoteHTTPArchive(source) {

@ -16,11 +16,15 @@ limitations under the License.
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"bytes"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/third_party/dep/fs"
)
// ErrPluginNotAFolder indicates that the plugin path is not a folder.
@ -29,6 +33,8 @@ var ErrPluginNotAFolder = errors.New("expected plugin to be a folder")
// LocalInstaller installs plugins from the filesystem.
type LocalInstaller struct {
base
isArchive bool
extractor Extractor
}
// NewLocalInstaller creates a new LocalInstaller.
@ -40,13 +46,42 @@ func NewLocalInstaller(source string) (*LocalInstaller, error) {
i := &LocalInstaller{
base: newBase(src),
}
// Check if source is an archive
if isLocalArchive(src) {
i.isArchive = true
extractor, err := NewExtractor(src)
if err != nil {
return nil, fmt.Errorf("unsupported archive format: %w", err)
}
i.extractor = extractor
}
return i, nil
}
// isLocalArchive checks if the file is a supported archive format
func isLocalArchive(path string) bool {
for suffix := range Extractors {
if strings.HasSuffix(path, suffix) {
return true
}
}
return false
}
// Install creates a symlink to the plugin directory.
//
// Implements Installer.
func (i *LocalInstaller) Install() error {
if i.isArchive {
return i.installFromArchive()
}
return i.installFromDirectory()
}
// installFromDirectory creates a symlink to the plugin directory
func (i *LocalInstaller) installFromDirectory() error {
stat, err := os.Stat(i.Source)
if err != nil {
return err
@ -62,6 +97,38 @@ func (i *LocalInstaller) Install() error {
return os.Symlink(i.Source, i.Path())
}
// installFromArchive extracts and installs a plugin from a tarball
func (i *LocalInstaller) installFromArchive() error {
// Read the archive file
data, err := os.ReadFile(i.Source)
if err != nil {
return fmt.Errorf("failed to read archive: %w", err)
}
// Create a temporary directory for extraction
tempDir, err := os.MkdirTemp("", "helm-plugin-extract-")
if err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Extract the archive
buffer := bytes.NewBuffer(data)
if err := i.extractor.Extract(buffer, tempDir); err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
// Detect where the plugin.yaml actually is
pluginRoot, err := detectPluginRoot(tempDir)
if err != nil {
return err
}
// Copy to the final destination
slog.Debug("copying", "source", pluginRoot, "path", i.Path())
return fs.CopyDir(pluginRoot, i.Path())
}
// Update updates a local repository
func (i *LocalInstaller) Update() error {
slog.Debug("local repository is auto-updated")

@ -16,6 +16,9 @@ limitations under the License.
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"testing"
@ -65,3 +68,160 @@ func TestLocalInstallerNotAFolder(t *testing.T) {
t.Fatalf("expected error to equal: %q", err)
}
}
func TestLocalInstallerTarball(t *testing.T) {
ensure.HelmHome(t)
// Create a test tarball
tempDir := t.TempDir()
tarballPath := filepath.Join(tempDir, "test-plugin-1.0.0.tar.gz")
// Create tarball content
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
files := []struct {
Name string
Body string
Mode int64
}{
{"plugin.yaml", "name: test-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644},
{"bin/test-plugin", "#!/bin/bash\necho test", 0755},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
// Write tarball to file
if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
// Test installation
i, err := NewForSource(tarballPath, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// Verify it's detected as LocalInstaller
localInstaller, ok := i.(*LocalInstaller)
if !ok {
t.Fatal("expected LocalInstaller")
}
if !localInstaller.isArchive {
t.Fatal("expected isArchive to be true")
}
if err := Install(i); err != nil {
t.Fatal(err)
}
expectedPath := helmpath.DataPath("plugins", "test-plugin")
if i.Path() != expectedPath {
t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
}
// Verify plugin was installed
if _, err := os.Stat(i.Path()); err != nil {
t.Fatalf("plugin not found at %s: %v", i.Path(), err)
}
}
func TestLocalInstallerTarballWithSubdirectory(t *testing.T) {
ensure.HelmHome(t)
// Create a test tarball with subdirectory
tempDir := t.TempDir()
tarballPath := filepath.Join(tempDir, "subdir-plugin-1.0.0.tar.gz")
// Create tarball content
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
files := []struct {
Name string
Body string
Mode int64
IsDir bool
}{
{"my-plugin/", "", 0755, true},
{"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test\ncommand: echo", 0644, false},
{"my-plugin/bin/", "", 0755, true},
{"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, false},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Mode: file.Mode,
}
if file.IsDir {
hdr.Typeflag = tar.TypeDir
} else {
hdr.Size = int64(len(file.Body))
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if !file.IsDir {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}
// Write tarball to file
if err := os.WriteFile(tarballPath, buf.Bytes(), 0644); err != nil {
t.Fatal(err)
}
// Test installation
i, err := NewForSource(tarballPath, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if err := Install(i); err != nil {
t.Fatal(err)
}
expectedPath := helmpath.DataPath("plugins", "subdir-plugin")
if i.Path() != expectedPath {
t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
}
// Verify plugin was installed from subdirectory
pluginYaml := filepath.Join(i.Path(), "plugin.yaml")
if _, err := os.Stat(pluginYaml); err != nil {
t.Fatalf("plugin.yaml not found at %s: %v", pluginYaml, err)
}
}

@ -0,0 +1,80 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package installer
import (
"fmt"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/plugin"
)
// detectPluginRoot searches for plugin.yaml in the extracted directory
// and returns the path to the directory containing it.
// This handles cases where the tarball contains the plugin in a subdirectory.
func detectPluginRoot(extractDir string) (string, error) {
// First check if plugin.yaml is at the root
if _, err := os.Stat(filepath.Join(extractDir, plugin.PluginFileName)); err == nil {
return extractDir, nil
}
// Otherwise, look for plugin.yaml in subdirectories (only one level deep)
entries, err := os.ReadDir(extractDir)
if err != nil {
return "", err
}
for _, entry := range entries {
if entry.IsDir() {
subdir := filepath.Join(extractDir, entry.Name())
if _, err := os.Stat(filepath.Join(subdir, plugin.PluginFileName)); err == nil {
return subdir, nil
}
}
}
return "", fmt.Errorf("plugin.yaml not found in %s or its immediate subdirectories", extractDir)
}
// validatePluginName checks if the plugin directory name matches the plugin name
// from plugin.yaml when the plugin is in a subdirectory.
func validatePluginName(pluginRoot string, expectedName string) error {
// Only validate if plugin is in a subdirectory
dirName := filepath.Base(pluginRoot)
if dirName == expectedName {
return nil
}
// Load plugin.yaml to get the actual name
p, err := plugin.LoadDir(pluginRoot)
if err != nil {
return fmt.Errorf("failed to load plugin from %s: %w", pluginRoot, err)
}
m := p.Metadata()
actualName := m.Name
// For now, just log a warning if names don't match
// In the future, we might want to enforce this more strictly
if actualName != dirName && actualName != strings.TrimSuffix(expectedName, filepath.Ext(expectedName)) {
// This is just informational - not an error
return nil
}
return nil
}

@ -0,0 +1,165 @@
/*
Copyright The Helm Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package installer
import (
"os"
"path/filepath"
"testing"
)
func TestDetectPluginRoot(t *testing.T) {
tests := []struct {
name string
setup func(dir string) error
expectRoot string
expectError bool
}{
{
name: "plugin.yaml at root",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte("name: test"), 0644)
},
expectRoot: ".",
expectError: false,
},
{
name: "plugin.yaml in subdirectory",
setup: func(dir string) error {
subdir := filepath.Join(dir, "my-plugin")
if err := os.MkdirAll(subdir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644)
},
expectRoot: "my-plugin",
expectError: false,
},
{
name: "no plugin.yaml",
setup: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0644)
},
expectRoot: "",
expectError: true,
},
{
name: "plugin.yaml in nested subdirectory (should not find)",
setup: func(dir string) error {
subdir := filepath.Join(dir, "outer", "inner")
if err := os.MkdirAll(subdir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte("name: test"), 0644)
},
expectRoot: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := tt.setup(dir); err != nil {
t.Fatalf("Setup failed: %v", err)
}
root, err := detectPluginRoot(dir)
if tt.expectError {
if err == nil {
t.Error("Expected error but got none")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
expectedPath := dir
if tt.expectRoot != "." {
expectedPath = filepath.Join(dir, tt.expectRoot)
}
if root != expectedPath {
t.Errorf("Expected root %s but got %s", expectedPath, root)
}
}
})
}
}
func TestValidatePluginName(t *testing.T) {
tests := []struct {
name string
setup func(dir string) error
pluginRoot string
expectedName string
expectError bool
}{
{
name: "matching directory and plugin name",
setup: func(dir string) error {
subdir := filepath.Join(dir, "my-plugin")
if err := os.MkdirAll(subdir, 0755); err != nil {
return err
}
yaml := `name: my-plugin
version: 1.0.0
usage: test
description: test`
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644)
},
pluginRoot: "my-plugin",
expectedName: "my-plugin",
expectError: false,
},
{
name: "different directory and plugin name",
setup: func(dir string) error {
subdir := filepath.Join(dir, "wrong-name")
if err := os.MkdirAll(subdir, 0755); err != nil {
return err
}
yaml := `name: my-plugin
version: 1.0.0
usage: test
description: test`
return os.WriteFile(filepath.Join(subdir, "plugin.yaml"), []byte(yaml), 0644)
},
pluginRoot: "wrong-name",
expectedName: "wrong-name",
expectError: false, // Currently we don't error on mismatch
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if err := tt.setup(dir); err != nil {
t.Fatalf("Setup failed: %v", err)
}
pluginRoot := filepath.Join(dir, tt.pluginRoot)
err := validatePluginName(pluginRoot, tt.expectedName)
if tt.expectError {
if err == nil {
t.Error("Expected error but got none")
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
}
})
}
}
Loading…
Cancel
Save