diff --git a/commands.go b/commands.go index 48e3fc773d3a..d503364ec41d 100644 --- a/commands.go +++ b/commands.go @@ -434,6 +434,12 @@ func initCommands( }, nil } + Commands["stacks"] = func() (cli.Command, error) { + return &command.StacksCLICommand{ + Meta: meta, + }, nil + } + // "rpcapi" is handled a bit differently because the whole point of // this interface is to bypass the CLI layer so wrapping automation can // get as-direct-as-possible access to Terraform Core functionality, diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index 97e5d845dc37..91d122b0ea34 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -228,6 +228,11 @@ func (b *Cloud) ServiceDiscoveryAliases() ([]backendrun.HostAlias, error) { } // Configure implements backend.Backend (which is embedded in backendrun.OperationsBackend). +func (b *Cloud) Services() *disco.Disco { + return b.services +} + +// Configure implements backend.Enhanced. func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { var diags tfdiags.Diagnostics if obj.IsNull() { diff --git a/internal/command/stacks_cli.go b/internal/command/stacks_cli.go new file mode 100644 index 000000000000..22003f576372 --- /dev/null +++ b/internal/command/stacks_cli.go @@ -0,0 +1,342 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "net/url" + "os" + "os/exec" + "path" + "runtime" + "strings" + + "google.golang.org/grpc/metadata" + + "github.com/hashicorp/go-plugin" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/disco" + backendInit "github.com/hashicorp/terraform/internal/backend/init" + "github.com/hashicorp/terraform/internal/cloud" + "github.com/hashicorp/terraform/internal/logging" + "github.com/hashicorp/terraform/internal/stackscliplugin" + "github.com/hashicorp/terraform/internal/stackscliplugin/stackscliplugin1" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +// StacksCLICommand is a Command implementation that interacts with Terraform +// Cloud for operations that relate to stacks. It delegates +// all execution to an internal plugin. +type StacksCLICommand struct { + Meta + // Path to the plugin server executable + pluginBinary string + // Service URL we can download plugin release binaries from + pluginService *url.URL + // Everything the plugin needs to build a client and Do Things + pluginConfig StacksCLIPluginConfig +} + +const ( + // DefaultStacksCLIVersion is the implied protocol version, though all + // historical versions are defined explicitly. + DefaultStacksCLIVersion = 1 + + // // ExitRPCError is the exit code that is returned if an plugin + // // communication error occurred. + // ExitRPCError = 99 + + // // ExitPluginError is the exit code that is returned if the plugin + // // cannot be downloaded. + // ExitPluginError = 98 + + // // The regular HCP Terraform API service that the go-tfe client relies on. + // tfeServiceID = "tfe.v2" + // The stacks CLI release download service that the BinaryManager relies + // on to fetch the plugin. + stacksclipluginServiceID = "stackscliplugin.v1" +) + +var ( + // StacksCLIHandshake is used to verify that the plugin is the appropriate plugin for + // the client. This is not a security verification. + StacksCLIHandshake = plugin.HandshakeConfig{ + MagicCookieKey: "TF_STACKSCLIPLUGIN_MAGIC_COOKIE", + MagicCookieValue: "123", // TODO: generate a value + ProtocolVersion: DefaultStacksCLIVersion, + } + // StacksCLIDataDir is the name of the directory within the data directory + StacksCLIDataDir = "stackscliplugin" +) + +func (c *StacksCLICommand) realRun(args []string, stdout, stderr io.Writer) int { + args = c.Meta.process(args) + fmt.Fprintf(stdout, "!!!terraform stacks cli command with args: %#v", args) + + diags := c.initPlugin() + if diags.HasWarnings() || diags.HasErrors() { + c.View.Diagnostics(diags) + } + if diags.HasErrors() { + return ExitPluginError + } + + client := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: StacksCLIHandshake, + AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, + Cmd: exec.Command(c.pluginBinary), + Logger: logging.NewStacksCLILogger(), + VersionedPlugins: map[int]plugin.PluginSet{ + 1: { + "stacks": &stackscliplugin1.GRPCStacksCLIPlugin{ + Metadata: c.pluginConfig.ToMetadata(), + }, + }, + }, + }) + defer client.Kill() + + // Connect via RPC + rpcClient, err := client.Client() + if err != nil { + fmt.Fprintf(stderr, "Failed to create stacks CLI client: %s", err) + return ExitRPCError + } + + // Request the plugin + raw, err := rpcClient.Dispense("stacks") + if err != nil { + fmt.Fprintf(stderr, "Failed to request stacks CLI interface: %s", err) + return ExitRPCError + } + + // Proxy the request + // Note: future changes will need to determine the type of raw when + // multiple versions are possible. + stacksCLI1, ok := raw.(stackscliplugin.StacksCLI1) + if !ok { + c.Ui.Error("If more than one stackscliplugin versions are available, they need to be added to the stacks cli command. This is a bug in Terraform.") + return ExitRPCError + } + return stacksCLI1.Execute(args, stdout, stderr) +} + +// discoverAndConfigure is an implementation detail of initPlugin. It fills in the +// pluginService and pluginConfig fields on a StacksCLICommand struct. +func (c *StacksCLICommand) discoverAndConfigure() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // stacks cli requires a cloud backend in order to work, + // however `cloud` block in not yet allowed in the stacks working directory + // initialize an empty cloud backend + bf := backendInit.Backend("cloud") + if bf == nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "`cloud` backend not found, this should not happen", + "`cloud` backend is a valid backend type, yet it was not found, this is could be a bug, report it.", + )) + } + b := bf() + cb, ok := b.(*cloud.Cloud) + if !ok { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "`cloud` backend could not be initialized", + "Could not initialize a `cloud` backend, this is could be a bug, report it.", + )) + return diags + } + + displayHostname := os.Getenv("TF_STACKS_CLI_HOSTNAME") + if strings.TrimSpace(displayHostname) == "" { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "TF_STACKS_CLI_HOSTNAME is not set", + "TF_STACKS_CLI_HOSTNAME must be set to the hostname of the HCP Terraform instance", + )) + } + + token := os.Getenv("TF_STACKS_CLI_TOKEN") + if strings.TrimSpace(token) == "" { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "TF_STACKS_CLI_TOKEN is not set", + "TF_STACKS_CLI_TOKEN must be set to the token of the HCP Terraform instance", + )) + } + + hostname, err := svchost.ForComparison(displayHostname) + if err != nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Hostname string cannot be parsed into a svc.Hostname", + err.Error(), + )) + } + + host, err := cb.Services().Discover(hostname) + if err != nil { + // Network errors from Discover() can read like non-sequiters, so we wrap em. + var serviceDiscoErr *disco.ErrServiceDiscoveryNetworkRequest + if errors.As(err, &serviceDiscoErr) { + err = fmt.Errorf("a network issue prevented cloud configuration; %w", err) + } + + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Hostname discovery failed", + err.Error(), + )) + } + + // The discovery request worked, so cache the full results. + cb.ServicesHost = host + + // re-use the cached service discovery info for this TFC + // instance to find our plugin service and TFE API URLs: + pluginService, err := cb.ServicesHost.ServiceURL(stacksclipluginServiceID) + if err != nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Stacks CLI plugin service not found", + err.Error(), + )) + } + c.pluginService = pluginService + + tfeService, err := cb.ServicesHost.ServiceURL(tfeServiceID) + if err != nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "HCP Terraform API service not found", + err.Error(), + )) + } + + // Now just steal everything we need so we can pass it to the plugin later. + c.pluginConfig = StacksCLIPluginConfig{ + Address: tfeService.String(), + BasePath: tfeService.Path, + DisplayHostname: displayHostname, + Token: token, + } + + return diags +} + +func (c *StacksCLICommand) initPlugin() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + var errorSummary = "Stacks CLI plugin initialization error" + + // Initialization can be aborted by interruption signals + ctx, done := c.InterruptibleContext(c.CommandContext()) + defer done() + + // Discover service URLs, and build out the plugin config + diags = diags.Append(c.discoverAndConfigure()) + if diags.HasErrors() { + return diags + } + + packagesPath, err := c.initPackagesCache() + if err != nil { + return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error())) + } + + overridePath := os.Getenv("TF_STACKS_CLI_PLUGIN_DEV_OVERRIDE") + + bm, err := stackscliplugin.NewStacksCLIBinaryManager(ctx, packagesPath, overridePath, c.pluginService, runtime.GOOS, runtime.GOARCH) + if err != nil { + return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error())) + } + + version, err := bm.Resolve() + if err != nil { + return diags.Append(tfdiags.Sourceless(tfdiags.Error, "Stacks CLI plugin download error", err.Error())) + } + + var cacheTraceMsg = "" + if version.ResolvedFromCache { + cacheTraceMsg = " (resolved from cache)" + } + if version.ResolvedFromDevOverride { + cacheTraceMsg = " (resolved from dev override)" + detailMsg := fmt.Sprintf("Instead of using the current released version, Terraform is loading the stacks CLI from the following location:\n\n - %s\n\nOverriding the stacks CLI location can cause unexpected behavior, and is only intended for use when developing new versions of the plugin.", version.Path) + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Stacks CLI plugin development overrides are in effect", + detailMsg, + )) + } + log.Printf("[TRACE] plugin %q binary located at %q%s", version.ProductVersion, version.Path, cacheTraceMsg) + c.pluginBinary = version.Path + return diags +} + +func (c *StacksCLICommand) initPackagesCache() (string, error) { + packagesPath := path.Join(c.WorkingDir.DataDir(), StacksCLIDataDir) + + if info, err := os.Stat(packagesPath); err != nil || !info.IsDir() { + log.Printf("[TRACE] initialized stackscliplugin cache directory at %q", packagesPath) + err = os.MkdirAll(packagesPath, 0755) + if err != nil { + return "", fmt.Errorf("failed to initialize stackscliplugin cache directory: %w", err) + } + } else { + log.Printf("[TRACE] stackscliplugin cache directory found at %q", packagesPath) + } + + return packagesPath, nil +} + +// Run runs the stacks cli command with the given arguments. +func (c *StacksCLICommand) Run(args []string) int { + args = c.Meta.process(args) + return c.realRun(args, c.Meta.Streams.Stdout.File, c.Meta.Streams.Stderr.File) +} + +// Help returns help text for the stacks cli command. +func (c *StacksCLICommand) Help() string { + helpText := new(bytes.Buffer) + if exitCode := c.realRun([]string{}, helpText, io.Discard); exitCode != 0 { + return "" + } + + return helpText.String() +} + +// Synopsis returns a short summary of the stacks cli command. +func (c *StacksCLICommand) Synopsis() string { + return "Manage HCP Terraform settings and metadata" +} + +// StacksCLIPluginConfig is everything the plugin needs to know to configure a +// client and talk to HCP Terraform. +type StacksCLIPluginConfig struct { + // Maybe someday we can use struct tags to automate grabbing these out of + // the metadata headers! And verify client-side that we're sending the right + // stuff, instead of having it all be a stringly-typed mystery ball! I want + // to believe in that distant shining day! 🌻 Meantime, these struct tags + // serve purely as docs. + Address string `md:"tfc-address"` + BasePath string `md:"tfc-base-path"` + DisplayHostname string `md:"tfc-display-hostname"` + Token string `md:"tfc-token"` + // TODO: how to read relevant env vars and pass it to the stacks-cli plugin +} + +func (c StacksCLIPluginConfig) ToMetadata() metadata.MD { + md := metadata.Pairs( + "tfc-address", c.Address, + "tfc-base-path", c.BasePath, + "tfc-display-hostname", c.DisplayHostname, + "tfc-token", c.Token, + ) + return md +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 9a71787ddf4c..9ee703b9bd0e 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -23,9 +23,10 @@ const ( // Allow logging of specific subsystems. // We only separate core and providers for now, but this could be extended // to other loggers, like provisioners and remote-state backends. - envLogCore = "TF_LOG_CORE" - envLogProvider = "TF_LOG_PROVIDER" - envLogCloud = "TF_LOG_CLOUD" + envLogCore = "TF_LOG_CORE" + envLogProvider = "TF_LOG_PROVIDER" + envLogCloud = "TF_LOG_CLOUD" + envLogStacksCLI = "TF_LOG_STACKSCLI" ) var ( @@ -144,6 +145,20 @@ func NewCloudLogger() hclog.Logger { return l } +// NewStacksCLILogger returns a logger for the StacksCLI plugin, possibly with a +// different log level from the global logger. +func NewStacksCLILogger() hclog.Logger { + l := &logPanicWrapper{ + Logger: logger.Named("stacks"), + } + + level := stacksCLILogLevel() + logger.Debug("created stacks cli logger", "level", level) + + l.SetLevel(level) + return l +} + // CurrentLogLevel returns the current log level string based the environment vars func CurrentLogLevel() string { ll, _ := globalLogLevel() @@ -168,6 +183,15 @@ func cloudLogLevel() hclog.Level { return parseLogLevel(providerEnvLevel) } +func stacksCLILogLevel() hclog.Level { + pluginEnvLevel := strings.ToUpper(os.Getenv(envLogStacksCLI)) + if pluginEnvLevel == "" { + pluginEnvLevel = strings.ToUpper(os.Getenv(envLog)) + } + + return parseLogLevel(pluginEnvLevel) +} + func globalLogLevel() (hclog.Level, bool) { var json bool envLevel := strings.ToUpper(os.Getenv(envLog)) diff --git a/internal/stackscliplugin/binary.go b/internal/stackscliplugin/binary.go new file mode 100644 index 000000000000..40beef0cef76 --- /dev/null +++ b/internal/stackscliplugin/binary.go @@ -0,0 +1,277 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackscliplugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/hashicorp/go-getter" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform/internal/releaseauth" +) + +// StacksCLIBinaryManager downloads, caches, and returns information about the +// stacks-cli plugin binary downloaded from the specified backend. +type StacksCLIBinaryManager struct { + signingKey string + binaryName string + stacksCLIPluginDataDir string + overridePath string + host svchost.Hostname + client *StacksCLIPluginClient + goos string + arch string + ctx context.Context +} + +// 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 + ResolvedFromDevOverride bool +} + +const ( + KB = 1000 + MB = 1000 * KB +) + +const binaryName = "terraform-stacks-cli-plugin" + +// StacksCLIBinaryManager initializes a new StacksCLIBinaryManager to broker data between the +// specified directory location containing stacks-cli plugin package data and a +// HCP Terraform backend URL. +func NewStacksCLIBinaryManager(ctx context.Context, stacksCLIPluginDataDir, overridePath string, serviceURL *url.URL, goos, arch string) (*StacksCLIBinaryManager, error) { + client, err := NewStacksCLIPluginClient(ctx, serviceURL) + if err != nil { + return nil, fmt.Errorf("could not initialize stacks-cli plugin version manager: %w", err) + } + + return &StacksCLIBinaryManager{ + stacksCLIPluginDataDir: stacksCLIPluginDataDir, + overridePath: overridePath, + host: svchost.Hostname(serviceURL.Host), + client: client, + binaryName: binaryName, + goos: goos, + arch: arch, + ctx: ctx, + }, nil +} + +func (v StacksCLIBinaryManager) binaryLocation() string { + return path.Join(v.stacksCLIPluginDataDir, "bin", fmt.Sprintf("%s_%s", v.goos, v.arch)) +} + +func (v StacksCLIBinaryManager) 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 StacksCLIBinaryManager) Resolve() (*Binary, error) { + if v.overridePath != "" { + log.Printf("[TRACE] Using dev override for stacks-cli plugin binary") + return v.resolveDev() + } + return v.resolveRelease() +} + +func (v StacksCLIBinaryManager) resolveDev() (*Binary, error) { + return &Binary{ + Path: v.overridePath, + ProductVersion: "dev", + ResolvedFromDevOverride: true, + }, nil +} + +func (v StacksCLIBinaryManager) resolveRelease() (*Binary, error) { + manifest, err := v.latestManifest(v.ctx) + if err != nil { + return nil, fmt.Errorf("could not resolve stacks-cli plugin version for host %q: %w", v.host.ForDisplay(), err) + } + + buildInfo, err := manifest.Select(v.goos, v.arch) + if err != nil { + return nil, err + } + + // Check if there's a cached binary + if cachedBinary := v.cachedVersion(manifest.Version); cachedBinary != nil { + return &Binary{ + Path: *cachedBinary, + ProductVersion: manifest.Version, + ResolvedFromCache: true, + }, nil + } + + // Download the archive + t, err := os.CreateTemp(os.TempDir(), binaryName) + if err != nil { + return nil, fmt.Errorf("failed to create temp file for download: %w", err) + } + defer os.Remove(t.Name()) + + err = v.client.DownloadFile(buildInfo.URL, t) + if err != nil { + return nil, err + } + t.Close() // Close only returns an error if it's already been called + + // Authenticate the archive + err = v.verifyStacksCLIPlugin(manifest, buildInfo, t.Name()) + if err != nil { + return nil, fmt.Errorf("could not resolve stacks-cli plugin version %q: %w", manifest.Version, err) + } + + // Unarchive + unzip := getter.ZipDecompressor{ + FilesLimit: 1, + FileSizeLimit: 500 * MB, + } + targetPath := v.binaryLocation() + log.Printf("[TRACE] decompressing %q to %q", t.Name(), targetPath) + + err = unzip.Decompress(targetPath, t.Name(), true, 0000) + if err != nil { + return nil, fmt.Errorf("failed to decompress stacks-cli plugin: %w", err) + } + + err = os.WriteFile(path.Join(targetPath, ".version"), []byte(manifest.Version), 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.Version, + ResolvedFromCache: false, + }, nil +} + +// Useful for small files that can be decoded all at once +func (v StacksCLIBinaryManager) downloadFileBuffer(pathOrURL string) ([]byte, error) { + buffer := bytes.Buffer{} + err := v.client.DownloadFile(pathOrURL, &buffer) + if err != nil { + return nil, err + } + + return buffer.Bytes(), err +} + +// verifyStacksCLIPlugin authenticates the downloaded release archive +func (v StacksCLIBinaryManager) verifyStacksCLIPlugin(archiveManifest *Release, info *BuildArtifact, archiveLocation string) error { + signature, err := v.downloadFileBuffer(archiveManifest.URLSHASumsSignatures[0]) + if err != nil { + return fmt.Errorf("failed to download stacks-cli plugin SHA256SUMS signature file: %w", err) + } + sums, err := v.downloadFileBuffer(archiveManifest.URLSHASums) + if err != nil { + return fmt.Errorf("failed to download stacks-cli plugin SHA256SUMS file: %w", err) + } + + checksums, err := releaseauth.ParseChecksums(sums) + if err != nil { + return fmt.Errorf("failed to parse stacks-cli plugin SHA256SUMS file: %w", err) + } + + filename := path.Base(info.URL) + reportedSHA, ok := checksums[filename] + if !ok { + return fmt.Errorf("could not find checksum for file %q", filename) + } + + sigAuth := releaseauth.NewSignatureAuthentication(signature, sums) + if len(v.signingKey) > 0 { + sigAuth.PublicKey = v.signingKey + } + + all := releaseauth.AllAuthenticators( + releaseauth.NewChecksumAuthentication(reportedSHA, archiveLocation), + sigAuth, + ) + + return all.Authenticate() +} + +func (v StacksCLIBinaryManager) latestManifest(ctx context.Context) (*Release, error) { + manifestCacheLocation := path.Join(v.stacksCLIPluginDataDir, v.host.String(), "manifest.json") + + // Find the manifest cache for the hostname. + data, err := os.ReadFile(manifestCacheLocation) + modTime := time.Time{} + var localManifest *Release + if err != nil { + log.Printf("[TRACE] no stacks-cli plugin manifest cache found for host %q", v.host) + } else { + log.Printf("[TRACE] stacks-cli plugin manifest cache found for host %q", v.host) + + localManifest, err = decodeManifest(bytes.NewBuffer(data)) + modTime = localManifest.TimestampUpdated + if err != nil { + log.Printf("[WARN] failed to decode stacks-cli plugin manifest cache %q: %s", manifestCacheLocation, err) + } + } + + // Even though we may have a local manifest, always see if there is a newer remote manifest + result, err := v.client.FetchManifest(modTime) + // FetchManifest can return nil, nil (see below) + if err != nil { + return nil, fmt.Errorf("failed to fetch stacks-cli plugin manifest: %w", err) + } + + // No error and no remoteManifest means the existing manifest is not modified + // and it's safe to use the local manifest + if result == nil && localManifest != nil { + result = localManifest + } else { + data, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to dump stacks-cli plugin manifest to JSON: %w", err) + } + + // Ensure target directory exists + if err := os.MkdirAll(filepath.Dir(manifestCacheLocation), 0755); err != nil { + return nil, fmt.Errorf("failed to create stacks-cli plugin manifest cache directory: %w", err) + } + + output, err := os.Create(manifestCacheLocation) + if err != nil { + return nil, fmt.Errorf("failed to create stacks-cli plugin manifest cache: %w", err) + } + + _, err = output.Write(data) + if err != nil { + return nil, fmt.Errorf("failed to write stacks-cli plugin manifest cache: %w", err) + } + log.Printf("[TRACE] wrote stacks-cli plugin manifest cache to %q", manifestCacheLocation) + } + + return result, nil +} diff --git a/internal/stackscliplugin/client.go b/internal/stackscliplugin/client.go new file mode 100644 index 000000000000..6ba74d6de4ac --- /dev/null +++ b/internal/stackscliplugin/client.go @@ -0,0 +1,323 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackscliplugin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform/internal/httpclient" + "github.com/hashicorp/terraform/internal/logging" + "github.com/hashicorp/terraform/internal/releaseauth" +) + +var ( + defaultRequestTimeout = 60 * time.Second +) + +// SHASumsSignatures holds a list of URLs, each referring a detached signature of the release's build artifacts. +type SHASumsSignatures []string + +// BuildArtifact represents a single build artifact in a release response. +type BuildArtifact struct { + + // The hardware architecture of the build artifact + // Enum: [386 all amd64 amd64-lxc arm arm5 arm6 arm64 arm7 armelv5 armhfv6 i686 mips mips64 mipsle ppc64le s390x ui x86_64] + Arch string `json:"arch"` + + // The Operating System corresponding to the build artifact + // Enum: [archlinux centos darwin debian dragonfly freebsd linux netbsd openbsd plan9 python solaris terraform web windows] + Os string `json:"os"` + + // This build is unsupported and provided for convenience only. + Unsupported bool `json:"unsupported,omitempty"` + + // The URL where this build can be downloaded + URL string `json:"url"` +} + +// ReleaseStatus Status of the product release +// Example: {"message":"This release is supported","state":"supported"} +type ReleaseStatus struct { + + // Provides information about the most recent change; must be provided when Name="withdrawn" + Message string `json:"message,omitempty"` + + // The state name of the release + // Enum: [supported unsupported withdrawn] + State string `json:"state"` + + // The timestamp for the creation of the product release status + // Example: 2009-11-10T23:00:00Z + // Format: date-time + TimestampUpdated time.Time `json:"timestamp_updated"` +} + +// Release All metadata for a single product release +type Release struct { + // builds + Builds []*BuildArtifact `json:"builds,omitempty"` + + // A docker image name and tag for this release in the format `name`:`tag` + // Example: consul:1.10.0-beta3 + DockerNameTag string `json:"docker_name_tag,omitempty"` + + // True if and only if this product release is a prerelease. + IsPrerelease bool `json:"is_prerelease"` + + // The license class indicates how this product is licensed. + // Enum: [enterprise hcp oss] + LicenseClass string `json:"license_class"` + + // The product name + // Example: consul-enterprise + // Required: true + Name string `json:"name"` + + // Status + Status ReleaseStatus `json:"status"` + + // Timestamp at which this product release was created. + // Example: 2009-11-10T23:00:00Z + // Format: date-time + TimestampCreated time.Time `json:"timestamp_created"` + + // Timestamp when this product release was most recently updated. + // Example: 2009-11-10T23:00:00Z + // Format: date-time + TimestampUpdated time.Time `json:"timestamp_updated"` + + // URL for a blogpost announcing this release + URLBlogpost string `json:"url_blogpost,omitempty"` + + // URL for the changelog covering this release + URLChangelog string `json:"url_changelog,omitempty"` + + // The project's docker repo on Amazon ECR-Public + URLDockerRegistryDockerhub string `json:"url_docker_registry_dockerhub,omitempty"` + + // The project's docker repo on DockerHub + URLDockerRegistryEcr string `json:"url_docker_registry_ecr,omitempty"` + + // URL for the software license applicable to this release + // Required: true + URLLicense string `json:"url_license,omitempty"` + + // The project's website URL + URLProjectWebsite string `json:"url_project_website,omitempty"` + + // URL for this release's change notes + URLReleaseNotes string `json:"url_release_notes,omitempty"` + + // URL for this release's file containing checksums of all the included build artifacts + URLSHASums string `json:"url_shasums"` + + // An array of URLs, each pointing to a signature file. Each signature file is a detached signature + // of the checksums file (see field `url_shasums`). Signature files may or may not embed the signing + // key ID in the filename. + URLSHASumsSignatures SHASumsSignatures `json:"url_shasums_signatures"` + + // URL for the product's source code repository. This field is empty for + // enterprise and hcp products. + URLSourceRepository string `json:"url_source_repository,omitempty"` + + // The version of this release + // Example: 1.10.0-beta3 + // Required: true + Version string `json:"version"` +} + +// StacksCLIPluginClient fetches and verifies release distributions of the stacks-cli plugin +// that correspond to an upstream backend. +type StacksCLIPluginClient struct { + serviceURL *url.URL + httpClient *retryablehttp.Client + ctx context.Context +} + +func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) { + if i > 0 { + logger.Printf("[INFO] Previous request to the remote stacks-cli manifest failed, attempting retry.") + } +} + +func decodeManifest(data io.Reader) (*Release, error) { + var man Release + dec := json.NewDecoder(data) + if err := dec.Decode(&man); err != nil { + return nil, ErrQueryFailed{ + inner: fmt.Errorf("failed to decode response body: %w", err), + } + } + + return &man, nil +} + +// NewStacksCLIPluginClient creates a new client for downloading and verifying +// stacks-cli plugin archives +func NewStacksCLIPluginClient(ctx context.Context, serviceURL *url.URL) (*StacksCLIPluginClient, error) { + httpClient := httpclient.New() + httpClient.Timeout = defaultRequestTimeout + + retryableClient := retryablehttp.NewClient() + retryableClient.HTTPClient = httpClient + retryableClient.RetryMax = 3 + retryableClient.RequestLogHook = requestLogHook + retryableClient.Logger = logging.HCLogger() + + return &StacksCLIPluginClient{ + httpClient: retryableClient, + serviceURL: serviceURL, + ctx: ctx, + }, nil +} + +// FetchManifest retrieves the stacks-cli plugin manifest from HCP Terraform, +// but returns a nil manifest if a 304 response is received, depending +// on the lastModified time. +func (c StacksCLIPluginClient) FetchManifest(lastModified time.Time) (*Release, error) { + req, _ := retryablehttp.NewRequestWithContext(c.ctx, "GET", c.serviceURL.JoinPath("manifest.json").String(), nil) + req.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat)) + + resp, err := c.httpClient.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, ErrRequestCanceled + } + return nil, ErrQueryFailed{ + inner: err, + } + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + manifest, err := decodeManifest(resp.Body) + if err != nil { + return nil, err + } + return manifest, nil + case http.StatusNotModified: + return nil, nil + case http.StatusNotFound: + return nil, ErrStacksCLIPluginNotSupported + default: + return nil, ErrQueryFailed{ + inner: errors.New(resp.Status), + } + } +} + +// DownloadFile gets the URL at the specified path or URL and writes the +// contents to the specified Writer. +func (c StacksCLIPluginClient) DownloadFile(pathOrURL string, writer io.Writer) error { + url, err := c.resolveManifestURL(pathOrURL) + if err != nil { + return err + } + req, err := retryablehttp.NewRequestWithContext(c.ctx, "GET", url.String(), nil) + if err != nil { + return fmt.Errorf("invalid URL %q was provided by the stacks-cli plugin manifest: %w", url, err) + } + resp, err := c.httpClient.Do(req) + if err != nil { + if errors.Is(err, context.Canceled) { + return ErrRequestCanceled + } + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + // OK + case http.StatusNotFound: + return ErrStacksCLIPluginNotFound + default: + return ErrQueryFailed{ + inner: errors.New(resp.Status), + } + } + + _, err = io.Copy(writer, resp.Body) + if err != nil { + return fmt.Errorf("failed to write downloaded file: %w", err) + } + + return nil +} + +func (c StacksCLIPluginClient) resolveManifestURL(pathOrURL string) (*url.URL, error) { + if strings.HasPrefix(pathOrURL, "/") { + copy := *c.serviceURL + copy.Path = "" + return copy.JoinPath(pathOrURL), nil + } + + result, err := url.Parse(pathOrURL) + if err != nil { + return nil, fmt.Errorf("received malformed URL %q from stacks-cli plugin manifest: %w", pathOrURL, err) + } + return result, nil +} + +// Select gets the specific build data from the Manifest for the specified OS/Architecture +func (m Release) Select(goos, arch string) (*BuildArtifact, error) { + var supported []string + var found *BuildArtifact + for _, build := range m.Builds { + key := fmt.Sprintf("%s_%s", build.Os, build.Arch) + supported = append(supported, key) + + if goos == build.Os && arch == build.Arch { + found = build + } + } + + osArchKey := fmt.Sprintf("%s_%s", goos, arch) + log.Printf("[TRACE] checking for stacks-cli plugin archive for %s. Supported architectures: %v", osArchKey, supported) + + if found == nil { + return nil, ErrArchNotSupported + } + + return found, nil +} + +// PrimarySHASumsSignatureURL returns the URL among the URLSHASumsSignatures that matches +// the public key known by this version of terraform. It falls back to the first URL with no +// ID in the URL. +func (m Release) PrimarySHASumsSignatureURL() (string, error) { + if len(m.URLSHASumsSignatures) == 0 { + return "", fmt.Errorf("no SHA256SUMS URLs were available") + } + + findBySuffix := func(suffix string) string { + for _, url := range m.URLSHASumsSignatures { + if len(url) > len(suffix) && strings.EqualFold(suffix, url[len(url)-len(suffix):]) { + return url + } + } + return "" + } + + withKeyID := findBySuffix(fmt.Sprintf(".%s.sig", releaseauth.HashiCorpPublicKeyID)) + if withKeyID == "" { + withNoKeyID := findBySuffix("_SHA256SUMS.sig") + if withNoKeyID == "" { + return "", fmt.Errorf("no SHA256SUMS URLs matched the known public key") + } + return withNoKeyID, nil + } + return withKeyID, nil +} diff --git a/internal/stackscliplugin/errors.go b/internal/stackscliplugin/errors.go new file mode 100644 index 000000000000..8e3d291e38ef --- /dev/null +++ b/internal/stackscliplugin/errors.go @@ -0,0 +1,57 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackscliplugin + +import ( + "errors" + "fmt" +) + +var ( + // ErrStacksCLIPluginNotSupported is the error returned when the upstream HCP Terraform does not + // have a manifest. + ErrStacksCLIPluginNotSupported = errors.New("stacks-cli plugin is not supported by the remote version of Terraform Enterprise") + + // ErrRequestCanceled is the error returned when the context was cancelled. + ErrRequestCanceled = errors.New("request was canceled") + + // ErrArchNotSupported is the error returned when the stacks-cli plugin does not have a build for the + // current OS/Architecture. + ErrArchNotSupported = errors.New("stacks-cli plugin is not supported by your computer architecture/operating system") + + // ErrStacksCLIPluginNotFound is the error returned when the stacks-cliplugin manifest points to a location + // that was does not exist. + ErrStacksCLIPluginNotFound = errors.New("stacks-cli plugin download was not found in the location specified in the manifest") +) + +// ErrQueryFailed is the error returned when the stacks-cliplugin http client request fails +type ErrQueryFailed struct { + inner error +} + +// ErrStacksCLIPluginNotVerified is the error returned when the archive authentication process fails +type ErrStacksCLIPluginNotVerified struct { + inner error +} + +// Error returns a string representation of ErrQueryFailed +func (e ErrQueryFailed) Error() string { + return fmt.Sprintf("failed to fetch stacks-cli plugin from HCP Terraform: %s", e.inner) +} + +// Unwrap returns the inner error of ErrQueryFailed +func (e ErrQueryFailed) Unwrap() error { + // Return the inner error. + return e.inner +} + +// Error returns the string representation of ErrStacksCLIPluginNotVerified +func (e ErrStacksCLIPluginNotVerified) Error() string { + return fmt.Sprintf("failed to verify stacks-cli plugin. Ensure that the referenced plugin is the official HashiCorp distribution: %s", e.inner) +} + +// Unwrap returns the inner error of ErrStacksCLIPluginNotVerified +func (e ErrStacksCLIPluginNotVerified) Unwrap() error { + return e.inner +} diff --git a/internal/stackscliplugin/interface.go b/internal/stackscliplugin/interface.go new file mode 100644 index 000000000000..2c5ee0e4c751 --- /dev/null +++ b/internal/stackscliplugin/interface.go @@ -0,0 +1,12 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackscliplugin + +import ( + "io" +) + +type StacksCLI1 interface { + Execute(args []string, stdout, stderr io.Writer) int +} diff --git a/internal/stackscliplugin/stackscliplugin1/grpc_client.go b/internal/stackscliplugin/stackscliplugin1/grpc_client.go new file mode 100644 index 000000000000..c69a9f119c82 --- /dev/null +++ b/internal/stackscliplugin/stackscliplugin1/grpc_client.go @@ -0,0 +1,81 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackscliplugin1 + +import ( + "context" + "fmt" + "io" + "log" + + "github.com/hashicorp/terraform/internal/stackscliplugin" + "github.com/hashicorp/terraform/internal/stackscliplugin/stackscliproto1" +) + +// GRPCStacksCLIClient is the client interface for interacting with stacks-cli-plugin +type GRPCStacksCLIClient struct { + client stackscliproto1.CommandServiceClient + context context.Context +} + +// Proof that GRPCStacksCLIClient fulfills the go-plugin interface +var _ stackscliplugin.StacksCLI1 = GRPCStacksCLIClient{} + +// Execute sends the client Execute request and waits for the plugin to return +// an exit code response before returning +func (c GRPCStacksCLIClient) Execute(args []string, stdout, stderr io.Writer) int { + client, err := c.client.Execute(c.context, &stackscliproto1.CommandRequest{ + Args: args, + }) + + if err != nil { + fmt.Fprint(stderr, err.Error()) + return 1 + } + + for { + // stackscliplugin streams output as multiple CommandResponse value. Each + // value will either contain stdout bytes, stderr bytes, or an exit code. + response, err := client.Recv() + if err == io.EOF { + log.Print("[DEBUG] received EOF from stackscliplugin") + break + } else if err != nil { + fmt.Fprintf(stderr, "Failed to receive command response from stackscliplugin: %s", err) + return 1 + } + + if bytes := response.GetStdout(); len(bytes) > 0 { + written, err := fmt.Fprint(stdout, string(bytes)) + if err != nil { + log.Printf("[ERROR] Failed to write stackscliplugin output to stdout: %s", err) + return 1 + } + if written != len(bytes) { + log.Printf("[ERROR] Wrote %d bytes to stdout but expected to write %d", written, len(bytes)) + } + } else if bytes := response.GetStderr(); len(bytes) > 0 { + written, err := fmt.Fprint(stderr, string(bytes)) + if err != nil { + log.Printf("[ERROR] Failed to write stackscliplugin output to stderr: %s", err) + return 1 + } + if written != len(bytes) { + log.Printf("[ERROR] Wrote %d bytes to stdout but expected to write %d", written, len(bytes)) + } + } else { + exitCode := response.GetExitCode() + log.Printf("[TRACE] received exit code: %d", exitCode) + if exitCode < 0 || exitCode > 255 { + log.Printf("[ERROR] stackscliplugin returned an invalid error code %d", exitCode) + return 255 + } + return int(exitCode) + } + } + + // This should indicate a bug in the plugin + fmt.Fprint(stderr, "stackscliplugin exited without responding with an error code") + return 1 +} diff --git a/internal/stackscliplugin/stackscliplugin1/grpc_plugin.go b/internal/stackscliplugin/stackscliplugin1/grpc_plugin.go new file mode 100644 index 000000000000..896160a7b588 --- /dev/null +++ b/internal/stackscliplugin/stackscliplugin1/grpc_plugin.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackscliplugin1 + +import ( + "context" + "errors" + "net/rpc" + + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform/internal/stackscliplugin" + "github.com/hashicorp/terraform/internal/stackscliplugin/stackscliproto1" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// GRPCStacksCLIPlugin is the go-plugin implementation, but only the client +// implementation exists in this package. +type GRPCStacksCLIPlugin struct { + plugin.GRPCPlugin + Impl stackscliplugin.StacksCLI1 + // Any configuration metadata that the plugin executable needs in order to + // do something useful, which will be passed along via gRPC metadata headers. + Metadata metadata.MD +} + +// Server always returns an error; we're only implementing the GRPCPlugin +// interface, not the Plugin interface. +func (p *GRPCStacksCLIPlugin) Server(*plugin.MuxBroker) (interface{}, error) { + return nil, errors.New("stackscliplugin only implements gRPC clients") +} + +// Client always returns an error; we're only implementing the GRPCPlugin +// interface, not the Plugin interface. +func (p *GRPCStacksCLIPlugin) Client(*plugin.MuxBroker, *rpc.Client) (interface{}, error) { + return nil, errors.New("stackscliplugin only implements gRPC clients") +} + +// GRPCServer always returns an error; we're only implementing the client +// interface, not the server. +func (p *GRPCStacksCLIPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + return errors.New("stackscliplugin only implements gRPC clients") +} + +// GRPCClient returns a new GRPC client for interacting with the cloud plugin server. +func (p *GRPCStacksCLIPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + ctx = metadata.NewOutgoingContext(ctx, p.Metadata) + return &GRPCStacksCLIClient{ + client: stackscliproto1.NewCommandServiceClient(c), + context: ctx, + }, nil +} diff --git a/internal/stackscliplugin/stackscliproto1/stackscliproto1.pb.go b/internal/stackscliplugin/stackscliproto1/stackscliproto1.pb.go new file mode 100644 index 000000000000..fc3daa7b99be --- /dev/null +++ b/internal/stackscliplugin/stackscliproto1/stackscliproto1.pb.go @@ -0,0 +1,395 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v3.15.6 +// source: stackscliproto1.proto + +package stackscliproto1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// CommandRequest is used to request the execution of a specific command with +// provided flags. It is the raw args from the HCP Terraform command. +type CommandRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Args []string `protobuf:"bytes,1,rep,name=args,proto3" json:"args,omitempty"` +} + +func (x *CommandRequest) Reset() { + *x = CommandRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_stackscliproto1_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandRequest) ProtoMessage() {} + +func (x *CommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackscliproto1_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandRequest.ProtoReflect.Descriptor instead. +func (*CommandRequest) Descriptor() ([]byte, []int) { + return file_stackscliproto1_proto_rawDescGZIP(), []int{0} +} + +func (x *CommandRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +// CommandResponse contains the result of the command execution, including any +// output or errors. +type CommandResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Data: + // + // *CommandResponse_ExitCode + // *CommandResponse_Stdout + // *CommandResponse_Stderr + Data isCommandResponse_Data `protobuf_oneof:"data"` +} + +func (x *CommandResponse) Reset() { + *x = CommandResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_stackscliproto1_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResponse) ProtoMessage() {} + +func (x *CommandResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackscliproto1_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResponse.ProtoReflect.Descriptor instead. +func (*CommandResponse) Descriptor() ([]byte, []int) { + return file_stackscliproto1_proto_rawDescGZIP(), []int{1} +} + +func (m *CommandResponse) GetData() isCommandResponse_Data { + if m != nil { + return m.Data + } + return nil +} + +func (x *CommandResponse) GetExitCode() int32 { + if x, ok := x.GetData().(*CommandResponse_ExitCode); ok { + return x.ExitCode + } + return 0 +} + +func (x *CommandResponse) GetStdout() []byte { + if x, ok := x.GetData().(*CommandResponse_Stdout); ok { + return x.Stdout + } + return nil +} + +func (x *CommandResponse) GetStderr() []byte { + if x, ok := x.GetData().(*CommandResponse_Stderr); ok { + return x.Stderr + } + return nil +} + +type isCommandResponse_Data interface { + isCommandResponse_Data() +} + +type CommandResponse_ExitCode struct { + ExitCode int32 `protobuf:"varint,1,opt,name=exitCode,proto3,oneof"` +} + +type CommandResponse_Stdout struct { + Stdout []byte `protobuf:"bytes,2,opt,name=stdout,proto3,oneof"` +} + +type CommandResponse_Stderr struct { + Stderr []byte `protobuf:"bytes,3,opt,name=stderr,proto3,oneof"` +} + +func (*CommandResponse_ExitCode) isCommandResponse_Data() {} + +func (*CommandResponse_Stdout) isCommandResponse_Data() {} + +func (*CommandResponse_Stderr) isCommandResponse_Data() {} + +var File_stackscliproto1_proto protoreflect.FileDescriptor + +var file_stackscliproto1_proto_rawDesc = []byte{ + 0x0a, 0x15, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 0x63, 0x6c, 0x69, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 0x63, + 0x6c, 0x69, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x22, 0x24, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, + 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x22, 0x6b, + 0x0a, 0x0f, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x1c, 0x0a, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, + 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, + 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, 0x74, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, + 0x65, 0x72, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, + 0x65, 0x72, 0x72, 0x42, 0x06, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x32, 0x62, 0x0a, 0x0e, 0x43, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x50, 0x0a, + 0x07, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x73, 0x74, 0x61, 0x63, 0x6b, + 0x73, 0x63, 0x6c, 0x69, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, + 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x73, 0x74, 0x61, 0x63, + 0x6b, 0x73, 0x63, 0x6c, 0x69, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, + 0x4b, 0x5a, 0x49, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, + 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x74, 0x65, 0x72, 0x72, 0x61, 0x66, 0x6f, 0x72, + 0x6d, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x73, 0x74, 0x61, 0x63, 0x6b, + 0x73, 0x2d, 0x63, 0x6c, 0x69, 0x2d, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x73, 0x74, 0x61, + 0x63, 0x6b, 0x73, 0x63, 0x6c, 0x69, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_stackscliproto1_proto_rawDescOnce sync.Once + file_stackscliproto1_proto_rawDescData = file_stackscliproto1_proto_rawDesc +) + +func file_stackscliproto1_proto_rawDescGZIP() []byte { + file_stackscliproto1_proto_rawDescOnce.Do(func() { + file_stackscliproto1_proto_rawDescData = protoimpl.X.CompressGZIP(file_stackscliproto1_proto_rawDescData) + }) + return file_stackscliproto1_proto_rawDescData +} + +var file_stackscliproto1_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_stackscliproto1_proto_goTypes = []any{ + (*CommandRequest)(nil), // 0: stackscliproto1.CommandRequest + (*CommandResponse)(nil), // 1: stackscliproto1.CommandResponse +} +var file_stackscliproto1_proto_depIdxs = []int32{ + 0, // 0: stackscliproto1.CommandService.Execute:input_type -> stackscliproto1.CommandRequest + 1, // 1: stackscliproto1.CommandService.Execute:output_type -> stackscliproto1.CommandResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_stackscliproto1_proto_init() } +func file_stackscliproto1_proto_init() { + if File_stackscliproto1_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_stackscliproto1_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*CommandRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_stackscliproto1_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*CommandResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_stackscliproto1_proto_msgTypes[1].OneofWrappers = []any{ + (*CommandResponse_ExitCode)(nil), + (*CommandResponse_Stdout)(nil), + (*CommandResponse_Stderr)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_stackscliproto1_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stackscliproto1_proto_goTypes, + DependencyIndexes: file_stackscliproto1_proto_depIdxs, + MessageInfos: file_stackscliproto1_proto_msgTypes, + }.Build() + File_stackscliproto1_proto = out.File + file_stackscliproto1_proto_rawDesc = nil + file_stackscliproto1_proto_goTypes = nil + file_stackscliproto1_proto_depIdxs = nil +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConnInterface + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion6 + +// CommandServiceClient is the client API for CommandService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type CommandServiceClient interface { + // Execute runs a specific command with the provided flags and returns the result. + Execute(ctx context.Context, in *CommandRequest, opts ...grpc.CallOption) (CommandService_ExecuteClient, error) +} + +type commandServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewCommandServiceClient(cc grpc.ClientConnInterface) CommandServiceClient { + return &commandServiceClient{cc} +} + +func (c *commandServiceClient) Execute(ctx context.Context, in *CommandRequest, opts ...grpc.CallOption) (CommandService_ExecuteClient, error) { + stream, err := c.cc.NewStream(ctx, &_CommandService_serviceDesc.Streams[0], "/stackscliproto1.CommandService/Execute", opts...) + if err != nil { + return nil, err + } + x := &commandServiceExecuteClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type CommandService_ExecuteClient interface { + Recv() (*CommandResponse, error) + grpc.ClientStream +} + +type commandServiceExecuteClient struct { + grpc.ClientStream +} + +func (x *commandServiceExecuteClient) Recv() (*CommandResponse, error) { + m := new(CommandResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// CommandServiceServer is the server API for CommandService service. +type CommandServiceServer interface { + // Execute runs a specific command with the provided flags and returns the result. + Execute(*CommandRequest, CommandService_ExecuteServer) error +} + +// UnimplementedCommandServiceServer can be embedded to have forward compatible implementations. +type UnimplementedCommandServiceServer struct { +} + +func (*UnimplementedCommandServiceServer) Execute(*CommandRequest, CommandService_ExecuteServer) error { + return status.Errorf(codes.Unimplemented, "method Execute not implemented") +} + +func RegisterCommandServiceServer(s *grpc.Server, srv CommandServiceServer) { + s.RegisterService(&_CommandService_serviceDesc, srv) +} + +func _CommandService_Execute_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(CommandRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(CommandServiceServer).Execute(m, &commandServiceExecuteServer{stream}) +} + +type CommandService_ExecuteServer interface { + Send(*CommandResponse) error + grpc.ServerStream +} + +type commandServiceExecuteServer struct { + grpc.ServerStream +} + +func (x *commandServiceExecuteServer) Send(m *CommandResponse) error { + return x.ServerStream.SendMsg(m) +} + +var _CommandService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "stackscliproto1.CommandService", + HandlerType: (*CommandServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Execute", + Handler: _CommandService_Execute_Handler, + ServerStreams: true, + }, + }, + Metadata: "stackscliproto1.proto", +} diff --git a/internal/stackscliplugin/stackscliproto1/stackscliproto1.proto b/internal/stackscliplugin/stackscliproto1/stackscliproto1.proto new file mode 100644 index 000000000000..e6a72ff99e69 --- /dev/null +++ b/internal/stackscliplugin/stackscliproto1/stackscliproto1.proto @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +syntax = "proto3"; +package stackscliproto1; + +option go_package = "github.com/hashicorp/terraform/internal/stacks-cli-plugin/stackscliproto1"; + +// CommandRequest is used to request the execution of a specific command with +// provided flags. It is the raw args from the HCP Terraform command. +message CommandRequest { + repeated string args = 1; +} + +// CommandResponse contains the result of the command execution, including any +// output or errors. +message CommandResponse { + oneof data { + int32 exitCode = 1; + bytes stdout = 2; + bytes stderr = 3; + } +} + +// PluginService defines the gRPC service to handle available commands and +// their execution. +service CommandService { + // Execute runs a specific command with the provided flags and returns the result. + rpc Execute(CommandRequest) returns (stream CommandResponse) {} +} diff --git a/tools/protobuf-compile/protobuf-compile.go b/tools/protobuf-compile/protobuf-compile.go index b8cba691722a..c30b6e1852aa 100644 --- a/tools/protobuf-compile/protobuf-compile.go +++ b/tools/protobuf-compile/protobuf-compile.go @@ -114,6 +114,11 @@ var protocSteps = []protocStep{ "internal/cloudplugin/cloudproto1", []string{"--go_out=paths=source_relative,plugins=grpc:.", "cloudproto1.proto"}, }, + { + "stackscliproto1 (stacks-cli protocol version 1)", + "internal/stacks-cli-plugin/stackscliproto1", + []string{"--go_out=paths=source_relative,plugins=grpc:.", "stackscliproto1.proto"}, + }, } func main() {