From 8f4a430e418b0ac70c9b90ef05cd8dde1b10bd5c Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Thu, 17 Aug 2023 15:00:16 -0600 Subject: [PATCH] refactor: cloudplugin VersionManager -> BinaryManager --- .../cloudplugin/{versions.go => binary.go} | 60 ++++++++----- internal/cloudplugin/binary_test.go | 86 +++++++++++++++++++ internal/cloudplugin/versions_test.go | 68 --------------- internal/command/cloud.go | 8 +- internal/releaseauth/checksum.go | 2 +- 5 files changed, 128 insertions(+), 96 deletions(-) rename internal/cloudplugin/{versions.go => binary.go} (77%) create mode 100644 internal/cloudplugin/binary_test.go delete mode 100644 internal/cloudplugin/versions_test.go diff --git a/internal/cloudplugin/versions.go b/internal/cloudplugin/binary.go similarity index 77% rename from internal/cloudplugin/versions.go rename to internal/cloudplugin/binary.go index 029dd6b5b7..a4d2744c97 100644 --- a/internal/cloudplugin/versions.go +++ b/internal/cloudplugin/binary.go @@ -10,6 +10,7 @@ import ( "os" "path" "path/filepath" + "strings" "time" "github.com/hashicorp/go-getter" @@ -17,9 +18,9 @@ import ( "github.com/hashicorp/terraform/internal/releaseauth" ) -// VersionManager downloads, caches, and returns information about versions -// of terraform-cloudplugin binaries downloaded from the specified backend. -type VersionManager struct { +// BinaryManager downloads, caches, and returns information about the +// terraform-cloudplugin binary downloaded from the specified backend. +type BinaryManager struct { signingKey string binaryName string cloudPluginDataDir string @@ -30,10 +31,10 @@ type VersionManager struct { ctx context.Context } -// Version is a struct containing the path to the binary corresponding to -// the manifest version. -type Version struct { - BinaryLocation string +// Binary is a struct containing the path to an authenticated binary corresponding to +// a backend service. +type Binary struct { + Path string ProductVersion string ResolvedFromCache bool } @@ -43,16 +44,16 @@ const ( MB = 1000 * KB ) -// NewVersionManager initializes a new VersionManager to broker data between the +// BinaryManager initializes a new BinaryManager to broker data between the // specified directory location containing cloudplugin package data and a // Terraform Cloud backend URL. -func NewVersionManager(ctx context.Context, cloudPluginDataDir string, serviceURL *url.URL, goos, arch string) (*VersionManager, error) { +func NewBinaryManager(ctx context.Context, cloudPluginDataDir string, serviceURL *url.URL, goos, arch string) (*BinaryManager, error) { client, err := NewCloudPluginClient(ctx, serviceURL) if err != nil { return nil, fmt.Errorf("could not initialize cloudplugin version manager: %w", err) } - return &VersionManager{ + return &BinaryManager{ cloudPluginDataDir: cloudPluginDataDir, host: svchost.Hostname(serviceURL.Host), client: client, @@ -63,21 +64,29 @@ func NewVersionManager(ctx context.Context, cloudPluginDataDir string, serviceUR }, nil } -func (v VersionManager) versionedPackageLocation(version string) string { - return path.Join(v.cloudPluginDataDir, "bin", version, fmt.Sprintf("%s_%s", v.goos, v.arch)) +func (v BinaryManager) binaryLocation() string { + return path.Join(v.cloudPluginDataDir, "bin", fmt.Sprintf("%s_%s", v.goos, v.arch)) } -func (v VersionManager) cachedVersion(version string) *string { - binaryPath := path.Join(v.versionedPackageLocation(version), v.binaryName) +func (v BinaryManager) cachedVersion(version string) *string { + binaryPath := path.Join(v.binaryLocation(), v.binaryName) + if _, err := os.Stat(binaryPath); err != nil { return nil } + + // The version from the manifest must match the contents of ".version" + versionData, err := os.ReadFile(path.Join(v.binaryLocation(), ".version")) + if err != nil || strings.Trim(string(versionData), " \n\r\t") != version { + return nil + } + return &binaryPath } // Resolve fetches, authenticates, and caches a plugin binary matching the specifications // and returns its location and version. -func (v VersionManager) Resolve() (*Version, error) { +func (v BinaryManager) Resolve() (*Binary, error) { manifest, err := v.latestManifest(v.ctx) if err != nil { return nil, fmt.Errorf("could not resolve cloudplugin version for host %q: %w", v.host.ForDisplay(), err) @@ -90,8 +99,8 @@ func (v VersionManager) Resolve() (*Version, error) { // Check if there's a cached binary if cachedBinary := v.cachedVersion(manifest.ProductVersion); cachedBinary != nil { - return &Version{ - BinaryLocation: *cachedBinary, + return &Binary{ + Path: *cachedBinary, ProductVersion: manifest.ProductVersion, ResolvedFromCache: true, }, nil @@ -121,7 +130,7 @@ func (v VersionManager) Resolve() (*Version, error) { FilesLimit: 1, FileSizeLimit: 500 * MB, } - targetPath := v.versionedPackageLocation(manifest.ProductVersion) + targetPath := v.binaryLocation() log.Printf("[TRACE] decompressing %q to %q", t.Name(), targetPath) err = unzip.Decompress(targetPath, t.Name(), true, 0000) @@ -129,15 +138,20 @@ func (v VersionManager) Resolve() (*Version, error) { return nil, fmt.Errorf("failed to decompress cloud plugin: %w", err) } - return &Version{ - BinaryLocation: path.Join(targetPath, v.binaryName), + err = os.WriteFile(path.Join(targetPath, ".version"), []byte(manifest.ProductVersion), 0644) + if err != nil { + log.Printf("[ERROR] failed to write .version file to %q: %s", targetPath, err) + } + + return &Binary{ + Path: path.Join(targetPath, v.binaryName), ProductVersion: manifest.ProductVersion, ResolvedFromCache: false, }, nil } // Useful for small files that can be decoded all at once -func (v VersionManager) downloadFileBuffer(pathOrURL string) ([]byte, error) { +func (v BinaryManager) downloadFileBuffer(pathOrURL string) ([]byte, error) { buffer := bytes.Buffer{} err := v.client.DownloadFile(pathOrURL, &buffer) if err != nil { @@ -148,7 +162,7 @@ func (v VersionManager) downloadFileBuffer(pathOrURL string) ([]byte, error) { } // verifyCloudPlugin authenticates the downloaded release archive -func (v VersionManager) verifyCloudPlugin(archiveManifest *Manifest, info *ManifestReleaseBuild, archiveLocation string) error { +func (v BinaryManager) verifyCloudPlugin(archiveManifest *Manifest, info *ManifestReleaseBuild, archiveLocation string) error { signature, err := v.downloadFileBuffer(archiveManifest.SHA256SumsSignatureURL) if err != nil { return fmt.Errorf("failed to download cloudplugin SHA256SUMS signature file: %w", err) @@ -181,7 +195,7 @@ func (v VersionManager) verifyCloudPlugin(archiveManifest *Manifest, info *Manif return all.Authenticate() } -func (v VersionManager) latestManifest(ctx context.Context) (*Manifest, error) { +func (v BinaryManager) latestManifest(ctx context.Context) (*Manifest, error) { manifestCacheLocation := path.Join(v.cloudPluginDataDir, v.host.String(), "manifest.json") // Find the manifest cache for the hostname. diff --git a/internal/cloudplugin/binary_test.go b/internal/cloudplugin/binary_test.go new file mode 100644 index 0000000000..e1839ab463 --- /dev/null +++ b/internal/cloudplugin/binary_test.go @@ -0,0 +1,86 @@ +package cloudplugin + +import ( + "context" + "net/url" + "os" + "path/filepath" + "testing" +) + +func assertResolvedBinary(t *testing.T, binary *Binary, assertCached bool) { + t.Helper() + + if binary == nil { + t.Fatal("expected non-nil binary") + } + + if binary.ResolvedFromCache != assertCached { + t.Errorf("expected ResolvedFromCache to be %v, got %v", assertCached, binary.ResolvedFromCache) + } + + info, err := os.Stat(binary.Path) + if err != nil { + t.Fatalf("expected no error when getting binary location, got %q", err) + } + + if info.IsDir() || info.Size() == 0 { + t.Fatalf("expected non-zero file at %q", binary.Path) + } + + if binary.ProductVersion != "0.1.0" { // from sample manifest + t.Errorf("expected product binary %q, got %q", "0.1.0", binary.ProductVersion) + } +} + +func TestBinaryManager_Resolve(t *testing.T) { + publicKey, err := os.ReadFile("testdata/sample.public.key") + if err != nil { + t.Fatal(err) + } + + server, err := newCloudPluginManifestHTTPTestServer(t) + if err != nil { + t.Fatalf("could not create test server: %s", err) + } + defer server.Close() + + serverURL, _ := url.Parse(server.URL) + serviceURL := serverURL.JoinPath("/api/cloudplugin/v1") + + tempDir := t.TempDir() + manager, err := NewBinaryManager(context.Background(), tempDir, serviceURL, "darwin", "amd64") + if err != nil { + t.Fatalf("expected no err, got: %s", err) + } + manager.signingKey = string(publicKey) + manager.binaryName = "toucan.txt" // The file contained in the test archive + + binary, err := manager.Resolve() + if err != nil { + t.Fatalf("expected no err, got %s", err) + } + + assertResolvedBinary(t, binary, false) + + // Resolving a second time should return a cached binary + binary, err = manager.Resolve() + if err != nil { + t.Fatalf("expected no err, got %s", err) + } + + assertResolvedBinary(t, binary, true) + + // Change the local binary data + err = os.WriteFile(filepath.Join(filepath.Dir(binary.Path), ".version"), []byte("0.0.9"), 0644) + if err != nil { + t.Fatalf("could not write to .binary file: %s", err) + } + + binary, err = manager.Resolve() + if err != nil { + t.Fatalf("expected no err, got %s", err) + } + + assertResolvedBinary(t, binary, false) +} diff --git a/internal/cloudplugin/versions_test.go b/internal/cloudplugin/versions_test.go deleted file mode 100644 index ab25da2a7b..0000000000 --- a/internal/cloudplugin/versions_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package cloudplugin - -import ( - "context" - "net/url" - "os" - "testing" -) - -func TestVersionManager_Resolve(t *testing.T) { - publicKey, err := os.ReadFile("testdata/sample.public.key") - if err != nil { - t.Fatal(err) - } - - server, err := newCloudPluginManifestHTTPTestServer(t) - if err != nil { - t.Fatalf("could not create test server: %s", err) - } - defer server.Close() - - serverURL, _ := url.Parse(server.URL) - serviceURL := serverURL.JoinPath("/api/cloudplugin/v1") - - tempDir := t.TempDir() - manager, err := NewVersionManager(context.Background(), tempDir, serviceURL, "darwin", "amd64") - if err != nil { - t.Fatalf("expected no err, got: %s", err) - } - manager.signingKey = string(publicKey) - manager.binaryName = "toucan.txt" // The file contained in the test archive - - version, err := manager.Resolve() - if err != nil { - t.Fatalf("expected no err, got %s", err) - } - - if version == nil { - t.Fatal("expected non-nil version") - } - - if version.ResolvedFromCache { - t.Error("expected non-cached version on first call to Resolve") - } - - _, err = os.Stat(version.BinaryLocation) - if err != nil { - t.Fatalf("expected no error when getting binary location, got %q", err) - } - - if version.ProductVersion != "0.1.0" { // from sample manifest - t.Errorf("expected product version %q, got %q", "0.1.0", version.ProductVersion) - } - - // Resolving a second time should return a cached version - version, err = manager.Resolve() - if err != nil { - t.Fatalf("expected no err, got %s", err) - } - - if version == nil { - t.Fatal("expected non-nil version") - } - - if !version.ResolvedFromCache { - t.Error("expected cached version on second call to Resolve") - } -} diff --git a/internal/command/cloud.go b/internal/command/cloud.go index 2702b8a014..94bdba5aca 100644 --- a/internal/command/cloud.go +++ b/internal/command/cloud.go @@ -190,12 +190,12 @@ func (c *CloudCommand) initPlugin() tfdiags.Diagnostics { return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error())) } - vm, err := cloudplugin.NewVersionManager(ctx, packagesPath, serviceURL, runtime.GOOS, runtime.GOARCH) + bm, err := cloudplugin.NewBinaryManager(ctx, packagesPath, serviceURL, runtime.GOOS, runtime.GOARCH) if err != nil { return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error())) } - version, err := vm.Resolve() + version, err := bm.Resolve() if err != nil { return diags.Append(tfdiags.Sourceless(tfdiags.Error, "Cloud plugin download error", err.Error())) } @@ -204,8 +204,8 @@ func (c *CloudCommand) initPlugin() tfdiags.Diagnostics { if version.ResolvedFromCache { cacheTraceMsg = " (resolved from cache)" } - log.Printf("[TRACE] plugin %q binary located at %q%s", version.ProductVersion, version.BinaryLocation, cacheTraceMsg) - c.pluginBinary = version.BinaryLocation + log.Printf("[TRACE] plugin %q binary located at %q%s", version.ProductVersion, version.Path, cacheTraceMsg) + c.pluginBinary = version.Path return nil } diff --git a/internal/releaseauth/checksum.go b/internal/releaseauth/checksum.go index 43ebebd160..a30248609b 100644 --- a/internal/releaseauth/checksum.go +++ b/internal/releaseauth/checksum.go @@ -22,7 +22,7 @@ type ChecksumAuthentication struct { // ErrChecksumDoesNotMatch is the error returned when the archive checksum does // not match the given checksum. -var ErrChecksumDoesNotMatch = errors.New("failed to authenticate that the downloaded archive matches the release checksum") +var ErrChecksumDoesNotMatch = errors.New("downloaded archive does not match the release checksum") // NewChecksumAuthentication creates an instance of ChecksumAuthentication with the given // checksum and file location.