opentofu/internal/backend/remote/testing.go
Glenn Sarti 7e2e834aff
Emit warnings for certain run events in cloud backend (#33020)
The cloud backend, which communicates with TFC like APIs, can create
runs which may have one more configuration parameters altered. These
alterations are emitted as run-events on the run so that API clients
can consume and display them to users. This commit adds a step in
plan operation to query the run-events once a run is created and then
emit specific run-event descriptions to the console as warnings for
the user.
2023-04-17 08:53:47 -07:00

323 lines
9.0 KiB
Go

package remote
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path"
"testing"
"time"
tfe "github.com/hashicorp/go-tfe"
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/auth"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/httpclient"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/states/remote"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/version"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty"
backendLocal "github.com/hashicorp/terraform/internal/backend/local"
)
const (
testCred = "test-auth-token"
)
var (
tfeHost = svchost.Hostname(defaultHostname)
credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
tfeHost: {"token": testCred},
})
)
// mockInput is a mock implementation of terraform.UIInput.
type mockInput struct {
answers map[string]string
}
func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) {
v, ok := m.answers[opts.Id]
if !ok {
return "", fmt.Errorf("unexpected input request in test: %s", opts.Id)
}
if v == "wait-for-external-update" {
select {
case <-ctx.Done():
case <-time.After(time.Minute):
}
}
delete(m.answers, opts.Id)
return v, nil
}
func testInput(t *testing.T, answers map[string]string) *mockInput {
return &mockInput{answers: answers}
}
func testBackendDefault(t *testing.T) (*Remote, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"prefix": cty.NullVal(cty.String),
}),
})
return testBackend(t, obj)
}
func testBackendNoDefault(t *testing.T) (*Remote, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.NullVal(cty.String),
"prefix": cty.StringVal("my-app-"),
}),
})
return testBackend(t, obj)
}
func testBackendNoOperations(t *testing.T) (*Remote, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("no-operations"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"prefix": cty.NullVal(cty.String),
}),
})
return testBackend(t, obj)
}
func testRemoteClient(t *testing.T) remote.Client {
b, bCleanup := testBackendDefault(t)
defer bCleanup()
raw, err := b.StateMgr(backend.DefaultStateName)
if err != nil {
t.Fatalf("error: %v", err)
}
return raw.(*remote.State).Client
}
func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) {
s := testServer(t)
b := New(testDisco(s))
// Configure the backend so the client is created.
newObj, valDiags := b.PrepareConfig(obj)
if len(valDiags) != 0 {
t.Fatal(valDiags.ErrWithWarnings())
}
obj = newObj
confDiags := b.Configure(obj)
if len(confDiags) != 0 {
t.Fatal(confDiags.ErrWithWarnings())
}
// Get a new mock client.
mc := cloud.NewMockClient()
// Replace the services we use with our mock services.
b.CLI = cli.NewMockUi()
b.client.Applies = mc.Applies
b.client.ConfigurationVersions = mc.ConfigurationVersions
b.client.CostEstimates = mc.CostEstimates
b.client.Organizations = mc.Organizations
b.client.Plans = mc.Plans
b.client.PolicyChecks = mc.PolicyChecks
b.client.Runs = mc.Runs
b.client.RunEvents = mc.RunEvents
b.client.StateVersions = mc.StateVersions
b.client.Variables = mc.Variables
b.client.Workspaces = mc.Workspaces
// Set local to a local test backend.
b.local = testLocalBackend(t, b)
ctx := context.Background()
// Create the organization.
_, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
Name: tfe.String(b.organization),
})
if err != nil {
t.Fatalf("error: %v", err)
}
// Create the default workspace if required.
if b.workspace != "" {
_, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{
Name: tfe.String(b.workspace),
})
if err != nil {
t.Fatalf("error: %v", err)
}
}
return b, s.Close
}
func testLocalBackend(t *testing.T, remote *Remote) backend.Enhanced {
b := backendLocal.NewWithBackend(remote)
// Add a test provider to the local backend.
p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"null_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
},
},
},
})
p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("yes"),
})}
return b
}
// testServer returns a *httptest.Server used for local testing.
func testServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
// Respond to service discovery calls.
mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{
"state.v2": "/api/v2/",
"tfe.v2.1": "/api/v2/",
"versions.v1": "/v1/versions/"
}`)
})
// Respond to service version constraints calls.
mux.HandleFunc("/v1/versions/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, fmt.Sprintf(`{
"service": "%s",
"product": "terraform",
"minimum": "0.1.0",
"maximum": "10.0.0"
}`, path.Base(r.URL.Path)))
})
// Respond to pings to get the API version header.
mux.HandleFunc("/api/v2/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("TFP-API-Version", "2.4")
})
// Respond to the initial query to read the hashicorp org entitlements.
mux.HandleFunc("/api/v2/organizations/hashicorp/entitlement-set", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
"id": "org-GExadygjSbKP8hsY",
"type": "entitlement-sets",
"attributes": {
"operations": true,
"private-module-registry": true,
"sentinel": true,
"state-storage": true,
"teams": true,
"vcs-integrations": true
}
}
}`)
})
// Respond to the initial query to read the no-operations org entitlements.
mux.HandleFunc("/api/v2/organizations/no-operations/entitlement-set", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
"id": "org-ufxa3y8jSbKP8hsT",
"type": "entitlement-sets",
"attributes": {
"operations": false,
"private-module-registry": true,
"sentinel": true,
"state-storage": true,
"teams": true,
"vcs-integrations": true
}
}
}`)
})
// All tests that are assumed to pass will use the hashicorp organization,
// so for all other organization requests we will return a 404.
mux.HandleFunc("/api/v2/organizations/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
io.WriteString(w, `{
"errors": [
{
"status": "404",
"title": "not found"
}
]
}`)
})
return httptest.NewServer(mux)
}
// testDisco returns a *disco.Disco mapping app.terraform.io and
// localhost to a local test server.
func testDisco(s *httptest.Server) *disco.Disco {
services := map[string]interface{}{
"state.v2": fmt.Sprintf("%s/api/v2/", s.URL),
"tfe.v2.1": fmt.Sprintf("%s/api/v2/", s.URL),
"versions.v1": fmt.Sprintf("%s/v1/versions/", s.URL),
}
d := disco.NewWithCredentialsSource(credsSrc)
d.SetUserAgent(httpclient.TerraformUserAgent(version.String()))
d.ForceHostServices(svchost.Hostname(defaultHostname), services)
d.ForceHostServices(svchost.Hostname("localhost"), services)
return d
}
type unparsedVariableValue struct {
value string
source terraform.ValueSourceType
}
func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
return &terraform.InputValue{
Value: cty.StringVal(v.value),
SourceType: v.source,
}, tfdiags.Diagnostics{}
}
// testVariable returns a backend.UnparsedVariableValue used for testing.
func testVariables(s terraform.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue {
vars := make(map[string]backend.UnparsedVariableValue, len(vs))
for _, v := range vs {
vars[v] = &unparsedVariableValue{
value: v,
source: s,
}
}
return vars
}