// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package cloud import ( "context" "fmt" "strings" tfe "github.com/hashicorp/go-tfe" "github.com/opentofu/opentofu/internal/cloud/cloudplan" "github.com/opentofu/opentofu/internal/plans" ) // ShowPlanForRun downloads the JSON plan output for the specified cloud run // (either the redacted or unredacted format, per the caller's request), and // returns it in a cloudplan.RemotePlanJSON wrapper struct (along with various // metadata required by tofu show). It's intended for use by the tofu // show command, in order to format and display a saved cloud plan. func (b *Cloud) ShowPlanForRun(ctx context.Context, runID, runHostname string, redacted bool) (*cloudplan.RemotePlanJSON, error) { var jsonBytes []byte mode := plans.NormalMode var opts []plans.Quality // Bail early if wrong hostname if runHostname != b.hostname { return nil, fmt.Errorf("hostname for run (%s) does not match the configured cloud integration (%s)", runHostname, b.hostname) } // Get run and plan r, err := b.client.Runs.ReadWithOptions(ctx, runID, &tfe.RunReadOptions{Include: []tfe.RunIncludeOpt{tfe.RunPlan, tfe.RunWorkspace}}) if err == tfe.ErrResourceNotFound { return nil, fmt.Errorf("couldn't read information for cloud run %s; make sure you've run `tofu login` and that you have permission to view the run", runID) } else if err != nil { return nil, fmt.Errorf("couldn't read information for cloud run %s: %w", runID, err) } // Sort out the run mode if r.IsDestroy { mode = plans.DestroyMode } else if r.RefreshOnly { mode = plans.RefreshOnlyMode } // Check that the plan actually finished switch r.Plan.Status { case tfe.PlanErrored: // Errored plans might still be displayable, but we want to mention it to the renderer. opts = append(opts, plans.Errored) case tfe.PlanFinished: // Good to go, but alert the renderer if it has no changes. if !r.Plan.HasChanges { opts = append(opts, plans.NoChanges) } default: // Bail, we can't use this. err = fmt.Errorf("can't display a cloud plan that is currently %s", r.Plan.Status) return nil, err } // Fetch the json plan! if redacted { jsonBytes, err = readRedactedPlan(ctx, b.client.BaseURL(), b.token, r.Plan.ID) } else { jsonBytes, err = b.client.Plans.ReadJSONOutput(ctx, r.Plan.ID) } if err == tfe.ErrResourceNotFound { if redacted { return nil, fmt.Errorf("couldn't read plan data for cloud run %s; make sure you've run `tofu login` and that you have permission to view the run", runID) } else { return nil, fmt.Errorf("couldn't read unredacted JSON plan data for cloud run %s; make sure you've run `tofu login` and that you have admin permissions on the workspace", runID) } } else if err != nil { return nil, fmt.Errorf("couldn't read plan data for cloud run %s: %w", runID, err) } // Format a run header and footer header := strings.TrimSpace(fmt.Sprintf(runHeader, b.hostname, b.organization, r.Workspace.Name, r.ID)) footer := strings.TrimSpace(statusFooter(r.Status, r.Actions.IsConfirmable, r.Workspace.Locked)) out := &cloudplan.RemotePlanJSON{ JSONBytes: jsonBytes, Redacted: redacted, Mode: mode, Qualities: opts, RunHeader: header, RunFooter: footer, } return out, nil } func statusFooter(status tfe.RunStatus, isConfirmable, locked bool) string { statusText := strings.ReplaceAll(string(status), "_", " ") statusColor := "red" statusNote := "not confirmable" if isConfirmable { statusColor = "green" statusNote = "confirmable" } lockedColor := "green" lockedText := "unlocked" if locked { lockedColor = "red" lockedText = "locked" } return fmt.Sprintf(statusFooterText, statusColor, statusText, statusNote, lockedColor, lockedText) } const statusFooterText = ` [reset][%s]Run status: %s (%s)[reset] [%s]Workspace is %s[reset] `