diff --git a/.changes/unreleased/ENHANCEMENTS-20260823-222957.yaml b/.changes/unreleased/ENHANCEMENTS-20260823-222957.yaml new file mode 100644 index 0000000..f77963d --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20260823-222957.yaml @@ -0,0 +1,3 @@ +kind: ENHANCEMENTS +body: "Added `tfctl module publish` for publishing VCS-backed private registry modules from existing OAuth or GitHub App connections" +time: 2026-08-23T22:29:57.000000-04:00 diff --git a/AGENTS.md b/AGENTS.md index 7508beb..4b05972 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ ## Repository Architecture - `cmd/tfctl/main.go` is the process entry point. It creates I/O, logging, profiles, telemetry, the shared invocation, and the command tree. -- `internal/commands/` contains command behavior. The top-level groups are `api`, `get`, `create`, `run`, `auth`, `variable`, `profile`, and `harness`. +- `internal/commands/` contains command behavior. The top-level groups are `api`, `get`, `create`, `module`, `run`, `auth`, `variable`, `profile`, and `harness`. - `internal/pkg/` contains reusable infrastructure. Important packages include `cmd`, `client`, `format`, `iostreams`, `logging`, `telemetry`, `profile`, `openapi`, and `execsession`. - `internal/commands/*` can depend on `internal/pkg/*`. Do not add dependencies from infrastructure packages to command packages. - `skills/` contains embedded coding-agent skills. diff --git a/assets/tfctl.png b/assets/tfctl.png index 43584b2..1317c3f 100644 Binary files a/assets/tfctl.png and b/assets/tfctl.png differ diff --git a/internal/commands/module/module.go b/internal/commands/module/module.go new file mode 100644 index 0000000..62dbe1c --- /dev/null +++ b/internal/commands/module/module.go @@ -0,0 +1,27 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +// Package module implements the `module` command group. +package module + +import ( + "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" + "github.com/hashicorp/tfctl-cli/internal/pkg/heredoc" + "github.com/hashicorp/tfctl-cli/version" +) + +// NewCmdModule creates the `module` command group. +func NewCmdModule(inv *cmd.Invocation) *cmd.Command { + c := &cmd.Command{ + Name: "module", + ShortHelp: "Manage private registry modules.", + LongHelp: heredoc.New(inv.IO).Mustf(` + The {{ template "mdCodeOrBold" "%s module" }} command group lets you manage + private registry modules in HCP Terraform and Terraform Enterprise. + `, version.Name), + } + + c.AddChild(NewCmdPublish(inv)) + + return c +} diff --git a/internal/commands/module/publish.go b/internal/commands/module/publish.go new file mode 100644 index 0000000..4aaaf16 --- /dev/null +++ b/internal/commands/module/publish.go @@ -0,0 +1,402 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package module + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/hashicorp/tfctl-cli/internal/commands/cmdutil" + "github.com/hashicorp/tfctl-cli/internal/pkg/client" + "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" + "github.com/hashicorp/tfctl-cli/internal/pkg/flagvalue" + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/heredoc" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/logging" + "github.com/hashicorp/tfctl-cli/version" +) + +const ( + registryModulePublishPath = "/organizations/{organization_name}/registry-modules/vcs" + jsonAPIContentType = "application/vnd.api+json" +) + +// PublishOpts defines the options for the `module publish` command. +type PublishOpts struct { + IO iostreams.IOStreams + Output *format.Outputter + Client *client.Client + ProfileOrganization string + Organization *string + Repository string + OAuthTokenID string + GitHubAppInstallationID string + Branch *string + InitialVersion *string + DryRun bool + Quiet bool +} + +// NewCmdPublish creates the `module publish` command. +func NewCmdPublish(inv *cmd.Invocation) *cmd.Command { + opts := &PublishOpts{ + IO: inv.IO, + Output: inv.Output, + } + + return &cmd.Command{ + Name: "publish", + ShortHelp: "Publish a VCS-backed private registry module.", + LongHelp: heredoc.New(inv.IO, heredoc.WithPreserveNewlines()).Mustf(` + The {{ template "mdCodeOrBold" "%s module publish" }} command publishes a private registry module from an existing VCS connection. + + Provide exactly one of {{ template "mdCodeOrBold" "--oauth-token-id" }} or {{ template "mdCodeOrBold" "--github-app-installation-id" }}. + + Publishing from tags is the default. Use {{ template "mdCodeOrBold" "--branch" }} to publish from a branch and optionally set its first version with {{ template "mdCodeOrBold" "--initial-version" }}. + + The command uses {{ template "mdCodeOrBold" "--repo" }} for both the VCS identifier and display identifier. Repositories that require different values, such as some Bitbucket Cloud repositories, are not supported. Use {{ template "mdCodeOrBold" "%s api" }} for those repositories. + + The module name and provider are derived from the repository name. Explicit overrides for repositories that do not follow Terraform module naming conventions are not supported. + `, version.Name, version.Name), + Flags: cmd.Flags{ + // These remote and repository-specific values have no reliable local predictors. + Local: []*cmd.Flag{ + { + Name: "repo", + DisplayValue: "REPOSITORY", + Description: "VCS repository identifier to publish.", + Value: flagvalue.Simple("", &opts.Repository), + Required: true, + }, + { + Name: "organization", + Shorthand: "o", + DisplayValue: "NAME", + Description: "Organization name (defaults to profile or Terraform cloud configuration context).", + Value: flagvalue.Simple((*string)(nil), &opts.Organization), + }, + { + Name: "oauth-token-id", + DisplayValue: "ID", + Description: "OAuth token ID for an existing VCS connection.", + Value: flagvalue.Simple("", &opts.OAuthTokenID), + }, + { + Name: "github-app-installation-id", + DisplayValue: "ID", + Description: "GitHub App installation ID for an existing VCS connection.", + Value: flagvalue.Simple("", &opts.GitHubAppInstallationID), + }, + { + Name: "branch", + DisplayValue: "BRANCH", + Description: "Branch to publish instead of publishing from tags.", + Value: flagvalue.Simple((*string)(nil), &opts.Branch), + }, + { + Name: "initial-version", + DisplayValue: "VERSION", + Description: "Initial module version for branch-based publishing.", + Value: flagvalue.Simple((*string)(nil), &opts.InitialVersion), + }, + }, + }, + Examples: []cmd.Example{ + { + Preamble: "Publish from tags with an OAuth connection:", + Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s module publish --repo ORG/REPO --oauth-token-id ot-...`, version.Name), + }, + { + Preamble: "Publish from tags with a GitHub App connection:", + Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s module publish --repo ORG/REPO --github-app-installation-id ghain-...`, version.Name), + }, + { + Preamble: "Publish from a branch with an initial version:", + Command: heredoc.New(inv.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Mustf(`$ %s module publish --repo ORG/REPO --oauth-token-id ot-... --branch main --initial-version 1.0.0`, version.Name), + }, + }, + RunF: func(_ *cmd.Command, _ []string) error { + opts.ProfileOrganization = inv.Profile.DefaultOrganization + opts.DryRun = inv.IsDryRun() + opts.Quiet = inv.IsQuiet() + + apiClient, err := inv.NewAPIClient() + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + opts.Client = apiClient + + return runPublish(inv.ShutdownCtx, opts) + }, + } +} + +func runPublish(ctx context.Context, opts *PublishOpts) error { + repository := strings.TrimSpace(opts.Repository) + if repository == "" { + return errors.New("repository is required") + } + + organizationFlag := "" + if opts.Organization != nil { + organizationFlag = strings.TrimSpace(*opts.Organization) + if organizationFlag == "" { + return errors.New("--organization must not be blank") + } + } + + branch := "" + if opts.Branch != nil { + branch = strings.TrimSpace(*opts.Branch) + if branch == "" { + return errors.New("--branch must not be blank") + } + } + + initialVersion := "" + if opts.InitialVersion != nil { + initialVersion = strings.TrimSpace(*opts.InitialVersion) + if initialVersion == "" { + return errors.New("--initial-version must not be blank") + } + } + + oauthTokenID := strings.TrimSpace(opts.OAuthTokenID) + githubAppInstallationID := strings.TrimSpace(opts.GitHubAppInstallationID) + if (oauthTokenID == "") == (githubAppInstallationID == "") { + return errors.New("exactly one of --oauth-token-id or --github-app-installation-id must be provided") + } + + if initialVersion != "" && branch == "" { + return errors.New("--initial-version requires --branch") + } + + organization := strings.TrimSpace(cmdutil.ResolveOrganization( + strings.TrimSpace(opts.ProfileOrganization), + organizationFlag, + )) + path, err := cmdutil.ResolvePath(registryModulePublishPath, organization) + if err != nil { + return fmt.Errorf("failed to resolve registry module publish path: %w", err) + } + + request := publishRequestEnvelope{ + Data: publishRequestData{ + Type: "registry-modules", + Attributes: publishRequestAttributes{ + InitialVersion: initialVersion, + VCSRepo: publishRequestVCSRepo{ + Identifier: repository, + DisplayIdentifier: repository, + OAuthTokenID: oauthTokenID, + GitHubAppInstallationID: githubAppInstallationID, + Branch: branch, + }, + }, + }, + } + + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("failed to encode registry module publish request: %w", err) + } + + requestURL, err := client.ResolveURL(*opts.Client.BaseURL, path) + if err != nil { + return fmt.Errorf("failed to resolve registry module publish URL: %w", err) + } + + publishingMode := "tag-based" + if branch != "" { + publishingMode = "branch-based" + } + + if opts.DryRun { + fmt.Fprintf(opts.IO.Err(), "%s would publish VCS-backed module from repository %q to organization %q using %s publishing\n", + opts.IO.ColorScheme().DryRunLabel(), repository, organization, publishingMode) + return nil + } + + logger := logging.FromContext(ctx) + logger.Debug("Publishing VCS-backed registry module", + "method", http.MethodPost, + "path", requestURL.Path, + "organization", organization, + "mode", publishingMode, + ) + + resp, err := opts.Client.Do(ctx, &client.Request{ + Method: http.MethodPost, + URL: requestURL, + Headers: http.Header{ + "Content-Type": []string{jsonAPIContentType}, + }, + Body: body, + }) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + return fmt.Errorf("failed to publish registry module: %w", err) + } + + if opts.Quiet { + logger.Debug("Quiet mode enabled, rendering skipped") + return nil + } + + if resp == nil || resp.Body == nil { + return errors.New("failed to decode registry module publish response: response body is missing") + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read registry module publish response: %w", err) + } + + var response publishResponseEnvelope + if err := json.Unmarshal(responseBody, &response); err != nil { + return fmt.Errorf("failed to decode registry module publish response: %w", err) + } + if response.Data == nil { + return errors.New("failed to decode registry module publish response: data is missing") + } + + result := publishResult{ + ID: response.Data.ID, + Name: response.Data.Attributes.Name, + Namespace: response.Data.Attributes.Namespace, + Provider: response.Data.Attributes.Provider, + Status: response.Data.Attributes.Status, + } + + if response.Data.Links.Self != "" { + result.SelfLink, err = resolvePublishSelfLink(opts.Client.BaseURL, response.Data.Links.Self) + if err != nil { + return fmt.Errorf("failed to resolve registry module self link: %w", err) + } + } + + if err := opts.Output.Display(&publishDisplayer{result: result}); err != nil { + return fmt.Errorf("failed to display published registry module: %w", err) + } + + switch strings.ToLower(result.Status) { + case "pending", "processing": + fmt.Fprintf(opts.IO.ErrUnessential(), "Publish request accepted. The module is still processing (status: %s) and may not be ready yet.\n", result.Status) + } + + return nil +} + +type publishRequestEnvelope struct { + Data publishRequestData `json:"data"` +} + +type publishRequestData struct { + Type string `json:"type"` + Attributes publishRequestAttributes `json:"attributes"` +} + +type publishRequestAttributes struct { + InitialVersion string `json:"initial-version,omitempty"` + VCSRepo publishRequestVCSRepo `json:"vcs-repo"` +} + +type publishRequestVCSRepo struct { + Identifier string `json:"identifier"` + DisplayIdentifier string `json:"display-identifier"` + OAuthTokenID string `json:"oauth-token-id,omitempty"` + GitHubAppInstallationID string `json:"github-app-installation-id,omitempty"` + Branch string `json:"branch,omitempty"` +} + +type publishResponseEnvelope struct { + Data *publishResponseData `json:"data"` +} + +type publishResponseData struct { + ID string `json:"id"` + Attributes publishResponseAttributes `json:"attributes"` + Links publishResponseLinks `json:"links"` +} + +type publishResponseAttributes struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Provider string `json:"provider"` + Status string `json:"status"` +} + +type publishResponseLinks struct { + Self string `json:"self"` +} + +type publishResult struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Provider string `json:"provider,omitempty"` + Status string `json:"status,omitempty"` + SelfLink string `json:"self_link,omitempty"` +} + +type publishDisplayer struct { + result publishResult +} + +var _ format.Displayer = (*publishDisplayer)(nil) + +func (d *publishDisplayer) DefaultFormat() format.Format { return format.Pretty } +func (d *publishDisplayer) Payload() any { return d.result } +func (d *publishDisplayer) FieldTemplates() []format.Field { + fields := make([]format.Field, 0, 6) + if d.result.ID != "" { + fields = append(fields, format.NewField("ID", "{{ .ID }}")) + } + if d.result.Name != "" { + fields = append(fields, format.NewField("Name", "{{ .Name }}")) + } + if d.result.Namespace != "" { + fields = append(fields, format.NewField("Namespace", "{{ .Namespace }}")) + } + if d.result.Provider != "" { + fields = append(fields, format.NewField("Provider", "{{ .Provider }}")) + } + if d.result.Status != "" { + fields = append(fields, format.NewField("Status", "{{ .Status }}")) + } + if d.result.SelfLink != "" { + fields = append(fields, format.NewField("Self Link", "{{ .SelfLink }}")) + } + return fields +} + +func resolvePublishSelfLink(base *url.URL, self string) (string, error) { + ref, err := url.Parse(self) + if err != nil { + return "", err + } + if ref.IsAbs() { + return self, nil + } + if base == nil { + return "", errors.New("configured API origin is missing") + } + + // A relative API link must not replace the configured origin. + ref.Scheme = "" + ref.Opaque = "" + ref.User = nil + ref.Host = "" + origin := url.URL{Scheme: base.Scheme, Host: base.Host, Path: "/"} + return origin.ResolveReference(ref).String(), nil +} diff --git a/internal/commands/module/publish_test.go b/internal/commands/module/publish_test.go new file mode 100644 index 0000000..da58edf --- /dev/null +++ b/internal/commands/module/publish_test.go @@ -0,0 +1,671 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package module + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + + tfe "github.com/hashicorp/go-tfe/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/commands/cmdtest" + "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" +) + +const publishPath = "/api/v2/organizations/my-org/registry-modules/vcs" + +func TestNewCmdPublishArguments(t *testing.T) { + t.Parallel() + + t.Run("accepts no positional arguments", func(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + inv := cmdtest.NewInvocation(t, io, cmdtest.NewServer(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + writePublishJSONAPI(w, http.StatusCreated, publishResponse("/api/v2/registry-modules/mod-123", "setup_complete")) + }, + })) + inv.Profile.DefaultOrganization = "my-org" + + publish := NewCmdPublish(inv) + root := &cmd.Command{Name: "tfctl"} + module := &cmd.Command{Name: "module"} + module.AddChild(publish) + root.AddChild(module) + cmd.ConfigureRootCommand(inv, root) + + exitCode := publish.Run([]string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + }, inv) + assert.Equal(t, 0, exitCode) + }) + + t.Run("rejects a positional argument", func(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + inv := cmdtest.NewInvocation(t, io, cmdtest.NewServer(t, cmdtest.RouteMap{})) + publish := NewCmdPublish(inv) + root := &cmd.Command{Name: "tfctl"} + module := &cmd.Command{Name: "module"} + module.AddChild(publish) + root.AddChild(module) + cmd.ConfigureRootCommand(inv, root) + + exitCode := publish.Run([]string{ + "extra", + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + }, inv) + assert.NotEqual(t, 0, exitCode) + assert.Contains(t, io.Error.String(), "no arguments allowed") + }) + + t.Run("requires repository flag", func(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + inv := cmdtest.NewInvocation(t, io, cmdtest.NewServer(t, cmdtest.RouteMap{})) + publish := NewCmdPublish(inv) + root := &cmd.Command{Name: "tfctl"} + module := &cmd.Command{Name: "module"} + module.AddChild(publish) + root.AddChild(module) + cmd.ConfigureRootCommand(inv, root) + + exitCode := publish.Run([]string{"--oauth-token-id", "ot-valid"}, inv) + assert.NotEqual(t, 0, exitCode) + assert.Contains(t, io.Error.String(), "missing required flag: --repo") + }) +} + +func TestNewCmdPublishHelpDocumentsConnectionAndRepositoryLimits(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + inv := cmdtest.NewInvocation(t, io, cmdtest.NewServer(t, cmdtest.RouteMap{})) + publish := NewCmdPublish(inv) + longHelp := strings.Join(strings.Fields(publish.LongHelp), " ") + + assert.Contains(t, longHelp, "exactly one of --oauth-token-id or --github-app-installation-id") + assert.Contains(t, longHelp, "such as some Bitbucket Cloud repositories") + assert.Contains(t, longHelp, "tfctl api") +} + +func TestNewCmdPublishOptionalFlagPresence(t *testing.T) { + t.Parallel() + + const profilePublishPath = "/api/v2/organizations/profile-org/registry-modules/vcs" + baseAttributes := func(vcsRepo map[string]any) map[string]any { + return map[string]any{ + "data": map[string]any{ + "type": "registry-modules", + "attributes": map[string]any{ + "vcs-repo": vcsRepo, + }, + }, + } + } + + tests := map[string]struct { + args []string + wantErr string + wantBody map[string]any + }{ + "explicit empty organization is rejected": { + args: []string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + "--organization=", + }, + wantErr: "--organization must not be blank", + }, + "explicit empty branch is rejected": { + args: []string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + "--branch=", + }, + wantErr: "--branch must not be blank", + }, + "explicit empty initial version is rejected": { + args: []string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + "--branch", "main", + "--initial-version=", + }, + wantErr: "--initial-version must not be blank", + }, + "omitted organization uses profile fallback": { + args: []string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + "--branch", "main", + "--initial-version", "1.2.3", + }, + wantBody: map[string]any{ + "data": map[string]any{ + "type": "registry-modules", + "attributes": map[string]any{ + "initial-version": "1.2.3", + "vcs-repo": map[string]any{ + "identifier": "acme/terraform-aws-network", + "display-identifier": "acme/terraform-aws-network", + "oauth-token-id": "ot-valid", + "branch": "main", + }, + }, + }, + }, + }, + "omitted branch selects tag publishing": { + args: []string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + }, + wantBody: baseAttributes(map[string]any{ + "identifier": "acme/terraform-aws-network", + "display-identifier": "acme/terraform-aws-network", + "oauth-token-id": "ot-valid", + }), + }, + "omitted initial version is valid": { + args: []string{ + "--repo", "acme/terraform-aws-network", + "--oauth-token-id", "ot-valid", + "--branch", "main", + }, + wantBody: baseAttributes(map[string]any{ + "identifier": "acme/terraform-aws-network", + "display-identifier": "acme/terraform-aws-network", + "oauth-token-id": "ot-valid", + "branch": "main", + }), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + gotRequest := make(chan capturedRequest, 1) + streams := iostreams.Test() + inv := cmdtest.NewInvocation(t, streams, cmdtest.NewServer(t, cmdtest.RouteMap{ + "POST " + profilePublishPath: func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + body, err := io.ReadAll(r.Body) + gotRequest <- capturedRequest{body: body, err: err} + writePublishJSONAPI(w, http.StatusCreated, publishResponse("/api/v2/registry-modules/mod-123", "setup_complete")) + }, + })) + inv.Profile.DefaultOrganization = "profile-org" + + publish := NewCmdPublish(inv) + root := &cmd.Command{Name: "tfctl"} + module := &cmd.Command{Name: "module"} + module.AddChild(publish) + root.AddChild(module) + cmd.ConfigureRootCommand(inv, root) + + exitCode := publish.Run(tc.args, inv) + if tc.wantErr != "" { + assert.NotEqual(t, 0, exitCode) + assert.Contains(t, streams.Error.String(), tc.wantErr) + assert.EqualValues(t, 0, requestCount.Load()) + return + } + + require.Equal(t, 0, exitCode) + require.EqualValues(t, 1, requestCount.Load()) + request := <-gotRequest + require.NoError(t, request.err) + want, err := json.Marshal(tc.wantBody) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(request.body)) + }) + } +} + +func TestRunPublishValidation(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + mutate func(*PublishOpts) + wantErr string + }{ + "repository is required": { + mutate: func(opts *PublishOpts) { + opts.Repository = " " + }, + wantErr: "repository is required", + }, + "a connection is required": { + mutate: func(opts *PublishOpts) { + opts.OAuthTokenID = "" + }, + wantErr: "exactly one of --oauth-token-id or --github-app-installation-id", + }, + "connections are mutually exclusive": { + mutate: func(opts *PublishOpts) { + opts.GitHubAppInstallationID = "ghain-both" + }, + wantErr: "exactly one of --oauth-token-id or --github-app-installation-id", + }, + "initial version requires branch": { + mutate: func(opts *PublishOpts) { + opts.InitialVersion = publishStringPointer("1.2.3") + }, + wantErr: "--initial-version requires --branch", + }, + "explicit organization must not be blank": { + mutate: func(opts *PublishOpts) { + opts.ProfileOrganization = "profile-org" + opts.Organization = publishStringPointer(" \t ") + }, + wantErr: "--organization must not be blank", + }, + "branch must not be blank": { + mutate: func(opts *PublishOpts) { + opts.Branch = publishStringPointer(" \t ") + }, + wantErr: "--branch must not be blank", + }, + "initial version must not be blank": { + mutate: func(opts *PublishOpts) { + opts.Branch = publishStringPointer("main") + opts.InitialVersion = publishStringPointer(" \t ") + }, + wantErr: "--initial-version must not be blank", + }, + "organization is required": { + mutate: func(opts *PublishOpts) { + opts.ProfileOrganization = "" + }, + wantErr: "organization is required but not set", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + opts, _ := newPublishTestOpts(t, cmdtest.RouteMap{}) + tc.mutate(opts) + + err := runPublish(context.Background(), opts) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestRunPublishRequestContract(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + path string + mutate func(*PublishOpts) + want map[string]any + }{ + "OAuth tag publishing uses profile organization": { + path: "/api/v2/organizations/profile-org/registry-modules/vcs", + mutate: func(opts *PublishOpts) { + opts.ProfileOrganization = "profile-org" + opts.Repository = " acme/terraform-aws-network " + opts.OAuthTokenID = "ot-123" + }, + want: map[string]any{ + "data": map[string]any{ + "type": "registry-modules", + "attributes": map[string]any{ + "vcs-repo": map[string]any{ + "identifier": "acme/terraform-aws-network", + "display-identifier": "acme/terraform-aws-network", + "oauth-token-id": "ot-123", + }, + }, + }, + }, + }, + "GitHub App branch publishing uses explicit organization": { + path: "/api/v2/organizations/flag-org/registry-modules/vcs", + mutate: func(opts *PublishOpts) { + opts.ProfileOrganization = "profile-org" + opts.Organization = publishStringPointer("flag-org") + opts.Repository = "acme/terraform-google-network" + opts.OAuthTokenID = "" + opts.GitHubAppInstallationID = "ghain-456" + opts.Branch = publishStringPointer("main") + opts.InitialVersion = publishStringPointer("1.2.3") + }, + want: map[string]any{ + "data": map[string]any{ + "type": "registry-modules", + "attributes": map[string]any{ + "initial-version": "1.2.3", + "vcs-repo": map[string]any{ + "identifier": "acme/terraform-google-network", + "display-identifier": "acme/terraform-google-network", + "github-app-installation-id": "ghain-456", + "branch": "main", + }, + }, + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + gotRequest := make(chan capturedRequest, 1) + opts, _ := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + tc.path: func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + gotRequest <- capturedRequest{ + contentType: r.Header.Get("Content-Type"), + body: body, + err: err, + } + writePublishJSONAPI(w, http.StatusCreated, publishResponse("/api/v2/registry-modules/mod-123", "setup_complete")) + }, + }) + tc.mutate(opts) + + err := runPublish(context.Background(), opts) + require.NoError(t, err) + + request := <-gotRequest + require.NoError(t, request.err) + assert.Equal(t, "application/vnd.api+json", request.contentType) + want, err := json.Marshal(tc.want) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(request.body)) + }) + } +} + +func TestRunPublishDryRun(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + opts, streams := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + requestCount.Add(1) + writePublishJSONAPI(w, http.StatusCreated, publishResponse("/api/v2/registry-modules/mod-123", "setup_complete")) + }, + }) + opts.OAuthTokenID = "" + opts.GitHubAppInstallationID = "ghain-do-not-print" + opts.Branch = publishStringPointer("main") + opts.DryRun = true + + err := runPublish(context.Background(), opts) + require.NoError(t, err) + assert.EqualValues(t, 0, requestCount.Load()) + assert.Empty(t, streams.Output.String()) + + diagnostic := streams.Error.String() + assert.Contains(t, diagnostic, "DRY RUN:") + assert.Contains(t, diagnostic, "acme/terraform-aws-network") + assert.Contains(t, diagnostic, "my-org") + assert.Contains(t, strings.ToLower(diagnostic), "branch") + assert.NotContains(t, diagnostic, "ghain-do-not-print") + assert.NotContains(t, diagnostic, "github-app-installation-id") + assert.NotContains(t, diagnostic, `"data"`) +} + +func TestRunPublishOutputFormats(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + format format.Format + selfLink string + assertOutput func(*testing.T, string, string) + }{ + "default": { + format: format.Unset, + selfLink: "/api/v2/organizations/my-org/registry-modules/private/my-org/network/aws", + assertOutput: func(t *testing.T, output, resolvedSelf string) { + t.Helper() + assert.Contains(t, output, "mod-123") + assert.Contains(t, output, "network") + assert.Contains(t, output, "my-org") + assert.Contains(t, output, "aws") + assert.Contains(t, output, "setup_complete") + assert.Contains(t, output, resolvedSelf) + }, + }, + "JSON": { + format: format.JSON, + selfLink: "https://app.example.test/api/v2/organizations/my-org/registry-modules/private/my-org/network/aws", + assertOutput: func(t *testing.T, output, resolvedSelf string) { + t.Helper() + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(output), &got)) + assert.Equal(t, map[string]any{ + "id": "mod-123", + "name": "network", + "namespace": "my-org", + "provider": "aws", + "status": "setup_complete", + "self_link": resolvedSelf, + }, got) + }, + }, + "Markdown": { + format: format.Markdown, + selfLink: "/api/v2/organizations/my-org/registry-modules/private/my-org/network/aws", + assertOutput: func(t *testing.T, output, resolvedSelf string) { + t.Helper() + assert.Contains(t, output, "| Field") + assert.Contains(t, output, "| ID") + assert.Contains(t, output, "mod-123") + assert.Contains(t, output, "network") + assert.Contains(t, output, "my-org") + assert.Contains(t, output, "aws") + assert.Contains(t, output, "setup_complete") + assert.Contains(t, output, resolvedSelf) + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + opts, streams := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + writePublishJSONAPI(w, http.StatusCreated, publishResponse(tc.selfLink, "setup_complete")) + }, + }) + if tc.format != format.Unset { + opts.Output.SetFormat(tc.format) + } + + err := runPublish(context.Background(), opts) + require.NoError(t, err) + + resolvedSelf := tc.selfLink + if strings.HasPrefix(tc.selfLink, "/") { + resolvedSelf = strings.TrimSuffix(opts.Client.BaseURL.Scheme+"://"+opts.Client.BaseURL.Host, "/") + tc.selfLink + } + output := streams.Output.String() + tc.assertOutput(t, output, resolvedSelf) + + for _, unsafe := range []string{ + "ot-response-secret", + "ghain-response-secret", + "webhook-response-secret", + "vcs-repo", + "relationships", + } { + assert.NotContains(t, output, unsafe) + } + }) + } +} + +func TestRunPublishPendingAndQuiet(t *testing.T) { + t.Parallel() + + t.Run("pending response returns without polling", func(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + opts, streams := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + requestCount.Add(1) + writePublishJSONAPI(w, http.StatusCreated, publishResponse("/api/v2/registry-modules/mod-pending", "pending")) + }, + }) + + err := runPublish(context.Background(), opts) + require.NoError(t, err) + assert.EqualValues(t, 1, requestCount.Load()) + assert.Contains(t, streams.Output.String(), "pending") + diagnostic := strings.ToLower(streams.Error.String()) + assert.Contains(t, diagnostic, "accepted") + assert.Contains(t, diagnostic, "processing") + }) + + t.Run("quiet suppresses successful output and guidance", func(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + opts, streams := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + requestCount.Add(1) + writePublishJSONAPI(w, http.StatusCreated, publishResponse("/api/v2/registry-modules/mod-pending", "pending")) + }, + }) + opts.Quiet = true + streams.SetQuiet(true) + + err := runPublish(context.Background(), opts) + require.NoError(t, err) + assert.EqualValues(t, 1, requestCount.Load()) + assert.Empty(t, streams.Output.String()) + assert.Empty(t, streams.Error.String()) + }) +} + +func TestRunPublishAPIValidationError(t *testing.T) { + t.Parallel() + + opts, streams := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + writePublishJSONAPI(w, http.StatusUnprocessableEntity, map[string]any{ + "errors": []any{ + map[string]any{ + "title": "Validation failed", + "detail": "repository identifier is invalid", + }, + }, + }) + }, + }) + opts.Quiet = true + streams.SetQuiet(true) + + err := runPublish(context.Background(), opts) + require.Error(t, err) + assert.ErrorIs(t, err, tfe.ErrUnprocessableEntity) + var apiErr *tfe.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, []string{"Validation failed: repository identifier is invalid"}, apiErr.Details) +} + +func TestRunPublishMalformedSuccess(t *testing.T) { + t.Parallel() + + opts, _ := newPublishTestOpts(t, cmdtest.RouteMap{ + "POST " + publishPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"data":`) + }, + }) + + err := runPublish(context.Background(), opts) + require.ErrorContains(t, err, "decode") + var syntaxErr *json.SyntaxError + require.ErrorAs(t, err, &syntaxErr) +} + +type capturedRequest struct { + contentType string + body []byte + err error +} + +func newPublishTestOpts(t *testing.T, routes cmdtest.RouteMap) (*PublishOpts, *iostreams.Testing) { + t.Helper() + + streams := iostreams.Test() + inv := cmdtest.NewInvocation(t, streams, cmdtest.NewServer(t, routes)) + apiClient, err := inv.NewAPIClient() + require.NoError(t, err) + + return &PublishOpts{ + IO: streams, + Output: inv.Output, + Client: apiClient, + ProfileOrganization: "my-org", + Repository: "acme/terraform-aws-network", + OAuthTokenID: "ot-valid", + }, streams +} + +func publishResponse(selfLink, status string) map[string]any { + return map[string]any{ + "data": map[string]any{ + "id": "mod-123", + "type": "registry-modules", + "attributes": map[string]any{ + "name": "network", + "namespace": "my-org", + "provider": "aws", + "status": status, + "vcs-repo": map[string]any{ + "identifier": "acme/terraform-aws-network", + "oauth-token-id": "ot-response-secret", + "github-app-installation-id": "ghain-response-secret", + "webhook-url": "webhook-response-secret", + }, + }, + "relationships": map[string]any{ + "organization": map[string]any{ + "data": map[string]any{"id": "my-org", "type": "organizations"}, + }, + }, + "links": map[string]any{ + "self": selfLink, + }, + }, + } +} + +func writePublishJSONAPI(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(status) + cmdtest.WriteJSONAPI(w, payload) +} + +func publishStringPointer(value string) *string { + return &value +} diff --git a/internal/commands/root/root.go b/internal/commands/root/root.go index b556581..de01b48 100644 --- a/internal/commands/root/root.go +++ b/internal/commands/root/root.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/tfctl-cli/internal/commands/create" "github.com/hashicorp/tfctl-cli/internal/commands/get" "github.com/hashicorp/tfctl-cli/internal/commands/harness" + "github.com/hashicorp/tfctl-cli/internal/commands/module" "github.com/hashicorp/tfctl-cli/internal/commands/profile" "github.com/hashicorp/tfctl-cli/internal/commands/run" "github.com/hashicorp/tfctl-cli/internal/commands/variable" @@ -41,6 +42,7 @@ func NewCmdRoot(inv *cmd.Invocation) *cmd.Command { c.AddChild(api.NewCmdAPI(inv)) c.AddChild(get.NewCmdGet(inv)) c.AddChild(create.NewCmdCreate(inv)) + c.AddChild(module.NewCmdModule(inv)) c.AddChild(run.NewCmdRun(inv)) c.AddChild(auth.NewCmdAuth(inv)) c.AddChild(variable.NewCmdVariable(inv)) diff --git a/internal/commands/root/root_test.go b/internal/commands/root/root_test.go new file mode 100644 index 0000000..04f9bb4 --- /dev/null +++ b/internal/commands/root/root_test.go @@ -0,0 +1,32 @@ +// Copyright IBM Corp. 2026 +// SPDX-License-Identifier: MPL-2.0 + +package root + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" + "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" + "github.com/hashicorp/tfctl-cli/internal/pkg/profile" +) + +func TestNewCmdRootRegistersModulePublish(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + inv := &cmd.Invocation{ + IO: io, + Output: format.New(io), + ShutdownCtx: context.Background(), + Profile: profile.TestProfile(t), + } + commands := cmd.ToCommandMap(NewCmdRoot(inv), inv) + + require.Contains(t, commands, "module") + require.Contains(t, commands, "module publish") +}