Modify tfe client mocks to meet some new requirements

- Add plausible unredacted plan json for `plan-json-{basic,full}` testdata --
  Created by just running the relevant terraform commands locally.

- Add plan-json-no-changes testdata --
  The unredacted json was organically grown, but I edited the log and redacted
  json by hand to match what I observed from a real but unrelated
  planned-and-finished run in TFC.

- Add plan-json-basic-no-unredacted testdata --
  This mimics a lack of admin permissions, resulting in a 404.

- Hook up `MockPlans.ReadJSONOutput` to test fixtures, when present.
  This method has been implemented for ages, and has had a backing store for
  unredacted plan json, but has been effectively a no-op since nothing ever
  fills that backing store. So, when creating a mock plan, make an attempt to
  read unredacted json and stow it in the mocks on success.

- Make it possible to get the entire MockClient for a test backend
  In order to test some things, I'm going to need to mess with the internal
  state of runs and plans beyond what the go-tfe client API allows. I could add
  magic special-casing to the mock API methods, or I could locate the
  shenanigans next to the test that actually exploits it. The latter seems more
  comprehensible, but I need access to the full mock client struct in order to
  mess with its interior.

- Fill in some missing expectations around HasChanges when retrieving a run +
  plan.
This commit is contained in:
Nick Fagerlund 2023-06-28 16:47:08 -07:00 committed by Sebastian Rivera
parent ed27fa096e
commit 7f6b827987
12 changed files with 274 additions and 10 deletions

View File

@ -650,7 +650,7 @@ func TestCloud_setUnavailableTerraformVersion(t *testing.T) {
}),
})
b, bCleanup := testBackend(t, config, nil)
b, _, bCleanup := testBackend(t, config, nil)
defer bCleanup()
// Make sure the workspace doesn't exist yet -- otherwise, we can't test what

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -0,0 +1,116 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"triggers": null
},
"after_unknown": {
"id": true
},
"before_sensitive": false,
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -0,0 +1,3 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.409143-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605841-05:00","change":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605906-05:00","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -0,0 +1 @@
{"format_version":"1.1","terraform_version":"1.4.4","planned_values":{"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"triggers":null},"sensitive_values":{}}]}},"resource_changes":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","change":{"actions":["create"],"before":null,"after":{"triggers":null},"after_unknown":{"id":true},"before_sensitive":false,"after_sensitive":{}}}],"configuration":{"provider_config":{"null":{"name":"null","full_name":"registry.terraform.io/hashicorp/null"}},"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_config_key":"null","schema_version":0}]}}}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -0,0 +1,118 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"no-op"
],
"before": {
"id": "3549869958859575216",
"triggers": null
},
"after": {
"id": "3549869958859575216",
"triggers": null
},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -0,0 +1 @@
{"format_version":"1.1","terraform_version":"1.4.4","planned_values":{"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"id":"3549869958859575216","triggers":null},"sensitive_values":{}}]}},"resource_changes":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","change":{"actions":["no-op"],"before":{"id":"3549869958859575216","triggers":null},"after":{"id":"3549869958859575216","triggers":null},"after_unknown":{},"before_sensitive":{},"after_sensitive":{}}}],"prior_state":{"format_version":"1.0","terraform_version":"1.4.4","values":{"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"id":"3549869958859575216","triggers":null},"sensitive_values":{}}]}}},"configuration":{"provider_config":{"null":{"name":"null","full_name":"registry.terraform.io/hashicorp/null"}},"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_config_key":"null","schema_version":0}]}}}

View File

@ -0,0 +1,2 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.409143-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"Plan: 0 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605906-05:00","changes":{"add":0,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -83,6 +83,11 @@ func testInput(t *testing.T, answers map[string]string) *mockInput {
}
func testBackendWithName(t *testing.T) (*Cloud, func()) {
b, _, c := testBackendAndMocksWithName(t)
return b, c
}
func testBackendAndMocksWithName(t *testing.T) (*Cloud, *MockClient, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
@ -109,7 +114,8 @@ func testBackendWithTags(t *testing.T) (*Cloud, func()) {
),
}),
})
return testBackend(t, obj, nil)
b, _, c := testBackend(t, obj, nil)
return b, c
}
func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
@ -122,7 +128,8 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
"tags": cty.NullVal(cty.Set(cty.String)),
}),
})
return testBackend(t, obj, nil)
b, _, c := testBackend(t, obj, nil)
return b, c
}
func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) {
@ -135,7 +142,8 @@ func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.Respons
"tags": cty.NullVal(cty.Set(cty.String)),
}),
})
return testBackend(t, obj, handlers)
b, _, c := testBackend(t, obj, handlers)
return b, c
}
func testCloudState(t *testing.T) *State {
@ -212,7 +220,7 @@ func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
return b, cleanup
}
func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) {
func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, *MockClient, func()) {
var s *httptest.Server
if handlers != nil {
s = testServerWithHandlers(handlers)
@ -287,7 +295,7 @@ func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.Resp
}
}
return b, s.Close
return b, mc, s.Close
}
// testUnconfiguredBackend is used for testing the configuration of the backend

View File

@ -513,7 +513,7 @@ func (m *MockRedactedPlans) Read(ctx context.Context, hostname, token, planID st
type MockPlans struct {
client *MockClient
logs map[string]string
planOutputs map[string]string
planOutputs map[string][]byte
plans map[string]*tfe.Plan
}
@ -521,7 +521,7 @@ func newMockPlans(client *MockClient) *MockPlans {
return &MockPlans{
client: client,
logs: make(map[string]string),
planOutputs: make(map[string]string),
planOutputs: make(map[string][]byte),
plans: make(map[string]*tfe.Plan),
}
}
@ -548,6 +548,17 @@ func (m *MockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) {
w.WorkingDirectory,
"plan.log",
)
// Try to load unredacted json output, if it exists
outputPath := filepath.Join(
m.client.ConfigurationVersions.uploadPaths[cvID],
w.WorkingDirectory,
"plan-unredacted.json",
)
if outBytes, err := os.ReadFile(outputPath); err == nil {
m.planOutputs[p.ID] = outBytes
}
m.plans[p.ID] = p
return p, nil
@ -608,7 +619,7 @@ func (m *MockPlans) ReadJSONOutput(ctx context.Context, planID string) ([]byte,
return nil, tfe.ErrResourceNotFound
}
return []byte(planOutput), nil
return planOutput, nil
}
type MockTaskStages struct {
@ -1101,7 +1112,7 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, options *t
}
logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL])
if r.Status == tfe.RunPlanning && r.Plan.Status == tfe.PlanFinished {
if (r.Status == tfe.RunPlanning || r.Status == tfe.RunPlannedAndSaved) && r.Plan.Status == tfe.PlanFinished {
hasChanges := r.IsDestroy ||
bytes.Contains(logs, []byte("1 to add")) ||
bytes.Contains(logs, []byte("1 to change")) ||
@ -1110,6 +1121,7 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, options *t
r.Actions.IsCancelable = false
r.Actions.IsConfirmable = true
r.HasChanges = true
r.Plan.HasChanges = true
r.Permissions.CanApply = true
}