github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/show.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package command 5 6 import ( 7 "context" 8 "errors" 9 "fmt" 10 "os" 11 "strings" 12 13 "github.com/terramate-io/tf/backend" 14 "github.com/terramate-io/tf/cloud" 15 "github.com/terramate-io/tf/cloud/cloudplan" 16 "github.com/terramate-io/tf/command/arguments" 17 "github.com/terramate-io/tf/command/views" 18 "github.com/terramate-io/tf/configs" 19 "github.com/terramate-io/tf/plans" 20 "github.com/terramate-io/tf/plans/planfile" 21 "github.com/terramate-io/tf/states/statefile" 22 "github.com/terramate-io/tf/states/statemgr" 23 "github.com/terramate-io/tf/terraform" 24 "github.com/terramate-io/tf/tfdiags" 25 ) 26 27 // Many of the methods we get data from can emit special error types if they're 28 // pretty sure about the file type but still can't use it. But they can't all do 29 // that! So, we have to do a couple ourselves if we want to preserve that data. 30 type errUnusableDataMisc struct { 31 inner error 32 kind string 33 } 34 35 func errUnusable(err error, kind string) *errUnusableDataMisc { 36 return &errUnusableDataMisc{inner: err, kind: kind} 37 } 38 func (e *errUnusableDataMisc) Error() string { 39 return e.inner.Error() 40 } 41 func (e *errUnusableDataMisc) Unwrap() error { 42 return e.inner 43 } 44 45 // ShowCommand is a Command implementation that reads and outputs the 46 // contents of a Terraform plan or state file. 47 type ShowCommand struct { 48 Meta 49 viewType arguments.ViewType 50 } 51 52 func (c *ShowCommand) Run(rawArgs []string) int { 53 // Parse and apply global view arguments 54 common, rawArgs := arguments.ParseView(rawArgs) 55 c.View.Configure(common) 56 57 // Parse and validate flags 58 args, diags := arguments.ParseShow(rawArgs) 59 if diags.HasErrors() { 60 c.View.Diagnostics(diags) 61 c.View.HelpPrompt("show") 62 return 1 63 } 64 c.viewType = args.ViewType 65 66 // Set up view 67 view := views.NewShow(args.ViewType, c.View) 68 69 // Check for user-supplied plugin path 70 var err error 71 if c.pluginPath, err = c.loadPluginPath(); err != nil { 72 diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err)) 73 view.Diagnostics(diags) 74 return 1 75 } 76 77 // Get the data we need to display 78 plan, jsonPlan, stateFile, config, schemas, showDiags := c.show(args.Path) 79 diags = diags.Append(showDiags) 80 if showDiags.HasErrors() { 81 view.Diagnostics(diags) 82 return 1 83 } 84 85 // Display the data 86 return view.Display(config, plan, jsonPlan, stateFile, schemas) 87 } 88 89 func (c *ShowCommand) Help() string { 90 helpText := ` 91 Usage: terraform [global options] show [options] [path] 92 93 Reads and outputs a Terraform state or plan file in a human-readable 94 form. If no path is specified, the current state will be shown. 95 96 Options: 97 98 -no-color If specified, output won't contain any color. 99 -json If specified, output the Terraform plan or state in 100 a machine-readable form. 101 102 ` 103 return strings.TrimSpace(helpText) 104 } 105 106 func (c *ShowCommand) Synopsis() string { 107 return "Show the current state or a saved plan" 108 } 109 110 func (c *ShowCommand) show(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) { 111 var diags, showDiags tfdiags.Diagnostics 112 var plan *plans.Plan 113 var jsonPlan *cloudplan.RemotePlanJSON 114 var stateFile *statefile.File 115 var config *configs.Config 116 var schemas *terraform.Schemas 117 118 // No plan file or state file argument provided, 119 // so get the latest state snapshot 120 if path == "" { 121 stateFile, showDiags = c.showFromLatestStateSnapshot() 122 diags = diags.Append(showDiags) 123 if showDiags.HasErrors() { 124 return plan, jsonPlan, stateFile, config, schemas, diags 125 } 126 } 127 128 // Plan file or state file argument provided, 129 // so try to load the argument as a plan file first. 130 // If that fails, try to load it as a statefile. 131 if path != "" { 132 plan, jsonPlan, stateFile, config, showDiags = c.showFromPath(path) 133 diags = diags.Append(showDiags) 134 if showDiags.HasErrors() { 135 return plan, jsonPlan, stateFile, config, schemas, diags 136 } 137 } 138 139 // Get schemas, if possible 140 if config != nil || stateFile != nil { 141 schemas, diags = c.MaybeGetSchemas(stateFile.State, config) 142 if diags.HasErrors() { 143 return plan, jsonPlan, stateFile, config, schemas, diags 144 } 145 } 146 147 return plan, jsonPlan, stateFile, config, schemas, diags 148 } 149 func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Diagnostics) { 150 var diags tfdiags.Diagnostics 151 152 // Load the backend 153 b, backendDiags := c.Backend(nil) 154 diags = diags.Append(backendDiags) 155 if backendDiags.HasErrors() { 156 return nil, diags 157 } 158 c.ignoreRemoteVersionConflict(b) 159 160 // Load the workspace 161 workspace, err := c.Workspace() 162 if err != nil { 163 diags = diags.Append(fmt.Errorf("error selecting workspace: %s", err)) 164 return nil, diags 165 } 166 167 // Get the latest state snapshot from the backend for the current workspace 168 stateFile, stateErr := getStateFromBackend(b, workspace) 169 if stateErr != nil { 170 diags = diags.Append(stateErr) 171 return nil, diags 172 } 173 174 return stateFile, diags 175 } 176 177 func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, tfdiags.Diagnostics) { 178 var diags tfdiags.Diagnostics 179 var planErr, stateErr error 180 var plan *plans.Plan 181 var jsonPlan *cloudplan.RemotePlanJSON 182 var stateFile *statefile.File 183 var config *configs.Config 184 185 // Path might be a local plan file, a bookmark to a saved cloud plan, or a 186 // state file. First, try to get a plan and associated data from a local 187 // plan file. If that fails, try to get a json plan from the path argument. 188 // If that fails, try to get the statefile from the path argument. 189 plan, jsonPlan, stateFile, config, planErr = c.getPlanFromPath(path) 190 if planErr != nil { 191 stateFile, stateErr = getStateFromPath(path) 192 if stateErr != nil { 193 // To avoid spamming the user with irrelevant errors, first check to 194 // see if one of our errors happens to know for a fact what file 195 // type we were dealing with. If so, then we can ignore the other 196 // ones (which are likely to be something unhelpful like "not a 197 // valid zip file"). If not, we can fall back to dumping whatever 198 // we've got. 199 var unLocal *planfile.ErrUnusableLocalPlan 200 var unState *statefile.ErrUnusableState 201 var unMisc *errUnusableDataMisc 202 if errors.As(planErr, &unLocal) { 203 diags = diags.Append( 204 tfdiags.Sourceless( 205 tfdiags.Error, 206 "Couldn't show local plan", 207 fmt.Sprintf("Plan read error: %s", unLocal), 208 ), 209 ) 210 } else if errors.As(planErr, &unMisc) { 211 diags = diags.Append( 212 tfdiags.Sourceless( 213 tfdiags.Error, 214 fmt.Sprintf("Couldn't show %s", unMisc.kind), 215 fmt.Sprintf("Plan read error: %s", unMisc), 216 ), 217 ) 218 } else if errors.As(stateErr, &unState) { 219 diags = diags.Append( 220 tfdiags.Sourceless( 221 tfdiags.Error, 222 "Couldn't show state file", 223 fmt.Sprintf("Plan read error: %s", unState), 224 ), 225 ) 226 } else if errors.As(stateErr, &unMisc) { 227 diags = diags.Append( 228 tfdiags.Sourceless( 229 tfdiags.Error, 230 fmt.Sprintf("Couldn't show %s", unMisc.kind), 231 fmt.Sprintf("Plan read error: %s", unMisc), 232 ), 233 ) 234 } else { 235 // Ok, give up and show the really big error 236 diags = diags.Append( 237 tfdiags.Sourceless( 238 tfdiags.Error, 239 "Failed to read the given file as a state or plan file", 240 fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr), 241 ), 242 ) 243 } 244 245 return nil, nil, nil, nil, diags 246 } 247 } 248 return plan, jsonPlan, stateFile, config, diags 249 } 250 251 // getPlanFromPath returns a plan, json plan, statefile, and config if the 252 // user-supplied path points to either a local or cloud plan file. Note that 253 // some of the return values will be nil no matter what; local plan files do not 254 // yield a json plan, and cloud plans do not yield real plan/state/config 255 // structs. An error generally suggests that the given path is either a 256 // directory or a statefile. 257 func (c *ShowCommand) getPlanFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, error) { 258 var err error 259 var plan *plans.Plan 260 var jsonPlan *cloudplan.RemotePlanJSON 261 var stateFile *statefile.File 262 var config *configs.Config 263 264 pf, err := planfile.OpenWrapped(path) 265 if err != nil { 266 return nil, nil, nil, nil, err 267 } 268 269 if lp, ok := pf.Local(); ok { 270 plan, stateFile, config, err = getDataFromPlanfileReader(lp) 271 } else if cp, ok := pf.Cloud(); ok { 272 redacted := c.viewType != arguments.ViewJSON 273 jsonPlan, err = c.getDataFromCloudPlan(cp, redacted) 274 } 275 276 return plan, jsonPlan, stateFile, config, err 277 } 278 279 func (c *ShowCommand) getDataFromCloudPlan(plan *cloudplan.SavedPlanBookmark, redacted bool) (*cloudplan.RemotePlanJSON, error) { 280 // Set up the backend 281 b, backendDiags := c.Backend(nil) 282 if backendDiags.HasErrors() { 283 return nil, errUnusable(backendDiags.Err(), "cloud plan") 284 } 285 // Cloud plans only work if we're cloud. 286 cl, ok := b.(*cloud.Cloud) 287 if !ok { 288 return nil, errUnusable(fmt.Errorf("can't show a saved cloud plan unless the current root module is connected to Terraform Cloud"), "cloud plan") 289 } 290 291 result, err := cl.ShowPlanForRun(context.Background(), plan.RunID, plan.Hostname, redacted) 292 if err != nil { 293 err = errUnusable(err, "cloud plan") 294 } 295 return result, err 296 } 297 298 // getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file. 299 func getDataFromPlanfileReader(planReader *planfile.Reader) (*plans.Plan, *statefile.File, *configs.Config, error) { 300 // Get plan 301 plan, err := planReader.ReadPlan() 302 if err != nil { 303 return nil, nil, nil, err 304 } 305 306 // Get statefile 307 stateFile, err := planReader.ReadStateFile() 308 if err != nil { 309 return nil, nil, nil, err 310 } 311 312 // Get config 313 config, diags := planReader.ReadConfig() 314 if diags.HasErrors() { 315 return nil, nil, nil, errUnusable(diags.Err(), "local plan") 316 } 317 318 return plan, stateFile, config, err 319 } 320 321 // getStateFromPath returns a statefile if the user-supplied path points to a statefile. 322 func getStateFromPath(path string) (*statefile.File, error) { 323 file, err := os.Open(path) 324 if err != nil { 325 return nil, fmt.Errorf("Error loading statefile: %w", err) 326 } 327 defer file.Close() 328 329 var stateFile *statefile.File 330 stateFile, err = statefile.Read(file) 331 if err != nil { 332 return nil, fmt.Errorf("Error reading %s as a statefile: %w", path, err) 333 } 334 return stateFile, nil 335 } 336 337 // getStateFromBackend returns the State for the current workspace, if available. 338 func getStateFromBackend(b backend.Backend, workspace string) (*statefile.File, error) { 339 // Get the state store for the given workspace 340 stateStore, err := b.StateMgr(workspace) 341 if err != nil { 342 return nil, fmt.Errorf("Failed to load state manager: %w", err) 343 } 344 345 // Refresh the state store with the latest state snapshot from persistent storage 346 if err := stateStore.RefreshState(); err != nil { 347 return nil, fmt.Errorf("Failed to load state: %w", err) 348 } 349 350 // Get the latest state snapshot and return it 351 stateFile := statemgr.Export(stateStore) 352 return stateFile, nil 353 }