Skip to content
Open
17 changes: 17 additions & 0 deletions backend/infrahub/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ def __init__(
self.directory = directory


class RepositoryConfigurationError(RepositoryError):
"""Raised when repository configuration file is missing or invalid.

This exception provides clear error messages when a Git repository
lacks a .infrahub.yml/.infrahub.yaml file or when the file cannot
be parsed correctly.
"""

def __init__(self, identifier: str, message: str | None = None) -> None:
super().__init__(
identifier=identifier,
message=message
or f"Repository '{identifier}' is missing a configuration file. "
f"Please add a '.infrahub.yml' or '.infrahub.yaml' file to the repository root.",
)


class CommitNotFoundError(Error):
HTTP_CODE: int = 400

Expand Down
90 changes: 60 additions & 30 deletions backend/infrahub/git/integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@
from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent
from infrahub.events.models import EventMeta
from infrahub.events.repository_action import CommitUpdatedEvent
from infrahub.exceptions import CheckError, RepositoryInvalidFileSystemError, TransformError
from infrahub.exceptions import (
CheckError,
RepositoryConfigurationError,
RepositoryInvalidFileSystemError,
TransformError,
)
from infrahub.git.base import InfrahubRepositoryBase, extract_repo_file_information
from infrahub.log import get_logger
from infrahub.workers.dependencies import get_event_service
Expand Down Expand Up @@ -180,31 +185,27 @@ async def import_objects_from_files(
self.create_commit_worktree(commit)
await self._update_sync_status(branch_name=infrahub_branch_name, status=RepositorySyncStatus.SYNCING)

config_file = await self.get_repository_config(branch_name=infrahub_branch_name, commit=commit) # type: ignore[misc]
sync_status = RepositorySyncStatus.IN_SYNC if config_file else RepositorySyncStatus.ERROR_IMPORT

sync_status = RepositorySyncStatus.IN_SYNC
error: Exception | None = None

try:
if config_file:
await self.import_schema_files(branch_name=infrahub_branch_name, commit=commit, config_file=config_file) # type: ignore[misc]
await self.import_all_graphql_query(
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
) # type: ignore[misc]
await self.import_objects(
branch_name=infrahub_branch_name,
commit=commit,
config_file=config_file,
) # type: ignore[misc]
await self.import_all_python_files( # type: ignore[call-overload]
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
) # type: ignore[misc]
await self.import_jinja2_transforms(
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
) # type: ignore[misc]
await self.import_artifact_definitions(
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
) # type: ignore[misc]
config_file = await self.get_repository_config(branch_name=infrahub_branch_name, commit=commit) # type: ignore[misc]
await self.import_schema_files(branch_name=infrahub_branch_name, commit=commit, config_file=config_file) # type: ignore[misc]
await self.import_all_graphql_query( # type: ignore[misc]
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
)
await self.import_objects( # type: ignore[misc]
branch_name=infrahub_branch_name,
commit=commit,
config_file=config_file,
)
await self.import_all_python_files(branch_name=infrahub_branch_name, commit=commit, config_file=config_file) # type: ignore[misc, call-overload]
await self.import_jinja2_transforms( # type: ignore[misc]
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
)
await self.import_artifact_definitions( # type: ignore[misc]
branch_name=infrahub_branch_name, commit=commit, config_file=config_file
)

except Exception as exc:
sync_status = RepositorySyncStatus.ERROR_IMPORT
Expand Down Expand Up @@ -433,7 +434,20 @@ async def update_artifact_definition(
await existing_artifact_definition.save()

@task(name="repository-get-config", task_run_name="get repository config", cache_policy=NONE)
async def get_repository_config(self, branch_name: str, commit: str) -> InfrahubRepositoryConfig | None:
async def get_repository_config(self, branch_name: str, commit: str) -> InfrahubRepositoryConfig:
"""Load and parse the repository configuration file.

Args:
branch_name: The name of the branch to load the config from.
commit: The commit hash to load the config from.

Returns:
The parsed repository configuration.

Raises:
RepositoryConfigurationError: If the configuration file is missing,
cannot be parsed as YAML, or has an invalid format.
"""
branch_wt = self.get_worktree(identifier=commit or branch_name)
log = get_run_logger()

Expand All @@ -448,15 +462,27 @@ async def get_repository_config(self, branch_name: str, commit: str) -> Infrahub
config_file = config_file_yaml
config_file_name = ".infrahub.yaml"
else:
log.debug("Unable to find the configuration file (.infrahub.yml or .infrahub.yaml), skipping")
return None
log.error(
f"Repository '{self.name}' is missing a configuration file. "
"Expected '.infrahub.yml' or '.infrahub.yaml' in the repository root."
)
raise RepositoryConfigurationError(
identifier=self.name,
message=f"Repository '{self.name}' is missing a configuration file. "
f"Please add a '.infrahub.yml' or '.infrahub.yaml' file to the repository root. "
f"See https://docs.infrahub.app/topics/repository for more information.",
)

config_file_content = config_file.read_text(encoding="utf-8")
try:
data = yaml.safe_load(config_file_content)
except yaml.YAMLError as exc:
log.error(f"Unable to load the configuration file in YAML format {config_file_name} : {exc}")
return None
log.error(f"Unable to load the configuration file in YAML format {config_file_name}: {exc}")
raise RepositoryConfigurationError(
identifier=self.name,
message=f"Repository '{self.name}' has an invalid configuration file '{config_file_name}'. "
f"The file could not be parsed as valid YAML: {exc}",
) from exc

# Convert data to a dictionary to avoid it being `None` if the yaml file is just an empty document
data = data or {}
Expand All @@ -466,8 +492,12 @@ async def get_repository_config(self, branch_name: str, commit: str) -> Infrahub
log.info(f"Successfully parsed {config_file_name}")
return configuration
except PydanticValidationError as exc:
log.error(f"Unable to load the configuration file {config_file_name}, the format is not valid : {exc}")
return None
log.error(f"Unable to load the configuration file {config_file_name}, the format is not valid: {exc}")
raise RepositoryConfigurationError(
identifier=self.name,
message=f"Repository '{self.name}' has an invalid configuration file '{config_file_name}'. "
f"The file format is not valid: {exc}",
) from exc

@task(name="import-schema-files", task_run_name="Import schema files", cache_policy=NONE)
async def import_schema_files(self, branch_name: str, commit: str, config_file: InfrahubRepositoryConfig) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# yaml-language-server: $schema=https://schema.infrahub.app/python-sdk/repository-config/develop.json
---
# Minimal configuration for test repository
4 changes: 3 additions & 1 deletion backend/tests/unit/git/test_git_read_only_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ async def test_sync_from_remote_new_ref(git_repo_01_read_only: InfrahubReadOnlyR
mock_client = AsyncMock(InfrahubClient)
repo.client = mock_client

await repo.sync_from_remote()
# Mock import_objects_from_files since we're testing git sync, not import functionality
with patch("infrahub.git.repository.InfrahubReadOnlyRepository.import_objects_from_files", new_callable=AsyncMock):
await repo.sync_from_remote()

worktree_commits = {wt.identifier for wt in repo.get_worktrees()}
assert worktree_commits == {"main", "92700512b5b16c0144f7fd2869669273577f1bd8", branch_02_head_commit}
Expand Down
17 changes: 9 additions & 8 deletions backend/tests/unit/git/test_git_repository.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

import anyio
Expand Down Expand Up @@ -498,19 +498,18 @@ async def test_sync_new_branch(
method="POST", json=commit_response, match_headers={"X-Infrahub-Tracker": "mutation-repository-update-commit"}
)
admin_response = {"data": {"CoreGenericRepositoryUpdate": {"ok": True}}}
httpx_mock.add_response(
method="POST",
json=admin_response,
match_headers={"X-Infrahub-Tracker": "mutation-repository-update-admin-status"},
)
# Note: The admin-status endpoint is only called from within import_objects_from_files,
# which we're mocking below, so we don't need to mock it here.
httpx_mock.add_response(
method="POST",
json=admin_response,
match_headers={"X-Infrahub-Tracker": "mutation-repository-update-operational-status"},
)

repo.client = client
await repo.sync()
# Mock import_objects_from_files since we're testing git sync, not import functionality
with patch("infrahub.git.repository.InfrahubRepository.import_objects_from_files", new_callable=AsyncMock):
await repo.sync()
worktrees = repo.get_worktrees()

assert repo.get_commit_value(branch_name=branch.name) == "92700512b5b16c0144f7fd2869669273577f1bd8"
Expand All @@ -526,7 +525,9 @@ async def test_sync_updated_branch(prefect_test_fixture, git_repo_04: InfrahubRe
# Mock update_commit_value query
commit = repo.get_commit_value(branch_name="branch01", remote=True)

await repo.sync()
# Mock import_objects_from_files since we're testing git sync, not import functionality
with patch("infrahub.git.repository.InfrahubRepository.import_objects_from_files", new_callable=AsyncMock):
await repo.sync()

assert repo.get_commit_value(branch_name="branch01") == str(commit)

Expand Down
Loading
Loading