4 Commits

Author SHA1 Message Date
CIAgent ea5884c0f2 docs(P02): complete parsers phase
---ci---
phase: 2
milestone: v0.1
status: complete
requirements:
  covered: [REQ-02, REQ-03]
  partial: [REQ-01]
---/ci---
2026-10-14 23:04:20 +00:00
CIAgent 9d1116096b docs(P01): phase status — REQ-05 complete, REQ-01 partial, shipped v0.1.1
---ci---
phase: 1
milestone: v0.1
status: complete
---/ci---
2026-10-14 22:38:28 +00:00
CIAgent a6c5a1bb4f docs(P01): complete cli-skeleton phase
---ci---
phase: 1
milestone: v0.1
status: complete
requirements:
  covered: [REQ-05]
  partial: [REQ-01]
---/ci---
2026-10-14 22:38:14 +00:00
CIAgent 3e86118383 chore(P00): checkpoint — phase 0 complete, shipped v0.1.0
---ci---
phase: 0
milestone: v0.1
status: complete
---/ci---
2026-10-14 22:13:36 +00:00
28 changed files with 3438 additions and 6 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
{
"phase": 0,
"phase": 2,
"stage": "ship_pending",
"milestone": "v0.1",
"phase_role": "pre_execution",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-14T23:00:00Z"
"updated_at": "2026-10-15T01:30:00Z"
}
+2 -2
View File
@@ -54,11 +54,11 @@
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | P1 | pending |
| REQ-01 | P1 | partial (surface + discovery complete; parsers P2, wiring P4, README P6) |
| REQ-02 | P2 | pending |
| REQ-03 | P2 | pending |
| REQ-04 | P4 | pending |
| REQ-05 | P1 | pending |
| REQ-05 | P1 | complete |
| REQ-06 | P6 | pending |
| REQ-07 | P5 | pending |
| REQ-08 | P3 | pending |
+1 -1
View File
@@ -8,7 +8,7 @@ Milestone v0.1-go-live: deliver Docker / Docker Compose ergonomics for deploying
### Phase 1 — CLI Skeleton & Docker-Ergonomic Command Surface
- Status: not_started
- Status: complete (shipped v0.1.1)
- Depends on: (none)
- Requirements: REQ-01, REQ-05
- Success criteria: Python project scaffold with packaging; docker-ergonomic command surface (build/up/down/ps/...) with routing wired
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "nova-cli"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"click>=8.1",
"PyYAML>=6.0",
]
[project.scripts]
nova = "nova.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
+3
View File
@@ -0,0 +1,3 @@
"""nova - Docker-style ergonomics for infrastructure deployment."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
"""nova.cli - click command surface and console entry point."""
from nova.cli.main import main
__all__ = ["main"]
+11
View File
@@ -0,0 +1,11 @@
"""Command implementations for nova lifecycle commands.
P2 introduces the ``nova.cli.commands`` package (verifier P1 flag): ``config``
and ``build`` gain real parser-backed behavior here; the deployment engines
(``up``/``down``/``ps``/...) land in phase 4.
"""
from nova.cli.commands.build import run_build
from nova.cli.commands.config import run_config
__all__ = ["run_build", "run_config"]
+61
View File
@@ -0,0 +1,61 @@
"""``nova build`` — parse a Novafile and print the resolved module manifest."""
from __future__ import annotations
from pathlib import Path
import click
from nova.cli.config_files import NovaFileError, find_novafile
from nova.parsers.novafile import NovafileError, parse_novafile
def run_build(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Parse the Novafile, validate every stage, and print the module manifest."""
try:
novafile_path = find_novafile(Path.cwd(), Path(file[0]) if file else None)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
try:
parsed = parse_novafile(
novafile_path.read_text(encoding="utf-8"),
filename=str(novafile_path),
)
except NovafileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
for warning in parsed.warnings:
click.echo(f"WARNING: {warning}", err=True)
click.echo(f"[+] Building module manifest from {novafile_path}")
for stage in parsed.stages:
ref = stage.module
label = stage.name or "main"
click.echo(f" --> stage {label}")
click.echo(f" module {ref.source}")
if ref.version is not None:
click.echo(f" version {ref.version}")
if ref.submodule is not None:
click.echo(f" subdir {ref.submodule}")
if stage.args:
for arg in stage.args:
default = arg.default if arg.default is not None else "(required)"
click.echo(f" arg {arg.name}={default}")
for key, value in stage.env:
click.echo(f" env {key}={value}")
for key, value in stage.labels:
click.echo(f" label {key}={value}")
click.echo(f" workdir {stage.workdir}")
click.echo(
f"Build manifest resolved: {len(parsed.stages)} stage(s), "
f"{len(parsed.global_args)} global arg(s)"
)
+67
View File
@@ -0,0 +1,67 @@
"""``nova config`` — validate and print the normalized compose configuration."""
from __future__ import annotations
import os
from pathlib import Path
import click
import yaml
from nova.cli.config_files import (
NovaFileError,
find_compose_file,
load_env_files,
)
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
from nova.parsers.interp import InterpolationError
def _interpolation_env(
compose_dir: Path, env_files: tuple[str, ...], interpolate: bool
) -> dict[str, str] | None:
"""Build the interpolation env: .env, then --env-file overrides, then shell.
docker compose precedence: shell environment > --env-file > project .env.
Returns None when interpolation is disabled.
"""
if not interpolate:
return None
env: dict[str, str] = {}
project_dotenv = compose_dir / ".env"
if project_dotenv.is_file():
env.update(load_env_files([project_dotenv]))
for env_file in env_files:
env.update(load_env_files([Path(env_file)]))
env.update(dict(os.environ))
return env
def run_config(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
no_interpolate: bool,
) -> None:
"""Load, validate, and print the normalized compose configuration."""
try:
compose_path = find_compose_file(
Path.cwd(), tuple(Path(f) for f in file)
)
env = _interpolation_env(compose_path.parent, env_file, not no_interpolate)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
try:
spec = parse_compose(
compose_path.read_text(encoding="utf-8"),
filename=str(compose_path),
env=env,
)
except (ComposeParseError, InterpolationError) as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
click.echo(yaml.safe_dump(render_compose(spec), sort_keys=False))
+151
View File
@@ -0,0 +1,151 @@
"""Compose-style config file discovery and env-file loading (task P01-01-03).
Discovery rules mirror docker compose (verified against the compose-spec
research in .ciagent/RESEARCH.md, area 3):
1. explicit ``-f`` files — the first is the primary file, but every listed
path must exist;
2. the ``NOVA_COMPOSE_FILE`` environment variable — ``:``-separated paths,
validated like ``-f`` (the ``COMPOSE_FILE`` analog);
3. an upward directory search — walk from the working directory to the
filesystem root, checking ``compose.yaml`` before ``compose.yml`` in
each directory.
``find_novafile`` mirrors ``docker build`` instead: the Novafile is only
looked up in the working directory or at an explicitly given path, never
found by an upward search (Dockerfile parity).
All functions are pure: they take ``pathlib.Path`` arguments, read the
``NOVA_COMPOSE_FILE`` environment variable only as the documented
discovery input, and keep no global state.
"""
import os
from collections.abc import Sequence
from pathlib import Path
#: Environment variable holding ``:``-separated compose file paths.
NOVA_COMPOSE_FILE_ENV = "NOVA_COMPOSE_FILE"
#: Candidate compose file names, in per-directory preference order.
COMPOSE_FILE_NAMES: tuple[str, ...] = ("compose.yaml", "compose.yml")
#: Default build recipe file name (Dockerfile parity).
NOVAFILE_NAME = "Novafile"
class NovaFileError(RuntimeError):
"""A configuration file is missing or could not be located."""
def _resolve(workdir: Path, path: Path) -> Path:
"""Anchor a relative path to ``workdir``; absolute paths pass through."""
return path if path.is_absolute() else workdir / path
def _require_file(target: Path) -> None:
"""Raise the compose-style error when ``target`` is not a file."""
if not target.is_file():
raise NovaFileError(f"no such file: {target}")
def _primary_or_raise(resolved: Sequence[Path]) -> Path:
"""Return the first path, after validating that every path exists."""
for target in resolved:
_require_file(target)
return resolved[0]
def _from_env(workdir: Path) -> list[Path] | None:
"""Interpret ``NOVA_COMPOSE_FILE`` as a ``:``-separated path list.
Relative entries are anchored to ``workdir``. An unset, empty, or
all-separator value means "no override" and returns None so the
upward search can take over.
"""
raw = os.environ.get(NOVA_COMPOSE_FILE_ENV, "")
entries = [part for part in raw.split(":") if part]
if not entries:
return None
return [_resolve(workdir, Path(part)) for part in entries]
def _search_upward(workdir: Path) -> Path | None:
"""Walk from ``workdir`` to the filesystem root looking for compose files.
In each directory ``compose.yaml`` is preferred over ``compose.yml``;
only when neither exists does the search continue upward.
"""
current = workdir.resolve()
while True:
for name in COMPOSE_FILE_NAMES:
candidate = current / name
if candidate.is_file():
return candidate
if current.parent == current: # reached the filesystem root
return None
current = current.parent
def find_compose_file(workdir: Path, files: Sequence[Path] | None = None) -> Path:
"""Locate the primary compose file, docker-compose style.
Precedence: explicit ``files`` (first is primary, all must exist) >
``NOVA_COMPOSE_FILE`` (same validation) > upward search for
``compose.yaml``/``compose.yml``. Raises :class:`NovaFileError` when a
listed file is missing or no configuration file exists at all.
"""
if files:
return _primary_or_raise([_resolve(workdir, path) for path in files])
env_files = _from_env(workdir)
if env_files is not None:
return _primary_or_raise(env_files)
found = _search_upward(workdir)
if found is not None:
return found
raise NovaFileError("no configuration file found: specify a file with -f")
def find_novafile(workdir: Path, file: Path | None = None) -> Path:
"""Locate the Novafile with docker-build parity: no upward search.
``file`` (from ``build -f``) wins when given; otherwise only
``<workdir>/Novafile`` is considered — exactly like ``docker build``
looking at ``./Dockerfile`` and never in parent directories.
"""
target = _resolve(workdir, file) if file is not None else workdir / NOVAFILE_NAME
_require_file(target)
return target
def load_env_files(paths: Sequence[Path]) -> dict[str, str]:
"""Load ``KEY=VALUE`` env files in order, later files overriding earlier.
Blank lines and ``#`` comments are skipped; one pair of surrounding
single or double quotes is stripped from each value. A missing file
raises :class:`NovaFileError`.
"""
env: dict[str, str] = {}
for path in paths:
_require_file(path)
env.update(_parse_env_file(path))
return env
def _parse_env_file(path: Path) -> dict[str, str]:
"""Parse one .env-format file into a key/value mapping."""
parsed: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
parsed[key.strip()] = _unquote(value.strip())
return parsed
def _unquote(value: str) -> str:
"""Strip one pair of surrounding single or double quotes from a value."""
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
+358
View File
@@ -0,0 +1,358 @@
"""nova CLI entry point: root command group, version command, console script target.
This phase wires the docker compose-ergonomic command *surface*: every
lifecycle command parses its full flag set, resolves its configuration
file compose-style (``-f`` override, ``NOVA_COMPOSE_FILE``, upward
``compose.yaml``/``compose.yml`` search; ``build`` instead resolves its
Novafile, docker build parity), then reports the later phase that will
implement the behavior. Novafile/compose parsers land in phase 2,
environment discovery in phase 3, the IaC engine in phase 4, and policy
integration in phase 5.
"""
import importlib.metadata
from pathlib import Path
from typing import Callable, NoReturn, TypeVar
import click
import nova
from nova.cli.commands import run_build, run_config
from nova.cli.config_files import NovaFileError, find_compose_file, find_novafile
def _resolve_version() -> str:
"""Resolve the installed nova-cli version, falling back to the package constant."""
try:
return importlib.metadata.version("nova-cli")
except importlib.metadata.PackageNotFoundError:
return nova.__version__
# --- Phase-landing stub handlers -------------------------------------------------
# Exact stderr contract for surface-only commands, keyed by the phase in
# which the implementation lands. ``{command_name}`` is substituted per
# command: build/config -> phase 2 parsers, env group -> phase 3 discovery,
# lifecycle engines -> phase 4, policy group -> phase 5.
_PHASE_LANDING: dict[int, str] = {
2: "the '{command_name}' parser lands in phase 2 (Novafile + compose parsers)",
3: "nova {command_name} lands in phase 3 (environment discovery)",
4: "the '{command_name}' engine lands in phase 4 (IaC adapter)",
5: "nova {command_name} lands in phase 5 (vendored c7n + c7n-left)",
}
def _pending(command_name: str, landing: int) -> NoReturn:
"""Emit the phase-landing error for a surface-only command and exit 1.
click has already parsed and validated the full flag set by the time a
handler runs; handlers first run config discovery (P01-01-03), then
report where the behavior implementation lands.
"""
message = _PHASE_LANDING[landing].format(command_name=command_name)
click.echo(f"Error: {message}", err=True)
raise SystemExit(1)
def _require_compose_file(file: tuple[str, ...]) -> None:
"""Run compose-file discovery for a lifecycle command, hard-failing on error.
docker-compose parity: a missing configuration file is reported as
``Error: <message>`` on stderr with exit code 1, before the command
reports its phase landing. On success the resolved file is simply
validated — the engines that consume it land in later phases.
"""
try:
find_compose_file(Path.cwd(), tuple(Path(f) for f in file))
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
def _require_novafile(file: tuple[str, ...]) -> None:
"""Run Novafile discovery for ``build``, hard-failing on error.
Retained for the phase-4 engine wiring; ``build`` itself now performs
discovery in :mod:`nova.cli.commands.build`.
"""
try:
find_novafile(Path.cwd(), Path(file[0]) if file else None)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
# --- Root command group -----------------------------------------------------------
@click.group(
invoke_without_command=False,
no_args_is_help=True,
help="nova - Docker-style ergonomics for infrastructure deployment",
)
def cli() -> None:
"""nova - Docker-style ergonomics for infrastructure deployment."""
@cli.command()
def version() -> None:
"""Print the nova-cli version."""
click.echo(f"nova-cli v{_resolve_version()}")
# --- Shared lifecycle flags (docker compose parity) -------------------------------
_FC = TypeVar("_FC", bound=Callable[..., object])
def _lifecycle_options(
file_short: bool = True,
file_help: str = "Specify an alternate Novafile/compose file (repeatable)",
) -> Callable[[_FC], _FC]:
"""Build the decorator stack of compose-parity options for a lifecycle command.
``file_short=False`` omits the ``-f`` short flag. It is used by ``rm``,
where ``-f`` is ``--force`` -- the same resolution docker compose ships
for the collision, since ``docker compose rm -f`` means forced removal.
``file_help`` overrides the ``--file`` help text; ``build`` uses it to
document that its ``-f`` is the Novafile path (docker build parity),
unlike the compose-file meaning everywhere else.
"""
# click lists options in reverse application order in `--help`, so apply
# them back-to-front to display the spec order (file, project-name,
# detach, env-file, profile) above each command's extras.
def decorate(command: _FC) -> _FC:
file_decl: tuple[str, ...] = ("-f", "--file") if file_short else ("--file",)
command = click.option(
"--profile",
"profile",
multiple=True,
help="Specify a deployment profile to enable (repeatable)",
)(command)
command = click.option(
"--env-file",
"env_file",
multiple=True,
help="Specify an alternate environment file (repeatable)",
)(command)
command = click.option(
"-d",
"--detach",
"detach",
is_flag=True,
help="Detached mode: do not stream engine output",
)(command)
command = click.option(
"-p",
"--project-name",
"project_name",
help="Specify an alternate project name",
)(command)
command = click.option(
*file_decl,
"file",
multiple=True,
type=click.Path(),
help=file_help,
)(command)
return command
return decorate
# --- Lifecycle commands -----------------------------------------------------------
@cli.command()
@_lifecycle_options(file_help="Specify the Novafile to build from (accepts the Novafile path, Dockerfile -f parity)")
def build(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Create a module build from a Novafile."""
run_build(file, project_name, detach, env_file, profile)
@cli.command()
@_lifecycle_options()
def up(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Deploy infrastructure modules."""
_require_compose_file(file)
_pending("up", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"-v",
"--volumes",
"volumes",
is_flag=True,
help="Remove named volumes declared in the Novafile",
)
def down(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
volumes: bool,
) -> None:
"""Remove deployed infrastructure modules."""
_require_compose_file(file)
_pending("down", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"-a",
"--all",
"all",
is_flag=True,
help="Show all services, including stopped ones",
)
def ps(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
all: bool,
) -> None:
"""List deployed services."""
_require_compose_file(file)
_pending("ps", 4)
@cli.command()
@_lifecycle_options()
def stop(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Stop services."""
_require_compose_file(file)
_pending("stop", 4)
@cli.command()
@_lifecycle_options()
def start(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Start services."""
_require_compose_file(file)
_pending("start", 4)
@cli.command()
@_lifecycle_options()
def restart(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Restart services."""
_require_compose_file(file)
_pending("restart", 4)
@cli.command()
@_lifecycle_options(file_short=False)
@click.option(
"-f",
"--force",
"force",
is_flag=True,
help="Force removal of service states",
)
def rm(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
force: bool,
) -> None:
"""Remove stopped service states."""
_require_compose_file(file)
_pending("rm", 4)
@cli.command()
@_lifecycle_options()
@click.argument("service", nargs=-1)
def logs(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
service: tuple[str, ...],
) -> None:
"""View output from services."""
_require_compose_file(file)
_pending("logs", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"--no-interpolate",
"no_interpolate",
is_flag=True,
help="Print the configuration without interpolating variables",
)
def config(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
no_interpolate: bool,
) -> None:
"""Validate and view the compose file."""
run_config(file, project_name, detach, env_file, profile, no_interpolate)
# --- Placeholder command groups ---------------------------------------------------
@cli.group(invoke_without_command=True)
def policy() -> None:
"""Manage deployment policies."""
_pending("policy", 5)
@cli.group(invoke_without_command=True)
def env() -> None:
"""Inspect deployment environments."""
_pending("env", 3)
# Console entry point (`nova = "nova.cli:main"`): the click group object is
# itself callable, so main() dispatches exactly as cli() would. This keeps
# `nova.cli.main` a click.BaseCommand instance, which is also what the test
# suite asserts.
main: click.Group = cli
+32
View File
@@ -0,0 +1,32 @@
"""Parsers for Novafile and compose.yml, plus module-reference resolution."""
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
from nova.parsers.interp import InterpolationError, interpolate
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
from nova.parsers.models import BuildSpec, ComposeSpec, ServiceSpec
from nova.parsers.novafile import (
ArgDef,
Novafile,
NovafileError,
NovafileStage,
parse_novafile,
)
__all__ = [
"ArgDef",
"BuildSpec",
"ComposeParseError",
"ComposeSpec",
"InterpolationError",
"ModuleRef",
"ModuleRefError",
"Novafile",
"NovafileError",
"NovafileStage",
"ServiceSpec",
"interpolate",
"parse_compose",
"parse_module_ref",
"parse_novafile",
"render_compose",
]
+339
View File
@@ -0,0 +1,339 @@
"""compose.yml loader — compose-indistinguishable schema plus the nova `providers` block."""
from __future__ import annotations
from typing import Any, Mapping
import yaml
from nova.parsers.interp import InterpolationError, interpolate
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
from nova.parsers.models import BuildSpec, ComposeSpec, ServiceSpec
_SUPPORTED_TOP = ("name", "services", "providers")
_VALID_PROVIDERS = ("aws", "azure", "gcp")
class ComposeParseError(ValueError):
"""Raised when a compose file violates the nova compose schema."""
def _err(filename: str, message: str) -> ComposeParseError:
return ComposeParseError(f"{filename}: {message}")
def _as_str_pairs(
value: Any, filename: str, service: str, key: str
) -> tuple[tuple[str, str], ...]:
"""Normalize environment/vars mappings and KEY=VALUE lists to pairs."""
if isinstance(value, dict):
pairs = []
for k, v in value.items():
if not isinstance(k, str):
raise _err(
filename,
f"service '{service}': {key} keys must be strings",
)
pairs.append((k, "" if v is None else str(v)))
return tuple(pairs)
if isinstance(value, list):
pairs = []
for item in value:
if not isinstance(item, str) or "=" not in item:
raise _err(
filename,
f"service '{service}': {key} list entries must be 'KEY=VALUE'",
)
k, _, v = item.partition("=")
pairs.append((k, v))
return tuple(pairs)
raise _err(
filename,
f"service '{service}': {key} must be a mapping or a list of 'KEY=VALUE'",
)
def _as_str_list(
value: Any, filename: str, service: str, key: str
) -> tuple[str, ...]:
if isinstance(value, str):
return (value,)
if isinstance(value, list) and all(isinstance(v, str) for v in value):
return tuple(value)
raise _err(
filename, f"service '{service}': {key} must be a string or list of strings"
)
def _interpolate_mapping(data: Any, env: dict[str, str]) -> Any:
"""Recursively interpolate all string scalars in parsed YAML data."""
if isinstance(data, str):
return interpolate(data, env)
if isinstance(data, dict):
return {
_interpolate_mapping(k, env): _interpolate_mapping(v, env)
for k, v in data.items()
}
if isinstance(data, list):
return [_interpolate_mapping(v, env) for v in data]
return data
def parse_compose(
data: Mapping[str, Any] | str,
filename: str = "compose.yml",
env: Mapping[str, str] | None = None,
) -> ComposeSpec:
"""Parse compose content (YAML text or a pre-loaded mapping) into a ComposeSpec.
When env is None no interpolation runs; otherwise every string value is
interpolated against env before validation.
"""
if isinstance(data, str):
try:
loaded = yaml.safe_load(data)
except yaml.YAMLError as exc:
raise _err(filename, f"invalid YAML: {exc}") from exc
else:
loaded = data
if env is not None:
loaded = _interpolate_mapping(loaded, dict(env))
if not isinstance(loaded, dict):
raise _err(filename, "expected a mapping at top level")
for key in loaded:
if key not in _SUPPORTED_TOP:
raise _err(
filename,
f"top-level key '{key}' is not supported "
f"(supported: {', '.join(_SUPPORTED_TOP)})",
)
name = loaded.get("name")
if name is not None and not isinstance(name, str):
raise _err(filename, "'name' must be a string")
services_raw = loaded.get("services")
if not isinstance(services_raw, dict):
raise _err(filename, "'services' must be a mapping")
providers_raw = loaded.get("providers")
providers: dict[str, dict] = {}
if providers_raw is not None:
if not isinstance(providers_raw, dict):
raise _err(filename, "'providers' must be a mapping")
for key, value in providers_raw.items():
if key not in _VALID_PROVIDERS:
raise _err(
filename,
f"providers: unknown provider '{key}' "
f"(supported: {', '.join(_VALID_PROVIDERS)})",
)
if not isinstance(value, dict):
raise _err(filename, f"providers.{key} must be a mapping")
providers[key] = dict(value)
services: list[ServiceSpec] = []
for service_name, service_body in services_raw.items():
if not isinstance(service_body, dict):
raise _err(
filename, f"service '{service_name}' must be a mapping"
)
services.append(
_parse_service(service_name, service_body, filename)
)
spec = ComposeSpec(
name=name, services=tuple(services), providers=providers
)
_validate_dependencies(spec, filename)
return spec
def _parse_service(name: str, body: dict, filename: str) -> ServiceSpec:
for key in body:
allowed = (
"module",
"build",
"version",
"environment",
"vars",
"env_file",
"depends_on",
"working_dir",
"profiles",
"provider",
)
if key not in allowed:
raise _err(
filename,
f"service '{name}': key '{key}' is not supported",
)
module_raw = body.get("module")
build_raw = body.get("build")
if module_raw is not None and build_raw is not None:
raise _err(
filename, f"service '{name}': specify either 'module' or 'build', not both"
)
if module_raw is None and build_raw is None:
raise _err(
filename,
f"service '{name}': requires a 'module' (registry path) or 'build' "
f"(Novafile) key",
)
module: ModuleRef | None = None
if module_raw is not None:
if not isinstance(module_raw, str):
raise _err(filename, f"service '{name}': 'module' must be a string")
try:
module = parse_module_ref(module_raw)
except ModuleRefError as exc:
raise _err(filename, f"service '{name}': {exc}") from exc
build: BuildSpec | None = None
if build_raw is not None:
if not isinstance(build_raw, dict):
raise _err(filename, f"service '{name}': 'build' must be a mapping")
build = BuildSpec(
context=str(build_raw.get("context", ".")),
novafile=str(build_raw.get("novafile", "Novafile")),
)
version = body.get("version")
if version is not None:
if not isinstance(version, str):
raise _err(filename, f"service '{name}': 'version' must be a string")
if module is not None and module.version is not None:
raise _err(
filename,
f"service '{name}': version specified twice "
f"(both @{module.version} sugar and the version key)",
)
if module is not None:
try:
module = parse_module_ref(f"{module_raw}@{version}")
except ModuleRefError as exc:
raise _err(filename, f"service '{name}': {exc}") from exc
environment: tuple[tuple[str, str], ...] = ()
if "environment" in body and "vars" in body:
raise _err(
filename,
f"service '{name}': 'vars' is an alias of 'environment' — use only one",
)
env_key = "environment" if "environment" in body else (
"vars" if "vars" in body else None
)
if env_key is not None:
environment = _as_str_pairs(body[env_key], filename, name, env_key)
env_files: tuple[str, ...] = ()
if "env_file" in body:
env_files = _as_str_list(body["env_file"], filename, name, "env_file")
depends_on: tuple[str, ...] = ()
if "depends_on" in body:
dep_raw = body["depends_on"]
if isinstance(dep_raw, list):
if not all(isinstance(v, str) for v in dep_raw):
raise _err(
filename,
f"service '{name}': depends_on short form must be a list of "
f"service names",
)
depends_on = tuple(dep_raw)
elif isinstance(dep_raw, dict):
depends_on = tuple(dep_raw.keys())
else:
raise _err(
filename,
f"service '{name}': depends_on must be a list or a mapping",
)
working_dir = body.get("working_dir")
if working_dir is not None and not isinstance(working_dir, str):
raise _err(filename, f"service '{name}': 'working_dir' must be a string")
profiles: tuple[str, ...] = ()
if "profiles" in body:
profiles = _as_str_list(body["profiles"], filename, name, "profiles")
provider = body.get("provider")
if provider is not None:
if not isinstance(provider, str) or provider.lower() not in _VALID_PROVIDERS:
raise _err(
filename,
f"service '{name}': provider must be one of "
f"{', '.join(_VALID_PROVIDERS)}",
)
provider = provider.lower()
return ServiceSpec(
name=name,
module=module,
build=build,
version=None if version is None else version,
environment=environment,
env_files=env_files,
depends_on=depends_on,
working_dir=working_dir,
profiles=profiles,
provider=provider,
)
def _validate_dependencies(spec: ComposeSpec, filename: str) -> None:
names = {s.name for s in spec.services}
for service in spec.services:
for dep in service.depends_on:
if dep not in names:
raise _err(
filename,
f"service '{service.name}' depends on unknown service '{dep}'",
)
if service.name in service.depends_on:
raise _err(
filename,
f"service '{service.name}' cannot depend on itself",
)
def render_compose(spec: ComposeSpec) -> dict:
"""Render a ComposeSpec back to the normalized `docker compose config` dict."""
services: dict[str, dict] = {}
for service in spec.services:
rendered: dict[str, Any] = {}
if service.module is not None:
rendered["module"] = service.module.source
if service.module.version is not None:
rendered["version"] = service.module.version
if service.build is not None:
rendered["build"] = {
"context": service.build.context,
"novafile": service.build.novafile,
}
if service.environment:
rendered["environment"] = dict(service.environment)
if service.env_files:
rendered["env_file"] = list(service.env_files)
if service.depends_on:
rendered["depends_on"] = list(service.depends_on)
if service.working_dir is not None:
rendered["working_dir"] = service.working_dir
if service.profiles:
rendered["profiles"] = list(service.profiles)
if service.provider is not None:
rendered["provider"] = service.provider
services[service.name] = rendered
out: dict[str, Any] = {}
if spec.name is not None:
out["name"] = spec.name
out["services"] = services
if spec.providers:
out["providers"] = spec.providers
return out
+93
View File
@@ -0,0 +1,93 @@
"""Compose-style variable interpolation: ${VAR}, ${VAR:-def}, ${VAR:?err}, ${VAR:+alt}."""
from __future__ import annotations
import re
from typing import Mapping
_NAME = r"[A-Za-z_][A-Za-z0-9_]*"
_BRACED = re.compile(rf"\$\{{({_NAME})(?:(:?[-+?])([^}}]*))?\}}")
_BARE = re.compile(rf"\$({_NAME})")
class InterpolationError(ValueError):
"""Raised when a ${VAR:?} requirement is unmet or syntax is invalid."""
def _replace_bare(match: re.Match, env: Mapping[str, str]) -> str:
value = env.get(match.group(1))
return value if value is not None else ""
def _replace_braced(match: re.Match, env: Mapping[str, str]) -> str:
name, operator, operand = match.group(1), match.group(2), match.group(3)
value = env.get(name)
if operator is None:
return value if value is not None else ""
if operator in (":-", "-"):
# ":-" fires on unset OR empty; "-" fires only on unset.
if value is None:
return operand or ""
if value == "" and operator == ":-":
return operand or ""
return value
if operator in (":?", "?"):
if value is None or (value == "" and operator == ":?"):
message = operand or "required variable is missing"
raise InterpolationError(f"{name}: {message}")
return value
if operator in (":+", "+"):
# ":+" fires on set AND non-empty; "+" fires on set (even empty).
present = value is not None and (value != "" or operator == "+")
return operand or "" if present else ""
raise InterpolationError(f"unsupported interpolation operator {operator!r}")
raise InterpolationError(f"unsupported interpolation operator {operator!r}")
def interpolate(text: str, env: Mapping[str, str]) -> str:
"""Interpolate compose-style variables in text against env.
Supported: ${VAR}, $VAR, ${VAR:-def}, ${VAR-def}, ${VAR:?msg}, ${VAR?msg},
${VAR:+alt}, ${VAR+alt}, and $$ as an escaped literal dollar.
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch != "$":
out.append(ch)
i += 1
continue
if i + 1 < n and text[i + 1] == "$":
out.append("$")
i += 2
continue
m = _BRACED.match(text, i)
if m:
out.append(_replace_braced(m, env))
i = m.end()
continue
if text.startswith("${", i):
# An unclosed or invalidly-named braced expression is an error,
# matching compose behavior.
end = text.find("}", i)
inner = text[i + 2 : end] if end != -1 else text[i + 2 :]
raise InterpolationError(
f"invalid interpolation syntax: ${{{inner}}}"
)
m = _BARE.match(text, i)
if m:
out.append(_replace_bare(m, env))
i = m.end()
continue
# A lone '$' followed by a non-name character stays literal.
out.append("$")
i += 1
return "".join(out)
+40
View File
@@ -0,0 +1,40 @@
"""Typed models for the parsed compose file (compose-indistinguishable subset)."""
from __future__ import annotations
from dataclasses import dataclass, field
from nova.parsers.moduleref import ModuleRef
@dataclass(frozen=True)
class BuildSpec:
"""A `build:` service source — the Novafile analog of a compose build context."""
context: str
novafile: str
@dataclass(frozen=True)
class ServiceSpec:
"""One deployable module under `services:`."""
name: str
module: ModuleRef | None
build: BuildSpec | None
version: str | None
environment: tuple[tuple[str, str], ...] = ()
env_files: tuple[str, ...] = ()
depends_on: tuple[str, ...] = ()
working_dir: str | None = None
profiles: tuple[str, ...] = ()
provider: str | None = None
@dataclass(frozen=True)
class ComposeSpec:
"""The whole compose.yml document."""
name: str | None
services: tuple[ServiceSpec, ...]
providers: dict[str, dict] = field(default_factory=dict)
+178
View File
@@ -0,0 +1,178 @@
"""Terraform module reference resolver (task P02-01-01).
Grammar (verified: developer.hashicorp.com/terraform/language/block/module;
see .ciagent/RESEARCH.md area 1):
- Public registry shorthand: ``<namespace>/<name>/<provider>`` — exactly
three segments, e.g. ``terraform-aws-modules/vpc/aws``.
- Private registry: ``<hostname>/<namespace>/<name>/<provider>`` — four
segments, e.g. ``app.terraform.io/example-corp/k8s-cluster/azurerm``.
- Full public registry web URLs — ``registry.terraform.io/<ns>/<name>/<provider>``
and ``registry.terraform.io/modules/<ns>/<name>/<provider>`` — are web
addresses, not source grammar; nova normalizes them to shorthand.
- Submodules: append ``//<subdir>`` to any registry source (query parameters
would come after the subdir; none are supported in v0.1).
- Local sources: MUST start with ``./`` or ``../`` (or be absolute paths);
local sources never support version pinning.
- Version: nova sugar ``@<version>`` — split into the separate ``version``
field, valid ONLY for registry sources, validated as a Terraform-style
constraint (``1.2.3``, ``~> 6.0``, ``>= 1.2, < 2.0`` — comma-separated
operator/version checks allowed; empty and non-matching strings rejected).
Terraform's own grammar has no ``@version`` suffix: :func:`parse_module_ref`
translates the sugar so callers can emit ``source`` plus ``version`` arguments.
"""
import re
from dataclasses import dataclass
#: Hostname of the public registry whose web URLs normalize to shorthand.
PUBLIC_REGISTRY_HOST = "registry.terraform.io"
#: Path element the public registry web UI inserts between host and module.
PUBLIC_REGISTRY_MODULES_PATH = "modules"
_NEEDS_THREE = "registry references need <namespace>/<name>/<provider>"
_AT_MOST_FOUR = "registry references allow at most <hostname>/<namespace>/<name>/<provider>"
_BARE_HOST = "bare registry hostname without <namespace>/<name>/<provider>"
# Version constraint grammar: comma-separated checks. Each check is an
# optional Terraform operator followed by a 1-3 part version with optional
# pre-release and metadata suffixes; whitespace is allowed around operators
# and separators (e.g. ">= 1.2, < 2.0", "~> 6.0").
_VERSION_OPERATOR = r"(?:~>|>=|<=|!=|=|>|<)"
_VERSION_NUMBER = r"\d+(?:\.\d+){0,2}"
_VERSION_SUFFIX = (
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
)
_VERSION_CHECK = rf"{_VERSION_OPERATOR}?\s*{_VERSION_NUMBER}{_VERSION_SUFFIX}"
_VERSION_CONSTRAINT_RE = re.compile(rf"^\s*{_VERSION_CHECK}(?:\s*,\s*{_VERSION_CHECK})*\s*$")
# Windows drive-letter prefix, the other legal absolute local form.
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
class ModuleRefError(ValueError):
"""A module reference does not match the Terraform source grammar."""
@dataclass(frozen=True)
class ModuleRef:
"""A resolved Terraform module source address.
``source`` is the Terraform-legal string to emit — public shorthand,
private host form, or local path — including any ``//submodule`` part;
``version`` is nova's ``@`` sugar split off (registry sources only).
"""
source: str
submodule: str | None
version: str | None
is_local: bool
host: str | None
@property
def terraform_source(self) -> str:
"""The source string a Terraform module block accepts."""
return self.source
def _invalid(ref: str, reason: str) -> ModuleRefError:
"""Build the one-line docker/compose-style error naming ``ref``."""
return ModuleRefError(f"invalid module reference '{ref}': {reason}")
def _is_local_source(base: str) -> bool:
"""True for ``./``, ``../``, unix-absolute, and windows-drive sources."""
if base.startswith(("./", "../")):
return True
return base.startswith("/") or _WINDOWS_DRIVE_RE.match(base) is not None
def _is_valid_version_constraint(version: str) -> bool:
"""True for Terraform-style constraint strings (e.g. ``~> 6.0``)."""
return _VERSION_CONSTRAINT_RE.match(version) is not None
def _segment_reason(segments: list[str]) -> str:
"""The count-error reason for a wrong number of registry segments."""
if len(segments) == 1 and "." in segments[0]:
return _BARE_HOST
if len(segments) <= 2:
return _NEEDS_THREE
return _AT_MOST_FOUR
def _normalize_public_url(segments: list[str]) -> list[str]:
"""Drop the registry.terraform.io host and its /modules/ path element."""
segments = segments[1:]
if segments and segments[0] == PUBLIC_REGISTRY_MODULES_PATH:
segments = segments[1:]
return segments
def parse_module_ref(ref: str) -> ModuleRef:
"""Resolve a module reference into its Terraform-legal parts.
Accepts public registry shorthand, private registry hosts, full public
registry web URLs (normalized to shorthand), ``//subdir`` submodules,
``@version`` sugar, and local ``./``/``../``/absolute paths. Surrounding
whitespace is stripped.
Raises :class:`ModuleRefError` with a one-line docker/compose-style
message for anything outside the grammar: empty references, ``//`` at
the start, bare hostnames, 2-segment or 5+-segment registry paths,
version pinning on a local source, and empty or malformed version
constraints.
"""
text = ref.strip()
if not text:
raise _invalid(ref, "module source cannot be empty")
if text.startswith("//"):
raise _invalid(ref, "module source cannot start with '//'")
base, sugar, version_text = text.partition("@")
version: str | None = version_text.strip() if sugar else None
if _is_local_source(base):
if version is not None:
raise _invalid(ref, "local module sources do not support version pinning")
return ModuleRef(source=base, submodule=None, version=None, is_local=True, host=None)
package, marker, subdir = base.partition("//")
submodule = subdir if marker else None
if marker and not subdir:
raise _invalid(ref, "submodule '//' must be followed by a path")
segments = package.split("/")
if "" in segments:
raise _invalid(ref, "module source contains an empty path segment")
host: str | None
if len(segments) >= 2 and segments[0] == PUBLIC_REGISTRY_HOST:
segments = _normalize_public_url(segments)
if len(segments) != 3:
raise _invalid(ref, _segment_reason(segments))
host = None
elif len(segments) == 3:
host = None
elif len(segments) == 4:
host = segments[0]
else:
raise _invalid(ref, _segment_reason(segments))
if version is not None and not _is_valid_version_constraint(version):
raise _invalid(ref, f"invalid version constraint '{version}'")
source = "/".join(segments)
if submodule is not None:
source = f"{source}//{submodule}"
return ModuleRef(
source=source,
submodule=submodule,
version=version,
is_local=False,
host=host,
)
+310
View File
@@ -0,0 +1,310 @@
"""Novafile parser — Dockerfile-compatible grammar for infrastructure modules.
A Novafile is practically a Dockerfile: same line grammar, case-insensitive
instructions, comments, and backslash continuations. The FROM instruction
takes Terraform module references instead of container images (D-010).
"""
from __future__ import annotations
import posixpath
from dataclasses import dataclass
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
_CONTAINER_ONLY = ("RUN", "CMD", "ENTRYPOINT", "HEALTHCHECK", "EXPOSE")
_UNSUPPORTED = ("COPY", "ADD")
class NovafileError(ValueError):
"""Raised when a Novafile cannot be parsed. Messages are BuildKit-style."""
@dataclass(frozen=True)
class ArgDef:
"""An ARG definition; a missing default means the arg is required at build time."""
name: str
default: str | None
@dataclass(frozen=True)
class NovafileStage:
"""One build stage: a FROM instruction and everything that follows it."""
name: str | None
module: ModuleRef
args: tuple[ArgDef, ...] = ()
env: tuple[tuple[str, str], ...] = ()
labels: tuple[tuple[str, str], ...] = ()
workdir: str = "/"
@dataclass(frozen=True)
class Novafile:
stages: tuple[NovafileStage, ...]
global_args: tuple[ArgDef, ...] = ()
warnings: tuple[str, ...] = ()
def _join_continuations(
text: str, filename: str
) -> list[tuple[str, int]]:
"""Split text into logical lines, joining backslash continuations.
Returns (logical_line, first_physical_line_number) pairs. Comment lines
and blank lines are preserved here and skipped by the instruction loop.
"""
physical = text.splitlines()
out: list[tuple[str, int]] = []
idx = 0
while idx < len(physical):
line = physical[idx]
first = idx + 1
while line.endswith("\\") and idx + 1 < len(physical):
line = line[:-1] + physical[idx + 1]
idx += 1
if line.endswith("\\"):
# Trailing continuation with nothing to join.
line = line[:-1]
out.append((line, first))
idx += 1
return out
def _unquote(value: str, line: int, filename: str) -> str:
"""Strip one layer of double quotes, honouring backslash escapes."""
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
inner = value[1:-1]
return inner.replace('\\"', '"').replace("\\\\", "\\")
return value
def _parse_pairs(
remainder: str, line: int, filename: str
) -> list[tuple[str, str]]:
"""Parse `key=value key2="quoted value"` sequences (Dockerfile ENV/LABEL form)."""
pairs: list[tuple[str, str]] = []
i = 0
n = len(remainder)
while i < n:
while i < n and remainder[i].isspace():
i += 1
if i >= n:
break
eq = remainder.find("=", i)
if eq == -1 or eq == i:
raise NovafileError(
f"{filename}:{line}:1: expected key=value pairs, got: {remainder[i:]!r}"
)
key = remainder[i:eq]
if not key or not key.strip():
raise NovafileError(
f"{filename}:{line}:1: expected key=value pairs, got: {remainder[i:]!r}"
)
key = key.strip()
i = eq + 1
if i < n and remainder[i] == '"':
i += 1
buf: list[str] = []
closed = False
while i < n:
ch = remainder[i]
if ch == "\\" and i + 1 < n and remainder[i + 1] in ('"', "\\"):
buf.append(remainder[i + 1])
i += 2
elif ch == '"':
i += 1
closed = True
break
else:
buf.append(ch)
i += 1
if not closed:
raise NovafileError(
f"{filename}:{line}:1: unterminated quoted value for {key!r}"
)
value = "".join(buf)
else:
j = i
while j < n and not remainder[j].isspace():
j += 1
value = remainder[i:j]
i = j
pairs.append((key, value))
return pairs
def _parse_arg(remainder: str, line: int, filename: str) -> ArgDef:
eq = remainder.find("=")
if eq == -1:
name = remainder.strip()
if not name or name != remainder:
raise NovafileError(
f"{filename}:{line}:1: ARG expects <name>[=<default>], got: {remainder!r}"
)
return ArgDef(name=name, default=None)
name = remainder[:eq].strip()
raw_default = remainder[eq + 1 :].strip()
if not name:
raise NovafileError(
f"{filename}:{line}:1: ARG expects <name>[=<default>], got: {remainder!r}"
)
return ArgDef(
name=name, default=_unquote(raw_default, line, filename) if raw_default else None
)
def parse_novafile(text: str, filename: str = "Novafile") -> Novafile:
"""Parse Novafile content into a :class:`Novafile` model."""
global_args: list[ArgDef] = []
warnings: list[str] = []
stages: list[NovafileStage] = []
# Mutable per-stage accumulators.
cur: dict | None = None
def close_stage() -> None:
nonlocal cur
if cur is not None:
stages.append(
NovafileStage(
name=cur["name"],
module=cur["module"],
args=tuple(cur["args"]),
env=tuple(cur["env"]),
labels=tuple(cur["labels"]),
workdir=cur["workdir"],
)
)
cur = None
saw_instruction = False
for raw_line, line_no in _join_continuations(text, filename):
line = raw_line.strip()
if not line:
continue
if line.startswith("#"):
directive = line[1:].strip()
if directive.startswith("escape=") and directive != "escape=\\":
warnings.append(
f"line {line_no}: non-default escape directive is treated as "
f"backslash in v0.1"
)
continue
parts = line.split(None, 1)
instruction = parts[0].upper()
remainder = parts[1].strip() if len(parts) > 1 else ""
if not saw_instruction and instruction not in ("FROM", "ARG"):
raise NovafileError(
f"{filename}:{line_no}:1: first instruction must be FROM"
)
saw_instruction = True
if instruction == "FROM":
close_stage()
ref_text, alias = _split_from(remainder, line_no, filename)
try:
module = parse_module_ref(ref_text)
except ModuleRefError as exc:
raise NovafileError(f"{filename}:{line_no}:1: {exc}") from exc
cur = {
"name": alias,
"module": module,
"args": [],
"env": [],
"labels": [],
"workdir": "/",
}
elif instruction == "ARG":
argdef = _parse_arg(remainder, line_no, filename)
if cur is None:
global_args.append(argdef)
else:
cur["args"].append(argdef)
elif instruction == "ENV":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: ENV must appear after a FROM"
)
if remainder and "=" in remainder.split()[0]:
cur["env"].extend(_parse_pairs(remainder, line_no, filename))
else:
split = remainder.split(None, 1)
if len(split) == 1:
raise NovafileError(
f"{filename}:{line_no}:1: ENV requires a value, got: {remainder!r}"
)
key, value = split[0], split[1].strip()
cur["env"].append((key, value))
elif instruction == "LABEL":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: LABEL must appear after a FROM"
)
cur["labels"].extend(_parse_pairs(remainder, line_no, filename))
elif instruction == "WORKDIR":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: WORKDIR must appear after a FROM"
)
path = remainder.strip()
if not path:
raise NovafileError(
f"{filename}:{line_no}:1: WORKDIR requires a path"
)
if path.startswith("/"):
cur["workdir"] = posixpath.normpath(path)
else:
cur["workdir"] = posixpath.normpath(
posixpath.join(cur["workdir"], path)
)
elif instruction in _CONTAINER_ONLY:
warnings.append(
f"line {line_no}: {instruction} is a container-only instruction "
f"and is ignored for infrastructure modules"
)
elif instruction in _UNSUPPORTED:
raise NovafileError(
f"{filename}:{line_no}:1: {instruction} is not supported in "
f"Novafiles - modules replace container filesystems"
)
else:
raise NovafileError(
f"{filename}:{line_no}:1: unknown instruction {parts[0].upper()}"
)
close_stage()
if not stages:
raise NovafileError(f"{filename}:0:1: no instructions found")
return Novafile(
stages=tuple(stages),
global_args=tuple(global_args),
warnings=tuple(warnings),
)
def _split_from(
remainder: str, line: int, filename: str
) -> tuple[str, str | None]:
"""Split `FROM <module-ref> [AS <name>]` into (ref_text, alias)."""
if not remainder:
raise NovafileError(f"{filename}:{line}:1: FROM requires a module reference")
parts = remainder.split()
ref_text = parts[0]
alias: str | None = None
if len(parts) == 2:
raise NovafileError(
f"{filename}:{line}:1: FROM expects '<module> [AS <name>]', got: {remainder!r}"
)
if len(parts) >= 3:
if parts[1].upper() != "AS":
raise NovafileError(
f"{filename}:{line}:1: FROM expects '<module> [AS <name>]', "
f"got: {remainder!r}"
)
alias = " ".join(parts[2:])
if not alias:
raise NovafileError(f"{filename}:{line}:1: AS requires a stage name")
return ref_text, alias
View File
+293
View File
@@ -0,0 +1,293 @@
"""Validated command behaviors with precise phase-landing errors (task P01-01-04).
The phase-1 "must-have" behavior table, exercised end to end through the
CliRunner: every lifecycle command parses its flags, discovers its
configuration file compose-style through the CLI itself, and fails with
the exact error naming the phase where its implementation lands — exit
code 1 throughout, never a crash.
Table rows, in test order:
1. no config anywhere -> discovery error for every lifecycle command
2. compose.yml in cwd -> each command lands in its documented phase
3. compose.yml + Novafile -> build reaches the phase-2 parser
4. explicit -f missing -> -f wins over discovery and hard-fails
5. upward search -> compose.yaml in a parent dir is found via the CLI
6. compose.yaml preference over compose.yml
7. version -> the one exit-0 path, printing the version
8. policy/env groups -> phase 5 / phase 3 landings
9. help surface -> Usage + docker-parity options; 13 commands listed
10. exit codes -> every surface error is exactly exit 1
"""
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Protocol
import click
import click.testing
import pytest
from nova.cli import main
from nova.cli.config_files import find_compose_file
runner = click.testing.CliRunner()
#: The 10 lifecycle commands that carry the compose-parity flag set.
LIFECYCLE_COMMANDS: tuple[str, ...] = (
"build",
"up",
"down",
"ps",
"stop",
"start",
"restart",
"rm",
"logs",
"config",
)
#: Lifecycle commands resolving a compose file (build resolves a Novafile).
COMPOSE_COMMANDS: tuple[str, ...] = LIFECYCLE_COMMANDS[1:]
#: The complete command surface: 10 lifecycle + policy + env + version.
ALL_COMMANDS: tuple[str, ...] = (*LIFECYCLE_COMMANDS, "policy", "env", "version")
MINIMAL_COMPOSE = (
"name: demo\n"
"services:\n"
" vpc:\n"
" module: terraform-aws-modules/vpc/aws\n"
)
MINIMAL_NOVAFILE = "FROM terraform-aws-modules/vpc/aws\n"
@pytest.fixture(autouse=True)
def _no_compose_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep NOVA_COMPOSE_FILE out of every behavior test (discovery isolation)."""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
class ProjectFactory(Protocol):
"""Callable creating an isolated project dir and making it the cwd."""
def __call__(
self,
files: Mapping[str, str] | None = None,
subdir: str | None = None,
) -> Path:
...
@pytest.fixture
def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ProjectFactory:
"""Create a project dir under tmp_path and chdir into it (monkeypatch.chdir).
``files`` maps relative paths (nested paths create their parents) to
content, so a project can ship a compose file, a Novafile, or both.
``subdir`` makes that subdirectory of the project the cwd, for the
upward-discovery row. Returns the project root.
"""
def _project(
files: Mapping[str, str] | None = None,
subdir: str | None = None,
) -> Path:
root = tmp_path / "proj"
root.mkdir(exist_ok=True)
for rel, content in (files or {}).items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
cwd = root / subdir if subdir is not None else root
cwd.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(cwd)
return root
return _project
def run(*args: str) -> click.testing.Result:
"""Invoke `nova <args...>` through the shared CliRunner."""
return runner.invoke(main, list(args))
def output(result: click.testing.Result) -> str:
"""Everything the command printed, on stdout and stderr alike.
click >= 8.2 always separates the streams, so both are concatenated;
on older click versions (merged streams) ``output`` already carries
stderr and the ``stderr`` access raises — same fallback as the
P01-01-02 helper in test_main.py.
"""
try:
return result.output + getattr(result, "stderr", "")
except ValueError:
return result.output
# --- Row 1: no configuration file anywhere -------------------------------------------
@pytest.mark.parametrize("command", COMPOSE_COMMANDS)
def test_no_config_compose_commands_report_missing_configuration(
project: ProjectFactory, command: str
) -> None:
"""With no config in cwd (or any parent), every compose command fails on discovery."""
project()
result = run(command)
assert result.exit_code == 1
assert "no configuration file found" in output(result)
def test_no_config_build_reports_missing_novafile(project: ProjectFactory) -> None:
"""build needs a Novafile and reports it missing, docker-build style."""
project()
result = run("build")
assert result.exit_code == 1
printed = output(result)
assert "no such file" in printed
assert "Novafile" in printed
# --- Rows 2-3: a config file lets discovery pass; each command lands in its phase ----
@pytest.mark.parametrize(
("command", "phase"),
[
("up", 4),
("down", 4),
("ps", 4),
("stop", 4),
("start", 4),
("restart", 4),
("rm", 4),
("logs", 4),
],
)
def test_with_compose_file_each_command_lands_in_its_phase(
project: ProjectFactory, command: str, phase: int
) -> None:
"""A minimal compose.yml in cwd: engine commands land in phase 4."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run(command)
assert result.exit_code == 1
assert f"phase {phase}" in output(result)
def test_with_compose_file_config_exits_zero(project: ProjectFactory) -> None:
"""Since P2, `config` validates and prints the normalized compose file."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run("config")
assert result.exit_code == 0
assert "module: terraform-aws-modules/vpc/aws" in output(result)
def test_build_with_novafile_prints_manifest(project: ProjectFactory) -> None:
"""Since P2, `build` parses the Novafile and prints the module manifest."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run("build")
assert result.exit_code == 0
assert "module terraform-aws-modules/vpc/aws" in output(result)
# --- Row 4: explicit -f with a missing file ------------------------------------------
@pytest.mark.parametrize("command", ("up", "ps"))
def test_explicit_f_with_missing_file_beats_discovery(
project: ProjectFactory, command: str
) -> None:
"""An explicit -f wins over discovery and hard-fails naming the missing file."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run(command, "-f", "nope.yml")
assert result.exit_code == 1
printed = output(result)
assert "no such file" in printed
assert "nope.yml" in printed
# --- Rows 5-6: discovery reaches the right file through the CLI ----------------------
def test_upward_search_finds_parent_compose_via_cli(project: ProjectFactory) -> None:
"""From a subdirectory, `logs` finds compose.yaml in the project root."""
project(files={"compose.yaml": MINIMAL_COMPOSE}, subdir="sub")
result = run("logs")
assert result.exit_code == 1
assert "phase 4" in output(result)
def test_compose_yaml_preferred_over_compose_yml(project: ProjectFactory) -> None:
"""With both files present, the CLI discovers compose.yaml, not compose.yml."""
root = project(files={"compose.yaml": MINIMAL_COMPOSE, "compose.yml": MINIMAL_COMPOSE})
result = run("up")
assert result.exit_code == 1
assert "phase 4" in output(result)
assert find_compose_file(root) == root / "compose.yaml"
# --- Rows 7-8: version and the placeholder groups ------------------------------------
def test_version_exits_zero() -> None:
"""`nova version` is the one success path in phase 1."""
result = run("version")
assert result.exit_code == 0
assert "0.1.0" in output(result)
@pytest.mark.parametrize(
("group", "phase"),
[("policy", "phase 5"), ("env", "phase 3")],
)
def test_placeholder_groups_land_in_their_phases(group: str, phase: str) -> None:
"""`policy` alone lands in phase 5; `env` alone lands in phase 3."""
result = run(group)
assert result.exit_code == 1
assert phase in output(result)
# --- Row 9: help surface --------------------------------------------------------------
@pytest.mark.parametrize("command", LIFECYCLE_COMMANDS)
def test_lifecycle_help_shows_usage_and_parity_options(command: str) -> None:
"""Every lifecycle `--help` shows Usage plus the docker-parity option set."""
result = run(command, "--help")
assert result.exit_code == 0
printed = output(result)
assert "Usage:" in printed
assert "--project-name" in printed
def test_root_help_lists_all_thirteen_commands() -> None:
"""`nova --help` lists the full 13-command surface."""
assert len(ALL_COMMANDS) == 13
result = run("--help")
assert result.exit_code == 0
printed = output(result)
for command in ALL_COMMANDS:
# word-boundary match: "start" must not be satisfied by "restart"
assert re.search(rf"\b{re.escape(command)}\b", printed), command
# --- Row 10: exit codes are exactly 1 -------------------------------------------------
@pytest.mark.parametrize("command", ("up", "rm", "logs"))
def test_phase_landings_exit_exactly_one(project: ProjectFactory, command: str) -> None:
"""Landing errors are exit 1 precisely (rows 1-2 assert the same for no-config)."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run(command)
assert result.exit_code == 1
assert "lands in phase" in output(result)
@pytest.mark.parametrize("command", ("build", "config"))
def test_parser_commands_exit_zero(project: ProjectFactory, command: str) -> None:
"""Since P2, build/config succeed (exit 0) on a valid project."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run(command)
assert result.exit_code == 0
+161
View File
@@ -0,0 +1,161 @@
"""End-to-end CLI tests for `nova config` and `nova build` (parser-backed behavior)."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from nova.cli.main import cli
COMPOSE = """
name: demo
services:
vpc:
module: terraform-aws-modules/vpc/aws
environment:
region: ${REGION:-us-east-1}
name: ${PROJECT:?project name is required}
"""
NOVAFILE = """\
FROM terraform-aws-modules/vpc/aws@5.0.0 AS vpc
ARG REGION
ENV region=us-east-1
LABEL owner=platform
WORKDIR /state
RUN echo ignored
"""
@pytest.fixture()
def project(tmp_path, monkeypatch):
"""An isolated project dir with compose.yml and a Novafile."""
proj = tmp_path / "proj"
proj.mkdir()
(proj / "compose.yml").write_text(COMPOSE)
(proj / "Novafile").write_text(NOVAFILE)
monkeypatch.chdir(proj)
return proj
@pytest.fixture()
def runner():
return CliRunner()
def _out(result) -> str:
return result.output + getattr(result, "stderr", "")
def test_config_prints_normalized_yaml(project, runner):
result = runner.invoke(cli, ["config"], env={"PROJECT": "demo"})
assert result.exit_code == 0, _out(result)
assert "services:" in result.output
assert "module: terraform-aws-modules/vpc/aws" in result.output
def test_config_interpolates_from_shell_env(project, runner, monkeypatch):
monkeypatch.setenv("REGION", "eu-west-1")
result = runner.invoke(cli, ["config"], env={"REGION": "eu-west-1", "PROJECT": "demo"})
assert result.exit_code == 0, _out(result)
assert "eu-west-1" in result.output
def test_config_env_file_precedence(project, runner, monkeypatch):
(project / ".env").write_text("REGION=from-dotenv\nPROJECT=from-dotenv\n")
(project / "extra.env").write_text("REGION=from-env-file\n")
monkeypatch.delenv("REGION", raising=False)
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config", "--env-file", "extra.env"])
assert result.exit_code == 0, _out(result)
# --env-file beats .env; shell env beats both (unset here).
assert "from-env-file" in result.output
assert "from-dotenv" not in result.output or "PROJECT" not in result.output
def test_config_dotenv_fallback(project, runner, monkeypatch):
(project / ".env").write_text("REGION=from-dotenv\nPROJECT=from-dotenv\n")
monkeypatch.delenv("REGION", raising=False)
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config"])
assert result.exit_code == 0, _out(result)
assert "from-dotenv" in result.output
def test_config_no_interpolate(project, runner):
result = runner.invoke(cli, ["config", "--no-interpolate"], env={"PROJECT": "x"})
assert result.exit_code == 0, _out(result)
assert "${REGION:-us-east-1}" in result.output
def test_config_required_var_missing(project, runner, monkeypatch):
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config"])
assert result.exit_code == 1, _out(result)
assert "project name is required" in _out(result)
def test_config_invalid_schema(project, runner):
(project / "compose.yml").write_text(
"services:\n a:\n module: hashicorp/vpc\n"
)
result = runner.invoke(cli, ["config"], env={"PROJECT": "x"})
assert result.exit_code == 1, _out(result)
assert "invalid module reference" in _out(result)
def test_config_missing_env_file_errors_cleanly(project, runner, monkeypatch):
"""A missing --env-file is a compose-style error, never a traceback (UX criterion)."""
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config", "--env-file", "nope.env"])
assert result.exit_code == 1, _out(result)
assert "no such file" in _out(result)
assert "Traceback" not in _out(result)
def test_config_missing_file(project, runner):
(project / "compose.yml").unlink()
result = runner.invoke(cli, ["config"])
assert result.exit_code == 1, _out(result)
assert "no configuration file found" in _out(result)
def test_build_prints_module_manifest(project, runner):
result = runner.invoke(cli, ["build"])
assert result.exit_code == 0, _out(result)
assert "module terraform-aws-modules/vpc/aws" in result.output
assert "version 5.0.0" in result.output
assert "stage vpc" in result.output
assert "arg REGION=(required)" in result.output
assert "workdir /state" in result.output
assert "Build manifest resolved: 1 stage(s)" in result.output
def test_build_warns_on_container_only_instructions(project, runner):
result = runner.invoke(cli, ["build"])
assert result.exit_code == 0, _out(result)
assert "WARNING" in _out(result)
assert "RUN is a container-only instruction" in _out(result)
def test_build_invalid_novafile(project, runner):
(project / "Novafile").write_text("FROM hashicorp/vpc\n")
result = runner.invoke(cli, ["build"])
assert result.exit_code == 1, _out(result)
assert "Error:" in _out(result)
assert "invalid module reference" in _out(result)
def test_build_missing_novafile(project, runner):
(project / "Novafile").unlink()
result = runner.invoke(cli, ["build"])
assert result.exit_code == 1, _out(result)
assert "no such file" in _out(result)
def test_build_with_explicit_novafile(project, runner):
alt = project / "Custom.Novafile"
alt.write_text("FROM hashicorp/consul/aws\n")
result = runner.invoke(cli, ["build", "-f", "Custom.Novafile"])
assert result.exit_code == 0, _out(result)
assert "hashicorp/consul/aws" in result.output
+188
View File
@@ -0,0 +1,188 @@
"""Config file discovery tests (task P01-01-03).
Pure unit tests — no CliRunner, no CLI process: every discovery rule is
exercised directly against tmp_path directory trees, mirroring the
compose-spec rules verified in .ciagent/RESEARCH.md area 3.
"""
from pathlib import Path
import pytest
from nova.cli.config_files import (
NovaFileError,
find_compose_file,
find_novafile,
load_env_files,
)
NO_CONFIG = "no configuration file found: specify a file with -f"
@pytest.fixture(autouse=True)
def _no_compose_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep NOVA_COMPOSE_FILE out of every discovery test unless set explicitly."""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
def _write(path: Path, content: str = "# nova config\n") -> Path:
"""Create a config file (discovery only checks existence, parsers land in P2)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
# --- find_compose_file: explicit -f files ------------------------------------------
def test_explicit_files_first_is_primary(tmp_path: Path) -> None:
"""Explicit files: the first is primary once every listed file exists."""
first = _write(tmp_path / "one.yml")
second = _write(tmp_path / "two.yml")
assert find_compose_file(tmp_path, [first, second]) == first
def test_explicit_files_missing_raises(tmp_path: Path) -> None:
"""Every explicit file must exist; a missing one is a hard error."""
present = _write(tmp_path / "one.yml")
missing = tmp_path / "two.yml"
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(tmp_path, [present, missing])
assert str(excinfo.value) == f"no such file: {missing}"
def test_explicit_file_relative_resolves_against_workdir(tmp_path: Path) -> None:
"""Relative -f paths anchor to the workdir (click hands us raw strings)."""
_write(tmp_path / "compose.yaml")
assert find_compose_file(tmp_path, [Path("compose.yaml")]) == tmp_path / "compose.yaml"
# --- find_compose_file: NOVA_COMPOSE_FILE env var ----------------------------------
def test_env_var_single_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""NOVA_COMPOSE_FILE supplies the compose file without any directory search."""
custom = _write(tmp_path / "custom.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(custom))
assert find_compose_file(tmp_path) == custom
def test_env_var_multiple_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A `:`-separated NOVA_COMPOSE_FILE resolves with the first as primary."""
one = _write(tmp_path / "one.yml")
two = _write(tmp_path / "two.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", f"{one}:{two}")
assert find_compose_file(tmp_path) == one
def test_env_var_missing_path_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Env-var paths get the same existence validation as -f files."""
missing = tmp_path / "gone.yml"
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(missing))
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(tmp_path)
assert str(excinfo.value) == f"no such file: {missing}"
def test_explicit_files_beat_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Precedence: -f files win over NOVA_COMPOSE_FILE."""
explicit = _write(tmp_path / "explicit.yml")
from_env = _write(tmp_path / "from-env.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(from_env))
assert find_compose_file(tmp_path, [explicit]) == explicit
# --- find_compose_file: upward directory search ------------------------------------
def test_upward_search_finds_parent_compose_yaml(tmp_path: Path) -> None:
"""Without -f/env, discovery walks up parent directories to a compose file."""
compose = _write(tmp_path / "a" / "compose.yaml")
nested = tmp_path / "a" / "b" / "c"
nested.mkdir(parents=True)
assert find_compose_file(nested) == compose
def test_upward_search_prefers_compose_yaml_in_same_dir(tmp_path: Path) -> None:
"""compose.yaml beats compose.yml within the same directory."""
yaml = _write(tmp_path / "compose.yaml")
_write(tmp_path / "compose.yml")
assert find_compose_file(tmp_path) == yaml
def test_upward_search_compose_yml_fallback(tmp_path: Path) -> None:
"""compose.yml alone is a valid hit when compose.yaml is absent."""
yml = _write(tmp_path / "compose.yml")
assert find_compose_file(tmp_path) == yml
def test_no_config_found_raises(tmp_path: Path) -> None:
"""Nothing anywhere up to the root: the exact compose-style error."""
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(empty)
assert str(excinfo.value) == NO_CONFIG
# --- find_novafile (docker build parity) ---------------------------------------------
def test_novafile_default_in_workdir(tmp_path: Path) -> None:
"""Default lookup is <workdir>/Novafile, nothing else."""
novafile = _write(tmp_path / "Novafile", "FROM demo\n")
assert find_novafile(tmp_path) == novafile
def test_novafile_no_upward_search(tmp_path: Path) -> None:
"""Dockerfile parity: a Novafile in a parent directory is never found."""
_write(tmp_path / "Novafile", "FROM demo\n")
nested = tmp_path / "nested"
nested.mkdir()
with pytest.raises(NovaFileError) as excinfo:
find_novafile(nested)
assert str(excinfo.value) == f"no such file: {nested / 'Novafile'}"
def test_novafile_override(tmp_path: Path) -> None:
"""An explicit build -f path replaces the default Novafile location."""
_write(tmp_path / "Novafile", "FROM demo\n")
custom = _write(tmp_path / "Novafile.prod", "FROM demo\n")
assert find_novafile(tmp_path, Path("Novafile.prod")) == custom
# --- load_env_files ------------------------------------------------------------------
def test_env_file_parsing_comments_blanks_and_quotes(tmp_path: Path) -> None:
"""KEY=VALUE lines parse; comments/blanks skip; surrounding quotes strip."""
env_file = _write(
tmp_path / "test.env",
"# comment line\n"
"\n"
"PLAIN=value\n"
'DOUBLE="quoted value"\n'
"SINGLE='single value'\n"
"EMPTY=\n",
)
assert load_env_files([env_file]) == {
"PLAIN": "value",
"DOUBLE": "quoted value",
"SINGLE": "single value",
"EMPTY": "",
}
def test_env_files_later_overrides_earlier(tmp_path: Path) -> None:
"""Later files override earlier keys; untouched keys survive."""
first = _write(tmp_path / "first.env", "A=1\nB=keep\n")
second = _write(tmp_path / "second.env", "A=2\n")
assert load_env_files([first, second]) == {"A": "2", "B": "keep"}
def test_env_files_missing_raises(tmp_path: Path) -> None:
"""A missing env file is a hard error naming the path."""
missing = tmp_path / "nope.env"
with pytest.raises(NovaFileError) as excinfo:
load_env_files([missing])
assert str(excinfo.value) == f"no such file: {missing}"
+226
View File
@@ -0,0 +1,226 @@
"""CLI surface tests: docker-ergonomic command surface (task P01-01-02).
Every lifecycle command parses its full flag set (click rejects unknown
options with exit code 2), resolves its configuration file compose-style
(P01-01-03), then reports the later phase in which its implementation
lands with exit code 1.
"""
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import click
import click.testing
import pytest
from nova.cli import main
runner = click.testing.CliRunner()
@contextmanager
def _project_dir(
tmp_path: Path,
files: tuple[str, ...] | dict[str, str] = ("compose.yml",),
) -> Iterator[Path]:
"""Run with cwd inside a fresh project dir containing the named files.
Since P01-01-03, lifecycle handlers discover a compose file (build: a
Novafile) against ``Path.cwd()`` before reporting their phase landing,
so the landing tests need a minimal project directory to land in.
``files=()`` yields an empty project — the no-config case. A dict maps
file names to explicit content.
"""
root = Path(tempfile.mkdtemp(dir=tmp_path))
if isinstance(files, dict):
for name, content in files.items():
(root / name).write_text(content, encoding="utf-8")
else:
for name in files:
(root / name).write_text("# minimal nova config\n", encoding="utf-8")
previous = Path.cwd()
os.chdir(root)
try:
yield root
finally:
os.chdir(previous)
def _visible_output(result: click.testing.Result) -> str:
"""Everything the command printed, on stdout and stderr alike.
click >= 8.2 always separates the streams and ``Result.output`` already
mixes them in terminal order; concatenating ``stderr`` as well keeps
older click versions (where ``output`` may be stdout-only and stderr is
accessed separately) working. See the P01-01-02 task note.
"""
try:
return result.output + getattr(result, "stderr", "")
except ValueError:
# click < 8.2 with merged streams: output already contains stderr.
return result.output
def test_help_lists_all_commands() -> None:
"""`nova --help` shows the full command surface."""
result = runner.invoke(main, ["--help"])
assert result.exit_code == 0
output = _visible_output(result)
assert "Usage:" in output
for command in (
"build",
"up",
"down",
"ps",
"stop",
"start",
"restart",
"rm",
"logs",
"config",
"policy",
"env",
"version",
):
assert command in output, f"`nova --help` should list `{command}`"
def test_flag_parsing_up(tmp_path: Path) -> None:
"""`up` accepts every compose-parity flag, then reports the phase-4 engine."""
with _project_dir(tmp_path, files=("a.yml", "b.yml")):
result = runner.invoke(
main,
[
"up",
"-d",
"-f",
"a.yml",
"-f",
"b.yml",
"-p",
"proj",
"--env-file",
"e1",
"--profile",
"p1",
],
)
assert result.exit_code == 1
output = _visible_output(result)
assert "phase 4" in output
assert "Error: the 'up' engine lands in phase 4 (IaC adapter)" in output
def test_down_volumes_flag(tmp_path: Path) -> None:
"""`down -v` parses the volumes flag, then reports the phase-4 engine."""
with _project_dir(tmp_path):
result = runner.invoke(main, ["down", "-v"])
assert result.exit_code == 1
assert "phase 4" in _visible_output(result)
def test_build_config_real_behavior(tmp_path: Path) -> None:
"""Since P2, `build` and `config` run the real parsers.
`build` prints the resolved module manifest (exit 0); `config` prints
the normalized compose YAML (exit 0). Phase-2 landing errors are gone.
"""
files = {
"Novafile": "FROM terraform-aws-modules/vpc/aws@5.0.0\n",
"compose.yml": (
"name: demo\n"
"services:\n"
" vpc:\n"
" module: terraform-aws-modules/vpc/aws\n"
),
}
with _project_dir(tmp_path, files=files):
result = runner.invoke(main, ["build"])
assert result.exit_code == 0
assert "module terraform-aws-modules/vpc/aws" in _visible_output(result)
result = runner.invoke(main, ["config"])
assert result.exit_code == 0
assert "module: terraform-aws-modules/vpc/aws" in _visible_output(result)
def test_policy_env_groups() -> None:
"""`policy`/`env` groups report their phase-5/phase-3 landings."""
result = runner.invoke(main, ["policy"])
assert result.exit_code == 1
assert (
"Error: nova policy lands in phase 5 (vendored c7n + c7n-left)"
in _visible_output(result)
)
result = runner.invoke(main, ["env"])
assert result.exit_code == 1
assert (
"Error: nova env lands in phase 3 (environment discovery)"
in _visible_output(result)
)
def test_version_still_works() -> None:
"""`nova version` still prints the installed version and exits 0."""
result = runner.invoke(main, ["version"])
assert result.exit_code == 0
assert "0.1.0" in _visible_output(result)
def test_lifecycle_extras_parse(tmp_path: Path) -> None:
"""Per-command extras parse cleanly (an unknown option would exit 2)."""
cases = [
["down", "-v", "--profile", "prod"],
["ps", "-a"],
["stop"],
["start"],
["restart"],
["rm", "-f"],
["rm", "--file", "nova.yml", "--force"],
["logs", "web", "db", "-d"],
]
for args in cases:
with _project_dir(tmp_path, files=("compose.yml", "nova.yml")):
result = runner.invoke(main, args)
assert result.exit_code == 1, args
output = _visible_output(result)
assert "Error:" in output, args
assert "lands in phase" in output, args
def test_config_extras_parse(tmp_path: Path) -> None:
"""config's extra flags parse cleanly; with no compose file it still errors."""
with _project_dir(tmp_path, files=()):
result = runner.invoke(
main, ["config", "--no-interpolate", "-p", "proj"]
)
assert result.exit_code == 1
assert "no configuration file found" in _visible_output(result)
def test_lifecycle_without_config_reports_missing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without any config file, discovery fails before the phase landing.
`up` finds no compose file anywhere up to the root (the exact
compose-style message); `build` finds no Novafile in the working
directory (docker build parity: no upward search).
"""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
with _project_dir(tmp_path, files=()) as project:
result = runner.invoke(main, ["up"])
assert result.exit_code == 1
output = _visible_output(result)
assert (
"Error: no configuration file found: specify a file with -f" in output
)
assert "phase 4" not in output
result = runner.invoke(main, ["build"])
assert result.exit_code == 1
assert f"no such file: {project / 'Novafile'}" in _visible_output(result)
+1
View File
@@ -0,0 +1 @@
"""Tests for the nova.parsers package."""
+331
View File
@@ -0,0 +1,331 @@
"""Tests for the compose.yml loader schema (compose-indistinguishable subset)."""
import pytest
import yaml
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
MINIMAL = """
name: demo
services:
vpc:
module: terraform-aws-modules/vpc/aws
environment:
name: demo
"""
def test_minimal_file_parses():
spec = parse_compose(MINIMAL)
assert spec.name == "demo"
assert len(spec.services) == 1
svc = spec.services[0]
assert svc.name == "vpc"
assert svc.module is not None
assert svc.module.source == "terraform-aws-modules/vpc/aws"
assert svc.environment == (("name", "demo"),)
def test_providers_block_stored():
spec = parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws
providers:
aws:
region: us-east-1
profile: prod
"""
)
assert spec.providers == {"aws": {"region": "us-east-1", "profile": "prod"}}
def test_module_version_key_pins():
spec = parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws
version: "5.0.0"
"""
)
assert spec.services[0].module is not None
assert spec.services[0].module.version == "5.0.0"
def test_version_sugar_and_key_conflict():
with pytest.raises(ComposeParseError, match="version specified twice"):
parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws@5.0.0
version: "5.0.0"
"""
)
def test_environment_mapping_and_list_forms():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
environment:
K1: v1
K2: v2
b:
module: terraform-aws-modules/vpc/aws
environment:
- K3=v3
"""
)
assert spec.services[0].environment == (("K1", "v1"), ("K2", "v2"))
assert spec.services[1].environment == (("K3", "v3"),)
def test_vars_alias_accepted():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
vars:
K1: v1
"""
)
assert spec.services[0].environment == (("K1", "v1"),)
def test_vars_and_environment_conflict():
with pytest.raises(ComposeParseError, match="use only one"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
vars: {K: v}
environment: {K: v}
"""
)
def test_env_file_single_and_list():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
env_file: .env
b:
module: terraform-aws-modules/vpc/aws
env_file:
- one.env
- two.env
"""
)
assert spec.services[0].env_files == (".env",)
assert spec.services[1].env_files == ("one.env", "two.env")
def test_depends_on_short_and_long_forms():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
b:
module: terraform-aws-modules/vpc/aws
depends_on:
- a
c:
module: terraform-aws-modules/vpc/aws
depends_on:
a:
condition: module_deployed
"""
)
assert spec.services[1].depends_on == ("a",)
assert spec.services[2].depends_on == ("a",)
def test_depends_on_unknown_service():
with pytest.raises(ComposeParseError, match="unknown service 'ghost'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
depends_on:
- ghost
"""
)
def test_depends_on_self_is_error():
with pytest.raises(ComposeParseError, match="cannot depend on itself"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
depends_on:
- a
"""
)
def test_working_dir_profiles_provider():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
working_dir: /state/prod
profiles:
- prod
provider: AWS
"""
)
svc = spec.services[0]
assert svc.working_dir == "/state/prod"
assert svc.profiles == ("prod",)
assert svc.provider == "aws"
def test_invalid_provider_rejected():
with pytest.raises(ComposeParseError, match="provider must be one of"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
provider: openstack
"""
)
def test_build_form_defaults_and_explicit():
spec = parse_compose(
"""
services:
a:
build: {}
b:
build:
context: ./infra
novafile: Custom.Novafile
"""
)
assert spec.services[0].build is not None
assert spec.services[0].build.context == "."
assert spec.services[0].build.novafile == "Novafile"
assert spec.services[1].build.context == "./infra"
assert spec.services[1].build.novafile == "Custom.Novafile"
def test_module_and_build_both_rejected():
with pytest.raises(ComposeParseError, match="not both"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
build: {}
"""
)
def test_neither_module_nor_build_rejected():
with pytest.raises(ComposeParseError, match="requires a 'module'"):
parse_compose("services:\n a: {}\n")
def test_unknown_top_level_key():
with pytest.raises(ComposeParseError, match="top-level key 'networks'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
networks: {}
"""
)
def test_top_level_not_mapping():
with pytest.raises(ComposeParseError, match="expected a mapping"):
parse_compose("- just\n- a list\n")
def test_services_not_mapping():
with pytest.raises(ComposeParseError, match="'services' must be a mapping"):
parse_compose("services: []\n")
def test_unknown_provider_in_providers_block():
with pytest.raises(ComposeParseError, match="unknown provider 'openstack'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
providers:
openstack: {}
"""
)
def test_invalid_module_ref_surfaces_with_service():
with pytest.raises(ComposeParseError, match="service 'a': invalid module reference"):
parse_compose(
"""
services:
a:
module: hashicorp/vpc
"""
)
def test_interpolation_applied_to_values():
spec = parse_compose(
"""
name: ${PROJECT}
services:
a:
module: terraform-aws-modules/vpc/aws
environment:
region: ${REGION:-us-east-1}
""",
env={"PROJECT": "demo-project", "REGION": "eu-west-1"},
)
assert spec.name == "demo-project"
assert spec.services[0].environment == (("region", "eu-west-1"),)
def test_interpolation_disabled_when_env_none():
spec = parse_compose(
"services:\n a:\n module: terraform-aws-modules/vpc/aws\n"
" environment:\n region: ${REGION:-us-east-1}\n"
)
assert spec.services[0].environment == (("region", "${REGION:-us-east-1}"),)
def test_render_compose_round_trip():
spec = parse_compose(MINIMAL)
rendered = render_compose(spec)
assert rendered["name"] == "demo"
assert rendered["services"]["vpc"]["module"] == "terraform-aws-modules/vpc/aws"
assert rendered["services"]["vpc"]["environment"] == {"name": "demo"}
# Round trip: rendered dict re-parses to the same model.
reparsed = parse_compose(rendered)
assert reparsed.name == spec.name
assert reparsed.services[0].module == spec.services[0].module
assert reparsed.services[0].environment == spec.services[0].environment
def test_render_is_yaml_serializable():
spec = parse_compose(MINIMAL)
text = yaml.safe_dump(render_compose(spec), sort_keys=False)
assert "module: terraform-aws-modules/vpc/aws" in text
+83
View File
@@ -0,0 +1,83 @@
"""Tests for compose-style variable interpolation."""
import pytest
from nova.parsers.interp import InterpolationError, interpolate
def test_bare_var_set_and_unset():
assert interpolate("hello $NAME", {"NAME": "world"}) == "hello world"
assert interpolate("hello $NAME", {}) == "hello "
def test_braced_var_set_and_unset():
assert interpolate("${VAR}", {"VAR": "x"}) == "x"
assert interpolate("${VAR}", {}) == ""
def test_default_when_unset():
assert interpolate("${VAR:-fallback}", {}) == "fallback"
assert interpolate("${VAR:-fallback}", {"VAR": "set"}) == "set"
def test_default_when_empty():
assert interpolate("${VAR:-fallback}", {"VAR": ""}) == "fallback"
assert interpolate("${VAR-fallback}", {"VAR": ""}) == ""
def test_dash_default_only_when_unset():
assert interpolate("${VAR-fallback}", {}) == "fallback"
def test_required_error_with_message():
with pytest.raises(InterpolationError, match="VAR: region is required"):
interpolate("${VAR:?region is required}", {})
def test_required_error_when_empty():
with pytest.raises(InterpolationError, match="VAR"):
interpolate("${VAR:?}", {"VAR": ""})
def test_required_passes_when_set():
assert interpolate("${VAR:?need}", {"VAR": "ok"}) == "ok"
def test_question_mark_only_when_unset():
assert interpolate("${VAR?need}", {"VAR": ""}) == ""
with pytest.raises(InterpolationError):
interpolate("${VAR?need}", {})
def test_alternate_when_set():
assert interpolate("${VAR:+alt}", {"VAR": "set"}) == "alt"
assert interpolate("${VAR:+alt}", {}) == ""
assert interpolate("${VAR:+alt}", {"VAR": ""}) == ""
def test_plus_alternate_when_set_even_empty():
assert interpolate("${VAR+alt}", {"VAR": ""}) == "alt"
def test_dollar_dollar_literal():
assert interpolate("cost $$5", {}) == "cost $5"
assert interpolate("$$VAR", {}) == "$VAR"
def test_lone_dollar_literal():
assert interpolate("100% of $", {}) == "100% of $"
assert interpolate("a$1b", {}) == "a$1b"
def test_mixed_operators_in_one_string():
env = {"A": "1"}
assert interpolate("x${A}y${B:-z}w", env) == "x1yzw"
def test_invalid_braced_name():
with pytest.raises(InterpolationError, match="1BAD"):
interpolate("${1BAD}", {})
def test_no_interpolation_needed():
assert interpolate("plain text", {}) == "plain text"
+236
View File
@@ -0,0 +1,236 @@
"""Module reference resolver tests (task P02-01-01).
Pure unit tests for the Terraform module source grammar verified in
.ciagent/RESEARCH.md area 1: registry shorthand, private registry hosts,
public registry web-URL normalization, ``//subdir`` submodules, ``@version``
sugar, and local path sources — with the exact docker/compose-style error
messages for every rejected shape.
"""
import pytest
from nova.parsers.moduleref import (
ModuleRef,
ModuleRefError,
parse_module_ref,
)
# --- valid registry references -------------------------------------------------------
def test_public_registry_shorthand() -> None:
"""Three segments resolve to public shorthand with no host or version."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws",
submodule=None,
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_private_registry_four_segments() -> None:
"""Four segments carry a private registry hostname in host."""
ref = parse_module_ref("app.terraform.io/example-corp/k8s-cluster/azurerm")
assert ref == ModuleRef(
source="app.terraform.io/example-corp/k8s-cluster/azurerm",
submodule=None,
version=None,
is_local=False,
host="app.terraform.io",
)
assert ref.terraform_source == "app.terraform.io/example-corp/k8s-cluster/azurerm"
def test_public_registry_url_normalizes_to_shorthand() -> None:
"""The registry.terraform.io/<ns>/<name>/<provider> web URL drops its host."""
ref = parse_module_ref("registry.terraform.io/terraform-aws-modules/vpc/aws")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws",
submodule=None,
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_public_registry_modules_url_normalizes_to_shorthand() -> None:
"""The /modules/ web-URL form also normalizes to plain shorthand."""
ref = parse_module_ref("registry.terraform.io/modules/terraform-aws-modules/vpc/aws")
assert ref.source == "terraform-aws-modules/vpc/aws"
assert ref.host is None
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_submodule_marker() -> None:
"""// selects a submodule; it stays part of the emitted source."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws//modules/vpc-endpoint")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws//modules/vpc-endpoint",
submodule="modules/vpc-endpoint",
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws//modules/vpc-endpoint"
# --- version sugar -------------------------------------------------------------------
def test_version_sugar_plain() -> None:
"""@1.2.3 splits into version="1.2.3", leaving a clean source."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@1.2.3")
assert ref.source == "terraform-aws-modules/vpc/aws"
assert ref.version == "1.2.3"
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_version_sugar_pessimistic() -> None:
"""@~> 6.0 keeps the pessimistic operator in the version field."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@~> 6.0")
assert ref.version == "~> 6.0"
assert ref.source == "terraform-aws-modules/vpc/aws"
def test_version_sugar_comma_separated_range() -> None:
"""@>= 1.2, < 2.0 keeps the full constraint, spaces and all."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@>= 1.2, < 2.0")
assert ref.version == ">= 1.2, < 2.0"
assert ref.source == "terraform-aws-modules/vpc/aws"
# --- local sources --------------------------------------------------------------------
@pytest.mark.parametrize(
"path", ["./modules/vpc", "../shared/vpc", "/opt/nova/modules/vpc"]
)
def test_local_sources(path: str) -> None:
"""./ ../ and absolute paths stay as-is and never carry a version."""
ref = parse_module_ref(path)
assert ref == ModuleRef(
source=path, submodule=None, version=None, is_local=True, host=None
)
assert ref.terraform_source == path
def test_private_host_submodule_and_version_combo() -> None:
"""Private host + //submodule + @version all resolve together."""
ref = parse_module_ref(
"app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam@>= 1.2, < 2.0"
)
assert ref == ModuleRef(
source="app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam",
submodule="modules/iam",
version=">= 1.2, < 2.0",
is_local=False,
host="app.terraform.io",
)
assert ref.terraform_source == (
"app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam"
)
def test_surrounding_whitespace_stripped() -> None:
"""Leading/trailing whitespace never reaches the source."""
ref = parse_module_ref("\t terraform-aws-modules/vpc/aws \n")
assert ref.source == "terraform-aws-modules/vpc/aws"
# --- rejected references --------------------------------------------------------------
def test_two_segments_rejected() -> None:
"""The exact compose-style message for a 2-segment registry reference."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("hashicorp/vpc")
assert str(excinfo.value) == (
"invalid module reference 'hashicorp/vpc': "
"registry references need <namespace>/<name>/<provider>"
)
@pytest.mark.parametrize(
("ref", "reason"),
[
("app.terraform.io", "bare registry hostname without <namespace>/<name>/<provider>"),
("vpc", "registry references need <namespace>/<name>/<provider>"),
],
)
def test_one_segment_rejected(ref: str, reason: str) -> None:
"""Bare hostnames and bare names are not registry references."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == f"invalid module reference '{ref}': {reason}"
def test_five_segments_rejected() -> None:
"""Five segments overshoot the 4-segment private form."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("a/b/c/d/e")
assert str(excinfo.value) == (
"invalid module reference 'a/b/c/d/e': "
"registry references allow at most <hostname>/<namespace>/<name>/<provider>"
)
@pytest.mark.parametrize("ref", ["", " ", "\t"])
def test_empty_reference_rejected(ref: str) -> None:
"""Empty (or whitespace-only) references never parse."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == (
f"invalid module reference '{ref}': module source cannot be empty"
)
@pytest.mark.parametrize(
"path", ["./modules/vpc", "../shared/vpc", "/opt/nova/modules/vpc"]
)
def test_version_on_local_rejected(path: str) -> None:
"""Version pinning is a registry-only feature (exact message)."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(f"{path}@1.2.3")
assert str(excinfo.value) == (
f"invalid module reference '{path}@1.2.3': "
"local module sources do not support version pinning"
)
@pytest.mark.parametrize(
("version", "reason"),
[
("1.2.3.4.5", "invalid version constraint '1.2.3.4.5'"),
("foo", "invalid version constraint 'foo'"),
("", "invalid version constraint ''"),
],
)
def test_invalid_version_constraint_rejected(version: str, reason: str) -> None:
"""Non-constraint strings after @ are rejected with their own reason."""
ref = f"terraform-aws-modules/vpc/aws@{version}"
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == f"invalid module reference '{ref}': {reason}"
def test_double_slash_start_rejected() -> None:
"""A submodule marker without a leading source is invalid."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("//modules/vpc")
assert str(excinfo.value) == (
"invalid module reference '//modules/vpc': module source cannot start with '//'"
)
def test_empty_path_segment_rejected() -> None:
"""A trailing slash leaves an empty provider segment."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("terraform-aws-modules/vpc/aws/")
assert str(excinfo.value) == (
"invalid module reference 'terraform-aws-modules/vpc/aws/': "
"module source contains an empty path segment"
)
+217
View File
@@ -0,0 +1,217 @@
"""Tests for the Novafile parser (Dockerfile-compatible grammar)."""
import pytest
from nova.parsers.moduleref import ModuleRef
from nova.parsers.novafile import NovafileError, parse_novafile
def test_case_insensitive_instructions():
nv = parse_novafile(
"from terraform-aws-modules/vpc/aws\n"
"env REGION=us-east-1\n"
"label owner=platform\n"
)
assert nv.stages[0].module.source == "terraform-aws-modules/vpc/aws"
assert nv.stages[0].env == (("REGION", "us-east-1"),)
assert nv.stages[0].labels == (("owner", "platform"),)
def test_comments_and_blank_lines_skipped():
text = (
"# a comment\n"
"\n"
" # indented comment\n"
"FROM terraform-aws-modules/vpc/aws\n"
"# trailing comment\n"
)
nv = parse_novafile(text)
assert len(nv.stages) == 1
def test_line_continuations():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"ENV A=1 \\\n"
" B=2\n"
)
nv = parse_novafile(text)
assert nv.stages[0].env == (("A", "1"), ("B", "2"))
def test_env_legacy_form_preserves_inner_spaces():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"ENV MESSAGE hello world with spaces\n"
)
nv = parse_novafile(text)
assert nv.stages[0].env == (("MESSAGE", "hello world with spaces"),)
def test_env_quoted_values_with_escapes():
text = (
'FROM terraform-aws-modules/vpc/aws\n'
'ENV A="quoted value" B="say \\"hi\\"" C=plain\n'
)
nv = parse_novafile(text)
assert nv.stages[0].env == (
("A", "quoted value"),
('B', 'say "hi"'),
("C", "plain"),
)
def test_arg_with_and_without_default():
text = (
"ARG VERSION=1.2.3\n"
"FROM terraform-aws-modules/vpc/aws\n"
"ARG REGION\n"
)
nv = parse_novafile(text)
assert nv.global_args[0].name == "VERSION"
assert nv.global_args[0].default == "1.2.3"
assert nv.stages[0].args[0].name == "REGION"
assert nv.stages[0].args[0].default is None
def test_arg_semantics_globally_scoped():
text = (
"ARG VERSION=1.2.3\n"
"FROM terraform-aws-modules/vpc/aws\n"
"ARG REGION\n"
)
nv = parse_novafile(text)
assert [a.name for a in nv.global_args] == ["VERSION"]
assert [a.name for a in nv.stages[0].args] == ["REGION"]
def test_multi_stage_with_alias():
text = (
"FROM terraform-aws-modules/vpc/aws AS base\n"
"ENV A=1\n"
"FROM hashicorp/consul/aws AS consul\n"
"ENV B=2\n"
)
nv = parse_novafile(text)
assert len(nv.stages) == 2
assert nv.stages[0].name == "base"
assert nv.stages[0].env == (("A", "1"),)
assert nv.stages[1].name == "consul"
assert nv.stages[1].env == (("B", "2"),)
def test_workdir_accumulates_relative():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"WORKDIR /state\n"
"WORKDIR vpc\n"
"WORKDIR nested\n"
)
nv = parse_novafile(text)
assert nv.stages[0].workdir == "/state/vpc/nested"
def test_workdir_absolute_resets():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"WORKDIR /a/b\n"
"WORKDIR /fresh\n"
)
nv = parse_novafile(text)
assert nv.stages[0].workdir == "/fresh"
def test_container_only_instructions_warn():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"RUN echo hi\n"
"CMD [\"serve\"]\n"
"EXPOSE 8080\n"
)
nv = parse_novafile(text)
assert len(nv.warnings) == 3
assert "line 2: RUN is a container-only instruction" in nv.warnings[0]
assert "CMD" in nv.warnings[1]
assert "EXPOSE" in nv.warnings[2]
def test_copy_is_hard_error():
with pytest.raises(NovafileError, match="COPY is not supported in Novafiles"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nCOPY . /app\n")
def test_add_is_hard_error():
with pytest.raises(NovafileError, match="ADD is not supported in Novafiles"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nADD . /app\n")
def test_unknown_instruction():
with pytest.raises(NovafileError, match="unknown instruction FOO"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nFOO bar\n")
def test_first_instruction_must_be_from():
with pytest.raises(NovafileError, match="first instruction must be FROM"):
parse_novafile("ENV A=1\nFROM terraform-aws-modules/vpc/aws\n")
def test_empty_file():
with pytest.raises(NovafileError, match="no instructions found"):
parse_novafile("# only comments\n\n")
def test_error_carries_filename_and_line():
with pytest.raises(NovafileError) as excinfo:
parse_novafile("FROM terraform-aws-modules/vpc/aws\nFOO x\n", "MyNovafile")
assert str(excinfo.value).startswith("MyNovafile:2:1:")
def test_invalid_module_ref_in_from_reports_line():
with pytest.raises(NovafileError) as excinfo:
parse_novafile("FROM hashicorp/vpc\n")
assert "invalid module reference" in str(excinfo.value)
assert "Novafile:1:1:" in str(excinfo.value)
def test_module_ref_parsed_in_from():
nv = parse_novafile("FROM terraform-aws-modules/vpc/aws@5.0.0\n")
assert isinstance(nv.stages[0].module, ModuleRef)
assert nv.stages[0].module.version == "5.0.0"
def test_from_without_ref():
with pytest.raises(NovafileError, match="FROM requires a module reference"):
parse_novafile("FROM\n")
def test_from_with_dangling_token():
with pytest.raises(NovafileError, match="FROM expects"):
parse_novafile("FROM terraform-aws-modules/vpc/aws extra\n")
def test_from_as_missing_name():
with pytest.raises(NovafileError, match="FROM expects"):
parse_novafile("FROM terraform-aws-modules/vpc/aws AS\n")
def test_env_before_from_is_error():
with pytest.raises(NovafileError, match="first instruction must be FROM"):
parse_novafile("ENV A=1\n")
def test_non_default_escape_directive_warns():
text = "# escape=`\nFROM terraform-aws-modules/vpc/aws\n"
nv = parse_novafile(text)
assert any("escape" in w for w in nv.warnings)
def test_multiple_env_pairs_one_line():
nv = parse_novafile(
"FROM terraform-aws-modules/vpc/aws\nENV A=1 B=2 C=3\n"
)
assert nv.stages[0].env == (("A", "1"), ("B", "2"), ("C", "3"))
def test_unterminated_quote():
with pytest.raises(NovafileError, match="unterminated quoted value"):
parse_novafile('FROM terraform-aws-modules/vpc/aws\nLABEL A="open\n')
+23
View File
@@ -0,0 +1,23 @@
"""Package acceptance tests: version metadata and CLI entry point."""
import importlib.metadata
import click
import nova
import nova.cli
def test_package_exposes_version_constant() -> None:
"""The package exposes its version as the __version__ constant."""
assert nova.__version__ == "0.1.0"
def test_installed_distribution_version_matches() -> None:
"""Installed distribution metadata reports the same version."""
assert importlib.metadata.version("nova-cli") == "0.1.0"
def test_cli_main_is_click_base_command() -> None:
"""The nova entry point (nova.cli:main) is a click command object."""
assert isinstance(nova.cli.main, click.BaseCommand)