Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions cmd/buildingblock/tfstate/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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",
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/buildingblock/tfstate/tfstate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <building-block-uuid> --override-backend -- tofu init`,
Example: ` meshstack bb tfstate show 0b5c1d3e-5f1a-4c2b-9d7e-2a6f8e4b1c90 | jq .resources
Expand Down
65 changes: 50 additions & 15 deletions cmd/internal/testacc/testdata/bootstrap/main.tf
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,6 +24,14 @@ variable "workspace_manager" {
type = string
}

variable "repository_url" {
type = string
}

variable "git_ref" {
type = string
}

provider "meshstack" {
profile = var.profile
}
Expand Down Expand Up @@ -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}."
}
}
}
11 changes: 11 additions & 0 deletions cmd/internal/testacc/testdata/buildingblock/main.tf
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 0 additions & 16 deletions cmd/internal/testacc/testdata/module/main.tf

This file was deleted.

69 changes: 58 additions & 11 deletions cmd/internal/testacc/tfstate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -63,21 +66,21 @@ 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")
})

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)
})

Expand All @@ -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())
Expand Down Expand Up @@ -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!")
})

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand All @@ -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...)...)
Expand Down
20 changes: 20 additions & 0 deletions internal/tfstate/override.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
39 changes: 22 additions & 17 deletions internal/tfstate/override_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions meshstack-satellite.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading