diff --git a/.changes/v1.13/NEW FEATURES-20250513-132953.yaml b/.changes/v1.13/NEW FEATURES-20250513-132953.yaml new file mode 100644 index 000000000000..6c81eabd0dd6 --- /dev/null +++ b/.changes/v1.13/NEW FEATURES-20250513-132953.yaml @@ -0,0 +1,5 @@ +kind: NEW FEATURES +body: 'The new command `terraform stacks` exposes some stack operations through the cli. The available subcommands depend on the stacks plugin implementation. Use `terraform stacks -help` to see available commands.' +time: 2025-05-13T13:29:53.189733-04:00 +custom: + Issue: "36931" diff --git a/commands.go b/commands.go index 48e3fc773d3a..7264bbec5ad9 100644 --- a/commands.go +++ b/commands.go @@ -434,6 +434,12 @@ func initCommands( }, nil } + Commands["stacks"] = func() (cli.Command, error) { + return &command.StacksCommand{ + 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..0ad4328a48d1 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -227,6 +227,10 @@ func (b *Cloud) ServiceDiscoveryAliases() ([]backendrun.HostAlias, error) { }, nil } +func (b *Cloud) Services() *disco.Disco { + return b.services +} + // Configure implements backend.Backend (which is embedded in backendrun.OperationsBackend). func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { var diags tfdiags.Diagnostics @@ -292,7 +296,7 @@ func (b *Cloud) Configure(obj cty.Value) tfdiags.Diagnostics { // Get the token from the CLI Config File in the credentials section // if no token was set in the configuration if token == "" { - token, err = cliConfigToken(hostname, b.services) + token, err = CliConfigToken(hostname, b.services) if err != nil { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, @@ -589,10 +593,10 @@ func resolveCloudConfig(obj cty.Value) (cloudConfig, tfdiags.Diagnostics) { return ret, diags } -// cliConfigToken returns the token for this host as configured in the credentials +// CliConfigToken returns the token for this host as configured in the credentials // section of the CLI Config File. If no token was configured, an empty // string will be returned instead. -func cliConfigToken(hostname svchost.Hostname, services *disco.Disco) (string, error) { +func CliConfigToken(hostname svchost.Hostname, services *disco.Disco) (string, error) { creds, err := services.CredentialsForHost(hostname) if err != nil { log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", hostname.ForDisplay(), err) diff --git a/internal/cloud/test.go b/internal/cloud/test.go index 3f97be0f8d23..96703b777f04 100644 --- a/internal/cloud/test.go +++ b/internal/cloud/test.go @@ -354,7 +354,7 @@ func (runner *TestSuiteRunner) client(addr tfaddr.Module, id tfe.RegistryModuleI return nil, nil, diags } - token, err := cliConfigToken(addr.Package.Host, runner.Services) + token, err := CliConfigToken(addr.Package.Host, runner.Services) if err != nil { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, diff --git a/internal/cloudplugin/cloudplugin1/grpc_client.go b/internal/cloudplugin/cloudplugin1/grpc_client.go index c236744e26e7..3c224220663f 100644 --- a/internal/cloudplugin/cloudplugin1/grpc_client.go +++ b/internal/cloudplugin/cloudplugin1/grpc_client.go @@ -9,8 +9,8 @@ import ( "io" "log" - "github.com/hashicorp/terraform/internal/cloudplugin" "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1" + "github.com/hashicorp/terraform/internal/pluginshared" ) // GRPCCloudClient is the client interface for interacting with terraform-cloudplugin @@ -20,7 +20,7 @@ type GRPCCloudClient struct { } // Proof that GRPCCloudClient fulfills the go-plugin interface -var _ cloudplugin.Cloud1 = GRPCCloudClient{} +var _ pluginshared.CustomPluginClient = GRPCCloudClient{} // Execute sends the client Execute request and waits for the plugin to return // an exit code response before returning diff --git a/internal/cloudplugin/cloudplugin1/grpc_plugin.go b/internal/cloudplugin/cloudplugin1/grpc_plugin.go index a515a0e10bd8..5eba18f2f357 100644 --- a/internal/cloudplugin/cloudplugin1/grpc_plugin.go +++ b/internal/cloudplugin/cloudplugin1/grpc_plugin.go @@ -9,8 +9,8 @@ import ( "net/rpc" "github.com/hashicorp/go-plugin" - "github.com/hashicorp/terraform/internal/cloudplugin" "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1" + "github.com/hashicorp/terraform/internal/pluginshared" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) @@ -19,7 +19,7 @@ import ( // implementation exists in this package. type GRPCCloudPlugin struct { plugin.GRPCPlugin - Impl cloudplugin.Cloud1 + Impl pluginshared.CustomPluginClient // 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 diff --git a/internal/command/cloud.go b/internal/command/cloud.go index 69f194823ece..00f3b1c40264 100644 --- a/internal/command/cloud.go +++ b/internal/command/cloud.go @@ -18,9 +18,9 @@ import ( "github.com/hashicorp/go-plugin" "github.com/hashicorp/terraform/internal/cloud" - "github.com/hashicorp/terraform/internal/cloudplugin" "github.com/hashicorp/terraform/internal/cloudplugin/cloudplugin1" "github.com/hashicorp/terraform/internal/logging" + "github.com/hashicorp/terraform/internal/pluginshared" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -112,7 +112,7 @@ func (c *CloudCommand) realRun(args []string, stdout, stderr io.Writer) int { // Proxy the request // Note: future changes will need to determine the type of raw when // multiple versions are possible. - cloud1, ok := raw.(cloudplugin.Cloud1) + cloud1, ok := raw.(pluginshared.CustomPluginClient) if !ok { c.Ui.Error("If more than one cloudplugin versions are available, they need to be added to the cloud command. This is a bug in Terraform.") return ExitRPCError @@ -217,7 +217,7 @@ func (c *CloudCommand) initPlugin() tfdiags.Diagnostics { overridePath := os.Getenv("TF_CLOUD_PLUGIN_DEV_OVERRIDE") - bm, err := cloudplugin.NewBinaryManager(ctx, packagesPath, overridePath, c.pluginService, runtime.GOOS, runtime.GOARCH) + bm, err := pluginshared.NewCloudBinaryManager(ctx, packagesPath, overridePath, c.pluginService, runtime.GOOS, runtime.GOARCH) if err != nil { return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error())) } diff --git a/internal/command/stacks.go b/internal/command/stacks.go new file mode 100644 index 000000000000..5e7af5de5b95 --- /dev/null +++ b/internal/command/stacks.go @@ -0,0 +1,370 @@ +// 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" + + "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/pluginshared" + "github.com/hashicorp/terraform/internal/stacksplugin/stacksplugin1" + + "github.com/hashicorp/terraform/internal/tfdiags" + "google.golang.org/grpc/metadata" +) + +// StacksCommand is a Command implementation that interacts with Terraform +// Cloud for stack operations. It delegates all execution to an internal plugin. +type StacksCommand 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 StacksPluginConfig +} + +const ( + // DefaultStacksPluginVersion is the implied protocol version, though all + // historical versions are defined explicitly. + DefaultStacksPluginVersion = 1 + + // ExitRPCError is the exit code that is returned if an plugin + // communication error occurred. + ExitStacksRPCError = 99 + + // ExitPluginError is the exit code that is returned if the plugin + // cannot be downloaded. + ExitStacksPluginError = 98 + + // The regular HCP Terraform API service that the go-tfe client relies on. + tfeStacksServiceID = "tfe.v2" + // The stacks plugin release download service that the BinaryManager relies + // on to fetch the plugin. + stackspluginServiceID = "stacksplugin.v1" + + defaultHostname = "app.terraform.io" +) + +var ( + // Handshake is used to verify that the plugin is the appropriate plugin for + // the client. This is not a security verification. + StacksHandshake = plugin.HandshakeConfig{ + MagicCookieKey: "TF_STACKS_MAGIC_COOKIE", + MagicCookieValue: "c9183f264a1db49ef2cbcc7b74f508a7bba9c3704c47cde3d130ae7f3b7a59c8f97a1e43d9e17ec0ac43a57fd250f373b2a8d991431d9fb1ea7bc48c8e7696fd", + ProtocolVersion: DefaultStacksPluginVersion, + } + // StacksPluginDataDir is the name of the directory within the data directory + StacksPluginDataDir = "stacksplugin" +) + +func (c *StacksCommand) realRun(args []string, stdout, stderr io.Writer) int { + args = c.Meta.process(args) + + diags := c.initPlugin() + if diags.HasWarnings() || diags.HasErrors() { + c.View.Diagnostics(diags) + } + if diags.HasErrors() { + return ExitPluginError + } + + client := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: StacksHandshake, + AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, + Cmd: exec.Command(c.pluginBinary), + Logger: logging.NewStacksLogger(), + VersionedPlugins: map[int]plugin.PluginSet{ + 1: { + "stacks": &stacksplugin1.GRPCStacksPlugin{ + Metadata: c.pluginConfig.ToMetadata(), + Services: c.Meta.Services, + }, + }, + }, + }) + defer client.Kill() + + // Connect via RPC + rpcClient, err := client.Client() + if err != nil { + fmt.Fprintf(stderr, "Failed to create stacks plugin client: %s", err) + return ExitRPCError + } + + // Request the plugin + raw, err := rpcClient.Dispense("stacks") + if err != nil { + fmt.Fprintf(stderr, "Failed to request stacks plugin interface: %s", err) + return ExitRPCError + } + + // Proxy the request + // Note: future changes will need to determine the type of raw when + // multiple versions are possible. + stacks1, ok := raw.(pluginshared.CustomPluginClient) + if !ok { + c.Ui.Error("If more than one stacksplugin versions are available, they need to be added to the stacks command. This is a bug in Terraform.") + return ExitRPCError + } + + return stacks1.Execute(args, stdout, stderr) +} + +// discoverAndConfigure is an implementation detail of initPlugin. It fills in the +// pluginService and pluginConfig fields on a StacksCommand struct. +func (c *StacksCommand) discoverAndConfigure() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // using the current terraform path for the plugin binary path + tfBinaryPath, err := os.Executable() + if err != nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Terraform binary path not found", + "Terraform binary path not found: "+err.Error(), + )) + } + + // stacks plugin 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_HOSTNAME") + if strings.TrimSpace(displayHostname) == "" { + log.Printf("[TRACE] stacksplugin hostname not set, falling back to %q", defaultHostname) + displayHostname = defaultHostname + } + + 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 + + token := os.Getenv("TF_STACKS_TOKEN") + if strings.TrimSpace(token) == "" { + // attempt to read from the credentials file + token, err = cloud.CliConfigToken(hostname, cb.Services()) + if err != nil { + // some commands like stacks init and validate could be run without a token so allow it without errors + diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Could not read token from credentials file, proceeding without a token", + err.Error(), + )) + } + } + + // 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(stackspluginServiceID) + if err != nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Stacks plugin service not found", + err.Error(), + )) + } + c.pluginService = pluginService + + tfeService, err := cb.ServicesHost.ServiceURL(tfeStacksServiceID) + if err != nil { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "HCP Terraform API service not found", + err.Error(), + )) + } + + // optional env values + orgName := os.Getenv("TF_STACKS_ORGANIZATION_NAME") + projectName := os.Getenv("TF_STACKS_PROJECT_NAME") + stackName := os.Getenv("TF_STACKS_STACK_NAME") + + // config to be passed to the plugin later. + c.pluginConfig = StacksPluginConfig{ + Address: tfeService.String(), + BasePath: tfeService.Path, + DisplayHostname: displayHostname, + Token: token, + TerraformBinaryPath: tfBinaryPath, + OrganizationName: orgName, + ProjectName: projectName, + StackName: stackName, + TerminalWidth: c.Meta.Streams.Stdout.Columns(), + } + + return diags +} + +func (c *StacksCommand) initPlugin() tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + var errorSummary = "Stacks 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_PLUGIN_DEV_OVERRIDE") + + bm, err := pluginshared.NewStacksBinaryManager(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 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 plugin from the following location:\n\n - %s\n\nOverriding the stacks plugin 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 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 *StacksCommand) initPackagesCache() (string, error) { + packagesPath := path.Join(c.WorkingDir.DataDir(), StacksPluginDataDir) + + if info, err := os.Stat(packagesPath); err != nil || !info.IsDir() { + log.Printf("[TRACE] initialized stacksplugin cache directory at %q", packagesPath) + err = os.MkdirAll(packagesPath, 0755) + if err != nil { + return "", fmt.Errorf("failed to initialize stacksplugin cache directory: %w", err) + } + } else { + log.Printf("[TRACE] stacksplugin cache directory found at %q", packagesPath) + } + + return packagesPath, nil +} + +// Run runs the stacks command with the given arguments. +func (c *StacksCommand) 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 command. +func (c *StacksCommand) 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 command. +func (c *StacksCommand) Synopsis() string { + return "Manage HCP Terraform stack operations" +} + +// StacksPluginConfig is everything the stacks plugin needs to know to configure a +// client and talk to HCP Terraform. +type StacksPluginConfig struct { + Address string `md:"tfc-address"` + BasePath string `md:"tfc-base-path"` + DisplayHostname string `md:"tfc-display-hostname"` + Token string `md:"tfc-token"` + TerraformBinaryPath string `md:"terraform-binary-path"` + OrganizationName string `md:"tfc-organization"` + ProjectName string `md:"tfc-project"` + StackName string `md:"tfc-stack"` + TerminalWidth int `md:"terminal-width"` +} + +func (c StacksPluginConfig) ToMetadata() metadata.MD { + md := metadata.Pairs( + "tfc-address", c.Address, + "tfc-base-path", c.BasePath, + "tfc-display-hostname", c.DisplayHostname, + "tfc-token", c.Token, + "terraform-binary-path", c.TerraformBinaryPath, + "tfc-organization", c.OrganizationName, + "tfc-project", c.ProjectName, + "tfc-stack", c.StackName, + "terminal-width", fmt.Sprintf("%d", c.TerminalWidth), + ) + return md +} diff --git a/internal/command/stacks_test.go b/internal/command/stacks_test.go new file mode 100644 index 000000000000..f8b201971215 --- /dev/null +++ b/internal/command/stacks_test.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "reflect" + "testing" + + "google.golang.org/grpc/metadata" +) + +func TestStacksPluginConfig_ToMetadata(t *testing.T) { + expected := metadata.Pairs( + "tfc-address", "https://app.staging.terraform.io", + "tfc-base-path", "/api/v2/", + "tfc-display-hostname", "app.staging.terraform.io", + "tfc-token", "not-a-legit-token", + "tfc-organization", "example-corp", + "tfc-project", "example-project", + "tfc-stack", "example-stack", + "terraform-binary-path", "", + "terminal-width", "78", + ) + inputStruct := StacksPluginConfig{ + Address: "https://app.staging.terraform.io", + BasePath: "/api/v2/", + DisplayHostname: "app.staging.terraform.io", + Token: "not-a-legit-token", + OrganizationName: "example-corp", + ProjectName: "example-project", + StackName: "example-stack", + TerraformBinaryPath: "", + TerminalWidth: 78, + } + result := inputStruct.ToMetadata() + if !reflect.DeepEqual(expected, result) { + t.Fatalf("Expected: %#v\nGot: %#v\n", expected, result) + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 9a71787ddf4c..b15f97433092 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -26,6 +26,7 @@ const ( envLogCore = "TF_LOG_CORE" envLogProvider = "TF_LOG_PROVIDER" envLogCloud = "TF_LOG_CLOUD" + envLogStacks = "TF_LOG_STACKS" ) 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 NewStacksLogger() hclog.Logger { + l := &logPanicWrapper{ + Logger: logger.Named("stacks"), + } + + level := stacksLogLevel() + logger.Debug("created stacks logger", "level", level) + + l.SetLevel(level) + return l +} + // CurrentLogLevel returns the current log level string based the environment vars func CurrentLogLevel() string { ll, _ := globalLogLevel() @@ -159,6 +174,15 @@ func providerLogLevel() hclog.Level { return parseLogLevel(providerEnvLevel) } +func stacksLogLevel() hclog.Level { + pluginEnvLevel := strings.ToUpper(os.Getenv(envLogStacks)) + if pluginEnvLevel == "" { + pluginEnvLevel = strings.ToUpper(os.Getenv(envLog)) + } + + return parseLogLevel(pluginEnvLevel) +} + func cloudLogLevel() hclog.Level { providerEnvLevel := strings.ToUpper(os.Getenv(envLogCloud)) if providerEnvLevel == "" { diff --git a/internal/cloudplugin/binary.go b/internal/pluginshared/binary.go similarity index 62% rename from internal/cloudplugin/binary.go rename to internal/pluginshared/binary.go index 606741924e71..ed7b02e28653 100644 --- a/internal/cloudplugin/binary.go +++ b/internal/pluginshared/binary.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "bytes" @@ -9,7 +9,6 @@ import ( "encoding/json" "fmt" "log" - "net/url" "os" "path" "path/filepath" @@ -22,17 +21,18 @@ import ( ) // BinaryManager downloads, caches, and returns information about the -// terraform-cloudplugin binary downloaded from the specified backend. +// plugin binary downloaded from the specified backend. type BinaryManager struct { - signingKey string - binaryName string - cloudPluginDataDir string - overridePath string - host svchost.Hostname - client *CloudPluginClient - goos string - arch string - ctx context.Context + signingKey string + binaryName string + pluginName string + pluginDataDir string + overridePath string + host svchost.Hostname + client *BasePluginClient + goos string + arch string + ctx context.Context } // Binary is a struct containing the path to an authenticated binary corresponding to @@ -49,29 +49,8 @@ const ( MB = 1000 * KB ) -// BinaryManager initializes a new BinaryManager to broker data between the -// specified directory location containing cloudplugin package data and a -// HCP Terraform backend URL. -func NewBinaryManager(ctx context.Context, cloudPluginDataDir, overridePath 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 &BinaryManager{ - cloudPluginDataDir: cloudPluginDataDir, - overridePath: overridePath, - host: svchost.Hostname(serviceURL.Host), - client: client, - binaryName: "terraform-cloudplugin", - goos: goos, - arch: arch, - ctx: ctx, - }, nil -} - func (v BinaryManager) binaryLocation() string { - return path.Join(v.cloudPluginDataDir, "bin", fmt.Sprintf("%s_%s", v.goos, v.arch)) + return path.Join(v.pluginDataDir, "bin", fmt.Sprintf("%s_%s", v.goos, v.arch)) } func (v BinaryManager) cachedVersion(version string) *string { @@ -94,7 +73,7 @@ func (v BinaryManager) cachedVersion(version string) *string { // and returns its location and version. func (v BinaryManager) Resolve() (*Binary, error) { if v.overridePath != "" { - log.Printf("[TRACE] Using dev override for cloudplugin binary") + log.Printf("[TRACE] Using dev override for %s binary", v.pluginName) return v.resolveDev() } return v.resolveRelease() @@ -111,10 +90,10 @@ func (v BinaryManager) resolveDev() (*Binary, error) { func (v BinaryManager) resolveRelease() (*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) + return nil, fmt.Errorf("could not resolve %s version for host %q: %w", v.pluginName, v.host.ForDisplay(), err) } - buildInfo, err := manifest.Select(v.goos, v.arch) + buildInfo, err := manifest.Select(v.pluginName, v.goos, v.arch) if err != nil { return nil, err } @@ -129,7 +108,7 @@ func (v BinaryManager) resolveRelease() (*Binary, error) { } // Download the archive - t, err := os.CreateTemp(os.TempDir(), "terraform-cloudplugin") + t, err := os.CreateTemp(os.TempDir(), v.binaryName) if err != nil { return nil, fmt.Errorf("failed to create temp file for download: %w", err) } @@ -142,9 +121,9 @@ func (v BinaryManager) resolveRelease() (*Binary, error) { t.Close() // Close only returns an error if it's already been called // Authenticate the archive - err = v.verifyCloudPlugin(manifest, buildInfo, t.Name()) + err = v.verifyPlugin(manifest, buildInfo, t.Name()) if err != nil { - return nil, fmt.Errorf("could not resolve cloudplugin version %q: %w", manifest.Version, err) + return nil, fmt.Errorf("could not resolve %s version %q: %w", v.pluginName, manifest.Version, err) } // Unarchive @@ -157,7 +136,7 @@ func (v BinaryManager) resolveRelease() (*Binary, error) { err = unzip.Decompress(targetPath, t.Name(), true, 0000) if err != nil { - return nil, fmt.Errorf("failed to decompress cloud plugin: %w", err) + return nil, fmt.Errorf("failed to decompress %s: %w", v.pluginName, err) } err = os.WriteFile(path.Join(targetPath, ".version"), []byte(manifest.Version), 0644) @@ -183,20 +162,20 @@ func (v BinaryManager) downloadFileBuffer(pathOrURL string) ([]byte, error) { return buffer.Bytes(), err } -// verifyCloudPlugin authenticates the downloaded release archive -func (v BinaryManager) verifyCloudPlugin(archiveManifest *Release, info *BuildArtifact, archiveLocation string) error { +// verifyPlugin authenticates the downloaded release archive +func (v BinaryManager) verifyPlugin(archiveManifest *Release, info *BuildArtifact, archiveLocation string) error { signature, err := v.downloadFileBuffer(archiveManifest.URLSHASumsSignatures[0]) if err != nil { - return fmt.Errorf("failed to download cloudplugin SHA256SUMS signature file: %w", err) + return fmt.Errorf("failed to download %s SHA256SUMS signature file: %w", v.pluginName, err) } sums, err := v.downloadFileBuffer(archiveManifest.URLSHASums) if err != nil { - return fmt.Errorf("failed to download cloudplugin SHA256SUMS file: %w", err) + return fmt.Errorf("failed to download %s SHA256SUMS file: %w", v.pluginName, err) } checksums, err := releaseauth.ParseChecksums(sums) if err != nil { - return fmt.Errorf("failed to parse cloudplugin SHA256SUMS file: %w", err) + return fmt.Errorf("failed to parse %s SHA256SUMS file: %w", v.pluginName, err) } filename := path.Base(info.URL) @@ -219,21 +198,21 @@ func (v BinaryManager) verifyCloudPlugin(archiveManifest *Release, info *BuildAr } func (v BinaryManager) latestManifest(ctx context.Context) (*Release, error) { - manifestCacheLocation := path.Join(v.cloudPluginDataDir, v.host.String(), "manifest.json") + manifestCacheLocation := path.Join(v.pluginDataDir, 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 cloudplugin manifest cache found for host %q", v.host) + log.Printf("[TRACE] no %s manifest cache found for host %q", v.pluginName, v.host) } else { - log.Printf("[TRACE] cloudplugin manifest cache found for host %q", v.host) + log.Printf("[TRACE] %s manifest cache found for host %q", v.pluginName, v.host) localManifest, err = decodeManifest(bytes.NewBuffer(data)) modTime = localManifest.TimestampUpdated if err != nil { - log.Printf("[WARN] failed to decode cloudplugin manifest cache %q: %s", manifestCacheLocation, err) + log.Printf("[WARN] failed to decode %s manifest cache %q: %s", v.pluginName, manifestCacheLocation, err) } } @@ -241,7 +220,7 @@ func (v BinaryManager) latestManifest(ctx context.Context) (*Release, error) { result, err := v.client.FetchManifest(modTime) // FetchManifest can return nil, nil (see below) if err != nil { - return nil, fmt.Errorf("failed to fetch cloudplugin manifest: %w", err) + return nil, fmt.Errorf("failed to fetch %s manifest: %w", v.pluginName, err) } // No error and no remoteManifest means the existing manifest is not modified @@ -251,24 +230,24 @@ func (v BinaryManager) latestManifest(ctx context.Context) (*Release, error) { } else { data, err := json.Marshal(result) if err != nil { - return nil, fmt.Errorf("failed to dump cloudplugin manifest to JSON: %w", err) + return nil, fmt.Errorf("failed to dump %s manifest to JSON: %w", v.pluginName, err) } // Ensure target directory exists if err := os.MkdirAll(filepath.Dir(manifestCacheLocation), 0755); err != nil { - return nil, fmt.Errorf("failed to create cloudplugin manifest cache directory: %w", err) + return nil, fmt.Errorf("failed to create %s manifest cache directory: %w", v.pluginName, err) } output, err := os.Create(manifestCacheLocation) if err != nil { - return nil, fmt.Errorf("failed to create cloudplugin manifest cache: %w", err) + return nil, fmt.Errorf("failed to create %s manifest cache: %w", v.pluginName, err) } _, err = output.Write(data) if err != nil { - return nil, fmt.Errorf("failed to write cloudplugin manifest cache: %w", err) + return nil, fmt.Errorf("failed to write %s manifest cache: %w", v.pluginName, err) } - log.Printf("[TRACE] wrote cloudplugin manifest cache to %q", manifestCacheLocation) + log.Printf("[TRACE] wrote %s manifest cache to %q", v.pluginName, manifestCacheLocation) } return result, nil diff --git a/internal/cloudplugin/binary_test.go b/internal/pluginshared/binary_test.go similarity index 95% rename from internal/cloudplugin/binary_test.go rename to internal/pluginshared/binary_test.go index f14df7b8b921..5746a4407683 100644 --- a/internal/cloudplugin/binary_test.go +++ b/internal/pluginshared/binary_test.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "context" @@ -63,7 +63,7 @@ func TestBinaryManager_Resolve(t *testing.T) { serviceURL := serverURL.JoinPath("/api/cloudplugin/v1") tempDir := t.TempDir() - manager, err := NewBinaryManager(context.Background(), tempDir, "", serviceURL, "darwin", "amd64") + manager, err := NewCloudBinaryManager(context.Background(), tempDir, "", serviceURL, "darwin", "amd64") if err != nil { t.Fatalf("expected no err, got: %s", err) } diff --git a/internal/cloudplugin/client.go b/internal/pluginshared/client.go similarity index 79% rename from internal/cloudplugin/client.go rename to internal/pluginshared/client.go index c26e3abe5134..d79f42bcd86c 100644 --- a/internal/cloudplugin/client.go +++ b/internal/pluginshared/client.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "context" @@ -16,8 +16,6 @@ import ( "time" "github.com/hashicorp/go-retryablehttp" - "github.com/hashicorp/terraform/internal/httpclient" - "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/releaseauth" ) @@ -137,17 +135,18 @@ type Release struct { Version string `json:"version"` } -// CloudPluginClient fetches and verifies release distributions of the cloudplugin +// BasePluginClient fetches and verifies release distributions of the custom plugins // that correspond to an upstream backend. -type CloudPluginClient struct { +type BasePluginClient struct { serviceURL *url.URL httpClient *retryablehttp.Client ctx context.Context + pluginName string } func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) { if i > 0 { - logger.Printf("[INFO] Previous request to the remote cloud manifest failed, attempting retry.") + logger.Printf("[INFO] Previous request to the remote plugin manifest failed, attempting retry.") } } @@ -163,33 +162,14 @@ func decodeManifest(data io.Reader) (*Release, error) { return &man, nil } -// NewCloudPluginClient creates a new client for downloading and verifying -// terraform-cloudplugin archives -func NewCloudPluginClient(ctx context.Context, serviceURL *url.URL) (*CloudPluginClient, error) { - httpClient := httpclient.New() - httpClient.Timeout = defaultRequestTimeout - - retryableClient := retryablehttp.NewClient() - retryableClient.HTTPClient = httpClient - retryableClient.RetryMax = 3 - retryableClient.RequestLogHook = requestLogHook - retryableClient.Logger = logging.HCLogger() - - return &CloudPluginClient{ - httpClient: retryableClient, - serviceURL: serviceURL, - ctx: ctx, - }, nil -} - -// FetchManifest retrieves the cloudplugin manifest from HCP Terraform, +// FetchManifest retrieves the plugin manifest from HCP Terraform, // but returns a nil manifest if a 304 response is received, depending // on the lastModified time. -func (c CloudPluginClient) FetchManifest(lastModified time.Time) (*Release, error) { - req, _ := retryablehttp.NewRequestWithContext(c.ctx, "GET", c.serviceURL.JoinPath("manifest.json").String(), nil) +func (b BasePluginClient) FetchManifest(lastModified time.Time) (*Release, error) { + req, _ := retryablehttp.NewRequestWithContext(b.ctx, "GET", b.serviceURL.JoinPath("manifest.json").String(), nil) req.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat)) - resp, err := c.httpClient.Do(req) + resp, err := b.httpClient.Do(req) if err != nil { if errors.Is(err, context.Canceled) { return nil, ErrRequestCanceled @@ -210,7 +190,7 @@ func (c CloudPluginClient) FetchManifest(lastModified time.Time) (*Release, erro case http.StatusNotModified: return nil, nil case http.StatusNotFound: - return nil, ErrCloudPluginNotSupported + return nil, ErrPluginNotSupported default: return nil, ErrQueryFailed{ inner: errors.New(resp.Status), @@ -220,16 +200,16 @@ func (c CloudPluginClient) FetchManifest(lastModified time.Time) (*Release, erro // DownloadFile gets the URL at the specified path or URL and writes the // contents to the specified Writer. -func (c CloudPluginClient) DownloadFile(pathOrURL string, writer io.Writer) error { - url, err := c.resolveManifestURL(pathOrURL) +func (b BasePluginClient) DownloadFile(pathOrURL string, writer io.Writer) error { + url, err := b.resolveManifestURL(pathOrURL) if err != nil { return err } - req, err := retryablehttp.NewRequestWithContext(c.ctx, "GET", url.String(), nil) + req, err := retryablehttp.NewRequestWithContext(b.ctx, "GET", url.String(), nil) if err != nil { - return fmt.Errorf("invalid URL %q was provided by the cloudplugin manifest: %w", url, err) + return fmt.Errorf("invalid URL %q was provided by the %s manifest: %w", url, b.pluginName, err) } - resp, err := c.httpClient.Do(req) + resp, err := b.httpClient.Do(req) if err != nil { if errors.Is(err, context.Canceled) { return ErrRequestCanceled @@ -242,7 +222,7 @@ func (c CloudPluginClient) DownloadFile(pathOrURL string, writer io.Writer) erro case http.StatusOK: // OK case http.StatusNotFound: - return ErrCloudPluginNotFound + return ErrPluginNotFound default: return ErrQueryFailed{ inner: errors.New(resp.Status), @@ -257,22 +237,22 @@ func (c CloudPluginClient) DownloadFile(pathOrURL string, writer io.Writer) erro return nil } -func (c CloudPluginClient) resolveManifestURL(pathOrURL string) (*url.URL, error) { +func (b BasePluginClient) resolveManifestURL(pathOrURL string) (*url.URL, error) { if strings.HasPrefix(pathOrURL, "/") { - copy := *c.serviceURL + copy := *b.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 cloudplugin manifest: %w", pathOrURL, err) + return nil, fmt.Errorf("received malformed URL %q from %s manifest: %w", pathOrURL, b.pluginName, 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) { +func (m Release) Select(pluginName, goos, arch string) (*BuildArtifact, error) { var supported []string var found *BuildArtifact for _, build := range m.Builds { @@ -285,7 +265,7 @@ func (m Release) Select(goos, arch string) (*BuildArtifact, error) { } osArchKey := fmt.Sprintf("%s_%s", goos, arch) - log.Printf("[TRACE] checking for cloudplugin archive for %s. Supported architectures: %v", osArchKey, supported) + log.Printf("[TRACE] checking for %s archive for %s. Supported architectures: %v", pluginName, osArchKey, supported) if found == nil { return nil, ErrArchNotSupported diff --git a/internal/cloudplugin/client_test.go b/internal/pluginshared/client_test.go similarity index 96% rename from internal/cloudplugin/client_test.go rename to internal/pluginshared/client_test.go index f8bfad5b12ae..774bc9ce7049 100644 --- a/internal/cloudplugin/client_test.go +++ b/internal/pluginshared/client_test.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "bytes" @@ -52,8 +52,8 @@ func TestCloudPluginClient_DownloadFile(t *testing.T) { t.Run("404 response", func(t *testing.T) { err := client.DownloadFile("/archives/nope.zip", io.Discard) - if !errors.Is(err, ErrCloudPluginNotFound) { - t.Fatalf("expected error %q, got %q", ErrCloudPluginNotFound, err) + if !errors.Is(err, ErrPluginNotFound) { + t.Fatalf("expected error %q, got %q", ErrPluginNotFound, err) } }) } @@ -116,7 +116,7 @@ func TestCloudPluginClient_NotSupportedByTerraformCloud(t *testing.T) { } _, err = client.FetchManifest(time.Time{}) - if !errors.Is(err, ErrCloudPluginNotSupported) { + if !errors.Is(err, ErrPluginNotSupported) { t.Errorf("Expected ErrCloudPluginNotSupported, got %v", err) } } diff --git a/internal/pluginshared/cloudbinary.go b/internal/pluginshared/cloudbinary.go new file mode 100644 index 000000000000..d01086d18a08 --- /dev/null +++ b/internal/pluginshared/cloudbinary.go @@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pluginshared + +import ( + "context" + "fmt" + "net/url" + + svchost "github.com/hashicorp/terraform-svchost" +) + +// CloudBinaryManager downloads, caches, and returns information about the +// terraform-cloudplugin binary downloaded from the specified backend. +type CloudBinaryManager struct { + BinaryManager +} + +// NewCloudBinaryManager initializes a new BinaryManager to broker data between the +// specified directory location containing cloudplugin package data and a +// HCP Terraform backend URL. +func NewCloudBinaryManager(ctx context.Context, cloudPluginDataDir, overridePath string, serviceURL *url.URL, goos, arch string) (*CloudBinaryManager, error) { + client, err := NewCloudPluginClient(ctx, serviceURL) + if err != nil { + return nil, fmt.Errorf("could not initialize cloudplugin version manager: %w", err) + } + + return &CloudBinaryManager{ + BinaryManager{ + pluginDataDir: cloudPluginDataDir, + overridePath: overridePath, + host: svchost.Hostname(serviceURL.Host), + client: client, + binaryName: "terraform-cloudplugin", + pluginName: "cloudplugin", + goos: goos, + arch: arch, + ctx: ctx, + }}, nil +} diff --git a/internal/pluginshared/cloudclient.go b/internal/pluginshared/cloudclient.go new file mode 100644 index 000000000000..9b8229e3e823 --- /dev/null +++ b/internal/pluginshared/cloudclient.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pluginshared + +import ( + "context" + "net/url" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform/internal/httpclient" + "github.com/hashicorp/terraform/internal/logging" +) + +// NewCloudPluginClient creates a new client for downloading and verifying +// terraform-cloudplugin archives +func NewCloudPluginClient(ctx context.Context, serviceURL *url.URL) (*BasePluginClient, error) { + httpClient := httpclient.New() + httpClient.Timeout = defaultRequestTimeout + + retryableClient := retryablehttp.NewClient() + retryableClient.HTTPClient = httpClient + retryableClient.RetryMax = 3 + retryableClient.RequestLogHook = requestLogHook + retryableClient.Logger = logging.HCLogger() + + client := BasePluginClient{ + ctx: ctx, + serviceURL: serviceURL, + httpClient: retryableClient, + pluginName: "cloudplugin", + } + return &client, nil +} diff --git a/internal/cloudplugin/errors.go b/internal/pluginshared/errors.go similarity index 51% rename from internal/cloudplugin/errors.go rename to internal/pluginshared/errors.go index 7f15bd2c6ebb..dd3498495d8c 100644 --- a/internal/cloudplugin/errors.go +++ b/internal/pluginshared/errors.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "errors" @@ -9,23 +9,23 @@ import ( ) var ( - // ErrCloudPluginNotSupported is the error returned when the upstream HCP Terraform does not + // ErrPluginNotSupported is the error returned when the upstream HCP Terraform does not // have a manifest. - ErrCloudPluginNotSupported = errors.New("cloud plugin is not supported by the remote version of Terraform Enterprise") + ErrPluginNotSupported = errors.New("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 cloudplugin does not have a build for the + // ErrArchNotSupported is the error returned when the plugin does not have a build for the // current OS/Architecture. - ErrArchNotSupported = errors.New("cloud plugin is not supported by your computer architecture/operating system") + ErrArchNotSupported = errors.New("plugin is not supported by your computer architecture/operating system") - // ErrCloudPluginNotFound is the error returned when the cloudplugin manifest points to a location + // ErrPluginNotFound is the error returned when the plugin manifest points to a location // that was does not exist. - ErrCloudPluginNotFound = errors.New("cloud plugin download was not found in the location specified in the manifest") + ErrPluginNotFound = errors.New("plugin download was not found in the location specified in the manifest") ) -// ErrQueryFailed is the error returned when the cloudplugin http client request fails +// ErrQueryFailed is the error returned when the plugin http client request fails type ErrQueryFailed struct { inner error } @@ -37,7 +37,7 @@ type ErrCloudPluginNotVerified struct { // Error returns a string representation of ErrQueryFailed func (e ErrQueryFailed) Error() string { - return fmt.Sprintf("failed to fetch cloud plugin from HCP Terraform: %s", e.inner) + return fmt.Sprintf("failed to fetch plugin from HCP Terraform: %s", e.inner) } // Unwrap returns the inner error of ErrQueryFailed @@ -48,7 +48,7 @@ func (e ErrQueryFailed) Unwrap() error { // Error returns the string representation of ErrCloudPluginNotVerified func (e ErrCloudPluginNotVerified) Error() string { - return fmt.Sprintf("failed to verify cloud plugin. Ensure that the referenced plugin is the official HashiCorp distribution: %s", e.inner) + return fmt.Sprintf("failed to verify plugin. Ensure that the referenced plugin is the official HashiCorp distribution: %s", e.inner) } // Unwrap returns the inner error of ErrCloudPluginNotVerified diff --git a/internal/cloudplugin/interface.go b/internal/pluginshared/interface.go similarity index 71% rename from internal/cloudplugin/interface.go rename to internal/pluginshared/interface.go index 37e844d58cdc..8cab0bbed4a5 100644 --- a/internal/cloudplugin/interface.go +++ b/internal/pluginshared/interface.go @@ -1,12 +1,12 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "io" ) -type Cloud1 interface { +type CustomPluginClient interface { Execute(args []string, stdout, stderr io.Writer) int } diff --git a/internal/pluginshared/stacksbinary.go b/internal/pluginshared/stacksbinary.go new file mode 100644 index 000000000000..886775a8d90e --- /dev/null +++ b/internal/pluginshared/stacksbinary.go @@ -0,0 +1,41 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pluginshared + +import ( + "context" + "fmt" + "net/url" + + svchost "github.com/hashicorp/terraform-svchost" +) + +// StacksBinaryManager downloads, caches, and returns information about the +// terraform-stacksplugin binary downloaded from the specified backend. +type StacksBinaryManager struct { + BinaryManager +} + +// NewStacksBinaryManager initializes a new BinaryManager to broker data between the +// specified directory location containing stacksplugin package data and a +// HCP Terraform backend URL. +func NewStacksBinaryManager(ctx context.Context, stacksPluginDataDir, overridePath string, serviceURL *url.URL, goos, arch string) (*StacksBinaryManager, error) { + client, err := NewStacksPluginClient(ctx, serviceURL) + if err != nil { + return nil, fmt.Errorf("could not initialize stacksplugin version manager: %w", err) + } + + return &StacksBinaryManager{ + BinaryManager{ + pluginDataDir: stacksPluginDataDir, + overridePath: overridePath, + host: svchost.Hostname(serviceURL.Host), + client: client, + binaryName: "terraform-stacksplugin", + pluginName: "stacksplugin", + goos: goos, + arch: arch, + ctx: ctx, + }}, nil +} diff --git a/internal/pluginshared/stacksclient.go b/internal/pluginshared/stacksclient.go new file mode 100644 index 000000000000..b1d9633a9738 --- /dev/null +++ b/internal/pluginshared/stacksclient.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package pluginshared + +import ( + "context" + "net/url" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform/internal/httpclient" + "github.com/hashicorp/terraform/internal/logging" +) + +// NewStacksPluginClient creates a new client for downloading and verifying +// terraform-stacks plugin archives +func NewStacksPluginClient(ctx context.Context, serviceURL *url.URL) (*BasePluginClient, error) { + httpClient := httpclient.New() + httpClient.Timeout = defaultRequestTimeout + + retryableClient := retryablehttp.NewClient() + retryableClient.HTTPClient = httpClient + retryableClient.RetryMax = 3 + retryableClient.RequestLogHook = requestLogHook + retryableClient.Logger = logging.HCLogger() + + client := BasePluginClient{ + ctx: ctx, + serviceURL: serviceURL, + httpClient: retryableClient, + pluginName: "stacksplugin", + } + return &client, nil +} diff --git a/internal/cloudplugin/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS b/internal/pluginshared/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS similarity index 100% rename from internal/cloudplugin/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS rename to internal/pluginshared/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS diff --git a/internal/cloudplugin/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS.sig b/internal/pluginshared/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS.sig similarity index 100% rename from internal/cloudplugin/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS.sig rename to internal/pluginshared/testdata/archives/terraform-cloudplugin_0.1.0_SHA256SUMS.sig diff --git a/internal/cloudplugin/testdata/archives/terraform-cloudplugin_0.1.0_darwin_amd64.zip b/internal/pluginshared/testdata/archives/terraform-cloudplugin_0.1.0_darwin_amd64.zip similarity index 100% rename from internal/cloudplugin/testdata/archives/terraform-cloudplugin_0.1.0_darwin_amd64.zip rename to internal/pluginshared/testdata/archives/terraform-cloudplugin_0.1.0_darwin_amd64.zip diff --git a/internal/cloudplugin/testdata/cloudplugin-dev b/internal/pluginshared/testdata/cloudplugin-dev similarity index 100% rename from internal/cloudplugin/testdata/cloudplugin-dev rename to internal/pluginshared/testdata/cloudplugin-dev diff --git a/internal/cloudplugin/testdata/sample.md b/internal/pluginshared/testdata/sample.md similarity index 100% rename from internal/cloudplugin/testdata/sample.md rename to internal/pluginshared/testdata/sample.md diff --git a/internal/cloudplugin/testdata/sample.private.key b/internal/pluginshared/testdata/sample.private.key similarity index 100% rename from internal/cloudplugin/testdata/sample.private.key rename to internal/pluginshared/testdata/sample.private.key diff --git a/internal/cloudplugin/testdata/sample.public.key b/internal/pluginshared/testdata/sample.public.key similarity index 100% rename from internal/cloudplugin/testdata/sample.public.key rename to internal/pluginshared/testdata/sample.public.key diff --git a/internal/cloudplugin/testing.go b/internal/pluginshared/testing.go similarity index 99% rename from internal/cloudplugin/testing.go rename to internal/pluginshared/testing.go index 84007a4107e3..4dce7ffcba33 100644 --- a/internal/cloudplugin/testing.go +++ b/internal/pluginshared/testing.go @@ -1,7 +1,7 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 -package cloudplugin +package pluginshared import ( "fmt" diff --git a/internal/rpcapi/stacks_grpc_client.go b/internal/rpcapi/stacks_grpc_client.go new file mode 100644 index 000000000000..f3f069972963 --- /dev/null +++ b/internal/rpcapi/stacks_grpc_client.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package rpcapi + +import ( + "context" + "fmt" + "io" + "log" + "sync" + + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/pluginshared" + + "github.com/hashicorp/terraform/internal/rpcapi/dynrpcserver" + "github.com/hashicorp/terraform/internal/rpcapi/terraform1/dependencies" + "github.com/hashicorp/terraform/internal/rpcapi/terraform1/packages" + "github.com/hashicorp/terraform/internal/rpcapi/terraform1/stacks" + "github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1" + "google.golang.org/grpc" +) + +// GRPCStacksClient is the client interface for interacting with terraform-stacksplugin +type GRPCStacksClient struct { + Client stacksproto1.CommandServiceClient + Broker *plugin.GRPCBroker + Services *disco.Disco + Context context.Context +} + +// Proof that GRPCStacksClient fulfills the go-plugin interface +var _ pluginshared.CustomPluginClient = GRPCStacksClient{} + +type brokerIDs struct { + packagesBrokerID uint32 + dependenciesBrokerID uint32 + stacksBrokerID uint32 +} + +// registerBrokers starts the GRPC servers for the dependencies, packages, and stacks +// services and returns the broker IDs for each. +func (c GRPCStacksClient) registerBrokers(stdout, stderr io.Writer) brokerIDs { + handles := newHandleTable() + + dependenciesServer := dynrpcserver.NewDependenciesStub() + packagesServer := dynrpcserver.NewPackagesStub() + stacksServer := dynrpcserver.NewStacksStub() + + var serverWG sync.WaitGroup + // wait for all 3 servers to start + serverWG.Add(3) + + dependenciesServerFunc := func(opts []grpc.ServerOption) *grpc.Server { + s := grpc.NewServer(opts...) + dependencies.RegisterDependenciesServer(s, dependenciesServer) + dependenciesServer.ActivateRPCServer(newDependenciesServer(handles, c.Services)) + + serverWG.Done() + return s + } + + dependenciesBrokerID := c.Broker.NextId() + go c.Broker.AcceptAndServe(dependenciesBrokerID, dependenciesServerFunc) + + packagesServerFunc := func(opts []grpc.ServerOption) *grpc.Server { + s := grpc.NewServer(opts...) + packages.RegisterPackagesServer(s, packagesServer) + packagesServer.ActivateRPCServer(newPackagesServer(c.Services)) + + serverWG.Done() + return s + } + + packagesBrokerID := c.Broker.NextId() + go c.Broker.AcceptAndServe(packagesBrokerID, packagesServerFunc) + + stacksServerFunc := func(opts []grpc.ServerOption) *grpc.Server { + s := grpc.NewServer(opts...) + stacks.RegisterStacksServer(s, stacksServer) + stacksServer.ActivateRPCServer(newStacksServer( + newStopper(), handles, c.Services, &serviceOpts{experimentsAllowed: true})) + + serverWG.Done() + return s + } + + stacksBrokerID := c.Broker.NextId() + go c.Broker.AcceptAndServe(stacksBrokerID, stacksServerFunc) + + // block till all 3 servers have signaled readiness + serverWG.Wait() + + return brokerIDs{ + dependenciesBrokerID: dependenciesBrokerID, + packagesBrokerID: packagesBrokerID, + stacksBrokerID: stacksBrokerID, + } +} + +// Execute sends the client Execute request and waits for the plugin to return +// an exit code response before returning +func (c GRPCStacksClient) executeWithBrokers(brokerIDs brokerIDs, args []string, stdout, stderr io.Writer) int { + client, err := c.Client.Execute(c.Context, &stacksproto1.CommandRequest{ + DependenciesServer: brokerIDs.dependenciesBrokerID, + PackagesServer: brokerIDs.packagesBrokerID, + StacksServer: brokerIDs.stacksBrokerID, + Args: args, + }) + + if err != nil { + fmt.Fprint(stderr, err.Error()) + return 1 + } + + for { + // stacksplugin 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 stacksplugin") + break + } else if err != nil { + fmt.Fprintf(stderr, "Failed to receive command response from stacksplugin: %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 stacksplugin 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 stacksplugin 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] stacksplugin returned an invalid error code %d", exitCode) + return 255 + } + return int(exitCode) + } + } + + // This should indicate a bug in the plugin + fmt.Fprint(stderr, "stacksplugin exited without responding with an error code") + return 1 +} + +// Execute sends the client Execute request and waits for the plugin to return +// an exit code response before returning +func (c GRPCStacksClient) Execute(args []string, stdout, stderr io.Writer) int { + brokerIDs := c.registerBrokers(stdout, stderr) + return c.executeWithBrokers(brokerIDs, args, stdout, stderr) +} diff --git a/internal/rpcapi/stacks_grpc_client_test.go b/internal/rpcapi/stacks_grpc_client_test.go new file mode 100644 index 000000000000..0602a9c451f1 --- /dev/null +++ b/internal/rpcapi/stacks_grpc_client_test.go @@ -0,0 +1,226 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package rpcapi + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + plugin "github.com/hashicorp/go-plugin" + svchost "github.com/hashicorp/terraform-svchost" + "github.com/hashicorp/terraform-svchost/auth" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/httpclient" + "github.com/hashicorp/terraform/internal/stacksplugin/mock_stacksproto1" + "github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1" + "github.com/hashicorp/terraform/version" + "go.uber.org/mock/gomock" +) + +var mockError = "this is a mock error" +var mockBrokerIDs = brokerIDs{ + packagesBrokerID: 1, + dependenciesBrokerID: 2, + stacksBrokerID: 3, +} + +func mockDisco() *disco.Disco { + mux := http.NewServeMux() + s := httptest.NewServer(mux) + + host, _ := url.Parse(s.URL) + defaultHostname := "app.terraform.io" + tfeHost := svchost.Hostname(defaultHostname) + services := map[string]interface{}{ + "stacksplugin.v1": fmt.Sprintf("%s/api/stacksplugin/v1/", s.URL), + "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), + } + + credsSrc := auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ + tfeHost: {"token": "test-auth-token"}, + }) + + d := disco.NewWithCredentialsSource(credsSrc) + d.SetUserAgent(httpclient.TerraformUserAgent(version.String())) + d.ForceHostServices(tfeHost, services) + d.ForceHostServices(svchost.Hostname(host.Host), services) + + return d +} + +func mockGRPStacksClient(t *testing.T, ctrl *gomock.Controller, client *mock_stacksproto1.MockCommandService_ExecuteClient, executeError error) *GRPCStacksClient { + t.Helper() + + if client != nil && executeError != nil { + t.Fatal("one of client or executeError must be nil") + } + + result := mock_stacksproto1.NewMockCommandServiceClient(ctrl) + + result.EXPECT().Execute( + gomock.Any(), + gomock.Any(), + gomock.Any(), + ).Return(client, executeError) + + return &GRPCStacksClient{ + Client: result, + Context: context.Background(), + Services: mockDisco(), + Broker: &plugin.GRPCBroker{}, + } +} + +func Test_GRPCStacksClient_ExecuteError(t *testing.T) { + ctrl := gomock.NewController(t) + gRPCClient := mockGRPStacksClient(t, ctrl, nil, errors.New(mockError)) + + buffer := bytes.Buffer{} + // use executeWithBrokerIDs instead of Execute to allow mocking of the broker IDs + // This is necessary because the plugin.GRPCBroker cannot be mocked except the actual plugin process is started. + exitCode := gRPCClient.executeWithBrokers(mockBrokerIDs, []string{"init"}, io.Discard, &buffer) + + if exitCode != 1 { + t.Fatalf("expected exit %d, got %d", 1, exitCode) + } + + if buffer.String() != mockError { + t.Errorf("expected error %q, got %q", mockError, buffer.String()) + } +} + +func Test_GRPCStacksClient_Execute_RecvError(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_stacksproto1.NewMockCommandService_ExecuteClient(ctrl) + executeClient.EXPECT().Recv().Return(nil, errors.New(mockError)) + + gRPCClient := mockGRPStacksClient(t, ctrl, executeClient, nil) + + buffer := bytes.Buffer{} + // use executeWithBrokerIDs instead of Execute to allow mocking of the broker IDs + // This is necessary because the plugin.GRPCBroker cannot be mocked except the actual plugin process is started. + exitCode := gRPCClient.executeWithBrokers(mockBrokerIDs, []string{"init"}, io.Discard, &buffer) + + if exitCode != 1 { + t.Fatalf("expected exit %d, got %d", 1, exitCode) + } + + mockRecvError := fmt.Sprintf("Failed to receive command response from stacksplugin: %s", mockError) + + if buffer.String() != mockRecvError { + t.Errorf("expected error %q, got %q", mockRecvError, buffer.String()) + } +} + +func Test_GRPCStacksClient_Execute_HandleEOFError(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_stacksproto1.NewMockCommandService_ExecuteClient(ctrl) + executeClient.EXPECT().Recv().Return(&stacksproto1.CommandResponse{ + Data: &stacksproto1.CommandResponse_ExitCode{ + ExitCode: 0, + }, + }, io.EOF) + + gRPCClient := mockGRPStacksClient(t, ctrl, executeClient, nil) + + var logBuffer bytes.Buffer + originalOutput := log.Writer() + log.SetOutput(&logBuffer) + defer log.SetOutput(originalOutput) + + // use executeWithBrokerIDs instead of Execute to allow mocking of the broker IDs + // This is necessary because the plugin.GRPCBroker cannot be mocked except the actual plugin process is started. + exitCode := gRPCClient.executeWithBrokers(mockBrokerIDs, []string{"init"}, io.Discard, io.Discard) + if exitCode != 1 { + t.Fatalf("expected exit %d, got %d", 1, exitCode) + } + + recvLog := "[DEBUG] received EOF from stacksplugin\n" + if logBuffer.String() != recvLog { + t.Errorf("expected EOF message %q, got %q", recvLog, logBuffer.String()) + } +} + +func Test_GRPCStacksClient_Execute_Invalid_Exit(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_stacksproto1.NewMockCommandService_ExecuteClient(ctrl) + + executeClient.EXPECT().Recv().Return( + &stacksproto1.CommandResponse{ + Data: &stacksproto1.CommandResponse_ExitCode{ + ExitCode: 3_000, + }, + }, nil, + ) + gRPCClient := mockGRPStacksClient(t, ctrl, executeClient, nil) + + var logBuffer bytes.Buffer + originalOutput := log.Writer() + log.SetOutput(&logBuffer) + defer log.SetOutput(originalOutput) + + // use executeWithBrokerIDs instead of Execute to allow mocking of the broker IDs + // This is necessary because the plugin.GRPCBroker cannot be mocked except the actual plugin process is started. + exitCode := gRPCClient.executeWithBrokers(mockBrokerIDs, []string{"init"}, io.Discard, io.Discard) + if exitCode != 255 { + t.Fatalf("expected exit %q, got %q", 255, exitCode) + } + + recvLog := "[TRACE] received exit code: 3000\n[ERROR] stacksplugin returned an invalid error code 3000\n" + if logBuffer.String() != recvLog { + t.Errorf("expected error message %q, got %q", recvLog, logBuffer.String()) + } +} + +func Test_GRPCStacksClient_Execute(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_stacksproto1.NewMockCommandService_ExecuteClient(ctrl) + + gomock.InOrder( + executeClient.EXPECT().Recv().Return( + &stacksproto1.CommandResponse{ + Data: &stacksproto1.CommandResponse_Stdout{ + Stdout: []byte("firstresponse\n"), + }, + }, nil, + ), + executeClient.EXPECT().Recv().Return( + &stacksproto1.CommandResponse{ + Data: &stacksproto1.CommandResponse_Stdout{ + Stdout: []byte("secondresponse\n"), + }, + }, nil, + ), + executeClient.EXPECT().Recv().Return( + &stacksproto1.CommandResponse{ + Data: &stacksproto1.CommandResponse_ExitCode{ + ExitCode: 99, + }, + }, nil, + ), + ) + + gRPCClient := mockGRPStacksClient(t, ctrl, executeClient, nil) + + stdoutBuffer := bytes.Buffer{} + // use executeWithBrokerIDs instead of Execute to allow mocking of the broker IDs + // This is necessary because the plugin.GRPCBroker cannot be mocked except the actual plugin process is started. + exitCode := gRPCClient.executeWithBrokers(mockBrokerIDs, []string{"example"}, &stdoutBuffer, io.Discard) + if exitCode != 99 { + t.Fatalf("expected exit %q, got %q", 99, exitCode) + } + + recvResponse := "firstresponse\nsecondresponse\n" + if stdoutBuffer.String() != recvResponse { + t.Errorf("expected output %q, got %q", recvResponse, stdoutBuffer.String()) + } +} diff --git a/internal/stacksplugin/mock_stacksproto1/generate.go b/internal/stacksplugin/mock_stacksproto1/generate.go new file mode 100644 index 000000000000..4204d72d7a32 --- /dev/null +++ b/internal/stacksplugin/mock_stacksproto1/generate.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:generate go tool go.uber.org/mock/mockgen -destination mock.go github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1 CommandServiceClient,CommandService_ExecuteClient + +package mock_stacksproto1 diff --git a/internal/stacksplugin/mock_stacksproto1/mock.go b/internal/stacksplugin/mock_stacksproto1/mock.go new file mode 100644 index 000000000000..7bef6941b48b --- /dev/null +++ b/internal/stacksplugin/mock_stacksproto1/mock.go @@ -0,0 +1,186 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1 (interfaces: CommandServiceClient,CommandService_ExecuteClient) +// +// Generated by this command: +// +// mockgen -destination mock.go github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1 CommandServiceClient,CommandService_ExecuteClient +// + +// Package mock_stacksproto1 is a generated GoMock package. +package mock_stacksproto1 + +import ( + context "context" + reflect "reflect" + + stacksproto1 "github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1" + gomock "go.uber.org/mock/gomock" + grpc "google.golang.org/grpc" + metadata "google.golang.org/grpc/metadata" +) + +// MockCommandServiceClient is a mock of CommandServiceClient interface. +type MockCommandServiceClient struct { + ctrl *gomock.Controller + recorder *MockCommandServiceClientMockRecorder +} + +// MockCommandServiceClientMockRecorder is the mock recorder for MockCommandServiceClient. +type MockCommandServiceClientMockRecorder struct { + mock *MockCommandServiceClient +} + +// NewMockCommandServiceClient creates a new mock instance. +func NewMockCommandServiceClient(ctrl *gomock.Controller) *MockCommandServiceClient { + mock := &MockCommandServiceClient{ctrl: ctrl} + mock.recorder = &MockCommandServiceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCommandServiceClient) EXPECT() *MockCommandServiceClientMockRecorder { + return m.recorder +} + +// Execute mocks base method. +func (m *MockCommandServiceClient) Execute(arg0 context.Context, arg1 *stacksproto1.CommandRequest, arg2 ...grpc.CallOption) (stacksproto1.CommandService_ExecuteClient, error) { + m.ctrl.T.Helper() + varargs := []any{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Execute", varargs...) + ret0, _ := ret[0].(stacksproto1.CommandService_ExecuteClient) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Execute indicates an expected call of Execute. +func (mr *MockCommandServiceClientMockRecorder) Execute(arg0, arg1 any, arg2 ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandServiceClient)(nil).Execute), varargs...) +} + +// MockCommandService_ExecuteClient is a mock of CommandService_ExecuteClient interface. +type MockCommandService_ExecuteClient struct { + ctrl *gomock.Controller + recorder *MockCommandService_ExecuteClientMockRecorder +} + +// MockCommandService_ExecuteClientMockRecorder is the mock recorder for MockCommandService_ExecuteClient. +type MockCommandService_ExecuteClientMockRecorder struct { + mock *MockCommandService_ExecuteClient +} + +// NewMockCommandService_ExecuteClient creates a new mock instance. +func NewMockCommandService_ExecuteClient(ctrl *gomock.Controller) *MockCommandService_ExecuteClient { + mock := &MockCommandService_ExecuteClient{ctrl: ctrl} + mock.recorder = &MockCommandService_ExecuteClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCommandService_ExecuteClient) EXPECT() *MockCommandService_ExecuteClientMockRecorder { + return m.recorder +} + +// CloseSend mocks base method. +func (m *MockCommandService_ExecuteClient) CloseSend() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CloseSend") + ret0, _ := ret[0].(error) + return ret0 +} + +// CloseSend indicates an expected call of CloseSend. +func (mr *MockCommandService_ExecuteClientMockRecorder) CloseSend() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseSend", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).CloseSend)) +} + +// Context mocks base method. +func (m *MockCommandService_ExecuteClient) Context() context.Context { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Context") + ret0, _ := ret[0].(context.Context) + return ret0 +} + +// Context indicates an expected call of Context. +func (mr *MockCommandService_ExecuteClientMockRecorder) Context() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Context)) +} + +// Header mocks base method. +func (m *MockCommandService_ExecuteClient) Header() (metadata.MD, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Header") + ret0, _ := ret[0].(metadata.MD) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Header indicates an expected call of Header. +func (mr *MockCommandService_ExecuteClientMockRecorder) Header() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Header)) +} + +// Recv mocks base method. +func (m *MockCommandService_ExecuteClient) Recv() (*stacksproto1.CommandResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Recv") + ret0, _ := ret[0].(*stacksproto1.CommandResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Recv indicates an expected call of Recv. +func (mr *MockCommandService_ExecuteClientMockRecorder) Recv() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recv", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Recv)) +} + +// RecvMsg mocks base method. +func (m *MockCommandService_ExecuteClient) RecvMsg(arg0 any) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RecvMsg", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RecvMsg indicates an expected call of RecvMsg. +func (mr *MockCommandService_ExecuteClientMockRecorder) RecvMsg(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).RecvMsg), arg0) +} + +// SendMsg mocks base method. +func (m *MockCommandService_ExecuteClient) SendMsg(arg0 any) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendMsg", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendMsg indicates an expected call of SendMsg. +func (mr *MockCommandService_ExecuteClientMockRecorder) SendMsg(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).SendMsg), arg0) +} + +// Trailer mocks base method. +func (m *MockCommandService_ExecuteClient) Trailer() metadata.MD { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Trailer") + ret0, _ := ret[0].(metadata.MD) + return ret0 +} + +// Trailer indicates an expected call of Trailer. +func (mr *MockCommandService_ExecuteClientMockRecorder) Trailer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trailer", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Trailer)) +} diff --git a/internal/stacksplugin/stacksplugin1/stacks_grpc_plugin.go b/internal/stacksplugin/stacksplugin1/stacks_grpc_plugin.go new file mode 100644 index 000000000000..6a106f84fbf9 --- /dev/null +++ b/internal/stacksplugin/stacksplugin1/stacks_grpc_plugin.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stacksplugin1 + +import ( + "context" + "errors" + "net/rpc" + + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform-svchost/disco" + "github.com/hashicorp/terraform/internal/pluginshared" + "github.com/hashicorp/terraform/internal/rpcapi" + "github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// GRPCCloudPlugin is the go-plugin implementation, but only the client +// implementation exists in this package. +type GRPCStacksPlugin struct { + plugin.GRPCPlugin + Metadata metadata.MD + Impl pluginshared.CustomPluginClient + Services *disco.Disco +} + +// Server always returns an error; we're only implementing the GRPCPlugin +// interface, not the Plugin interface. +func (p *GRPCStacksPlugin) Server(*plugin.MuxBroker) (interface{}, error) { + return nil, errors.New("stacksplugin only implements gRPC clients") +} + +// Client always returns an error; we're only implementing the GRPCPlugin +// interface, not the Plugin interface. +func (p *GRPCStacksPlugin) Client(*plugin.MuxBroker, *rpc.Client) (interface{}, error) { + return nil, errors.New("stacksplugin only implements gRPC clients") +} + +// GRPCClient returns a new GRPC client for interacting with the cloud plugin server. +func (p *GRPCStacksPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + ctx = metadata.NewOutgoingContext(ctx, p.Metadata) + return &rpcapi.GRPCStacksClient{ + Client: stacksproto1.NewCommandServiceClient(c), + Broker: broker, + Services: p.Services, + Context: ctx, + }, nil +} + +// GRPCServer always returns an error; we're only implementing the client +// interface, not the server. +func (p *GRPCStacksPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + return errors.ErrUnsupported +} diff --git a/internal/stacksplugin/stacksproto1/stacksproto1.pb.go b/internal/stacksplugin/stacksproto1/stacksproto1.pb.go new file mode 100644 index 000000000000..76ef390cfc82 --- /dev/null +++ b/internal/stacksplugin/stacksproto1/stacksproto1.pb.go @@ -0,0 +1,399 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v3.15.6 +// source: stacksproto1.proto + +package stacksproto1 + +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" + unsafe "unsafe" +) + +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 `protogen:"open.v1"` + Args []string `protobuf:"bytes,1,rep,name=args,proto3" json:"args,omitempty"` + DependenciesServer uint32 `protobuf:"varint,2,opt,name=dependencies_server,json=dependenciesServer,proto3" json:"dependencies_server,omitempty"` + PackagesServer uint32 `protobuf:"varint,3,opt,name=packages_server,json=packagesServer,proto3" json:"packages_server,omitempty"` + StacksServer uint32 `protobuf:"varint,4,opt,name=stacks_server,json=stacksServer,proto3" json:"stacks_server,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandRequest) Reset() { + *x = CommandRequest{} + mi := &file_stacksproto1_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_stacksproto1_proto_msgTypes[0] + if 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_stacksproto1_proto_rawDescGZIP(), []int{0} +} + +func (x *CommandRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *CommandRequest) GetDependenciesServer() uint32 { + if x != nil { + return x.DependenciesServer + } + return 0 +} + +func (x *CommandRequest) GetPackagesServer() uint32 { + if x != nil { + return x.PackagesServer + } + return 0 +} + +func (x *CommandRequest) GetStacksServer() uint32 { + if x != nil { + return x.StacksServer + } + return 0 +} + +// CommandResponse contains the result of the command execution, including any +// output or errors. +type CommandResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Data: + // + // *CommandResponse_ExitCode + // *CommandResponse_Stdout + // *CommandResponse_Stderr + Data isCommandResponse_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandResponse) Reset() { + *x = CommandResponse{} + mi := &file_stacksproto1_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_stacksproto1_proto_msgTypes[1] + if 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_stacksproto1_proto_rawDescGZIP(), []int{1} +} + +func (x *CommandResponse) GetData() isCommandResponse_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *CommandResponse) GetExitCode() int32 { + if x != nil { + if x, ok := x.Data.(*CommandResponse_ExitCode); ok { + return x.ExitCode + } + } + return 0 +} + +func (x *CommandResponse) GetStdout() []byte { + if x != nil { + if x, ok := x.Data.(*CommandResponse_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *CommandResponse) GetStderr() []byte { + if x != nil { + if x, ok := x.Data.(*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_stacksproto1_proto protoreflect.FileDescriptor + +var file_stacksproto1_proto_rawDesc = string([]byte{ + 0x0a, 0x12, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0c, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x31, 0x22, 0xa3, 0x01, 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, 0x12, 0x2f, 0x0a, 0x13, 0x64, 0x65, 0x70, + 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x12, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, + 0x63, 0x69, 0x65, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x70, 0x61, + 0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x70, 0x61, 0x63, 0x6b, 0x61, 0x67, 0x65, 0x73, 0x53, 0x65, 0x72, + 0x76, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 0x5f, 0x73, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x73, 0x74, 0x61, 0x63, + 0x6b, 0x73, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 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, 0x5c, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4a, 0x0a, 0x07, 0x45, 0x78, 0x65, 0x63, 0x75, + 0x74, 0x65, 0x12, 0x1c, 0x2e, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1d, 0x2e, 0x73, 0x74, 0x61, 0x63, 0x6b, 0x73, 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, 0x43, 0x5a, 0x41, 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, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x73, 0x74, 0x61, 0x63, + 0x6b, 0x73, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_stacksproto1_proto_rawDescOnce sync.Once + file_stacksproto1_proto_rawDescData []byte +) + +func file_stacksproto1_proto_rawDescGZIP() []byte { + file_stacksproto1_proto_rawDescOnce.Do(func() { + file_stacksproto1_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stacksproto1_proto_rawDesc), len(file_stacksproto1_proto_rawDesc))) + }) + return file_stacksproto1_proto_rawDescData +} + +var file_stacksproto1_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_stacksproto1_proto_goTypes = []any{ + (*CommandRequest)(nil), // 0: stacksproto1.CommandRequest + (*CommandResponse)(nil), // 1: stacksproto1.CommandResponse +} +var file_stacksproto1_proto_depIdxs = []int32{ + 0, // 0: stacksproto1.CommandService.Execute:input_type -> stacksproto1.CommandRequest + 1, // 1: stacksproto1.CommandService.Execute:output_type -> stacksproto1.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_stacksproto1_proto_init() } +func file_stacksproto1_proto_init() { + if File_stacksproto1_proto != nil { + return + } + file_stacksproto1_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: unsafe.Slice(unsafe.StringData(file_stacksproto1_proto_rawDesc), len(file_stacksproto1_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stacksproto1_proto_goTypes, + DependencyIndexes: file_stacksproto1_proto_depIdxs, + MessageInfos: file_stacksproto1_proto_msgTypes, + }.Build() + File_stacksproto1_proto = out.File + file_stacksproto1_proto_goTypes = nil + file_stacksproto1_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], "/stacksproto1.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: "stacksproto1.CommandService", + HandlerType: (*CommandServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Execute", + Handler: _CommandService_Execute_Handler, + ServerStreams: true, + }, + }, + Metadata: "stacksproto1.proto", +} diff --git a/internal/stacksplugin/stacksproto1/stacksproto1.proto b/internal/stacksplugin/stacksproto1/stacksproto1.proto new file mode 100644 index 000000000000..f449630a08e8 --- /dev/null +++ b/internal/stacksplugin/stacksproto1/stacksproto1.proto @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +syntax = "proto3"; +package stacksproto1; + +option go_package = "github.com/hashicorp/terraform/internal/stacksplugin/stacksproto1"; + +// 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; + uint32 dependencies_server = 2; + uint32 packages_server = 3; + uint32 stacks_server = 4; +} + +// 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..f9bff7acac13 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"}, }, + { + "stacksproto1 (stacks protocol version 1)", + "internal/stacksplugin/stacksproto1", + []string{"--go_out=paths=source_relative,plugins=grpc:.", "stacksproto1.proto"}, + }, } func main() {