Repository: cloud-custodian/cloud-custodian

Issue:
shift-left - support passing variables/inputs on run cli



Discussion:
tfparse added support for passing a vars file. its currently only a single file, and we need to experiment document on if env files work automatically (they should), and if we can assemble or pass through on multiple vars files.
releasing tfparse 0.6.0 so we can expose the --var-file flag
https://github.com/cloud-custodian/tfparse/releases/tag/v0.6.0

Resolving patch:
diff --git a/tools/c7n_left/c7n_left/cli.py b/tools/c7n_left/c7n_left/cli.py
--- a/tools/c7n_left/c7n_left/cli.py
+++ b/tools/c7n_left/c7n_left/cli.py
@@ -27,14 +27,33 @@ def cli():
 
 @cli.command()
 @click.option("--format", default="terraform")
-@click.option("--filters", help="filter policies or resources as k=v pairs with globbing")
-@click.option("-p", "--policy-dir", type=click.Path())
-@click.option("-d", "--directory", type=click.Path())
-@click.option("-o", "--output", default="cli", type=click.Choice(report_outputs.keys()))
-@click.option("--output-file", type=click.File("w"), default="-")
-@click.option("--output-query", default=None)
+@click.option("--filters", help="Filter policies or resources as k=v pairs with globbing")
+@click.option("-p", "--policy-dir", type=click.Path(), help="Directory with policies")
+@click.option("-d", "--directory", type=click.Path(), help="IaC directory to evaluate")
+@click.option(
+    "-o",
+    "--output",
+    default="cli",
+    help="Output format (default cli)",
+    type=click.Choice(report_outputs.keys()),
+)
+@click.option(
+    "--output-file", help="Output file (default stdout)", type=click.File("w"), default="-"
+)
+@click.option(
+    "--var-file",
+    help="Load variables from the given file, can be used more than once",
+    type=click.Path(exists=True, dir_okay=False),
+    default=(),
+    multiple=True,
+)
+@click.option(
+    "--output-query", default=None, help="Use a jmespath expression to filter json output"
+)
 @click.option("--summary", default="policy", type=click.Choice(summary_options.keys()))
-def run(format, policy_dir, directory, output, output_file, output_query, summary, filters):
+def run(
+    format, policy_dir, directory, output, output_file, var_file, output_query, summary, filters
+):
     """evaluate policies against IaC sources.
 
     c7n-left -p policy_dir -d terraform_root --filters "severity=HIGH"
@@ -47,10 +66,12 @@ def run(format, policy_dir, directory, output, output_file, output_query, summar
         policy_dir=Path(policy_dir),
         output=output,
         output_file=output_file,
+        var_files=var_file,
         output_query=output_query,
         summary=summary,
         filters=filters,
     )
+
     exec_filter = ExecutionFilter.parse(config)
     config["exec_filter"] = exec_filter
     policies = exec_filter.filter_policies(load_policies(policy_dir, config))
@@ -75,6 +96,7 @@ def test(policy_dir, filters):
         policy_dir=policy_dir,
         output_file=sys.stdout,
         filters=filters,
+        var_files=(),
     )
 
     reporter = TestReporter(None, config)
diff --git a/tools/c7n_left/c7n_left/core.py b/tools/c7n_left/c7n_left/core.py
--- a/tools/c7n_left/c7n_left/core.py
+++ b/tools/c7n_left/c7n_left/core.py
@@ -215,7 +215,7 @@ def run(self) -> bool:
             log.warning("no %s source files found" % provider.type)
             return True
 
-        graph = provider.parse(self.options.source_dir)
+        graph = provider.parse(self.options.source_dir, self.options.var_files)
 
         for p in self.policies:
             p.expand_variables(p.get_variables())
diff --git a/tools/c7n_left/c7n_left/providers/terraform/provider.py b/tools/c7n_left/c7n_left/providers/terraform/provider.py
--- a/tools/c7n_left/c7n_left/providers/terraform/provider.py
+++ b/tools/c7n_left/c7n_left/providers/terraform/provider.py
@@ -1,6 +1,9 @@
 # Copyright The Cloud Custodian Authors.
 # SPDX-License-Identifier: Apache-2.0
 #
+import contextlib
+from pathlib import Path
+import tempfile
 
 from tfparse import load_from_path
 
@@ -42,17 +45,76 @@ def initialize_policies(self, policies, options):
             p.data["mode"] = {"type": "terraform-source"}
         return policies
 
-    def parse(self, source_dir):
-        graph = TerraformGraph(load_from_path(source_dir, allow_downloads=True), source_dir)
-        graph.build()
-        log.debug("Loaded %d %s resources", len(graph), self.type)
-        return graph
+    def parse(self, source_dir, var_files=()):
+        with self.get_variables(source_dir, var_files) as var_files:
+            graph = TerraformGraph(
+                load_from_path(source_dir, vars_paths=var_files, allow_downloads=True),
+                source_dir,
+            )
+            graph.build()
+            log.debug("Loaded %d %s resources", len(graph), self.type)
+            return graph
 
     def match_dir(self, source_dir):
         files = list(source_dir.glob("*.tf"))
         files += list(source_dir.glob("*.tf.json"))
         return files
 
+    @contextlib.contextmanager
+    def get_variables(self, source_dir, var_files, tf_vars=()):
+        """handle all the ways to pass variables into terraform
+
+        also perform various workarounds on tfparse's scanning to mirror terraform behavior.
+
+        - pickup tf var files not in the root module
+        - pickup auto.tfvars
+
+        note TF_VAR_ environment variables are handled by tfparse.
+
+        https://developer.hashicorp.com/terraform/language/values/variables#assigning-values-to-root-module-variables
+        precedence
+        https://www.ntweekly.com/2023/03/15/terraform-variables-precedence-and-order/
+        """
+        var_files = [Path(v) for v in var_files]
+        resolved_files = []
+        temp_files = []
+
+        def write_file_content(content):
+            fh = tempfile.NamedTemporaryFile(
+                dir=source_dir, prefix="c7n-left-", suffix=".tfvars", mode="w+"
+            )
+            fh.write(content)
+            fh.flush()
+            temp_files.append(fh)
+            resolved_files.append(Path(fh.name).relative_to(source_dir))
+
+        # auto vars
+        resolved_files.extend([f.relative_to(source_dir) for f in source_dir.rglob("*auto.tfvars")])
+        resolved_files.extend(
+            [f.relative_to(source_dir) for f in source_dir.rglob("*auto.tfvars.json")]
+        )
+
+        # see tf doc link above, these are also auto loaded.
+        if (source_dir / "terraform.tfvars").exists():
+            resolved_files.append(Path("terraform.tfvars"))
+        if (source_dir / "terraform.tfvars.json").exists():
+            resolved_files.append(Path("terraform.tfvars.json"))
+
+        # move any files outside of module root into module as temp files.
+        for v in var_files:
+            if not v.is_absolute() and (source_dir / v).exists():
+                resolved_files.append(v)
+            elif v.is_absolute() and str(v).startswith(str(source_dir)):
+                resolved_files.append(v.relative_to(source_dir))
+            else:
+                write_file_content(v.read_text())
+
+        try:
+            yield resolved_files
+        finally:
+            for fh in temp_files:
+                fh.close()
+
 
 @execution.register("terraform-source")
 class TerraformSource(IACSourceMode):


Test patch:
diff --git a/tools/c7n_left/tests/conftest.py b/tools/c7n_left/tests/conftest.py
--- a/tools/c7n_left/tests/conftest.py
+++ b/tools/c7n_left/tests/conftest.py
@@ -1,4 +1,8 @@
+import contextlib
+import io
+
 import pytest
+from click.testing import CliRunner
 
 from c7n.testing import PyTestUtils
 
@@ -7,3 +11,20 @@
 def test(request):
     test_utils = PyTestUtils(request)
     return test_utils
+
+
+class DebugCliRunner(CliRunner):
+    def invoke(self, cli, args=None, **kwargs):
+        params = kwargs.copy()
+        params['catch_exceptions'] = False
+        return super().invoke(cli, args=args, **params)
+
+    @contextlib.contextmanager
+    def isolation(self, input=None, env=None, color=False):
+        s = io.BytesIO(b"{stdout not captured because --pdb-trace}")
+        yield (s, not self.mix_stderr and s)
+
+
+@pytest.fixture
+def debug_cli_runner():
+    return DebugCliRunner()
diff --git a/tools/c7n_left/tests/test_left.py b/tools/c7n_left/tests/test_left.py
--- a/tools/c7n_left/tests/test_left.py
+++ b/tools/c7n_left/tests/test_left.py
@@ -49,7 +49,9 @@ def on_results(self, results):
 
 def run_policy(policy, terraform_dir, tmp_path):
     (tmp_path / "policies.json").write_text(json.dumps({"policies": [policy]}, indent=2))
-    config = Config.empty(policy_dir=tmp_path, source_dir=terraform_dir, exec_filter=None)
+    config = Config.empty(
+        policy_dir=tmp_path, source_dir=terraform_dir, exec_filter=None, var_files=()
+    )
     policies = utils.load_policies(tmp_path, config)
     reporter = ResultsReporter()
     core.CollectionRunner(policies, config, reporter).run()
@@ -257,6 +259,116 @@ def test_provider_parse():
     }
 
 
+@pytest.fixture
+def var_tf_setup(tmp_path):
+    (tmp_path / "tf").mkdir()
+    (tmp_path / "tf" / "main.tf").write_text(
+        """
+variable balancer_type {
+  type = string
+  default = "application"
+}
+
+resource "aws_alb" "positive1" {
+  name               = "test-lb-tf"
+  internal           = false
+  load_balancer_type = var.balancer_type
+  subnets            = aws_subnet.public.*.id
+}
+        """
+    )
+
+
+#
+# we can't test env vars, as they need to be set before
+# we import tfparse. manually verified they work as expected.
+#
+# def xtest_graph_var_env(test, tmp_path, var_tf_setup):
+#    os.putenv("TF_VAR_balancer_type", "network")
+#    graph = TerraformProvider().parse(tmp_path / "tf")
+#    resources = list(graph.get_resources_by_type("aws_alb"))
+#    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_graph_non_root_var_file(tmp_path, var_tf_setup):
+    (tmp_path / "vars.tfvars").write_text('balancer_type = "network"')
+    graph = TerraformProvider().parse(tmp_path / "tf", (tmp_path / "vars.tfvars",))
+    resources = list(graph.get_resources_by_type("aws_alb"))
+    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_graph_var_auto_default_json(tmp_path, var_tf_setup):
+    (tmp_path / "tf" / "terraform.tfvars.json").write_text(json.dumps({'balancer_type': 'network'}))
+    graph = TerraformProvider().parse(tmp_path / "tf")
+    resources = list(graph.get_resources_by_type("aws_alb"))
+    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_graph_var_auto_default(tmp_path, var_tf_setup):
+    (tmp_path / "tf" / "terraform.tfvars").write_text('balancer_type = "network"')
+    graph = TerraformProvider().parse(tmp_path / "tf")
+    resources = list(graph.get_resources_by_type("aws_alb"))
+    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_graph_var_auto(tmp_path, var_tf_setup):
+    (tmp_path / "tf" / "vars.auto.tfvars").write_text('balancer_type = "network"')
+    graph = TerraformProvider().parse(tmp_path / "tf")
+    resources = list(graph.get_resources_by_type("aws_alb"))
+    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_graph_var_file_abs(tmp_path, var_tf_setup):
+    (tmp_path / "tf" / "vars.tfvars").write_text('balancer_type = "network"')
+    graph = TerraformProvider().parse(tmp_path / "tf", (tmp_path / "tf" / "vars.tfvars",))
+    resources = list(graph.get_resources_by_type("aws_alb"))
+    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_graph_var_file(tmp_path, var_tf_setup):
+    (tmp_path / "tf" / "vars.tfvars").write_text('balancer_type = "network"')
+    graph = TerraformProvider().parse(tmp_path / "tf", ("vars.tfvars",))
+    resources = list(graph.get_resources_by_type("aws_alb"))
+    assert resources[0][1][0]['load_balancer_type'] == 'network'
+
+
+def test_cli_var_file(tmp_path, var_tf_setup, debug_cli_runner):
+    (tmp_path / "tf" / "vars.tfvars").write_text('balancer_type = "network"')
+    (tmp_path / "policy.json").write_text(
+        json.dumps(
+            {
+                "policies": [
+                    {
+                        "name": "check-multi",
+                        "resource": ["terraform.aws_alb"],
+                        "filters": [{"load_balancer_type": "network"}],
+                    }
+                ]
+            }
+        )
+    )
+    result = debug_cli_runner.invoke(
+        cli.cli,
+        [
+            "run",
+            "-p",
+            str(tmp_path),
+            "-d",
+            str(tmp_path / "tf"),
+            "-o",
+            "json",
+            "--var-file",
+            tmp_path / "tf" / "vars.tfvars",
+            "--output-file",
+            str(tmp_path / "output.json"),
+        ],
+        catch_exceptions=False,
+    )
+    assert result.exit_code == 1
+    data = json.loads((tmp_path / "output.json").read_text())
+    assert len(data["results"]) == 1
+
+
 def test_multi_resource_list_policy(tmp_path):
     (tmp_path / "policy.json").write_text(
         json.dumps(