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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,13 +351,18 @@ terranova apply --strategy parallel --auto-approve --fail-at-end
outside any resource group is ignored, and several changes in one group select it only once.
- A change below the `shared` directory selects every resource group whose manifest `dependencies` reference
it: a `source` that is a directory matches any file inside it.
- With `--base <ref>` (e.g. `--base origin/main`), changes are compared to the merge base of `<ref>` and
`HEAD` instead of `HEAD`. Commits already made on the branch are included, along with staged, unstaged and
untracked changes. This is what you want in CI, where the working tree is clean. `--base` requires
`--auto-scope`, and the history must be available (e.g. `fetch-depth: 0` on a CI checkout).
- It can't be combined with an explicit `path` (or, for `apply`, a `.tnplan` file), and it must be run
from inside a git repository.
- With `docs`, the docs directory isn't wiped first, so documentation of groups outside the scope is kept.

```bash
terranova plan --auto-scope
terranova apply -A --auto-approve
terranova plan -A --base origin/main
```

- To apply exactly what was planned, combine it with `plan --out`: the saved `.tnplan` file only contains
Expand Down
21 changes: 17 additions & 4 deletions src/terranova/binds.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,18 +584,31 @@ def repo_root(self) -> str:
).exec()
return capture.getvalue().strip()

def changed_files(self) -> list[str]:
def merge_base(self, ref: str) -> str:
"""Show the best common ancestor commit between `ref` and HEAD."""
capture = StringIO()
# `--` keeps a ref that looks like an option from being parsed as one, and
# stderr is kept out of `capture` so a git warning can't end up in the hash.
self._cmd.args("merge-base", "--", ref, "HEAD").stdout(capture).stderr(
StringIO()
).exec()
return capture.getvalue().strip()

def changed_files(self, base: str = "HEAD") -> list[str]:
"""
List files changed relative to HEAD (staged + unstaged) plus untracked files.
List files changed relative to `base` (staged + unstaged) plus untracked files.

Args:
base: commit-ish the working tree is compared to.

Returns:
paths relative to `cwd()` - callers should set `cwd()` to the repo
root first (via `repo_root()`) so output is unambiguous.
"""
capture = StringIO()
self._cmd.args("diff", "--name-only", "HEAD").stdout(capture).stderr(
self._cmd.args("diff", "--name-only", "--no-renames", base).stdout(
capture
).exec()
).stderr(capture).exec()
tracked = [line for line in capture.getvalue().splitlines() if line]

capture = StringIO()
Expand Down
7 changes: 6 additions & 1 deletion src/terranova/commands/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
TerraformTask,
auto_scope_option,
auto_scope_resource_dirs,
base_option,
check_base_requires_auto_scope,
complete_resource_path,
execute_tasks,
parse_execution_plan,
Expand Down Expand Up @@ -97,6 +99,7 @@ def run(self) -> None:
"path_or_plan", type=str, required=False, shell_complete=complete_resource_path
)
@auto_scope_option
@base_option
@click.option(
"--auto-approve",
help="Skip interactive approval of plan before applying.",
Expand Down Expand Up @@ -131,6 +134,7 @@ def apply(
ctx: AppContext,
path_or_plan: str | None,
auto_scope: bool,
base: str | None,
auto_approve: bool,
target: str,
fail_at_end: bool,
Expand All @@ -141,13 +145,14 @@ def apply(
# Check if there is a plan to apply
execution_plan: dict[str, str] | None
paths: list[tuple[Path, str]]
check_base_requires_auto_scope(auto_scope, base)
if auto_scope and path_or_plan:
raise click.UsageError(
"`--auto-scope`/`-A` can't be combined with `path_or_plan`."
)
if auto_scope:
execution_plan = None
paths = auto_scope_resource_dirs(ctx.conf_dir, ctx.resources_dir)
paths = auto_scope_resource_dirs(ctx.conf_dir, ctx.resources_dir, base)
elif path_or_plan and path_or_plan.endswith("tnplan"):
execution_plan = parse_execution_plan(
Path(path_or_plan).read_text(Constants.ENCODING_UTF_8)
Expand Down
10 changes: 8 additions & 2 deletions src/terranova/commands/destroy.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from terranova.commands.helpers import (
auto_scope_option,
base_option,
complete_resource_path,
mount_context,
resolve_resource_dirs,
Expand All @@ -30,11 +31,16 @@
@click.command("destroy")
@click.argument("path", type=str, required=False, shell_complete=complete_resource_path)
@auto_scope_option
@base_option
@click.pass_obj
def destroy(ctx: AppContext, path: str | None, auto_scope: bool) -> None:
def destroy(
ctx: AppContext, path: str | None, auto_scope: bool, base: str | None
) -> None:
"""Destroy previously-created resources."""
# Find all resources manifests
paths = resolve_resource_dirs(ctx.conf_dir, ctx.resources_dir, path, auto_scope)
paths = resolve_resource_dirs(
ctx.conf_dir, ctx.resources_dir, path, auto_scope, base
)

# Format all paths
for full_path, rel_path in paths:
Expand Down
14 changes: 12 additions & 2 deletions src/terranova/commands/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from terranova.commands.helpers import (
auto_scope_option,
base_option,
complete_resource_path,
discover_resources,
read_manifest,
Expand All @@ -42,6 +43,7 @@ def format_markdown(text: str) -> str:
@click.command("docs")
@click.argument("path", type=str, required=False, shell_complete=complete_resource_path)
@auto_scope_option
@base_option
@click.option(
"--docs-dir",
help="Docs directory path.",
Expand All @@ -50,10 +52,18 @@ def format_markdown(text: str) -> str:
default="./docs",
)
@click.pass_obj
def docs(ctx: AppContext, path: str | None, auto_scope: bool, docs_dir: Path) -> None:
def docs(
ctx: AppContext,
path: str | None,
auto_scope: bool,
base: str | None,
docs_dir: Path,
) -> None:
"""Generate documentation for all resources."""
# Find all resources manifests
paths = resolve_resource_dirs(ctx.conf_dir, ctx.resources_dir, path, auto_scope)
paths = resolve_resource_dirs(
ctx.conf_dir, ctx.resources_dir, path, auto_scope, base
)
jobs: list[tuple[Path, Path]] = [
(full_path, docs_dir.joinpath(rel_path)) for full_path, rel_path in paths
]
Expand Down
37 changes: 33 additions & 4 deletions src/terranova/commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from terranova.engines import default_engine_manager
from terranova.exceptions import (
EngineError,
GitBaseRefError,
GitRepositoryError,
GraphError,
ImportOutputError,
Expand Down Expand Up @@ -58,6 +59,16 @@
)
"""Shared `--auto-scope`/`-A` option for `plan`, `apply`, `destroy` and `docs`."""

base_option = click.option(
"--base",
help="With `--auto-scope`, compare against the merge base of this git ref "
+ "and HEAD (e.g. `origin/main`) instead of HEAD, so changes already "
+ "committed on the branch are included.",
type=str,
default=None,
)
"""Shared `--base` option, only valid together with `--auto-scope`."""

flat_strategy_option = click.option(
"--strategy",
help="Execution strategy across resource groups: `sequential` (default) runs "
Expand Down Expand Up @@ -307,12 +318,13 @@ def _match_shared_dependents(


def auto_scope_resource_dirs(
conf_dir: Path, resources_dir: Path
conf_dir: Path, resources_dir: Path, base: str | None = None
) -> list[tuple[Path, str]]:
"""
Scope resource dirs to those affected by the current git diff (working
tree and staged changes vs HEAD, including untracked files).
A change below the `shared` dir also selects every group depending on it.
With `base`, changes are taken relative to the merge base of `base` and HEAD.
This function handle errors by logging and exiting.
"""
# `conf_dir` is guaranteed to exist (`--conf-dir` requires it), unlike
Expand All @@ -327,7 +339,13 @@ def auto_scope_resource_dirs(
GitRepositoryError(conf_dir),
)
git.cwd(root)
changed_files = [root / rel for rel in git.changed_files()]
base_commit = "HEAD"
if base is not None:
try:
base_commit = git.merge_base(base)
except ErrorReturnCode:
log.fatal(f"resolve the git base ref `{base}`", GitBaseRefError(base))
changed_files = [root / rel for rel in git.changed_files(base_commit)]

all_dirs = find_all_resource_dirs(resources_dir)
matched = dict(_match_resource_dirs(resources_dir, all_dirs, changed_files))
Expand All @@ -339,16 +357,27 @@ def auto_scope_resource_dirs(
return sorted(matched.items(), key=lambda entry: entry[1])


def check_base_requires_auto_scope(auto_scope: bool, base: str | None) -> None:
"""Reject `--base` given without `--auto-scope`."""
if base is not None and not auto_scope:
raise click.UsageError("`--base` requires `--auto-scope`/`-A`.")


def resolve_resource_dirs(
conf_dir: Path, resources_dir: Path, path: str | None, auto_scope: bool
conf_dir: Path,
resources_dir: Path,
path: str | None,
auto_scope: bool,
base: str | None = None,
) -> list[tuple[Path, str]]:
"""Shared `path`/`--auto-scope` resolution for `plan`, `destroy` and `docs`."""
check_base_requires_auto_scope(auto_scope, base)
if auto_scope and path:
raise click.UsageError(
"`--auto-scope`/`-A` can't be combined with an explicit `path`."
)
if auto_scope:
return auto_scope_resource_dirs(conf_dir, resources_dir)
return auto_scope_resource_dirs(conf_dir, resources_dir, base)
return resource_dirs(resources_dir, path)


Expand Down
14 changes: 12 additions & 2 deletions src/terranova/commands/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from terranova.commands.helpers import (
auto_scope_option,
base_option,
complete_resource_path,
resolve_resource_dirs,
)
Expand All @@ -31,16 +32,25 @@
@click.command("migrate")
@click.argument("path", type=str, required=False, shell_complete=complete_resource_path)
@auto_scope_option
@base_option
@click.option(
"--dry-run",
help="Report what would change without writing any manifest.",
is_flag=True,
default=False,
)
@click.pass_obj
def migrate(ctx: AppContext, path: str | None, auto_scope: bool, dry_run: bool) -> None:
def migrate(
ctx: AppContext,
path: str | None,
auto_scope: bool,
base: str | None,
dry_run: bool,
) -> None:
"""Migrate manifests to the latest manifest version in batch."""
dirs = resolve_resource_dirs(ctx.conf_dir, ctx.resources_dir, path, auto_scope)
dirs = resolve_resource_dirs(
ctx.conf_dir, ctx.resources_dir, path, auto_scope, base
)

# Recipes prompt on first use only, then the answers apply to every manifest.
book = RecipeBook()
Expand Down
7 changes: 6 additions & 1 deletion src/terranova/commands/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from terranova.commands.helpers import (
TerraformTask,
auto_scope_option,
base_option,
complete_resource_path,
execute_tasks,
read_manifests_and_waves,
Expand Down Expand Up @@ -112,6 +113,7 @@ def run(self) -> None:
@click.command("plan")
@click.argument("path", type=str, required=False, shell_complete=complete_resource_path)
@auto_scope_option
@base_option
@click.option(
"--input/--no-input",
help="Ask for input for variables if not directly set.",
Expand Down Expand Up @@ -172,6 +174,7 @@ def plan(
ctx: AppContext,
path: str | None,
auto_scope: bool,
base: str | None,
input: bool,
no_color: bool,
parallelism: int,
Expand All @@ -183,7 +186,9 @@ def plan(
) -> None:
"""Show changes required by the current configuration."""
# Find all resources manifests
paths = resolve_resource_dirs(ctx.conf_dir, ctx.resources_dir, path, auto_scope)
paths = resolve_resource_dirs(
ctx.conf_dir, ctx.resources_dir, path, auto_scope, base
)

# Execution plan
execution_plan: dict[str, str] = {}
Expand Down
12 changes: 12 additions & 0 deletions src/terranova/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ def __init__(self, resources_dir: Path) -> None:
)


class GitBaseRefError(ExplainedError):
"""Represents a failure to resolve the git base ref given to `--base`."""

def __init__(self, ref: str) -> None:
"""Init git base ref error."""
super().__init__(
cause=f"can't compute the merge base between `{ref}` and HEAD",
resolution="Check that the ref exists and that the history is available "
+ "(e.g. `fetch-depth: 0` on a CI checkout).",
)


class GraphError(ExplainedError):
"""Represents an error building a dependency graph between resource groups."""

Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import pytest

from terranova.binds import Git
from terranova.utils import IS_WINDOWS, log


Expand Down Expand Up @@ -44,6 +45,13 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
item.add_marker(skip)


def commit_all(path: Path, message: str) -> None:
"""Stage everything in the git repo at `path` and commit it."""
git = Git(path)
git.add()
git.commit(message, author=("test", "test@example.com"))


_FAKE_TERRAFORM_SCRIPT = """#!/usr/bin/env python3
import base64
import json
Expand Down
7 changes: 3 additions & 4 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from terranova.engines import EngineManager
from terranova.exceptions import EngineError
from terranova.resources import ResourcesEngine
from tests.conftest import commit_all

# Needs `terraform_data` (1.4+); pinned so the checksum-verified download is reproducible.
REAL_TERRAFORM_VERSION: Final[str] = "1.9.8"
Expand Down Expand Up @@ -62,10 +63,8 @@ def copy_as_git_repo(fixture_dir: Path, dest: Path) -> None:
"""
shutil.copytree(fixture_dir, dest)

git = Git(dest)
git.init()
git.add()
git.commit("initial", author=("test", "test@example.com"))
Git(dest).init()
commit_all(dest, "initial")


def assert_result(result: Result) -> tuple[str, str | None]:
Expand Down
Loading
Loading