Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea5884c0f2 | |||
| 9d1116096b | |||
| a6c5a1bb4f | |||
| 3e86118383 |
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""nova - Docker-style ergonomics for infrastructure deployment."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""nova.cli - click command surface and console entry point."""
|
||||
|
||||
from nova.cli.main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -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"]
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the nova.parsers package."""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user