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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ terranova apply --strategy parallel --auto-approve --fail-at-end
files (respecting `.gitignore`).
- Each changed file is mapped to its nearest ancestor directory containing a `manifest.yml`; a change
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.
- 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.
Expand Down
42 changes: 41 additions & 1 deletion src/terranova/commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,46 @@ def _match_resource_dirs(
return sorted(matched.values(), key=lambda entry: entry[1])


def _match_shared_dependents(
shared_dir: Path,
all_dirs: list[tuple[Path, str]],
changed_files: list[Path],
) -> list[tuple[Path, str]]:
"""
Resource-group dirs whose manifest `dependencies` cover a changed shared file.

A dependency `source` is a file or a directory below `shared_dir`; a changed
file matches it when it is that file or lives inside that directory. Manifests
are only read when at least one changed file is below `shared_dir`.
"""
shared_root = shared_dir.resolve()
# Resolve the parent only: a changed file may itself be a symlink to shared.
changed_shared = [
resolved
for resolved in (f.parent.resolve() / f.name for f in changed_files)
if resolved.is_relative_to(shared_root)
]
if not changed_shared:
return []

matched: list[tuple[Path, str]] = []
for full_path, rel_path in all_dirs:
manifest = read_manifest(full_path)
for dependency in manifest.dependencies or []:
source = (shared_root / dependency.source).resolve()
if any(changed.is_relative_to(source) for changed in changed_shared):
matched.append((full_path, rel_path))
break
return matched


def auto_scope_resource_dirs(
conf_dir: Path, resources_dir: Path
) -> 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.
This function handle errors by logging and exiting.
"""
# `conf_dir` is guaranteed to exist (`--conf-dir` requires it), unlike
Expand All @@ -292,7 +326,13 @@ def auto_scope_resource_dirs(
changed_files = [root / rel for rel in git.changed_files()]

all_dirs = find_all_resource_dirs(resources_dir)
return _match_resource_dirs(resources_dir, all_dirs, changed_files)
matched = dict(_match_resource_dirs(resources_dir, all_dirs, changed_files))
shared_dir = AppContext(conf_dir=conf_dir).shared_dir
for full_path, rel_path in _match_shared_dependents(
shared_dir, all_dirs, changed_files
):
matched[full_path] = rel_path
return sorted(matched.items(), key=lambda entry: entry[1])


def resolve_resource_dirs(
Expand Down
91 changes: 91 additions & 0 deletions tests/it/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import click
import pytest
import yaml
from click.exceptions import Exit

from terranova.binds import Git, Terraform
Expand Down Expand Up @@ -56,6 +57,20 @@ def _write_manifest_dir(base: Path, *parts: str) -> Path:
return resource_dir


def _write_manifest_dir_depending_on(base: Path, name: str, *sources: str) -> Path:
resource_dir = base / name
resource_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"version": "1.0",
"metadata": {"name": name, "description": "test"},
"dependencies": [
{"source": source, "target": Path(source).name} for source in sources
],
}
(resource_dir / "manifest.yml").write_text(yaml.safe_dump(manifest))
return resource_dir


def _write_manifest_dir_with_engine(
base: Path, name: str, engine_name: str, version: str
) -> Path:
Expand Down Expand Up @@ -523,6 +538,82 @@ def test_no_changes_returns_empty_list(
assert auto_scope_resource_dirs(tmp_path, resources_dir) == []


class TestAutoScopeSharedDependencies:
def test_changed_shared_file_selects_every_dependent_group(
self, tmp_path: Path, resources_dir: Path
) -> None:
shared = tmp_path / "shared" / "providers"
shared.mkdir(parents=True)
(shared / "github.tf").write_text("# v1\n")
group_a = _write_manifest_dir_depending_on(
tmp_path / "resources", "group_a", "providers/github.tf"
)
group_b = _write_manifest_dir_depending_on(
tmp_path / "resources", "group_b", "providers/github.tf"
)
_write_manifest_dir(tmp_path, "resources", "group_c")
_init_git_repo(tmp_path)

(shared / "github.tf").write_text("# v2\n")

result = auto_scope_resource_dirs(tmp_path, resources_dir)

assert result == [(group_a, "group_a"), (group_b, "group_b")]

def test_changed_file_inside_shared_directory_source_matches_by_prefix(
self, tmp_path: Path, resources_dir: Path
) -> None:
shared = tmp_path / "shared" / "scripts" / "nested"
shared.mkdir(parents=True)
(shared / "run.sh").write_text("echo 1\n")
group_a = _write_manifest_dir_depending_on(
tmp_path / "resources", "group_a", "scripts"
)
_write_manifest_dir_depending_on(
tmp_path / "resources", "group_b", "providers/other.tf"
)
_init_git_repo(tmp_path)

(shared / "run.sh").write_text("echo 2\n")

result = auto_scope_resource_dirs(tmp_path, resources_dir)

assert result == [(group_a, "group_a")]

def test_unreferenced_shared_file_selects_nothing(
self, tmp_path: Path, resources_dir: Path
) -> None:
shared = tmp_path / "shared"
shared.mkdir()
(shared / "unused.tf").write_text("# v1\n")
_write_manifest_dir_depending_on(
tmp_path / "resources", "group_a", "providers/github.tf"
)
_init_git_repo(tmp_path)

(shared / "unused.tf").write_text("# v2\n")

assert auto_scope_resource_dirs(tmp_path, resources_dir) == []

def test_group_changed_directly_and_through_shared_is_listed_once(
self, tmp_path: Path, resources_dir: Path
) -> None:
shared = tmp_path / "shared"
shared.mkdir()
(shared / "github.tf").write_text("# v1\n")
group_a = _write_manifest_dir_depending_on(
tmp_path / "resources", "group_a", "github.tf"
)
_init_git_repo(tmp_path)

(shared / "github.tf").write_text("# v2\n")
(group_a / "main.tf").write_text('resource "aws_x" "y" {}\n')

result = auto_scope_resource_dirs(tmp_path, resources_dir)

assert result == [(group_a, "group_a")]


class TestResolveResourceDirs:
def test_path_and_auto_scope_together_raises_usage_error(
self, tmp_path: Path, resources_dir: Path
Expand Down
Loading