github.com/opentofu/opentofu@v1.7.1/internal/cloud/backend_test.go (about) 1 // Copyright (c) The OpenTofu Authors 2 // SPDX-License-Identifier: MPL-2.0 3 // Copyright (c) 2023 HashiCorp, Inc. 4 // SPDX-License-Identifier: MPL-2.0 5 6 package cloud 7 8 import ( 9 "context" 10 "fmt" 11 "net/http" 12 "strings" 13 "testing" 14 15 tfe "github.com/hashicorp/go-tfe" 16 version "github.com/hashicorp/go-version" 17 "github.com/opentofu/opentofu/internal/backend" 18 "github.com/opentofu/opentofu/internal/encryption" 19 "github.com/opentofu/opentofu/internal/tfdiags" 20 tfversion "github.com/opentofu/opentofu/version" 21 "github.com/zclconf/go-cty/cty" 22 23 backendLocal "github.com/opentofu/opentofu/internal/backend/local" 24 ) 25 26 func TestCloud(t *testing.T) { 27 var _ backend.Enhanced = New(nil, encryption.StateEncryptionDisabled()) 28 var _ backend.CLI = New(nil, encryption.StateEncryptionDisabled()) 29 } 30 31 func TestCloud_backendWithName(t *testing.T) { 32 b, bCleanup := testBackendWithName(t) 33 defer bCleanup() 34 35 workspaces, err := b.Workspaces() 36 if err != nil { 37 t.Fatalf("error: %v", err) 38 } 39 40 if len(workspaces) != 1 || workspaces[0] != testBackendSingleWorkspaceName { 41 t.Fatalf("should only have a single configured workspace matching the configured 'name' strategy, but got: %#v", workspaces) 42 } 43 44 if _, err := b.StateMgr("foo"); err != backend.ErrWorkspacesNotSupported { 45 t.Fatalf("expected fetching a state which is NOT the single configured workspace to have an ErrWorkspacesNotSupported error, but got: %v", err) 46 } 47 48 if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported { 49 t.Fatalf("expected deleting the single configured workspace name to result in an error, but got: %v", err) 50 } 51 52 if err := b.DeleteWorkspace("foo", true); err != backend.ErrWorkspacesNotSupported { 53 t.Fatalf("expected deleting a workspace which is NOT the configured workspace name to result in an error, but got: %v", err) 54 } 55 } 56 57 func TestCloud_backendWithoutHost(t *testing.T) { 58 s := testServer(t) 59 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 60 61 obj := cty.ObjectVal(map[string]cty.Value{ 62 "hostname": cty.NullVal(cty.String), 63 "organization": cty.StringVal("hashicorp"), 64 "token": cty.NullVal(cty.String), 65 "workspaces": cty.ObjectVal(map[string]cty.Value{ 66 "name": cty.StringVal(testBackendSingleWorkspaceName), 67 "tags": cty.NullVal(cty.Set(cty.String)), 68 "project": cty.NullVal(cty.String), 69 }), 70 }) 71 72 // Configure the backend so the client is created. 73 newObj, valDiags := b.PrepareConfig(obj) 74 if len(valDiags) != 0 { 75 t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings()) 76 } 77 obj = newObj 78 79 confDiags := b.Configure(obj) 80 81 if !confDiags.HasErrors() { 82 t.Fatalf("testBackend: backend.Configure() should have failed") 83 } 84 85 if !strings.Contains(confDiags.Err().Error(), "Hostname is required for the cloud backend") { 86 t.Fatalf("testBackend: backend.Configure() should have failed with missing hostname error") 87 } 88 } 89 90 func TestCloud_backendWithTags(t *testing.T) { 91 b, bCleanup := testBackendWithTags(t) 92 defer bCleanup() 93 94 backend.TestBackendStates(t, b) 95 96 // Test pagination works 97 for i := 0; i < 25; i++ { 98 _, err := b.StateMgr(fmt.Sprintf("foo-%d", i+1)) 99 if err != nil { 100 t.Fatalf("error: %s", err) 101 } 102 } 103 104 workspaces, err := b.Workspaces() 105 if err != nil { 106 t.Fatalf("error: %s", err) 107 } 108 actual := len(workspaces) 109 if actual != 26 { 110 t.Errorf("expected 26 workspaces (over one standard paginated response), got %d", actual) 111 } 112 } 113 114 func TestCloud_PrepareConfig(t *testing.T) { 115 cases := map[string]struct { 116 config cty.Value 117 expectedErr string 118 }{ 119 "null organization": { 120 config: cty.ObjectVal(map[string]cty.Value{ 121 "organization": cty.NullVal(cty.String), 122 "workspaces": cty.ObjectVal(map[string]cty.Value{ 123 "name": cty.StringVal("prod"), 124 "tags": cty.NullVal(cty.Set(cty.String)), 125 "project": cty.NullVal(cty.String), 126 }), 127 }), 128 expectedErr: `Invalid or missing required argument: "organization" must be set in the cloud configuration or as an environment variable: TF_CLOUD_ORGANIZATION.`, 129 }, 130 "null workspace": { 131 config: cty.ObjectVal(map[string]cty.Value{ 132 "organization": cty.StringVal("org"), 133 "workspaces": cty.NullVal(cty.String), 134 }), 135 expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, 136 }, 137 "workspace: empty tags, name": { 138 config: cty.ObjectVal(map[string]cty.Value{ 139 "organization": cty.StringVal("org"), 140 "workspaces": cty.ObjectVal(map[string]cty.Value{ 141 "name": cty.NullVal(cty.String), 142 "tags": cty.NullVal(cty.Set(cty.String)), 143 "project": cty.NullVal(cty.String), 144 }), 145 }), 146 expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`, 147 }, 148 "workspace: name present": { 149 config: cty.ObjectVal(map[string]cty.Value{ 150 "organization": cty.StringVal("org"), 151 "workspaces": cty.ObjectVal(map[string]cty.Value{ 152 "name": cty.StringVal("prod"), 153 "tags": cty.NullVal(cty.Set(cty.String)), 154 "project": cty.NullVal(cty.String), 155 }), 156 }), 157 expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, 158 }, 159 "workspace: name and tags present": { 160 config: cty.ObjectVal(map[string]cty.Value{ 161 "organization": cty.StringVal("org"), 162 "workspaces": cty.ObjectVal(map[string]cty.Value{ 163 "name": cty.StringVal("prod"), 164 "tags": cty.SetVal( 165 []cty.Value{ 166 cty.StringVal("billing"), 167 }, 168 ), 169 "project": cty.NullVal(cty.String), 170 }), 171 }), 172 expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`, 173 }, 174 } 175 176 for name, tc := range cases { 177 t.Run(name, func(t *testing.T) { 178 s := testServer(t) 179 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 180 181 // Validate 182 _, valDiags := b.PrepareConfig(tc.config) 183 if valDiags.Err() != nil && tc.expectedErr != "" { 184 actualErr := valDiags.Err().Error() 185 if !strings.Contains(actualErr, tc.expectedErr) { 186 t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) 187 } 188 } 189 }) 190 } 191 } 192 193 func TestCloud_PrepareConfigWithEnvVars(t *testing.T) { 194 cases := map[string]struct { 195 config cty.Value 196 vars map[string]string 197 expectedErr string 198 }{ 199 "with no organization": { 200 config: cty.ObjectVal(map[string]cty.Value{ 201 "organization": cty.NullVal(cty.String), 202 "workspaces": cty.ObjectVal(map[string]cty.Value{ 203 "name": cty.StringVal("prod"), 204 "tags": cty.NullVal(cty.Set(cty.String)), 205 "project": cty.NullVal(cty.String), 206 }), 207 }), 208 vars: map[string]string{ 209 "TF_CLOUD_ORGANIZATION": "example-org", 210 }, 211 }, 212 "with no organization attribute or env var": { 213 config: cty.ObjectVal(map[string]cty.Value{ 214 "organization": cty.NullVal(cty.String), 215 "workspaces": cty.ObjectVal(map[string]cty.Value{ 216 "name": cty.StringVal("prod"), 217 "tags": cty.NullVal(cty.Set(cty.String)), 218 "project": cty.NullVal(cty.String), 219 }), 220 }), 221 vars: map[string]string{}, 222 expectedErr: `Invalid or missing required argument: "organization" must be set in the cloud configuration or as an environment variable: TF_CLOUD_ORGANIZATION.`, 223 }, 224 "null workspace": { 225 config: cty.ObjectVal(map[string]cty.Value{ 226 "organization": cty.StringVal("hashicorp"), 227 "workspaces": cty.NullVal(cty.String), 228 }), 229 vars: map[string]string{ 230 "TF_WORKSPACE": "my-workspace", 231 }, 232 }, 233 "organization and workspace and project env var": { 234 config: cty.ObjectVal(map[string]cty.Value{ 235 "organization": cty.NullVal(cty.String), 236 "workspaces": cty.NullVal(cty.String), 237 }), 238 vars: map[string]string{ 239 "TF_CLOUD_ORGANIZATION": "hashicorp", 240 "TF_WORKSPACE": "my-workspace", 241 "TF_CLOUD_PROJECT": "example-project", 242 }, 243 }, 244 "with no project": { 245 config: cty.ObjectVal(map[string]cty.Value{ 246 "organization": cty.StringVal("organization"), 247 "workspaces": cty.ObjectVal(map[string]cty.Value{ 248 "name": cty.StringVal("prod"), 249 "tags": cty.NullVal(cty.Set(cty.String)), 250 "project": cty.NullVal(cty.String), 251 }), 252 }), 253 }, 254 "with null project": { 255 config: cty.ObjectVal(map[string]cty.Value{ 256 "organization": cty.StringVal("organization"), 257 "workspaces": cty.ObjectVal(map[string]cty.Value{ 258 "name": cty.StringVal("prod"), 259 "tags": cty.NullVal(cty.Set(cty.String)), 260 "project": cty.NullVal(cty.String), 261 }), 262 }), 263 vars: map[string]string{ 264 "TF_CLOUD_PROJECT": "example-project", 265 }, 266 }, 267 "with project env var overwrite config value": { 268 config: cty.ObjectVal(map[string]cty.Value{ 269 "organization": cty.StringVal("organization"), 270 "workspaces": cty.ObjectVal(map[string]cty.Value{ 271 "name": cty.StringVal("prod"), 272 "tags": cty.NullVal(cty.Set(cty.String)), 273 "project": cty.StringVal("project-name"), 274 }), 275 }), 276 vars: map[string]string{ 277 "TF_CLOUD_PROJECT": "example-project", 278 }, 279 }, 280 "with workspace defined by tags overwritten by TF_WORKSPACE": { 281 // see https://github.com/opentofu/opentofu/issues/814 for context 282 config: cty.ObjectVal(map[string]cty.Value{ 283 "hostname": cty.StringVal("foo"), 284 "organization": cty.StringVal("bar"), 285 "workspaces": cty.ObjectVal(map[string]cty.Value{ 286 "name": cty.NullVal(cty.String), 287 "project": cty.NullVal(cty.String), 288 "tags": cty.SetVal([]cty.Value{cty.StringVal("baz"), cty.StringVal("qux")}), 289 }), 290 }), 291 vars: map[string]string{ 292 "TF_WORKSPACE": "qux", 293 }, 294 }, 295 "with TF_WORKSPACE value outside of the tags set": { 296 // see https://github.com/opentofu/opentofu/issues/814 for context 297 config: cty.ObjectVal(map[string]cty.Value{ 298 "hostname": cty.StringVal("foo"), 299 "organization": cty.StringVal("bar"), 300 "workspaces": cty.ObjectVal(map[string]cty.Value{ 301 "name": cty.NullVal(cty.String), 302 "project": cty.NullVal(cty.String), 303 "tags": cty.SetVal([]cty.Value{cty.StringVal("baz"), cty.StringVal("qux")}), 304 }), 305 }), 306 vars: map[string]string{ 307 "TF_WORKSPACE": "quxx", 308 }, 309 expectedErr: `Invalid workspaces configuration: The workspace defined using the environment variable "TF_WORKSPACE" does not belong to "tags".`, 310 }, 311 "with workspace block w/o attributes, TF_WORKSPACE defined": { 312 config: cty.ObjectVal(map[string]cty.Value{ 313 "hostname": cty.StringVal("foo"), 314 "organization": cty.StringVal("bar"), 315 "workspaces": cty.ObjectVal(map[string]cty.Value{ 316 "name": cty.NullVal(cty.String), 317 "tags": cty.NullVal(cty.Set(cty.String)), 318 "project": cty.NullVal(cty.String), 319 }), 320 }), 321 vars: map[string]string{ 322 "TF_WORKSPACE": "qux", 323 }, 324 }, 325 } 326 327 for name, tc := range cases { 328 t.Run(name, func(t *testing.T) { 329 s := testServer(t) 330 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 331 332 for k, v := range tc.vars { 333 t.Setenv(k, v) 334 } 335 336 _, valDiags := b.PrepareConfig(tc.config) 337 if (valDiags.Err() == nil) != (tc.expectedErr == "") { 338 t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) 339 } 340 if valDiags.Err() != nil { 341 if !strings.Contains(valDiags.Err().Error(), tc.expectedErr) { 342 t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) 343 } 344 } 345 }) 346 } 347 } 348 349 func TestCloud_config(t *testing.T) { 350 cases := map[string]struct { 351 config cty.Value 352 confErr string 353 valErr string 354 }{ 355 "with_a_non_tfe_host": { 356 config: cty.ObjectVal(map[string]cty.Value{ 357 "hostname": cty.StringVal("nontfe.local"), 358 "organization": cty.StringVal("hashicorp"), 359 "token": cty.NullVal(cty.String), 360 "workspaces": cty.ObjectVal(map[string]cty.Value{ 361 "name": cty.StringVal("prod"), 362 "tags": cty.NullVal(cty.Set(cty.String)), 363 "project": cty.NullVal(cty.String), 364 }), 365 }), 366 confErr: "Host nontfe.local does not provide a tfe service", 367 }, 368 // localhost advertises TFE services, but has no token in the credentials 369 "without_a_token": { 370 config: cty.ObjectVal(map[string]cty.Value{ 371 "hostname": cty.StringVal("localhost"), 372 "organization": cty.StringVal("hashicorp"), 373 "token": cty.NullVal(cty.String), 374 "workspaces": cty.ObjectVal(map[string]cty.Value{ 375 "name": cty.StringVal("prod"), 376 "tags": cty.NullVal(cty.Set(cty.String)), 377 "project": cty.NullVal(cty.String), 378 }), 379 }), 380 confErr: "tofu login localhost", 381 }, 382 "with_tags": { 383 config: cty.ObjectVal(map[string]cty.Value{ 384 "hostname": cty.NullVal(cty.String), 385 "organization": cty.StringVal("hashicorp"), 386 "token": cty.NullVal(cty.String), 387 "workspaces": cty.ObjectVal(map[string]cty.Value{ 388 "name": cty.NullVal(cty.String), 389 "tags": cty.SetVal( 390 []cty.Value{ 391 cty.StringVal("billing"), 392 }, 393 ), 394 "project": cty.NullVal(cty.String), 395 }), 396 }), 397 }, 398 "with_a_name": { 399 config: cty.ObjectVal(map[string]cty.Value{ 400 "hostname": cty.NullVal(cty.String), 401 "organization": cty.StringVal("hashicorp"), 402 "token": cty.NullVal(cty.String), 403 "workspaces": cty.ObjectVal(map[string]cty.Value{ 404 "name": cty.StringVal("prod"), 405 "tags": cty.NullVal(cty.Set(cty.String)), 406 "project": cty.NullVal(cty.String), 407 }), 408 }), 409 }, 410 "without_a_name_tags": { 411 config: cty.ObjectVal(map[string]cty.Value{ 412 "hostname": cty.NullVal(cty.String), 413 "organization": cty.StringVal("hashicorp"), 414 "token": cty.NullVal(cty.String), 415 "workspaces": cty.ObjectVal(map[string]cty.Value{ 416 "name": cty.NullVal(cty.String), 417 "tags": cty.NullVal(cty.Set(cty.String)), 418 "project": cty.NullVal(cty.String), 419 }), 420 }), 421 valErr: `Missing workspace mapping strategy.`, 422 }, 423 "with_both_a_name_and_tags": { 424 config: cty.ObjectVal(map[string]cty.Value{ 425 "hostname": cty.NullVal(cty.String), 426 "organization": cty.StringVal("hashicorp"), 427 "token": cty.NullVal(cty.String), 428 "workspaces": cty.ObjectVal(map[string]cty.Value{ 429 "name": cty.StringVal("prod"), 430 "tags": cty.SetVal( 431 []cty.Value{ 432 cty.StringVal("billing"), 433 }, 434 ), 435 "project": cty.NullVal(cty.String), 436 }), 437 }), 438 valErr: `Only one of workspace "tags" or "name" is allowed.`, 439 }, 440 "null config": { 441 config: cty.NullVal(cty.EmptyObject), 442 }, 443 } 444 445 for name, tc := range cases { 446 t.Run(name, func(t *testing.T) { 447 b, cleanup := testUnconfiguredBackend(t) 448 t.Cleanup(cleanup) 449 450 // Validate 451 _, valDiags := b.PrepareConfig(tc.config) 452 if (valDiags.Err() != nil || tc.valErr != "") && 453 (valDiags.Err() == nil || !strings.Contains(valDiags.Err().Error(), tc.valErr)) { 454 t.Fatalf("unexpected validation result: %v", valDiags.Err()) 455 } 456 457 // Configure 458 confDiags := b.Configure(tc.config) 459 if (confDiags.Err() != nil || tc.confErr != "") && 460 (confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.confErr)) { 461 t.Fatalf("unexpected configure result: %v", confDiags.Err()) 462 } 463 }) 464 } 465 } 466 467 func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) { 468 config := cty.ObjectVal(map[string]cty.Value{ 469 "hostname": cty.StringVal(tfeHost), 470 "organization": cty.StringVal("hashicorp"), 471 "token": cty.NullVal(cty.String), 472 "workspaces": cty.ObjectVal(map[string]cty.Value{ 473 "name": cty.NullVal(cty.String), 474 "tags": cty.SetVal( 475 []cty.Value{ 476 cty.StringVal("billing"), 477 }, 478 ), 479 "project": cty.NullVal(cty.String), 480 }), 481 }) 482 483 handlers := map[string]func(http.ResponseWriter, *http.Request){ 484 "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { 485 w.Header().Set("Content-Type", "application/json") 486 w.Header().Set("TFP-API-Version", "2.4") 487 }, 488 } 489 s := testServerWithHandlers(handlers) 490 491 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 492 493 confDiags := b.Configure(config) 494 if confDiags.Err() == nil { 495 t.Fatalf("expected configure to error") 496 } 497 498 expected := `The 'cloud' option is not supported with this version of the cloud backend.` 499 if !strings.Contains(confDiags.Err().Error(), expected) { 500 t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) 501 } 502 } 503 504 func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) { 505 config := cty.ObjectVal(map[string]cty.Value{ 506 "hostname": cty.StringVal(tfeHost), 507 "organization": cty.StringVal("hashicorp"), 508 "token": cty.NullVal(cty.String), 509 "workspaces": cty.ObjectVal(map[string]cty.Value{ 510 "name": cty.NullVal(cty.String), 511 "tags": cty.SetVal( 512 []cty.Value{ 513 cty.StringVal("billing"), 514 }, 515 ), 516 "project": cty.NullVal(cty.String), 517 }), 518 }) 519 520 handlers := map[string]func(http.ResponseWriter, *http.Request){ 521 "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { 522 w.Header().Set("Content-Type", "application/json") 523 w.Header().Set("TFP-API-Version", "2.4") 524 }, 525 } 526 s := testServerWithHandlers(handlers) 527 528 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 529 b.runningInAutomation = true 530 531 confDiags := b.Configure(config) 532 if confDiags.Err() == nil { 533 t.Fatalf("expected configure to error") 534 } 535 536 expected := `This version of cloud backend does not support the state mechanism 537 attempting to be used by the platform. This should never happen.` 538 if !strings.Contains(confDiags.Err().Error(), expected) { 539 t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error()) 540 } 541 } 542 543 func TestCloud_setUnavailableTerraformVersion(t *testing.T) { 544 // go-tfe returns an error IRL if you try to set a Terraform version that's 545 // not available in your TFC instance. To test this, tfe_client_mock errors if 546 // you try to set any Terraform version for this specific workspace name. 547 workspaceName := "unavailable-terraform-version" 548 549 config := cty.ObjectVal(map[string]cty.Value{ 550 "hostname": cty.StringVal(tfeHost), 551 "organization": cty.StringVal("hashicorp"), 552 "token": cty.NullVal(cty.String), 553 "workspaces": cty.ObjectVal(map[string]cty.Value{ 554 "name": cty.NullVal(cty.String), 555 "tags": cty.SetVal( 556 []cty.Value{ 557 cty.StringVal("sometag"), 558 }, 559 ), 560 "project": cty.NullVal(cty.String), 561 }), 562 }) 563 564 b, _, bCleanup := testBackend(t, config, nil) 565 defer bCleanup() 566 567 // Make sure the workspace doesn't exist yet -- otherwise, we can't test what 568 // happens when a workspace gets created. This is why we can't use "name" in 569 // the backend config above, btw: if you do, testBackend() creates the default 570 // workspace before we get a chance to do anything. 571 _, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) 572 if err != tfe.ErrResourceNotFound { 573 t.Fatalf("the workspace we were about to try and create (%s/%s) already exists in the mocks somehow, so this test isn't trustworthy anymore", b.organization, workspaceName) 574 } 575 576 _, err = b.StateMgr(workspaceName) 577 if err != nil { 578 t.Fatalf("expected no error from StateMgr, despite not being able to set remote TF version: %#v", err) 579 } 580 // Make sure the workspace was created: 581 workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName) 582 if err != nil { 583 t.Fatalf("b.StateMgr() didn't actually create the desired workspace") 584 } 585 // Make sure our mocks still error as expected, using the same update function b.StateMgr() would call: 586 _, err = b.client.Workspaces.UpdateByID( 587 context.Background(), 588 workspace.ID, 589 tfe.WorkspaceUpdateOptions{TerraformVersion: tfe.String("1.1.0")}, 590 ) 591 if err == nil { 592 t.Fatalf("the mocks aren't emulating a nonexistent remote TF version correctly, so this test isn't trustworthy anymore") 593 } 594 } 595 596 func TestCloud_setConfigurationFieldsHappyPath(t *testing.T) { 597 cases := map[string]struct { 598 obj cty.Value 599 envVars map[string]string 600 expectedHostname string 601 expectedOrganization string 602 expectedWorkspaceName string 603 expectedProjectName string 604 expectedWorkspaceTags map[string]struct{} 605 expectedForceLocal bool 606 }{ 607 "with hostname, organization and tags set": { 608 obj: cty.ObjectVal(map[string]cty.Value{ 609 "organization": cty.StringVal("opentofu"), 610 "hostname": cty.StringVal("opentofu.org"), 611 "workspaces": cty.ObjectVal(map[string]cty.Value{ 612 "name": cty.NullVal(cty.String), 613 "tags": cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), 614 "project": cty.NullVal(cty.String), 615 }), 616 }), 617 expectedHostname: "opentofu.org", 618 expectedOrganization: "opentofu", 619 expectedWorkspaceTags: map[string]struct{}{"foo": {}, "bar": {}}, 620 }, 621 "with hostname and workspace name set": { 622 obj: cty.ObjectVal(map[string]cty.Value{ 623 "organization": cty.NullVal(cty.String), 624 "hostname": cty.StringVal("opentofu.org"), 625 "workspaces": cty.ObjectVal(map[string]cty.Value{ 626 "name": cty.StringVal("prod"), 627 "tags": cty.NullVal(cty.Set(cty.String)), 628 "project": cty.NullVal(cty.String), 629 }), 630 }), 631 expectedHostname: "opentofu.org", 632 expectedWorkspaceName: "prod", 633 }, 634 "with hostname and project name set": { 635 obj: cty.ObjectVal(map[string]cty.Value{ 636 "organization": cty.NullVal(cty.String), 637 "hostname": cty.StringVal("opentofu.org"), 638 "workspaces": cty.ObjectVal(map[string]cty.Value{ 639 "name": cty.NullVal(cty.String), 640 "tags": cty.NullVal(cty.Set(cty.String)), 641 "project": cty.StringVal("my-project"), 642 }), 643 }), 644 expectedHostname: "opentofu.org", 645 expectedProjectName: "my-project", 646 }, 647 "with hostname and force local set (env var)": { 648 obj: cty.ObjectVal(map[string]cty.Value{ 649 "organization": cty.NullVal(cty.String), 650 "hostname": cty.StringVal("opentofu.org"), 651 "workspaces": cty.ObjectVal(map[string]cty.Value{ 652 "name": cty.NullVal(cty.String), 653 "tags": cty.NullVal(cty.Set(cty.String)), 654 "project": cty.NullVal(cty.String), 655 }), 656 }), 657 expectedHostname: "opentofu.org", 658 envVars: map[string]string{ 659 "TF_FORCE_LOCAL_BACKEND": "1", 660 }, 661 expectedForceLocal: true, 662 }, 663 "with hostname and workspace tags set, and tags overwritten by TF_WORKSPACE": { 664 // see: https://github.com/opentofu/opentofu/issues/814 665 obj: cty.ObjectVal(map[string]cty.Value{ 666 "organization": cty.NullVal(cty.String), 667 "hostname": cty.StringVal("opentofu.org"), 668 "workspaces": cty.ObjectVal(map[string]cty.Value{ 669 "name": cty.NullVal(cty.String), 670 "tags": cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), 671 "project": cty.NullVal(cty.String), 672 }), 673 }), 674 envVars: map[string]string{ 675 "TF_WORKSPACE": "foo", 676 }, 677 expectedHostname: "opentofu.org", 678 expectedWorkspaceName: "foo", 679 expectedWorkspaceTags: nil, 680 }, 681 "with hostname and workspace name set, and TF_WORKSPACE specified": { 682 obj: cty.ObjectVal(map[string]cty.Value{ 683 "organization": cty.NullVal(cty.String), 684 "hostname": cty.StringVal("opentofu.org"), 685 "workspaces": cty.ObjectVal(map[string]cty.Value{ 686 "name": cty.StringVal("old"), 687 "tags": cty.NullVal(cty.Set(cty.String)), 688 "project": cty.NullVal(cty.String), 689 }), 690 }), 691 envVars: map[string]string{ 692 "TF_WORKSPACE": "new", 693 }, 694 expectedHostname: "opentofu.org", 695 expectedWorkspaceName: "old", 696 expectedWorkspaceTags: nil, 697 }, 698 "with hostname and project set, and project overwritten by TF_CLOUD_PROJECT": { 699 obj: cty.ObjectVal(map[string]cty.Value{ 700 "organization": cty.NullVal(cty.String), 701 "hostname": cty.StringVal("opentofu.org"), 702 "workspaces": cty.ObjectVal(map[string]cty.Value{ 703 "name": cty.NullVal(cty.String), 704 "tags": cty.NullVal(cty.Set(cty.String)), 705 "project": cty.StringVal("old"), 706 }), 707 }), 708 envVars: map[string]string{ 709 "TF_CLOUD_PROJECT": "new", 710 }, 711 expectedHostname: "opentofu.org", 712 expectedProjectName: "old", 713 }, 714 "with hostname set, and project specified by TF_CLOUD_PROJECT": { 715 obj: cty.ObjectVal(map[string]cty.Value{ 716 "organization": cty.NullVal(cty.String), 717 "hostname": cty.StringVal("opentofu.org"), 718 "workspaces": cty.ObjectVal(map[string]cty.Value{ 719 "name": cty.NullVal(cty.String), 720 "tags": cty.NullVal(cty.Set(cty.String)), 721 "project": cty.NullVal(cty.String), 722 }), 723 }), 724 envVars: map[string]string{ 725 "TF_CLOUD_PROJECT": "new", 726 }, 727 expectedHostname: "opentofu.org", 728 expectedProjectName: "new", 729 }, 730 "with hostname set, and organization specified by TF_CLOUD_ORGANIZATION": { 731 obj: cty.ObjectVal(map[string]cty.Value{ 732 "hostname": cty.StringVal("opentofu.org"), 733 "token": cty.NullVal(cty.String), 734 "organization": cty.NullVal(cty.String), 735 "workspaces": cty.ObjectVal(map[string]cty.Value{ 736 "name": cty.NullVal(cty.String), 737 "tags": cty.NullVal(cty.Set(cty.String)), 738 "project": cty.NullVal(cty.String), 739 }), 740 }), 741 envVars: map[string]string{ 742 "TF_CLOUD_ORGANIZATION": "my-org", 743 }, 744 expectedHostname: "opentofu.org", 745 expectedOrganization: "my-org", 746 }, 747 "with hostname set, and TF_CLOUD_HOSTNAME defined": { 748 obj: cty.ObjectVal(map[string]cty.Value{ 749 "hostname": cty.StringVal("opentofu.org"), 750 "token": cty.NullVal(cty.String), 751 "organization": cty.NullVal(cty.String), 752 "workspaces": cty.ObjectVal(map[string]cty.Value{ 753 "name": cty.NullVal(cty.String), 754 "tags": cty.NullVal(cty.Set(cty.String)), 755 "project": cty.NullVal(cty.String), 756 }), 757 }), 758 envVars: map[string]string{ 759 "TF_CLOUD_HOSTNAME": "new", 760 }, 761 expectedHostname: "opentofu.org", 762 }, 763 "with hostname specified by TF_CLOUD_HOSTNAME": { 764 obj: cty.ObjectVal(map[string]cty.Value{ 765 "hostname": cty.NullVal(cty.String), 766 "token": cty.NullVal(cty.String), 767 "organization": cty.NullVal(cty.String), 768 "workspaces": cty.ObjectVal(map[string]cty.Value{ 769 "name": cty.NullVal(cty.String), 770 "tags": cty.NullVal(cty.Set(cty.String)), 771 "project": cty.NullVal(cty.String), 772 }), 773 }), 774 envVars: map[string]string{ 775 "TF_CLOUD_HOSTNAME": "new", 776 }, 777 expectedHostname: "new", 778 }, 779 "with nothing set, all configured using env vars": { 780 obj: cty.ObjectVal(map[string]cty.Value{ 781 "hostname": cty.NullVal(cty.String), 782 "organization": cty.NullVal(cty.String), 783 "workspaces": cty.NullVal(cty.String), 784 }), 785 envVars: map[string]string{ 786 "TF_CLOUD_HOSTNAME": "opentofu.org", 787 "TF_CLOUD_ORGANIZATION": "opentofu", 788 "TF_WORKSPACE": "foo", 789 "TF_CLOUD_PROJECT": "bar", 790 }, 791 expectedHostname: "opentofu.org", 792 expectedOrganization: "opentofu", 793 expectedWorkspaceName: "foo", 794 expectedProjectName: "bar", 795 }, 796 } 797 798 for name, tc := range cases { 799 t.Run(name, func(t *testing.T) { 800 for k, v := range tc.envVars { 801 t.Setenv(k, v) 802 } 803 804 b := &Cloud{} 805 errDiags := b.setConfigurationFields(tc.obj) 806 807 if errDiags.HasErrors() { 808 t.Fatalf("%s: unexpected validation result: %v", name, errDiags.Err()) 809 } 810 if b.hostname != tc.expectedHostname { 811 t.Fatalf("%s: expected hostname %s to match configured hostname %s", name, b.hostname, tc.expectedHostname) 812 } 813 if b.organization != tc.expectedOrganization { 814 t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganization) 815 } 816 if b.WorkspaceMapping.Name != tc.expectedWorkspaceName { 817 t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName) 818 } 819 if b.forceLocal != tc.expectedForceLocal { 820 t.Fatalf("%s: expected force local backend to be set to %v", name, tc.expectedForceLocal) 821 } 822 if b.WorkspaceMapping.Project != tc.expectedProjectName { 823 t.Fatalf("%s: expected project name mapping (%s) to match configured project name (%s)", name, b.WorkspaceMapping.Project, tc.expectedProjectName) 824 } 825 826 // read map of configured tags 827 gotTags := map[string]struct{}{} 828 for _, v := range b.WorkspaceMapping.Tags { 829 gotTags[v] = struct{}{} 830 } 831 832 if len(gotTags) != len(tc.expectedWorkspaceTags) { 833 t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags) 834 } 835 836 for k := range tc.expectedWorkspaceTags { 837 if _, ok := gotTags[k]; !ok { 838 t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags) 839 } 840 } 841 }) 842 } 843 } 844 845 func TestCloud_setConfigurationFieldsUnhappyPath(t *testing.T) { 846 cases := map[string]struct { 847 obj cty.Value 848 envVars map[string]string 849 wantSummary string 850 wantDetail string 851 }{ 852 "cloud block is not configured": { 853 obj: cty.ObjectVal(map[string]cty.Value{ 854 "organization": cty.NullVal(cty.String), 855 "hostname": cty.NullVal(cty.String), 856 "workspaces": cty.NullVal(cty.String), 857 }), 858 wantSummary: "Hostname is required for the cloud backend", 859 wantDetail: `OpenTofu does not provide a default "hostname" attribute, so it must be set to the hostname of the cloud backend.`, 860 }, 861 "with hostname and workspace tags set, and tags overwritten by TF_WORKSPACE": { 862 // see: https://github.com/opentofu/opentofu/issues/814 863 obj: cty.ObjectVal(map[string]cty.Value{ 864 "organization": cty.NullVal(cty.String), 865 "hostname": cty.StringVal("opentofu.org"), 866 "workspaces": cty.ObjectVal(map[string]cty.Value{ 867 "name": cty.NullVal(cty.String), 868 "tags": cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}), 869 "project": cty.NullVal(cty.String), 870 }), 871 }), 872 envVars: map[string]string{ 873 "TF_WORKSPACE": "qux", 874 }, 875 wantSummary: invalidWorkspaceConfigMisconfigurationEnvVar.Description().Summary, 876 wantDetail: invalidWorkspaceConfigMisconfigurationEnvVar.Description().Detail, 877 }, 878 } 879 880 for name, tc := range cases { 881 t.Run(name, func(t *testing.T) { 882 for k, v := range tc.envVars { 883 t.Setenv(k, v) 884 } 885 886 b := &Cloud{} 887 errDiags := b.setConfigurationFields(tc.obj) 888 if (tc.wantDetail != "" || tc.wantSummary != "") != errDiags.HasErrors() { 889 t.Fatalf("%s error expected", name) 890 } 891 892 gotSummary := errDiags[0].Description().Summary 893 if gotSummary != tc.wantSummary { 894 t.Fatalf("%s diagnostic summary mismatch, want: %s, got: %s", name, tc.wantSummary, gotSummary) 895 } 896 897 gotDetail := errDiags[0].Description().Detail 898 if gotDetail != tc.wantDetail { 899 t.Fatalf("%s diagnostic details mismatch, want: %s, got: %s", name, tc.wantDetail, gotDetail) 900 } 901 }) 902 } 903 } 904 905 func TestCloud_localBackend(t *testing.T) { 906 b, bCleanup := testBackendWithName(t) 907 defer bCleanup() 908 909 local, ok := b.local.(*backendLocal.Local) 910 if !ok { 911 t.Fatalf("expected b.local to be \"*local.Local\", got: %T", b.local) 912 } 913 914 cloud, ok := local.Backend.(*Cloud) 915 if !ok { 916 t.Fatalf("expected local.Backend to be *cloud.Cloud, got: %T", cloud) 917 } 918 } 919 920 func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { 921 b, bCleanup := testBackendWithName(t) 922 defer bCleanup() 923 924 if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { 925 t.Fatalf("expected no error, got %v", err) 926 } 927 928 if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported { 929 t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) 930 } 931 } 932 933 func TestCloud_StateMgr_versionCheck(t *testing.T) { 934 b, bCleanup := testBackendWithName(t) 935 defer bCleanup() 936 937 // Some fixed versions for testing with. This logic is a simple string 938 // comparison, so we don't need many test cases. 939 v0135 := version.Must(version.NewSemver("0.13.5")) 940 v0140 := version.Must(version.NewSemver("0.14.0")) 941 942 // Save original local version state and restore afterwards 943 p := tfversion.Prerelease 944 v := tfversion.Version 945 s := tfversion.SemVer 946 defer func() { 947 tfversion.Prerelease = p 948 tfversion.Version = v 949 tfversion.SemVer = s 950 }() 951 952 // For this test, the local Terraform version is set to 0.14.0 953 tfversion.Prerelease = "" 954 tfversion.Version = v0140.String() 955 tfversion.SemVer = v0140 956 957 // Update the mock remote workspace Terraform version to match the local 958 // Terraform version 959 if _, err := b.client.Workspaces.Update( 960 context.Background(), 961 b.organization, 962 b.WorkspaceMapping.Name, 963 tfe.WorkspaceUpdateOptions{ 964 TerraformVersion: tfe.String(v0140.String()), 965 }, 966 ); err != nil { 967 t.Fatalf("error: %v", err) 968 } 969 970 // This should succeed 971 if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { 972 t.Fatalf("expected no error, got %v", err) 973 } 974 975 // Now change the remote workspace to a different Terraform version 976 if _, err := b.client.Workspaces.Update( 977 context.Background(), 978 b.organization, 979 b.WorkspaceMapping.Name, 980 tfe.WorkspaceUpdateOptions{ 981 TerraformVersion: tfe.String(v0135.String()), 982 }, 983 ); err != nil { 984 t.Fatalf("error: %v", err) 985 } 986 987 // This should fail 988 want := `Remote workspace TF version "0.13.5" does not match local OpenTofu version "0.14.0"` 989 if _, err := b.StateMgr(testBackendSingleWorkspaceName); err.Error() != want { 990 t.Fatalf("wrong error\n got: %v\nwant: %v", err.Error(), want) 991 } 992 } 993 994 func TestCloud_StateMgr_versionCheckLatest(t *testing.T) { 995 b, bCleanup := testBackendWithName(t) 996 defer bCleanup() 997 998 v0140 := version.Must(version.NewSemver("0.14.0")) 999 1000 // Save original local version state and restore afterwards 1001 p := tfversion.Prerelease 1002 v := tfversion.Version 1003 s := tfversion.SemVer 1004 defer func() { 1005 tfversion.Prerelease = p 1006 tfversion.Version = v 1007 tfversion.SemVer = s 1008 }() 1009 1010 // For this test, the local Terraform version is set to 0.14.0 1011 tfversion.Prerelease = "" 1012 tfversion.Version = v0140.String() 1013 tfversion.SemVer = v0140 1014 1015 // Update the remote workspace to the pseudo-version "latest" 1016 if _, err := b.client.Workspaces.Update( 1017 context.Background(), 1018 b.organization, 1019 b.WorkspaceMapping.Name, 1020 tfe.WorkspaceUpdateOptions{ 1021 TerraformVersion: tfe.String("latest"), 1022 }, 1023 ); err != nil { 1024 t.Fatalf("error: %v", err) 1025 } 1026 1027 // This should succeed despite not being a string match 1028 if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil { 1029 t.Fatalf("expected no error, got %v", err) 1030 } 1031 } 1032 1033 func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) { 1034 testCases := []struct { 1035 local string 1036 remote string 1037 executionMode string 1038 wantErr bool 1039 }{ 1040 {"0.13.5", "0.13.5", "agent", false}, 1041 {"0.14.0", "0.13.5", "remote", true}, 1042 {"0.14.0", "0.13.5", "local", false}, 1043 {"0.14.0", "0.14.1", "remote", false}, 1044 {"0.14.0", "1.0.99", "remote", false}, 1045 {"0.14.0", "1.1.0", "remote", false}, 1046 {"0.14.0", "1.3.0", "remote", true}, 1047 {"1.2.0", "1.2.99", "remote", false}, 1048 {"1.2.0", "1.3.0", "remote", true}, 1049 {"0.15.0", "latest", "remote", false}, 1050 {"1.1.5", "~> 1.1.1", "remote", false}, 1051 {"1.1.5", "> 1.1.0, < 1.3.0", "remote", false}, 1052 {"1.1.5", "~> 1.0.1", "remote", true}, 1053 // pre-release versions are comparable within their pre-release stage (dev, 1054 // alpha, beta), but not comparable to different stages and not comparable 1055 // to final releases. 1056 {"1.1.0-beta1", "1.1.0-beta1", "remote", false}, 1057 {"1.1.0-beta1", "~> 1.1.0-beta", "remote", false}, 1058 {"1.1.0", "~> 1.1.0-beta", "remote", true}, 1059 {"1.1.0-beta1", "~> 1.1.0-dev", "remote", true}, 1060 } 1061 for _, tc := range testCases { 1062 t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) { 1063 b, bCleanup := testBackendWithName(t) 1064 defer bCleanup() 1065 1066 local := version.Must(version.NewSemver(tc.local)) 1067 1068 // Save original local version state and restore afterwards 1069 p := tfversion.Prerelease 1070 v := tfversion.Version 1071 s := tfversion.SemVer 1072 defer func() { 1073 tfversion.Prerelease = p 1074 tfversion.Version = v 1075 tfversion.SemVer = s 1076 }() 1077 1078 // Override local version as specified 1079 tfversion.Prerelease = "" 1080 tfversion.Version = local.String() 1081 tfversion.SemVer = local 1082 1083 // Update the mock remote workspace Terraform version to the 1084 // specified remote version 1085 if _, err := b.client.Workspaces.Update( 1086 context.Background(), 1087 b.organization, 1088 b.WorkspaceMapping.Name, 1089 tfe.WorkspaceUpdateOptions{ 1090 ExecutionMode: &tc.executionMode, 1091 TerraformVersion: tfe.String(tc.remote), 1092 }, 1093 ); err != nil { 1094 t.Fatalf("error: %v", err) 1095 } 1096 1097 diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) 1098 if tc.wantErr { 1099 if len(diags) != 1 { 1100 t.Fatal("expected diag, but none returned") 1101 } 1102 if got := diags.Err().Error(); !strings.Contains(got, "Incompatible TF version") { 1103 t.Fatalf("unexpected error: %s", got) 1104 } 1105 } else { 1106 if len(diags) != 0 { 1107 t.Fatalf("unexpected diags: %s", diags.Err()) 1108 } 1109 } 1110 }) 1111 } 1112 } 1113 1114 func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) { 1115 b, bCleanup := testBackendWithName(t) 1116 defer bCleanup() 1117 1118 // Attempting to check the version against a workspace which doesn't exist 1119 // should result in no errors 1120 diags := b.VerifyWorkspaceTerraformVersion("invalid-workspace") 1121 if len(diags) != 0 { 1122 t.Fatalf("unexpected error: %s", diags.Err()) 1123 } 1124 1125 // Use a special workspace ID to trigger a 500 error, which should result 1126 // in a failed check 1127 diags = b.VerifyWorkspaceTerraformVersion("network-error") 1128 if len(diags) != 1 { 1129 t.Fatal("expected diag, but none returned") 1130 } 1131 if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Workspace read failed") { 1132 t.Fatalf("unexpected error: %s", got) 1133 } 1134 1135 // Update the mock remote workspace Terraform version to an invalid version 1136 if _, err := b.client.Workspaces.Update( 1137 context.Background(), 1138 b.organization, 1139 b.WorkspaceMapping.Name, 1140 tfe.WorkspaceUpdateOptions{ 1141 TerraformVersion: tfe.String("1.0.cheetarah"), 1142 }, 1143 ); err != nil { 1144 t.Fatalf("error: %v", err) 1145 } 1146 diags = b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) 1147 1148 if len(diags) != 1 { 1149 t.Fatal("expected diag, but none returned") 1150 } 1151 if got := diags.Err().Error(); !strings.Contains(got, "Incompatible TF version: The remote workspace specified") { 1152 t.Fatalf("unexpected error: %s", got) 1153 } 1154 } 1155 1156 func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { 1157 b, bCleanup := testBackendWithName(t) 1158 defer bCleanup() 1159 1160 // If the ignore flag is set, the behaviour changes 1161 b.IgnoreVersionConflict() 1162 1163 // Different local & remote versions to cause an error 1164 local := version.Must(version.NewSemver("0.14.0")) 1165 remote := version.Must(version.NewSemver("0.13.5")) 1166 1167 // Save original local version state and restore afterwards 1168 p := tfversion.Prerelease 1169 v := tfversion.Version 1170 s := tfversion.SemVer 1171 defer func() { 1172 tfversion.Prerelease = p 1173 tfversion.Version = v 1174 tfversion.SemVer = s 1175 }() 1176 1177 // Override local version as specified 1178 tfversion.Prerelease = "" 1179 tfversion.Version = local.String() 1180 tfversion.SemVer = local 1181 1182 // Update the mock remote workspace Terraform version to the 1183 // specified remote version 1184 if _, err := b.client.Workspaces.Update( 1185 context.Background(), 1186 b.organization, 1187 b.WorkspaceMapping.Name, 1188 tfe.WorkspaceUpdateOptions{ 1189 TerraformVersion: tfe.String(remote.String()), 1190 }, 1191 ); err != nil { 1192 t.Fatalf("error: %v", err) 1193 } 1194 1195 diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName) 1196 if len(diags) != 1 { 1197 t.Fatal("expected diag, but none returned") 1198 } 1199 1200 if got, want := diags[0].Severity(), tfdiags.Warning; got != want { 1201 t.Errorf("wrong severity: got %#v, want %#v", got, want) 1202 } 1203 if got, want := diags[0].Description().Summary, "Incompatible TF version"; got != want { 1204 t.Errorf("wrong summary: got %s, want %s", got, want) 1205 } 1206 wantDetail := "The local OpenTofu version (0.14.0) does not meet the version requirements for remote workspace hashicorp/app-prod (0.13.5)." 1207 if got := diags[0].Description().Detail; got != wantDetail { 1208 t.Errorf("wrong summary: got %s, want %s", got, wantDetail) 1209 } 1210 } 1211 1212 func TestCloudBackend_DeleteWorkspace_SafeAndForce(t *testing.T) { 1213 b, bCleanup := testBackendWithTags(t) 1214 defer bCleanup() 1215 safeDeleteWorkspaceName := "safe-delete-workspace" 1216 forceDeleteWorkspaceName := "force-delete-workspace" 1217 1218 _, err := b.StateMgr(safeDeleteWorkspaceName) 1219 if err != nil { 1220 t.Fatalf("error: %s", err) 1221 } 1222 1223 _, err = b.StateMgr(forceDeleteWorkspaceName) 1224 if err != nil { 1225 t.Fatalf("error: %s", err) 1226 } 1227 1228 // sanity check that the mock now contains two workspaces 1229 wl, err := b.Workspaces() 1230 if err != nil { 1231 t.Fatalf("error fetching workspace names: %v", err) 1232 } 1233 if len(wl) != 2 { 1234 t.Fatalf("expected 2 workspaced but got %d", len(wl)) 1235 } 1236 1237 c := context.Background() 1238 safeDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, safeDeleteWorkspaceName) 1239 if err != nil { 1240 t.Fatalf("error fetching workspace: %v", err) 1241 } 1242 1243 // Lock a workspace so that it should fail to be safe deleted 1244 _, err = b.client.Workspaces.Lock(context.Background(), safeDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")}) 1245 if err != nil { 1246 t.Fatalf("error locking workspace: %v", err) 1247 } 1248 err = b.DeleteWorkspace(safeDeleteWorkspaceName, false) 1249 if err == nil { 1250 t.Fatalf("workspace should have failed to safe delete") 1251 } 1252 1253 // unlock the workspace and confirm that safe-delete now works 1254 _, err = b.client.Workspaces.Unlock(context.Background(), safeDeleteWorkspace.ID) 1255 if err != nil { 1256 t.Fatalf("error unlocking workspace: %v", err) 1257 } 1258 err = b.DeleteWorkspace(safeDeleteWorkspaceName, false) 1259 if err != nil { 1260 t.Fatalf("error safe deleting workspace: %v", err) 1261 } 1262 1263 // lock a workspace and then confirm that force deleting it works 1264 forceDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, forceDeleteWorkspaceName) 1265 if err != nil { 1266 t.Fatalf("error fetching workspace: %v", err) 1267 } 1268 _, err = b.client.Workspaces.Lock(context.Background(), forceDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")}) 1269 if err != nil { 1270 t.Fatalf("error locking workspace: %v", err) 1271 } 1272 err = b.DeleteWorkspace(forceDeleteWorkspaceName, true) 1273 if err != nil { 1274 t.Fatalf("error force deleting workspace: %v", err) 1275 } 1276 } 1277 1278 func TestCloudBackend_DeleteWorkspace_DoesNotExist(t *testing.T) { 1279 b, bCleanup := testBackendWithTags(t) 1280 defer bCleanup() 1281 1282 err := b.DeleteWorkspace("non-existent-workspace", false) 1283 if err != nil { 1284 t.Fatalf("expected deleting a workspace which does not exist to succeed") 1285 } 1286 } 1287 1288 func TestCloud_ServiceDiscoveryAliases(t *testing.T) { 1289 s := testServer(t) 1290 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 1291 1292 diag := b.Configure(cty.ObjectVal(map[string]cty.Value{ 1293 "hostname": cty.StringVal(tfeHost), 1294 "organization": cty.StringVal("hashicorp"), 1295 "token": cty.NullVal(cty.String), 1296 "workspaces": cty.ObjectVal(map[string]cty.Value{ 1297 "name": cty.StringVal("prod"), 1298 "tags": cty.NullVal(cty.Set(cty.String)), 1299 "project": cty.NullVal(cty.String), 1300 }), 1301 })) 1302 if diag.HasErrors() { 1303 t.Fatalf("expected no diagnostic errors, got %s", diag.Err()) 1304 } 1305 1306 aliases, err := b.ServiceDiscoveryAliases() 1307 if err != nil { 1308 t.Fatalf("expected no errors, got %s", err) 1309 } 1310 if len(aliases) != 1 { 1311 t.Fatalf("expected 1 alias but got %d", len(aliases)) 1312 } 1313 }