feat: Add support for tofu.workspace which will be resolved in the same way as terraform.workspace (#1305)

Signed-off-by: Syasusu <syasusu@163.com>
This commit is contained in:
Syasusu 2024-08-01 20:14:34 +08:00 committed by GitHub
parent c748fa0176
commit 1c0cb13bf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 377 additions and 28 deletions

View File

@ -328,11 +328,19 @@ func parseRef(traversal hcl.Traversal) (*Reference, tfdiags.Diagnostics) {
case "terraform":
name, rng, remain, diags := parseSingleAttrRef(traversal)
return &Reference{
Subject: TerraformAttr{Name: name},
Subject: NewTerraformAttr(IdentTerraform, name),
SourceRange: tfdiags.SourceRangeFromHCL(rng),
Remaining: remain,
}, diags
case "tofu":
name, rng, remain, parsedDiags := parseSingleAttrRef(traversal)
return &Reference{
Subject: NewTerraformAttr(IdentTofu, name),
SourceRange: tfdiags.SourceRangeFromHCL(rng),
Remaining: remain,
}, parsedDiags
case "var":
name, rng, remain, diags := parseSingleAttrRef(traversal)
return &Reference{

View File

@ -606,9 +606,7 @@ func TestParseRef(t *testing.T) {
{
`terraform.workspace`,
&Reference{
Subject: TerraformAttr{
Name: "workspace",
},
Subject: NewTerraformAttr("terraform", "workspace"),
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 20, Byte: 19},
@ -619,9 +617,7 @@ func TestParseRef(t *testing.T) {
{
`terraform.workspace.blah`,
&Reference{
Subject: TerraformAttr{
Name: "workspace",
},
Subject: NewTerraformAttr("terraform", "workspace"),
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 20, Byte: 19},
@ -649,6 +645,49 @@ func TestParseRef(t *testing.T) {
`The "terraform" object does not support this operation.`,
},
// tofu
{
`tofu.workspace`,
&Reference{
Subject: NewTerraformAttr("tofu", "workspace"),
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 15, Byte: 14},
},
},
``,
},
{
`tofu.workspace.blah`,
&Reference{
Subject: NewTerraformAttr("tofu", "workspace"),
SourceRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 1, Column: 15, Byte: 14},
},
Remaining: hcl.Traversal{
hcl.TraverseAttr{
Name: "blah",
SrcRange: hcl.Range{
Start: hcl.Pos{Line: 1, Column: 15, Byte: 14},
End: hcl.Pos{Line: 1, Column: 20, Byte: 19},
},
},
},
},
``, // valid at this layer, but will fail during eval because "workspace" is a string
},
{
`tofu`,
nil,
`The "tofu" object cannot be accessed directly. Instead, access one of its attributes.`,
},
{
`tofu["workspace"]`,
nil,
`The "tofu" object does not support this operation.`,
},
// var
{
`var.foo`,

View File

@ -5,15 +5,28 @@
package addrs
// TerraformAttr is the address of an attribute of the "terraform" object in
// the interpolation scope, like "terraform.workspace".
const (
IdentTerraform = "terraform"
IdentTofu = "tofu"
)
func NewTerraformAttr(alias, name string) TerraformAttr {
return TerraformAttr{
Name: name,
Alias: alias,
}
}
// TerraformAttr is the address of an attribute of the "terraform" and "tofu" object in
// the interpolation scope, like "terraform.workspace" and "tofu.workspace".
type TerraformAttr struct {
referenceable
Name string
Name string
Alias string
}
func (ta TerraformAttr) String() string {
return "terraform." + ta.Name
return ta.Alias + "." + ta.Name
}
func (ta TerraformAttr) UniqueKey() UniqueKey {

View File

@ -1735,8 +1735,44 @@ func TestApply_disableBackup(t *testing.T) {
}
}
func TestApply_tfWorkspace(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("apply-tf-workspace"), td)
defer testChdir(t, td)()
statePath := testTempFile(t)
p := testProvider()
view, done := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}
args := []string{
"-auto-approve",
"-state", statePath,
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
expected := strings.TrimSpace(`
<no state>
Outputs:
output = default
`)
testStateOutput(t, statePath, expected)
}
// Test that the OpenTofu env is passed through
func TestApply_tofuEnv(t *testing.T) {
func TestApply_tofuWorkspace(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("apply-tofu-workspace"), td)
@ -1772,8 +1808,69 @@ output = default
testStateOutput(t, statePath, expected)
}
func TestApply_tfWorkspaceNonDefault(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("apply-tf-workspace"), td)
defer testChdir(t, td)()
// Create new env
{
ui := new(cli.MockUi)
newCmd := &WorkspaceNewCommand{
Meta: Meta{
Ui: ui,
},
}
if code := newCmd.Run([]string{"test"}); code != 0 {
t.Fatal("error creating workspace")
}
}
// Switch to it
{
args := []string{"test"}
ui := new(cli.MockUi)
selCmd := &WorkspaceSelectCommand{
Meta: Meta{
Ui: ui,
},
}
if code := selCmd.Run(args); code != 0 {
t.Fatal("error switching workspace")
}
}
p := testProvider()
view, done := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}
args := []string{
"-auto-approve",
}
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: %d\n\n%s", code, output.Stderr())
}
statePath := filepath.Join("terraform.tfstate.d", "test", "terraform.tfstate")
expected := strings.TrimSpace(`
<no state>
Outputs:
output = test
`)
testStateOutput(t, statePath, expected)
}
// Test that the OpenTofu env is passed through
func TestApply_tofuEnvNonDefault(t *testing.T) {
func TestApply_tofuWorkspaceNonDefault(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
testCopyDir(t, testFixturePath("apply-tofu-workspace"), td)

View File

@ -0,0 +1,3 @@
output "output" {
value = terraform.workspace
}

View File

@ -1,3 +1,3 @@
output "output" {
value = terraform.workspace
value = tofu.workspace
}

View File

@ -163,7 +163,7 @@ func onlySelfRefs(body hcl.Body) hcl.Diagnostics {
for _, v := range attr.Expr.Variables() {
valid := false
switch v.RootName() {
case "self", "path", "terraform":
case "self", "path", "terraform", "tofu":
valid = true
case "count":
// count must use "index"

View File

@ -0,0 +1,123 @@
// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package configs
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcltest"
)
func TestProvisionerBlock_decode(t *testing.T) {
tests := map[string]struct {
input *hcl.Block
want *Provisioner
err string
}{
"refer terraform.workspace when destroy": {
input: &hcl.Block{
Type: "provisioner",
Labels: []string{"local-exec"},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"when": {
Name: "when",
Expr: hcltest.MockExprTraversalSrc("destroy"),
},
"command": {
Name: "command",
Expr: hcltest.MockExprTraversalSrc("terraform.workspace"),
},
},
}),
DefRange: blockRange,
LabelRanges: []hcl.Range{hcl.Range{}},
},
want: &Provisioner{
Type: "local-exec",
When: ProvisionerWhenDestroy,
OnFailure: ProvisionerOnFailureFail,
DeclRange: blockRange,
},
},
"refer tofu.workspace when destroy": {
input: &hcl.Block{
Type: "provisioner",
Labels: []string{"local-exec"},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"when": {
Name: "when",
Expr: hcltest.MockExprTraversalSrc("destroy"),
},
"command": {
Name: "command",
Expr: hcltest.MockExprTraversalSrc("tofu.workspace"),
},
},
}),
DefRange: blockRange,
LabelRanges: []hcl.Range{hcl.Range{}},
},
want: &Provisioner{
Type: "local-exec",
When: ProvisionerWhenDestroy,
OnFailure: ProvisionerOnFailureFail,
DeclRange: blockRange,
},
},
"refer unknown.workspace when destroy": {
input: &hcl.Block{
Type: "provisioner",
Labels: []string{"local-exec"},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcl.Attributes{
"when": {
Name: "when",
Expr: hcltest.MockExprTraversalSrc("destroy"),
},
"command": {
Name: "command",
Expr: hcltest.MockExprTraversalSrc("unknown.workspace"),
},
},
}),
DefRange: blockRange,
LabelRanges: []hcl.Range{hcl.Range{}},
},
want: &Provisioner{
Type: "local-exec",
When: ProvisionerWhenDestroy,
OnFailure: ProvisionerOnFailureFail,
DeclRange: blockRange,
},
err: "Invalid reference from destroy provisioner",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, diags := decodeProvisionerBlock(test.input)
if diags.HasErrors() {
if test.err == "" {
t.Fatalf("unexpected error: %s", diags.Errs())
}
if gotErr := diags[0].Summary; gotErr != test.err {
t.Errorf("wrong error, got %q, want %q", gotErr, test.err)
}
} else if test.err != "" {
t.Fatal("expected error")
}
if !cmp.Equal(got, test.want, cmpopts.IgnoreInterfaces(struct{ hcl.Body }{})) {
t.Fatalf("wrong result: %s", cmp.Diff(got, test.want))
}
})
}
}

View File

@ -11,11 +11,12 @@ import (
"path/filepath"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/didyoumean"
"github.com/opentofu/opentofu/internal/lang"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
)
// newStaticScope creates a lang.Scope that's backed by the static view of the module represented by the StaticEvaluator

View File

@ -146,6 +146,7 @@ func (s *Scope) EvalSelfBlock(body hcl.Body, self cty.Value, schema *configschem
vals["path"] = cty.ObjectVal(pathAttrs)
vals["terraform"] = cty.ObjectVal(terraformAttrs)
vals["tofu"] = cty.ObjectVal(terraformAttrs)
ctx := &hcl.EvalContext{
Variables: vals,
@ -557,6 +558,7 @@ func (b *evalVarBuilder) buildAllVariablesInto(vals map[string]cty.Value) {
vals["local"] = cty.ObjectVal(b.localValues)
vals["path"] = cty.ObjectVal(b.pathAttrs)
vals["terraform"] = cty.ObjectVal(b.terraformAttrs)
vals["tofu"] = cty.ObjectVal(b.terraformAttrs)
vals["count"] = cty.ObjectVal(b.countAttrs)
vals["each"] = cty.ObjectVal(b.forEachAttrs)

View File

@ -349,6 +349,20 @@ func TestScopeEvalContext(t *testing.T) {
"terraform": cty.ObjectVal(map[string]cty.Value{
"workspace": cty.StringVal("default"),
}),
"tofu": cty.ObjectVal(map[string]cty.Value{
"workspace": cty.StringVal("default"),
}),
},
},
{
`tofu.workspace`,
map[string]cty.Value{
"terraform": cty.ObjectVal(map[string]cty.Value{
"workspace": cty.StringVal("default"),
}),
"tofu": cty.ObjectVal(map[string]cty.Value{
"workspace": cty.StringVal("default"),
}),
},
},
{
@ -887,6 +901,13 @@ func TestScopeEvalSelfBlock(t *testing.T) {
"num": cty.NullVal(cty.Number),
},
},
{
Config: `attr = tofu.workspace`,
Want: map[string]cty.Value{
"attr": cty.StringVal("default"),
"num": cty.NullVal(cty.Number),
},
},
}
for _, test := range tests {

View File

@ -13,7 +13,7 @@ import (
"strings"
"github.com/hashicorp/errwrap"
multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/hcl/v2"
)
@ -194,6 +194,10 @@ func (diags Diagnostics) Sort() {
sort.Stable(sortDiagnostics(diags))
}
func (diags Diagnostics) TrimDuplicated() {
sort.Stable(sortDiagnostics(diags))
}
type diagnosticsAsError struct {
Diagnostics
}

View File

@ -8649,7 +8649,34 @@ resource "null_instance" "depends" {
}
}
func TestContext2Apply_terraformWorkspace(t *testing.T) {
func TestContext2Apply_tfWorkspace(t *testing.T) {
m := testModule(t, "apply-tf-workspace")
p := testProvider("aws")
p.PlanResourceChangeFn = testDiffFn
ctx := testContext2(t, &ContextOpts{
Meta: &ContextMeta{Env: "foo"},
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("aws"): testProviderFuncFixed(p),
},
})
plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts)
assertNoErrors(t, diags)
state, diags := ctx.Apply(plan, m)
if diags.HasErrors() {
t.Fatalf("diags: %s", diags.Err())
}
actual := state.RootModule().OutputValues["output"]
expected := cty.StringVal("foo")
if actual == nil || actual.Value != expected {
t.Fatalf("wrong value\ngot: %#v\nwant: %#v", actual.Value, expected)
}
}
func TestContext2Apply_tofuWorkspace(t *testing.T) {
m := testModule(t, "apply-tofu-workspace")
p := testProvider("aws")
p.PlanResourceChangeFn = testDiffFn

View File

@ -919,7 +919,6 @@ func (d *evaluationStateData) getResourceSchema(addr addrs.Resource, providerAdd
func (d *evaluationStateData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfdiags.SourceRange) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
switch addr.Name {
case "workspace":
workspaceName := d.Evaluator.Meta.Env
return cty.StringVal(workspaceName), diags
@ -930,8 +929,8 @@ func (d *evaluationStateData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfd
// removed.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid "terraform" attribute`,
Detail: `The terraform.env attribute was deprecated in v0.10 and removed in v0.12. The "state environment" concept was renamed to "workspace" in v0.12, and so the workspace name can now be accessed using the terraform.workspace attribute.`,
Summary: fmt.Sprintf("Invalid %q attribute", addr.Alias),
Detail: fmt.Sprintf(`The %s.env attribute was deprecated in v0.10 and removed in v0.12. The "state environment" concept was renamed to "workspace" in v0.12, and so the workspace name can now be accessed using the %s.workspace attribute.`, addr.Alias, addr.Alias),
Subject: rng.ToHCL().Ptr(),
})
return cty.DynamicVal, diags
@ -939,8 +938,8 @@ func (d *evaluationStateData) GetTerraformAttr(addr addrs.TerraformAttr, rng tfd
default:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid "terraform" attribute`,
Detail: fmt.Sprintf(`The "terraform" object does not have an attribute named %q. The only supported attribute is terraform.workspace, the name of the currently-selected workspace.`, addr.Name),
Summary: fmt.Sprintf("Invalid %q attribute", addr.Alias),
Detail: fmt.Sprintf(`The %q object does not have an attribute named %q. The only supported attribute is %s.workspace, the name of the currently-selected workspace.`, addr.Alias, addr.Name, addr.Alias),
Subject: rng.ToHCL().Ptr(),
})
return cty.DynamicVal, diags

View File

@ -33,11 +33,20 @@ func TestEvaluatorGetTerraformAttr(t *testing.T) {
}
scope := evaluator.Scope(data, nil, nil, nil)
t.Run("workspace", func(t *testing.T) {
t.Run("terraform.workspace", func(t *testing.T) {
want := cty.StringVal("foo")
got, diags := scope.Data.GetTerraformAttr(addrs.TerraformAttr{
Name: "workspace",
}, tfdiags.SourceRange{})
got, diags := scope.Data.GetTerraformAttr(addrs.NewTerraformAttr("terraform", "workspace"), tfdiags.SourceRange{})
if len(diags) != 0 {
t.Errorf("unexpected diagnostics %s", spew.Sdump(diags))
}
if !got.RawEquals(want) {
t.Errorf("wrong result %q; want %q", got, want)
}
})
t.Run("tofu.workspace", func(t *testing.T) {
want := cty.StringVal("foo")
got, diags := scope.Data.GetTerraformAttr(addrs.NewTerraformAttr("tofu", "workspace"), tfdiags.SourceRange{})
if len(diags) != 0 {
t.Errorf("unexpected diagnostics %s", spew.Sdump(diags))
}

View File

@ -0,0 +1,3 @@
output "output" {
value = terraform.workspace
}

View File

@ -1,3 +1,3 @@
output "output" {
value = "${terraform.workspace}"
value = tofu.workspace
}