diff --git a/cmd/buildingblock/tfstate/exec.go b/cmd/buildingblock/tfstate/exec.go index a9d05d77..0a141d7e 100644 --- a/cmd/buildingblock/tfstate/exec.go +++ b/cmd/buildingblock/tfstate/exec.go @@ -69,9 +69,10 @@ metadata.ownedByWorkspace, unless --workspace names another one. The module needs a backend "http" block, which the runner adds only while it runs. --override-backend adds one while the command runs: it writes the file -` + tfstate.BackendOverrideFile + ` into the working directory, where it also replaces a backend -of the module's own, and removes the file when the command ends. So run exec in the module's -directory rather than with tofu -chdir, and run tofu init through exec with --override-backend once. +` + tfstate.BackendOverrideFile + ` into the directory that tofu -chdir names, or else into the +working directory, where it also replaces a backend of the module's own, and removes the file when +the command ends. Run a command that wraps tofu in the module's directory, and run tofu init through +exec with --override-backend once. tofu's meshstack provider talks to meshStack through exec as well, with the login of the CLI. This works with any version of the provider, as long as its provider "meshstack" block sets no endpoint @@ -151,7 +152,7 @@ With --backup-dir, it copies the stored state into that directory before it repl } if overrideBackend { - remove, overrideErr := tfstate.WriteBackendOverride(".") + remove, overrideErr := tfstate.WriteBackendOverride(tfstate.ModuleDir(command)) if overrideErr != nil { return fmt.Errorf("%w: remove it, or run without --override-backend", overrideErr) } @@ -170,7 +171,8 @@ With --backup-dir, it copies the stored state into that directory before it repl switch { case requests > 0: case overrideBackend: - slog.WarnContext(ctx, command[0]+" asked for no state. --override-backend adds the backend in the working directory, so run exec in the module's directory rather than with tofu -chdir") + slog.WarnContext(ctx, fmt.Sprintf("%s asked for no state, although --override-backend added the backend in %s. Run a command that wraps tofu in the module's directory", + command[0], tfstate.ModuleDir(command))) default: slog.WarnContext(ctx, fmt.Sprintf("%s asked for no state, so the module likely has no backend \"http\" block, which the runner adds only while it runs. "+ "Run again with --override-backend, and run `%s %s --override-backend -- tofu init` once", @@ -183,7 +185,7 @@ With --backup-dir, it copies the stored state into that directory before it repl flags := cmd.Flags() flags.Var(&access, "mode", "read serves the state, readwrite stores what the command writes as well") flags.BoolVar(&force, "force", false, "store the state even while a run of the building block is pending or in progress, or where it does not follow the stored state") - flags.BoolVar(&overrideBackend, "override-backend", false, "add the backend \"http\" block to the module in the working directory while the command runs") + flags.BoolVar(&overrideBackend, "override-backend", false, "add the backend \"http\" block to the module in tofu's -chdir or the working directory while the command runs") flags.StringVar(&backupDir, "backup-dir", "", "with --mode readwrite, copy the stored state into this directory before each write, in files only you can read, since a state can hold secrets") return cmd diff --git a/cmd/buildingblock/tfstate/tfstate.go b/cmd/buildingblock/tfstate/tfstate.go index 49ed4391..cc380b74 100644 --- a/cmd/buildingblock/tfstate/tfstate.go +++ b/cmd/buildingblock/tfstate/tfstate.go @@ -39,7 +39,7 @@ reads and writes through tofu's http backend. "meshstack buildingblock tfstate exec" serves the state to tofu's http backend, so the module needs a backend "http" block. The runner adds one only while it runs, and exec with --override-backend -adds one while its command runs. Run it in the module's directory, with tofu init once: +adds one while its command runs. Run tofu init through it once: meshstack buildingblock tfstate exec --override-backend -- tofu init`, Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources diff --git a/cmd/internal/testacc/testdata/bootstrap/main.tf b/cmd/internal/testacc/testdata/bootstrap/main.tf index e5304501..5ada3eb1 100644 --- a/cmd/internal/testacc/testdata/bootstrap/main.tf +++ b/cmd/internal/testacc/testdata/bootstrap/main.tf @@ -1,6 +1,6 @@ -# The building block that the tfstate tests work on: a block of the noop definition of -# meshstack-hub, in a workspace of its own, which the tf-block-runner applies with meshStack's state -# backend. +# The building block that the tfstate tests work on, in a workspace of its own. Its definition runs +# testdata/buildingblock at the commit under test, which the tf-block-runner applies with meshStack's +# state backend. terraform { required_providers { @@ -24,6 +24,14 @@ variable "workspace_manager" { type = string } +variable "repository_url" { + type = string +} + +variable "git_ref" { + type = string +} + provider "meshstack" { profile = var.profile } @@ -62,20 +70,47 @@ resource "meshstack_workspace_user_binding" "manager" { } } -module "noop" { - source = "git::https://github.com/meshcloud/meshstack-hub.git//modules/meshstack/noop/e2e?ref=main" - # test_context reaches a const variable of the noop module, which tofu evaluates at init, so it - # takes the names from var.run_id rather than from the resources above. +resource "meshstack_building_block_definition" "noop" { + metadata = { + owned_by_workspace = meshstack_workspace.this.metadata.name + } + spec = { + display_name = "${var.run_id} meshStack CLI noop" + description = "Runs testdata/buildingblock of the meshStack CLI acceptance tests." + target_type = "WORKSPACE_LEVEL" + } + version_spec = { + draft = true + deletion_mode = "DELETE" + implementation = { + terraform = { + repository_url = var.repository_url + repository_path = "cmd/internal/testacc/testdata/buildingblock" + ref_name = var.git_ref + terraform_version = "1.12.5" + } + } + } +} + +resource "meshstack_building_block" "noop" { depends_on = [meshstack_project.this, meshstack_workspace_user_binding.manager] - test_context = { - hub_git_ref = "main" - workspace = var.run_id - project = var.run_id - run_id = var.run_id - # The acceptance backend defines no mandatory tags. - meshstack = { - tag_schema = { mandatory = { payment_method = {} } } + wait_for_completion = true + spec = { + building_block_definition_version_ref = meshstack_building_block_definition.noop.version_latest + display_name = "${var.run_id}-noop" + inputs = {} + target_ref = { + kind = "meshWorkspace" + name = meshstack_workspace.this.metadata.name + } + } + + lifecycle { + postcondition { + condition = self.status.status == "SUCCEEDED" + error_message = "The run of building block ${self.metadata.uuid} is ${self.status.status}." } } } diff --git a/cmd/internal/testacc/testdata/buildingblock/main.tf b/cmd/internal/testacc/testdata/buildingblock/main.tf new file mode 100644 index 00000000..a9ff3c20 --- /dev/null +++ b/cmd/internal/testacc/testdata/buildingblock/main.tf @@ -0,0 +1,11 @@ +# The code that the tf-block-runner runs for the bootstrapped building block. It declares no backend, +# so the runner uses meshStack's state backend, and the tfstate tests run it with exec --override-backend. + +variable "input" { + type = string + default = null +} + +resource "terraform_data" "noop" { + input = var.input +} diff --git a/cmd/internal/testacc/testdata/module/main.tf b/cmd/internal/testacc/testdata/module/main.tf deleted file mode 100644 index 1a41578b..00000000 --- a/cmd/internal/testacc/testdata/module/main.tf +++ /dev/null @@ -1,16 +0,0 @@ -# Stands in for the noop module of meshstack-hub, which the bootstrapped building block runs: it -# keeps the one resource of that module, so that a plan against the block's state changes no resource. -# An apply with another input changes the state, as a test of concurrent writes needs. - -terraform { - backend "http" {} -} - -variable "input" { - type = string - default = null -} - -resource "terraform_data" "noop" { - input = var.input -} diff --git a/cmd/internal/testacc/tfstate_test.go b/cmd/internal/testacc/tfstate_test.go index 57fdbf36..a56c8b63 100644 --- a/cmd/internal/testacc/tfstate_test.go +++ b/cmd/internal/testacc/tfstate_test.go @@ -16,6 +16,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meshcloud/meshstack-cli/internal/tfstate" ) const noState = "has no state in workspace" @@ -33,7 +35,7 @@ func TestAccTfstate(t *testing.T) { require.NoErrorf(t, err, "the API key login did not finish:\n%s", output) block := bootstrapBuildingBlock(t, c, manager.Username) c.setEnv(envWorkspace, block.workspace) - module := copyTestdata(t, "module") + module := copyTestdata(t, "buildingblock") t.Run("show writes the state that the run stored", func(t *testing.T) { output, err := c.run("", "buildingblock", "tfstate", "show", block.uuid) @@ -42,12 +44,13 @@ func TestAccTfstate(t *testing.T) { }) t.Run("exec --mode read runs tofu against the state", func(t *testing.T) { - output := c.exec(t, block, "read", "tofu", "-chdir="+module, "init", "-input=false") + output := c.tofuExec(t, block, "read", module, "init", "-input=false") assert.Contains(t, output, "Successfully configured the backend \"http\"") - output = c.exec(t, block, "read", "tofu", "-chdir="+module, "state", "list") + output = c.tofuExec(t, block, "read", module, "state", "list") assert.Contains(t, output, "terraform_data.noop") - c.exec(t, block, "read", "tofu", "-chdir="+module, "plan", "-input=false") + c.tofuExec(t, block, "read", module, "plan", "-input=false") + assert.NoFileExists(t, filepath.Join(module, tfstate.BackendOverrideFile)) }) t.Run("exec --mode read lets the meshstack provider read meshStack, and nothing else", func(t *testing.T) { @@ -63,7 +66,7 @@ func TestAccTfstate(t *testing.T) { }) t.Run("exec --mode readwrite applies with tofu, and leaves no lock behind", func(t *testing.T) { - output := c.exec(t, block, "readwrite", "tofu", "-chdir="+module, "apply", "-auto-approve", "-input=false") + output := c.tofuExec(t, block, "readwrite", module, "apply", "-auto-approve", "-input=false") assert.Contains(t, output, "Apply complete!") assert.Contains(t, c.forceUnlock(t, block), "has no lock") }) @@ -71,13 +74,13 @@ func TestAccTfstate(t *testing.T) { t.Run("a second exec fails on the lock that the first one holds, and names it", func(t *testing.T) { release := c.holdLock(t, block, "held-by-the-first-exec") - output, err := c.run("", "buildingblock", "tfstate", "exec", block.uuid, "--mode", "readwrite", "--", + output, err := c.run("", "buildingblock", "tfstate", "exec", block.uuid, "--mode", "readwrite", "--override-backend", "--", "tofu", "-chdir="+module, "apply", "-auto-approve", "-input=false", "-lock-timeout=1s") require.Errorf(t, err, "tofu applied while another exec held the lock:\n%s", output) assert.Contains(t, output, "Error acquiring the state lock") assert.Contains(t, output, "held-by-the-first-exec") - c.exec(t, block, "read", "tofu", "-chdir="+module, "plan", "-input=false") + c.tofuExec(t, block, "read", module, "plan", "-input=false") release(t) }) @@ -102,8 +105,11 @@ func TestAccTfstate(t *testing.T) { before := c.storedVersion(t, block) runs := make([]*cliRun, execs) for i := range runs { - runs[i] = c.start("", "buildingblock", "tfstate", "exec", block.uuid, "--mode", "readwrite", "--", - "tofu", "-chdir="+module, "apply", "-auto-approve", "-input=false", "-lock-timeout=2m", "-var", fmt.Sprintf("input=exec-%d", i)) + // --override-backend refuses to replace an override file, so each exec runs a copy. + dir := filepath.Join(t.TempDir(), fmt.Sprintf("exec-%d", i)) + require.NoError(t, os.CopyFS(dir, os.DirFS(module))) + runs[i] = c.start("", "buildingblock", "tfstate", "exec", block.uuid, "--mode", "readwrite", "--override-backend", "--", + "tofu", "-chdir="+dir, "apply", "-auto-approve", "-input=false", "-lock-timeout=2m", "-var", fmt.Sprintf("input=exec-%d", i)) } for i, run := range runs { require.NoErrorf(t, run.wait(), "exec %d failed:\n%s", i, run.output.String()) @@ -138,7 +144,7 @@ func TestAccTfstate(t *testing.T) { output, err := browser.run("", "buildingblock", "tfstate", "show", block.uuid) require.NoErrorf(t, err, "the Workspace Manager could not read the state:\n%s", output) - output = browser.exec(t, block, "readwrite", "tofu", "-chdir="+module, "apply", "-auto-approve", "-input=false") + output = browser.tofuExec(t, block, "readwrite", module, "apply", "-auto-approve", "-input=false") assert.Contains(t, output, "Apply complete!") }) @@ -190,7 +196,10 @@ func bootstrapBuildingBlock(t *testing.T, c *cli, workspaceManager string) boots t.Helper() runId := "cli-" + strings.ToLower(rand.Text()[:8]) dir := copyTestdata(t, "bootstrap") - vars := []string{"-var", "profile=" + defaultProfile, "-var", "run_id=" + runId, "-var", "workspace_manager=" + workspaceManager} + vars := []string{ + "-var", "profile=" + defaultProfile, "-var", "run_id=" + runId, "-var", "workspace_manager=" + workspaceManager, + "-var", "repository_url=" + repositoryUrl, "-var", "git_ref=" + runnerRef(t), + } c.tofu(t, dir, append([]string{"init", "-input=false"}, vars...)...) t.Cleanup(func() { @@ -221,6 +230,36 @@ func bootstrapBuildingBlock(t *testing.T, c *cli, workspaceManager string) boots return bootstrappedBlock{} } +const repositoryUrl = "https://github.com/meshcloud/meshstack-cli.git" + +// runnerRef names the commit whose testdata/buildingblock the tf-block-runner runs, so that a branch +// tests its own module. The runner clones only the branches and tags of GitHub, so for the merge +// commit of a pull request, which meshfed-release's satellite lane tests, it takes the pull request's head. +func runnerRef(t *testing.T) string { + t.Helper() + head := git(t, "rev-parse", "HEAD") + remote := map[string]string{} + for line := range strings.Lines(git(t, "ls-remote", repositoryUrl)) { + sha, ref, _ := strings.Cut(strings.TrimSpace(line), "\t") + remote[ref] = sha + } + for ref, sha := range remote { + if pull, isMerge := strings.CutSuffix(ref, "/merge"); isMerge && strings.HasPrefix(ref, "refs/pull/") && sha == head { + return remote[pull+"/head"] + } + } + output, err := exec.CommandContext(t.Context(), "git", "fetch", "--dry-run", "--depth=1", repositoryUrl, head).CombinedOutput() + require.NoErrorf(t, err, "push HEAD %s first, as the tf-block-runner checks it out from %s:\n%s", head, repositoryUrl, output) + return head +} + +func git(t *testing.T, args ...string) string { + t.Helper() + output, err := exec.CommandContext(t.Context(), "git", args...).CombinedOutput() + require.NoErrorf(t, err, "git %s failed:\n%s", strings.Join(args, " "), output) + return strings.TrimSpace(string(output)) +} + // copyTestdata keeps what tofu writes, .terraform and the local state, out of the source tree. func copyTestdata(t *testing.T, name string) string { t.Helper() @@ -244,6 +283,14 @@ func (c *cli) tofu(t *testing.T, dir string, args ...string) { require.NoErrorf(t, err, "tofu %s failed:\n%s", args[0], output) } +// tofuExec runs tofu with the backend of --override-backend, as the module of the runner has none. +func (c *cli) tofuExec(t *testing.T, block bootstrappedBlock, mode, module string, args ...string) string { + t.Helper() + output, err := c.run("", append([]string{"buildingblock", "tfstate", "exec", block.uuid, "--mode", mode, "--override-backend", "--", "tofu", "-chdir=" + module}, args...)...) + require.NoErrorf(t, err, "the exec of tofu %s failed:\n%s", strings.Join(args, " "), output) + return output +} + func (c *cli) exec(t *testing.T, block bootstrappedBlock, mode string, command ...string) string { t.Helper() output, err := c.run("", append([]string{"buildingblock", "tfstate", "exec", block.uuid, "--mode", mode, "--"}, command...)...) diff --git a/internal/tfstate/override.go b/internal/tfstate/override.go index 8d17f13f..0a4b5b0c 100644 --- a/internal/tfstate/override.go +++ b/internal/tfstate/override.go @@ -6,6 +6,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" ) // BackendOverrideFile ends in _override.tf, so tofu adds its backend to a module without one, and @@ -34,3 +35,22 @@ func WriteBackendOverride(dir string) (remove func() error, err error) { } return remove, nil } + +// ModuleDir is where command runs its module: the directory of tofu's or terraform's -chdir, or else +// the working directory. It must stay in step with extractChdirOption of OpenTofu's +// internal/command/workdir/chdir.go, which takes -chdir only as -chdir=DIR before the subcommand. +func ModuleDir(command []string) string { + if name := strings.TrimSuffix(filepath.Base(command[0]), ".exe"); name != "tofu" && name != "terraform" { + return "." + } + dir := "." + for _, arg := range command[1:] { + if !strings.HasPrefix(arg, "-") { + break + } + if value, ok := strings.CutPrefix(arg, "-chdir="); ok && value != "" { + dir = value + } + } + return dir +} diff --git a/internal/tfstate/override_test.go b/internal/tfstate/override_test.go index 49e55903..9288200d 100644 --- a/internal/tfstate/override_test.go +++ b/internal/tfstate/override_test.go @@ -13,24 +13,29 @@ import ( func TestBackendOverride(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, BackendOverrideFile) + require.NoError(t, os.WriteFile(path, []byte("# the user's own\n"), 0o600)) - remove, err := WriteBackendOverride(dir) + _, err := WriteBackendOverride(dir) + require.ErrorContains(t, err, "exists already") + kept, err := fs.ReadFile(os.DirFS(dir), BackendOverrideFile) require.NoError(t, err) + assert.Equal(t, "# the user's own\n", string(kept)) +} - t.Run("adds the http backend", func(t *testing.T) { - written, err := fs.ReadFile(os.DirFS(dir), BackendOverrideFile) - require.NoError(t, err) - assert.Equal(t, backendOverride, string(written)) - }) - - t.Run("refuses to replace a file of the same name", func(t *testing.T) { - _, err := WriteBackendOverride(dir) - require.ErrorContains(t, err, "exists already") - assert.FileExists(t, path, "the file that was there stays") - }) - - t.Run("is gone once removed", func(t *testing.T) { - require.NoError(t, remove()) - assert.NoFileExists(t, path) - }) +func TestModuleDir(t *testing.T) { + for _, command := range []struct { + args []string + dir string + }{ + {[]string{"tofu", "-chdir=module", "plan"}, "module"}, + {[]string{"/usr/local/bin/terraform", "-chdir=module", "plan"}, "module"}, + {[]string{"tofu.exe", "-chdir=module", "plan"}, "module"}, + {[]string{"tofu", "-chdir=first", "-chdir=last", "plan"}, "last"}, + {[]string{"tofu", "plan", "-chdir=module"}, "."}, + {[]string{"tofu", "-chdir", "module", "plan"}, "."}, + {[]string{"tofu"}, "."}, + {[]string{"terragrunt", "-chdir=module", "plan"}, "."}, + } { + assert.Equalf(t, command.dir, ModuleDir(command.args), "%v", command.args) + } } diff --git a/meshstack-satellite.gradle b/meshstack-satellite.gradle index 6a9f7497..132d4238 100644 --- a/meshstack-satellite.gradle +++ b/meshstack-satellite.gradle @@ -6,6 +6,7 @@ satellite { acceptance { run = 'TestAcc' services 'meshfed-api', 'block-coordinator-api', 'building-block-runner:runners' + files 'cmd/internal/testacc/testdata/**' environment MESHSTACK_CLI_TEST_ACC: '1', MESHSTACK_CLI_NO_BROWSER: '1', MESHSTACK_API_KEY: fromBackendConfig('auth.openid.apiKeys.terraform-provider-acceptance.clientId'),