Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31edaf8e4c | |||
| ea5884c0f2 | |||
| 9d1116096b |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 1,
|
||||
"stage": "ship_pending",
|
||||
"phase": 2,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.1",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-10-15T00:10:00Z"
|
||||
"updated_at": "2026-10-15T01:40:00Z"
|
||||
}
|
||||
@@ -54,11 +54,11 @@
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-01 | P1 | pending |
|
||||
| REQ-02 | P2 | pending |
|
||||
| REQ-03 | P2 | pending |
|
||||
| REQ-01 | P1 | partial (surface + discovery + config behavior complete; engine wiring P4, README P6) |
|
||||
| REQ-02 | P2 | complete |
|
||||
| REQ-03 | P2 | complete |
|
||||
| REQ-04 | P4 | pending |
|
||||
| REQ-05 | P1 | pending |
|
||||
| REQ-05 | P1 | complete |
|
||||
| REQ-06 | P6 | pending |
|
||||
| REQ-07 | P5 | pending |
|
||||
| REQ-08 | P3 | pending |
|
||||
|
||||
+2
-2
@@ -8,14 +8,14 @@ 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
|
||||
|
||||
### Phase 2 — Novafile + Compose.yml Parsers
|
||||
|
||||
- Status: not_started
|
||||
- Status: complete (shipped v0.1.2)
|
||||
- Depends on: P1
|
||||
- Requirements: REQ-02, REQ-03
|
||||
- Success criteria: Novafile parses as a Dockerfile analog with Terraform module registry paths; compose.yml loads a compose-indistinguishable schema; parser tests pass
|
||||
|
||||
@@ -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))
|
||||
@@ -17,6 +17,7 @@ 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
|
||||
|
||||
|
||||
@@ -72,11 +73,8 @@ def _require_compose_file(file: tuple[str, ...]) -> None:
|
||||
def _require_novafile(file: tuple[str, ...]) -> None:
|
||||
"""Run Novafile discovery for ``build``, hard-failing on error.
|
||||
|
||||
``build`` is the lifecycle exception: its ``-f`` names the Novafile,
|
||||
exactly as ``docker build -f Dockerfile`` names the build recipe,
|
||||
while every other lifecycle command follows
|
||||
``docker compose -f compose.yaml``. Only the first ``-f`` is the
|
||||
Novafile; discovery never searches parent directories.
|
||||
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)
|
||||
@@ -177,8 +175,7 @@ def build(
|
||||
profile: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Create a module build from a Novafile."""
|
||||
_require_novafile(file)
|
||||
_pending("build", 2)
|
||||
run_build(file, project_name, detach, env_file, profile)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@@ -336,8 +333,7 @@ def config(
|
||||
no_interpolate: bool,
|
||||
) -> None:
|
||||
"""Validate and view the compose file."""
|
||||
_require_compose_file(file)
|
||||
_pending("config", 2)
|
||||
run_config(file, project_name, detach, env_file, profile, no_interpolate)
|
||||
|
||||
|
||||
# --- Placeholder command groups ---------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
+30
-10
@@ -53,8 +53,13 @@ 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 = "# minimal nova config\n"
|
||||
MINIMAL_NOVAFILE = "FROM demo\n"
|
||||
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)
|
||||
@@ -159,25 +164,32 @@ def test_no_config_build_reports_missing_novafile(project: ProjectFactory) -> No
|
||||
("restart", 4),
|
||||
("rm", 4),
|
||||
("logs", 4),
|
||||
("config", 2),
|
||||
],
|
||||
)
|
||||
def test_with_compose_file_each_command_lands_in_its_phase(
|
||||
project: ProjectFactory, command: str, phase: int
|
||||
) -> None:
|
||||
"""A minimal compose.yml in cwd: engines land in phase 4, config in phase 2."""
|
||||
"""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_build_with_novafile_lands_in_phase_2(project: ProjectFactory) -> None:
|
||||
"""With compose.yml and a Novafile, build reaches the phase-2 parser landing."""
|
||||
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 == 1
|
||||
assert "phase 2" in output(result)
|
||||
assert result.exit_code == 0
|
||||
assert "module terraform-aws-modules/vpc/aws" in output(result)
|
||||
|
||||
|
||||
# --- Row 4: explicit -f with a missing file ------------------------------------------
|
||||
@@ -264,10 +276,18 @@ def test_root_help_lists_all_thirteen_commands() -> None:
|
||||
# --- Row 10: exit codes are exactly 1 -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ("up", "build", "config"))
|
||||
@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)
|
||||
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
|
||||
+45
-17
@@ -22,17 +22,25 @@ runner = click.testing.CliRunner()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _project_dir(tmp_path: Path, files: tuple[str, ...] = ("compose.yml",)) -> Iterator[Path]:
|
||||
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.
|
||||
``files=()`` yields an empty project — the no-config case. A dict maps
|
||||
file names to explicit content.
|
||||
"""
|
||||
root = Path(tempfile.mkdtemp(dir=tmp_path))
|
||||
for name in files:
|
||||
(root / name).write_text("# minimal nova config\n", encoding="utf-8")
|
||||
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:
|
||||
@@ -114,18 +122,29 @@ def test_down_volumes_flag(tmp_path: Path) -> None:
|
||||
assert "phase 4" in _visible_output(result)
|
||||
|
||||
|
||||
def test_build_config_phase2(tmp_path: Path) -> None:
|
||||
"""`build` and `config` report the phase-2 Novafile/compose parsers."""
|
||||
with _project_dir(tmp_path, files=("Novafile", "compose.yml")):
|
||||
for command in ("build", "config"):
|
||||
result = runner.invoke(main, [command])
|
||||
assert result.exit_code == 1
|
||||
output = _visible_output(result)
|
||||
assert "phase 2" in output
|
||||
assert (
|
||||
f"Error: the '{command}' parser lands in phase 2 (Novafile + compose parsers)"
|
||||
in output
|
||||
)
|
||||
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:
|
||||
@@ -163,7 +182,6 @@ def test_lifecycle_extras_parse(tmp_path: Path) -> None:
|
||||
["rm", "-f"],
|
||||
["rm", "--file", "nova.yml", "--force"],
|
||||
["logs", "web", "db", "-d"],
|
||||
["config", "--no-interpolate", "-p", "proj"],
|
||||
]
|
||||
for args in cases:
|
||||
with _project_dir(tmp_path, files=("compose.yml", "nova.yml")):
|
||||
@@ -174,6 +192,16 @@ def test_lifecycle_extras_parse(tmp_path: Path) -> None:
|
||||
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:
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user