2 Commits

Author SHA1 Message Date
CIAgent ea5884c0f2 docs(P02): complete parsers phase
---ci---
phase: 2
milestone: v0.1
status: complete
requirements:
  covered: [REQ-02, REQ-03]
  partial: [REQ-01]
---/ci---
2026-10-14 23:04:20 +00:00
CIAgent 9d1116096b docs(P01): phase status — REQ-05 complete, REQ-01 partial, shipped v0.1.1
---ci---
phase: 1
milestone: v0.1
status: complete
---/ci---
2026-10-14 22:38:28 +00:00
21 changed files with 2245 additions and 41 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"phase": 1,
"phase": 2,
"stage": "ship_pending",
"milestone": "v0.1",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-15T00:10:00Z"
"updated_at": "2026-10-15T01:30:00Z"
}
+2 -2
View File
@@ -54,11 +54,11 @@
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-01 | P1 | pending |
| REQ-01 | P1 | partial (surface + discovery complete; parsers P2, wiring P4, README P6) |
| REQ-02 | P2 | pending |
| REQ-03 | P2 | pending |
| REQ-04 | P4 | pending |
| REQ-05 | P1 | pending |
| REQ-05 | P1 | complete |
| REQ-06 | P6 | pending |
| REQ-07 | P5 | pending |
| REQ-08 | P3 | pending |
+1 -1
View File
@@ -8,7 +8,7 @@ Milestone v0.1-go-live: deliver Docker / Docker Compose ergonomics for deploying
### Phase 1 — CLI Skeleton & Docker-Ergonomic Command Surface
- Status: not_started
- Status: complete (shipped v0.1.1)
- Depends on: (none)
- Requirements: REQ-01, REQ-05
- Success criteria: Python project scaffold with packaging; docker-ergonomic command surface (build/up/down/ps/...) with routing wired
+11
View File
@@ -0,0 +1,11 @@
"""Command implementations for nova lifecycle commands.
P2 introduces the ``nova.cli.commands`` package (verifier P1 flag): ``config``
and ``build`` gain real parser-backed behavior here; the deployment engines
(``up``/``down``/``ps``/...) land in phase 4.
"""
from nova.cli.commands.build import run_build
from nova.cli.commands.config import run_config
__all__ = ["run_build", "run_config"]
+61
View File
@@ -0,0 +1,61 @@
"""``nova build`` — parse a Novafile and print the resolved module manifest."""
from __future__ import annotations
from pathlib import Path
import click
from nova.cli.config_files import NovaFileError, find_novafile
from nova.parsers.novafile import NovafileError, parse_novafile
def run_build(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Parse the Novafile, validate every stage, and print the module manifest."""
try:
novafile_path = find_novafile(Path.cwd(), Path(file[0]) if file else None)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
try:
parsed = parse_novafile(
novafile_path.read_text(encoding="utf-8"),
filename=str(novafile_path),
)
except NovafileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
for warning in parsed.warnings:
click.echo(f"WARNING: {warning}", err=True)
click.echo(f"[+] Building module manifest from {novafile_path}")
for stage in parsed.stages:
ref = stage.module
label = stage.name or "main"
click.echo(f" --> stage {label}")
click.echo(f" module {ref.source}")
if ref.version is not None:
click.echo(f" version {ref.version}")
if ref.submodule is not None:
click.echo(f" subdir {ref.submodule}")
if stage.args:
for arg in stage.args:
default = arg.default if arg.default is not None else "(required)"
click.echo(f" arg {arg.name}={default}")
for key, value in stage.env:
click.echo(f" env {key}={value}")
for key, value in stage.labels:
click.echo(f" label {key}={value}")
click.echo(f" workdir {stage.workdir}")
click.echo(
f"Build manifest resolved: {len(parsed.stages)} stage(s), "
f"{len(parsed.global_args)} global arg(s)"
)
+67
View File
@@ -0,0 +1,67 @@
"""``nova config`` — validate and print the normalized compose configuration."""
from __future__ import annotations
import os
from pathlib import Path
import click
import yaml
from nova.cli.config_files import (
NovaFileError,
find_compose_file,
load_env_files,
)
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
from nova.parsers.interp import InterpolationError
def _interpolation_env(
compose_dir: Path, env_files: tuple[str, ...], interpolate: bool
) -> dict[str, str] | None:
"""Build the interpolation env: .env, then --env-file overrides, then shell.
docker compose precedence: shell environment > --env-file > project .env.
Returns None when interpolation is disabled.
"""
if not interpolate:
return None
env: dict[str, str] = {}
project_dotenv = compose_dir / ".env"
if project_dotenv.is_file():
env.update(load_env_files([project_dotenv]))
for env_file in env_files:
env.update(load_env_files([Path(env_file)]))
env.update(dict(os.environ))
return env
def run_config(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
no_interpolate: bool,
) -> None:
"""Load, validate, and print the normalized compose configuration."""
try:
compose_path = find_compose_file(
Path.cwd(), tuple(Path(f) for f in file)
)
env = _interpolation_env(compose_path.parent, env_file, not no_interpolate)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
try:
spec = parse_compose(
compose_path.read_text(encoding="utf-8"),
filename=str(compose_path),
env=env,
)
except (ComposeParseError, InterpolationError) as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
click.echo(yaml.safe_dump(render_compose(spec), sort_keys=False))
+5 -9
View File
@@ -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 ---------------------------------------------------
+32
View File
@@ -0,0 +1,32 @@
"""Parsers for Novafile and compose.yml, plus module-reference resolution."""
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
from nova.parsers.interp import InterpolationError, interpolate
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
from nova.parsers.models import BuildSpec, ComposeSpec, ServiceSpec
from nova.parsers.novafile import (
ArgDef,
Novafile,
NovafileError,
NovafileStage,
parse_novafile,
)
__all__ = [
"ArgDef",
"BuildSpec",
"ComposeParseError",
"ComposeSpec",
"InterpolationError",
"ModuleRef",
"ModuleRefError",
"Novafile",
"NovafileError",
"NovafileStage",
"ServiceSpec",
"interpolate",
"parse_compose",
"parse_module_ref",
"parse_novafile",
"render_compose",
]
+339
View File
@@ -0,0 +1,339 @@
"""compose.yml loader — compose-indistinguishable schema plus the nova `providers` block."""
from __future__ import annotations
from typing import Any, Mapping
import yaml
from nova.parsers.interp import InterpolationError, interpolate
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
from nova.parsers.models import BuildSpec, ComposeSpec, ServiceSpec
_SUPPORTED_TOP = ("name", "services", "providers")
_VALID_PROVIDERS = ("aws", "azure", "gcp")
class ComposeParseError(ValueError):
"""Raised when a compose file violates the nova compose schema."""
def _err(filename: str, message: str) -> ComposeParseError:
return ComposeParseError(f"{filename}: {message}")
def _as_str_pairs(
value: Any, filename: str, service: str, key: str
) -> tuple[tuple[str, str], ...]:
"""Normalize environment/vars mappings and KEY=VALUE lists to pairs."""
if isinstance(value, dict):
pairs = []
for k, v in value.items():
if not isinstance(k, str):
raise _err(
filename,
f"service '{service}': {key} keys must be strings",
)
pairs.append((k, "" if v is None else str(v)))
return tuple(pairs)
if isinstance(value, list):
pairs = []
for item in value:
if not isinstance(item, str) or "=" not in item:
raise _err(
filename,
f"service '{service}': {key} list entries must be 'KEY=VALUE'",
)
k, _, v = item.partition("=")
pairs.append((k, v))
return tuple(pairs)
raise _err(
filename,
f"service '{service}': {key} must be a mapping or a list of 'KEY=VALUE'",
)
def _as_str_list(
value: Any, filename: str, service: str, key: str
) -> tuple[str, ...]:
if isinstance(value, str):
return (value,)
if isinstance(value, list) and all(isinstance(v, str) for v in value):
return tuple(value)
raise _err(
filename, f"service '{service}': {key} must be a string or list of strings"
)
def _interpolate_mapping(data: Any, env: dict[str, str]) -> Any:
"""Recursively interpolate all string scalars in parsed YAML data."""
if isinstance(data, str):
return interpolate(data, env)
if isinstance(data, dict):
return {
_interpolate_mapping(k, env): _interpolate_mapping(v, env)
for k, v in data.items()
}
if isinstance(data, list):
return [_interpolate_mapping(v, env) for v in data]
return data
def parse_compose(
data: Mapping[str, Any] | str,
filename: str = "compose.yml",
env: Mapping[str, str] | None = None,
) -> ComposeSpec:
"""Parse compose content (YAML text or a pre-loaded mapping) into a ComposeSpec.
When env is None no interpolation runs; otherwise every string value is
interpolated against env before validation.
"""
if isinstance(data, str):
try:
loaded = yaml.safe_load(data)
except yaml.YAMLError as exc:
raise _err(filename, f"invalid YAML: {exc}") from exc
else:
loaded = data
if env is not None:
loaded = _interpolate_mapping(loaded, dict(env))
if not isinstance(loaded, dict):
raise _err(filename, "expected a mapping at top level")
for key in loaded:
if key not in _SUPPORTED_TOP:
raise _err(
filename,
f"top-level key '{key}' is not supported "
f"(supported: {', '.join(_SUPPORTED_TOP)})",
)
name = loaded.get("name")
if name is not None and not isinstance(name, str):
raise _err(filename, "'name' must be a string")
services_raw = loaded.get("services")
if not isinstance(services_raw, dict):
raise _err(filename, "'services' must be a mapping")
providers_raw = loaded.get("providers")
providers: dict[str, dict] = {}
if providers_raw is not None:
if not isinstance(providers_raw, dict):
raise _err(filename, "'providers' must be a mapping")
for key, value in providers_raw.items():
if key not in _VALID_PROVIDERS:
raise _err(
filename,
f"providers: unknown provider '{key}' "
f"(supported: {', '.join(_VALID_PROVIDERS)})",
)
if not isinstance(value, dict):
raise _err(filename, f"providers.{key} must be a mapping")
providers[key] = dict(value)
services: list[ServiceSpec] = []
for service_name, service_body in services_raw.items():
if not isinstance(service_body, dict):
raise _err(
filename, f"service '{service_name}' must be a mapping"
)
services.append(
_parse_service(service_name, service_body, filename)
)
spec = ComposeSpec(
name=name, services=tuple(services), providers=providers
)
_validate_dependencies(spec, filename)
return spec
def _parse_service(name: str, body: dict, filename: str) -> ServiceSpec:
for key in body:
allowed = (
"module",
"build",
"version",
"environment",
"vars",
"env_file",
"depends_on",
"working_dir",
"profiles",
"provider",
)
if key not in allowed:
raise _err(
filename,
f"service '{name}': key '{key}' is not supported",
)
module_raw = body.get("module")
build_raw = body.get("build")
if module_raw is not None and build_raw is not None:
raise _err(
filename, f"service '{name}': specify either 'module' or 'build', not both"
)
if module_raw is None and build_raw is None:
raise _err(
filename,
f"service '{name}': requires a 'module' (registry path) or 'build' "
f"(Novafile) key",
)
module: ModuleRef | None = None
if module_raw is not None:
if not isinstance(module_raw, str):
raise _err(filename, f"service '{name}': 'module' must be a string")
try:
module = parse_module_ref(module_raw)
except ModuleRefError as exc:
raise _err(filename, f"service '{name}': {exc}") from exc
build: BuildSpec | None = None
if build_raw is not None:
if not isinstance(build_raw, dict):
raise _err(filename, f"service '{name}': 'build' must be a mapping")
build = BuildSpec(
context=str(build_raw.get("context", ".")),
novafile=str(build_raw.get("novafile", "Novafile")),
)
version = body.get("version")
if version is not None:
if not isinstance(version, str):
raise _err(filename, f"service '{name}': 'version' must be a string")
if module is not None and module.version is not None:
raise _err(
filename,
f"service '{name}': version specified twice "
f"(both @{module.version} sugar and the version key)",
)
if module is not None:
try:
module = parse_module_ref(f"{module_raw}@{version}")
except ModuleRefError as exc:
raise _err(filename, f"service '{name}': {exc}") from exc
environment: tuple[tuple[str, str], ...] = ()
if "environment" in body and "vars" in body:
raise _err(
filename,
f"service '{name}': 'vars' is an alias of 'environment' — use only one",
)
env_key = "environment" if "environment" in body else (
"vars" if "vars" in body else None
)
if env_key is not None:
environment = _as_str_pairs(body[env_key], filename, name, env_key)
env_files: tuple[str, ...] = ()
if "env_file" in body:
env_files = _as_str_list(body["env_file"], filename, name, "env_file")
depends_on: tuple[str, ...] = ()
if "depends_on" in body:
dep_raw = body["depends_on"]
if isinstance(dep_raw, list):
if not all(isinstance(v, str) for v in dep_raw):
raise _err(
filename,
f"service '{name}': depends_on short form must be a list of "
f"service names",
)
depends_on = tuple(dep_raw)
elif isinstance(dep_raw, dict):
depends_on = tuple(dep_raw.keys())
else:
raise _err(
filename,
f"service '{name}': depends_on must be a list or a mapping",
)
working_dir = body.get("working_dir")
if working_dir is not None and not isinstance(working_dir, str):
raise _err(filename, f"service '{name}': 'working_dir' must be a string")
profiles: tuple[str, ...] = ()
if "profiles" in body:
profiles = _as_str_list(body["profiles"], filename, name, "profiles")
provider = body.get("provider")
if provider is not None:
if not isinstance(provider, str) or provider.lower() not in _VALID_PROVIDERS:
raise _err(
filename,
f"service '{name}': provider must be one of "
f"{', '.join(_VALID_PROVIDERS)}",
)
provider = provider.lower()
return ServiceSpec(
name=name,
module=module,
build=build,
version=None if version is None else version,
environment=environment,
env_files=env_files,
depends_on=depends_on,
working_dir=working_dir,
profiles=profiles,
provider=provider,
)
def _validate_dependencies(spec: ComposeSpec, filename: str) -> None:
names = {s.name for s in spec.services}
for service in spec.services:
for dep in service.depends_on:
if dep not in names:
raise _err(
filename,
f"service '{service.name}' depends on unknown service '{dep}'",
)
if service.name in service.depends_on:
raise _err(
filename,
f"service '{service.name}' cannot depend on itself",
)
def render_compose(spec: ComposeSpec) -> dict:
"""Render a ComposeSpec back to the normalized `docker compose config` dict."""
services: dict[str, dict] = {}
for service in spec.services:
rendered: dict[str, Any] = {}
if service.module is not None:
rendered["module"] = service.module.source
if service.module.version is not None:
rendered["version"] = service.module.version
if service.build is not None:
rendered["build"] = {
"context": service.build.context,
"novafile": service.build.novafile,
}
if service.environment:
rendered["environment"] = dict(service.environment)
if service.env_files:
rendered["env_file"] = list(service.env_files)
if service.depends_on:
rendered["depends_on"] = list(service.depends_on)
if service.working_dir is not None:
rendered["working_dir"] = service.working_dir
if service.profiles:
rendered["profiles"] = list(service.profiles)
if service.provider is not None:
rendered["provider"] = service.provider
services[service.name] = rendered
out: dict[str, Any] = {}
if spec.name is not None:
out["name"] = spec.name
out["services"] = services
if spec.providers:
out["providers"] = spec.providers
return out
+93
View File
@@ -0,0 +1,93 @@
"""Compose-style variable interpolation: ${VAR}, ${VAR:-def}, ${VAR:?err}, ${VAR:+alt}."""
from __future__ import annotations
import re
from typing import Mapping
_NAME = r"[A-Za-z_][A-Za-z0-9_]*"
_BRACED = re.compile(rf"\$\{{({_NAME})(?:(:?[-+?])([^}}]*))?\}}")
_BARE = re.compile(rf"\$({_NAME})")
class InterpolationError(ValueError):
"""Raised when a ${VAR:?} requirement is unmet or syntax is invalid."""
def _replace_bare(match: re.Match, env: Mapping[str, str]) -> str:
value = env.get(match.group(1))
return value if value is not None else ""
def _replace_braced(match: re.Match, env: Mapping[str, str]) -> str:
name, operator, operand = match.group(1), match.group(2), match.group(3)
value = env.get(name)
if operator is None:
return value if value is not None else ""
if operator in (":-", "-"):
# ":-" fires on unset OR empty; "-" fires only on unset.
if value is None:
return operand or ""
if value == "" and operator == ":-":
return operand or ""
return value
if operator in (":?", "?"):
if value is None or (value == "" and operator == ":?"):
message = operand or "required variable is missing"
raise InterpolationError(f"{name}: {message}")
return value
if operator in (":+", "+"):
# ":+" fires on set AND non-empty; "+" fires on set (even empty).
present = value is not None and (value != "" or operator == "+")
return operand or "" if present else ""
raise InterpolationError(f"unsupported interpolation operator {operator!r}")
raise InterpolationError(f"unsupported interpolation operator {operator!r}")
def interpolate(text: str, env: Mapping[str, str]) -> str:
"""Interpolate compose-style variables in text against env.
Supported: ${VAR}, $VAR, ${VAR:-def}, ${VAR-def}, ${VAR:?msg}, ${VAR?msg},
${VAR:+alt}, ${VAR+alt}, and $$ as an escaped literal dollar.
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch != "$":
out.append(ch)
i += 1
continue
if i + 1 < n and text[i + 1] == "$":
out.append("$")
i += 2
continue
m = _BRACED.match(text, i)
if m:
out.append(_replace_braced(m, env))
i = m.end()
continue
if text.startswith("${", i):
# An unclosed or invalidly-named braced expression is an error,
# matching compose behavior.
end = text.find("}", i)
inner = text[i + 2 : end] if end != -1 else text[i + 2 :]
raise InterpolationError(
f"invalid interpolation syntax: ${{{inner}}}"
)
m = _BARE.match(text, i)
if m:
out.append(_replace_bare(m, env))
i = m.end()
continue
# A lone '$' followed by a non-name character stays literal.
out.append("$")
i += 1
return "".join(out)
+40
View File
@@ -0,0 +1,40 @@
"""Typed models for the parsed compose file (compose-indistinguishable subset)."""
from __future__ import annotations
from dataclasses import dataclass, field
from nova.parsers.moduleref import ModuleRef
@dataclass(frozen=True)
class BuildSpec:
"""A `build:` service source — the Novafile analog of a compose build context."""
context: str
novafile: str
@dataclass(frozen=True)
class ServiceSpec:
"""One deployable module under `services:`."""
name: str
module: ModuleRef | None
build: BuildSpec | None
version: str | None
environment: tuple[tuple[str, str], ...] = ()
env_files: tuple[str, ...] = ()
depends_on: tuple[str, ...] = ()
working_dir: str | None = None
profiles: tuple[str, ...] = ()
provider: str | None = None
@dataclass(frozen=True)
class ComposeSpec:
"""The whole compose.yml document."""
name: str | None
services: tuple[ServiceSpec, ...]
providers: dict[str, dict] = field(default_factory=dict)
+178
View File
@@ -0,0 +1,178 @@
"""Terraform module reference resolver (task P02-01-01).
Grammar (verified: developer.hashicorp.com/terraform/language/block/module;
see .ciagent/RESEARCH.md area 1):
- Public registry shorthand: ``<namespace>/<name>/<provider>`` — exactly
three segments, e.g. ``terraform-aws-modules/vpc/aws``.
- Private registry: ``<hostname>/<namespace>/<name>/<provider>`` — four
segments, e.g. ``app.terraform.io/example-corp/k8s-cluster/azurerm``.
- Full public registry web URLs — ``registry.terraform.io/<ns>/<name>/<provider>``
and ``registry.terraform.io/modules/<ns>/<name>/<provider>`` — are web
addresses, not source grammar; nova normalizes them to shorthand.
- Submodules: append ``//<subdir>`` to any registry source (query parameters
would come after the subdir; none are supported in v0.1).
- Local sources: MUST start with ``./`` or ``../`` (or be absolute paths);
local sources never support version pinning.
- Version: nova sugar ``@<version>`` — split into the separate ``version``
field, valid ONLY for registry sources, validated as a Terraform-style
constraint (``1.2.3``, ``~> 6.0``, ``>= 1.2, < 2.0`` — comma-separated
operator/version checks allowed; empty and non-matching strings rejected).
Terraform's own grammar has no ``@version`` suffix: :func:`parse_module_ref`
translates the sugar so callers can emit ``source`` plus ``version`` arguments.
"""
import re
from dataclasses import dataclass
#: Hostname of the public registry whose web URLs normalize to shorthand.
PUBLIC_REGISTRY_HOST = "registry.terraform.io"
#: Path element the public registry web UI inserts between host and module.
PUBLIC_REGISTRY_MODULES_PATH = "modules"
_NEEDS_THREE = "registry references need <namespace>/<name>/<provider>"
_AT_MOST_FOUR = "registry references allow at most <hostname>/<namespace>/<name>/<provider>"
_BARE_HOST = "bare registry hostname without <namespace>/<name>/<provider>"
# Version constraint grammar: comma-separated checks. Each check is an
# optional Terraform operator followed by a 1-3 part version with optional
# pre-release and metadata suffixes; whitespace is allowed around operators
# and separators (e.g. ">= 1.2, < 2.0", "~> 6.0").
_VERSION_OPERATOR = r"(?:~>|>=|<=|!=|=|>|<)"
_VERSION_NUMBER = r"\d+(?:\.\d+){0,2}"
_VERSION_SUFFIX = (
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
)
_VERSION_CHECK = rf"{_VERSION_OPERATOR}?\s*{_VERSION_NUMBER}{_VERSION_SUFFIX}"
_VERSION_CONSTRAINT_RE = re.compile(rf"^\s*{_VERSION_CHECK}(?:\s*,\s*{_VERSION_CHECK})*\s*$")
# Windows drive-letter prefix, the other legal absolute local form.
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
class ModuleRefError(ValueError):
"""A module reference does not match the Terraform source grammar."""
@dataclass(frozen=True)
class ModuleRef:
"""A resolved Terraform module source address.
``source`` is the Terraform-legal string to emit — public shorthand,
private host form, or local path — including any ``//submodule`` part;
``version`` is nova's ``@`` sugar split off (registry sources only).
"""
source: str
submodule: str | None
version: str | None
is_local: bool
host: str | None
@property
def terraform_source(self) -> str:
"""The source string a Terraform module block accepts."""
return self.source
def _invalid(ref: str, reason: str) -> ModuleRefError:
"""Build the one-line docker/compose-style error naming ``ref``."""
return ModuleRefError(f"invalid module reference '{ref}': {reason}")
def _is_local_source(base: str) -> bool:
"""True for ``./``, ``../``, unix-absolute, and windows-drive sources."""
if base.startswith(("./", "../")):
return True
return base.startswith("/") or _WINDOWS_DRIVE_RE.match(base) is not None
def _is_valid_version_constraint(version: str) -> bool:
"""True for Terraform-style constraint strings (e.g. ``~> 6.0``)."""
return _VERSION_CONSTRAINT_RE.match(version) is not None
def _segment_reason(segments: list[str]) -> str:
"""The count-error reason for a wrong number of registry segments."""
if len(segments) == 1 and "." in segments[0]:
return _BARE_HOST
if len(segments) <= 2:
return _NEEDS_THREE
return _AT_MOST_FOUR
def _normalize_public_url(segments: list[str]) -> list[str]:
"""Drop the registry.terraform.io host and its /modules/ path element."""
segments = segments[1:]
if segments and segments[0] == PUBLIC_REGISTRY_MODULES_PATH:
segments = segments[1:]
return segments
def parse_module_ref(ref: str) -> ModuleRef:
"""Resolve a module reference into its Terraform-legal parts.
Accepts public registry shorthand, private registry hosts, full public
registry web URLs (normalized to shorthand), ``//subdir`` submodules,
``@version`` sugar, and local ``./``/``../``/absolute paths. Surrounding
whitespace is stripped.
Raises :class:`ModuleRefError` with a one-line docker/compose-style
message for anything outside the grammar: empty references, ``//`` at
the start, bare hostnames, 2-segment or 5+-segment registry paths,
version pinning on a local source, and empty or malformed version
constraints.
"""
text = ref.strip()
if not text:
raise _invalid(ref, "module source cannot be empty")
if text.startswith("//"):
raise _invalid(ref, "module source cannot start with '//'")
base, sugar, version_text = text.partition("@")
version: str | None = version_text.strip() if sugar else None
if _is_local_source(base):
if version is not None:
raise _invalid(ref, "local module sources do not support version pinning")
return ModuleRef(source=base, submodule=None, version=None, is_local=True, host=None)
package, marker, subdir = base.partition("//")
submodule = subdir if marker else None
if marker and not subdir:
raise _invalid(ref, "submodule '//' must be followed by a path")
segments = package.split("/")
if "" in segments:
raise _invalid(ref, "module source contains an empty path segment")
host: str | None
if len(segments) >= 2 and segments[0] == PUBLIC_REGISTRY_HOST:
segments = _normalize_public_url(segments)
if len(segments) != 3:
raise _invalid(ref, _segment_reason(segments))
host = None
elif len(segments) == 3:
host = None
elif len(segments) == 4:
host = segments[0]
else:
raise _invalid(ref, _segment_reason(segments))
if version is not None and not _is_valid_version_constraint(version):
raise _invalid(ref, f"invalid version constraint '{version}'")
source = "/".join(segments)
if submodule is not None:
source = f"{source}//{submodule}"
return ModuleRef(
source=source,
submodule=submodule,
version=version,
is_local=False,
host=host,
)
+310
View File
@@ -0,0 +1,310 @@
"""Novafile parser — Dockerfile-compatible grammar for infrastructure modules.
A Novafile is practically a Dockerfile: same line grammar, case-insensitive
instructions, comments, and backslash continuations. The FROM instruction
takes Terraform module references instead of container images (D-010).
"""
from __future__ import annotations
import posixpath
from dataclasses import dataclass
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
_CONTAINER_ONLY = ("RUN", "CMD", "ENTRYPOINT", "HEALTHCHECK", "EXPOSE")
_UNSUPPORTED = ("COPY", "ADD")
class NovafileError(ValueError):
"""Raised when a Novafile cannot be parsed. Messages are BuildKit-style."""
@dataclass(frozen=True)
class ArgDef:
"""An ARG definition; a missing default means the arg is required at build time."""
name: str
default: str | None
@dataclass(frozen=True)
class NovafileStage:
"""One build stage: a FROM instruction and everything that follows it."""
name: str | None
module: ModuleRef
args: tuple[ArgDef, ...] = ()
env: tuple[tuple[str, str], ...] = ()
labels: tuple[tuple[str, str], ...] = ()
workdir: str = "/"
@dataclass(frozen=True)
class Novafile:
stages: tuple[NovafileStage, ...]
global_args: tuple[ArgDef, ...] = ()
warnings: tuple[str, ...] = ()
def _join_continuations(
text: str, filename: str
) -> list[tuple[str, int]]:
"""Split text into logical lines, joining backslash continuations.
Returns (logical_line, first_physical_line_number) pairs. Comment lines
and blank lines are preserved here and skipped by the instruction loop.
"""
physical = text.splitlines()
out: list[tuple[str, int]] = []
idx = 0
while idx < len(physical):
line = physical[idx]
first = idx + 1
while line.endswith("\\") and idx + 1 < len(physical):
line = line[:-1] + physical[idx + 1]
idx += 1
if line.endswith("\\"):
# Trailing continuation with nothing to join.
line = line[:-1]
out.append((line, first))
idx += 1
return out
def _unquote(value: str, line: int, filename: str) -> str:
"""Strip one layer of double quotes, honouring backslash escapes."""
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
inner = value[1:-1]
return inner.replace('\\"', '"').replace("\\\\", "\\")
return value
def _parse_pairs(
remainder: str, line: int, filename: str
) -> list[tuple[str, str]]:
"""Parse `key=value key2="quoted value"` sequences (Dockerfile ENV/LABEL form)."""
pairs: list[tuple[str, str]] = []
i = 0
n = len(remainder)
while i < n:
while i < n and remainder[i].isspace():
i += 1
if i >= n:
break
eq = remainder.find("=", i)
if eq == -1 or eq == i:
raise NovafileError(
f"{filename}:{line}:1: expected key=value pairs, got: {remainder[i:]!r}"
)
key = remainder[i:eq]
if not key or not key.strip():
raise NovafileError(
f"{filename}:{line}:1: expected key=value pairs, got: {remainder[i:]!r}"
)
key = key.strip()
i = eq + 1
if i < n and remainder[i] == '"':
i += 1
buf: list[str] = []
closed = False
while i < n:
ch = remainder[i]
if ch == "\\" and i + 1 < n and remainder[i + 1] in ('"', "\\"):
buf.append(remainder[i + 1])
i += 2
elif ch == '"':
i += 1
closed = True
break
else:
buf.append(ch)
i += 1
if not closed:
raise NovafileError(
f"{filename}:{line}:1: unterminated quoted value for {key!r}"
)
value = "".join(buf)
else:
j = i
while j < n and not remainder[j].isspace():
j += 1
value = remainder[i:j]
i = j
pairs.append((key, value))
return pairs
def _parse_arg(remainder: str, line: int, filename: str) -> ArgDef:
eq = remainder.find("=")
if eq == -1:
name = remainder.strip()
if not name or name != remainder:
raise NovafileError(
f"{filename}:{line}:1: ARG expects <name>[=<default>], got: {remainder!r}"
)
return ArgDef(name=name, default=None)
name = remainder[:eq].strip()
raw_default = remainder[eq + 1 :].strip()
if not name:
raise NovafileError(
f"{filename}:{line}:1: ARG expects <name>[=<default>], got: {remainder!r}"
)
return ArgDef(
name=name, default=_unquote(raw_default, line, filename) if raw_default else None
)
def parse_novafile(text: str, filename: str = "Novafile") -> Novafile:
"""Parse Novafile content into a :class:`Novafile` model."""
global_args: list[ArgDef] = []
warnings: list[str] = []
stages: list[NovafileStage] = []
# Mutable per-stage accumulators.
cur: dict | None = None
def close_stage() -> None:
nonlocal cur
if cur is not None:
stages.append(
NovafileStage(
name=cur["name"],
module=cur["module"],
args=tuple(cur["args"]),
env=tuple(cur["env"]),
labels=tuple(cur["labels"]),
workdir=cur["workdir"],
)
)
cur = None
saw_instruction = False
for raw_line, line_no in _join_continuations(text, filename):
line = raw_line.strip()
if not line:
continue
if line.startswith("#"):
directive = line[1:].strip()
if directive.startswith("escape=") and directive != "escape=\\":
warnings.append(
f"line {line_no}: non-default escape directive is treated as "
f"backslash in v0.1"
)
continue
parts = line.split(None, 1)
instruction = parts[0].upper()
remainder = parts[1].strip() if len(parts) > 1 else ""
if not saw_instruction and instruction not in ("FROM", "ARG"):
raise NovafileError(
f"{filename}:{line_no}:1: first instruction must be FROM"
)
saw_instruction = True
if instruction == "FROM":
close_stage()
ref_text, alias = _split_from(remainder, line_no, filename)
try:
module = parse_module_ref(ref_text)
except ModuleRefError as exc:
raise NovafileError(f"{filename}:{line_no}:1: {exc}") from exc
cur = {
"name": alias,
"module": module,
"args": [],
"env": [],
"labels": [],
"workdir": "/",
}
elif instruction == "ARG":
argdef = _parse_arg(remainder, line_no, filename)
if cur is None:
global_args.append(argdef)
else:
cur["args"].append(argdef)
elif instruction == "ENV":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: ENV must appear after a FROM"
)
if remainder and "=" in remainder.split()[0]:
cur["env"].extend(_parse_pairs(remainder, line_no, filename))
else:
split = remainder.split(None, 1)
if len(split) == 1:
raise NovafileError(
f"{filename}:{line_no}:1: ENV requires a value, got: {remainder!r}"
)
key, value = split[0], split[1].strip()
cur["env"].append((key, value))
elif instruction == "LABEL":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: LABEL must appear after a FROM"
)
cur["labels"].extend(_parse_pairs(remainder, line_no, filename))
elif instruction == "WORKDIR":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: WORKDIR must appear after a FROM"
)
path = remainder.strip()
if not path:
raise NovafileError(
f"{filename}:{line_no}:1: WORKDIR requires a path"
)
if path.startswith("/"):
cur["workdir"] = posixpath.normpath(path)
else:
cur["workdir"] = posixpath.normpath(
posixpath.join(cur["workdir"], path)
)
elif instruction in _CONTAINER_ONLY:
warnings.append(
f"line {line_no}: {instruction} is a container-only instruction "
f"and is ignored for infrastructure modules"
)
elif instruction in _UNSUPPORTED:
raise NovafileError(
f"{filename}:{line_no}:1: {instruction} is not supported in "
f"Novafiles - modules replace container filesystems"
)
else:
raise NovafileError(
f"{filename}:{line_no}:1: unknown instruction {parts[0].upper()}"
)
close_stage()
if not stages:
raise NovafileError(f"{filename}:0:1: no instructions found")
return Novafile(
stages=tuple(stages),
global_args=tuple(global_args),
warnings=tuple(warnings),
)
def _split_from(
remainder: str, line: int, filename: str
) -> tuple[str, str | None]:
"""Split `FROM <module-ref> [AS <name>]` into (ref_text, alias)."""
if not remainder:
raise NovafileError(f"{filename}:{line}:1: FROM requires a module reference")
parts = remainder.split()
ref_text = parts[0]
alias: str | None = None
if len(parts) == 2:
raise NovafileError(
f"{filename}:{line}:1: FROM expects '<module> [AS <name>]', got: {remainder!r}"
)
if len(parts) >= 3:
if parts[1].upper() != "AS":
raise NovafileError(
f"{filename}:{line}:1: FROM expects '<module> [AS <name>]', "
f"got: {remainder!r}"
)
alias = " ".join(parts[2:])
if not alias:
raise NovafileError(f"{filename}:{line}:1: AS requires a stage name")
return ref_text, alias
+30 -10
View File
@@ -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
+161
View File
@@ -0,0 +1,161 @@
"""End-to-end CLI tests for `nova config` and `nova build` (parser-backed behavior)."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from nova.cli.main import cli
COMPOSE = """
name: demo
services:
vpc:
module: terraform-aws-modules/vpc/aws
environment:
region: ${REGION:-us-east-1}
name: ${PROJECT:?project name is required}
"""
NOVAFILE = """\
FROM terraform-aws-modules/vpc/aws@5.0.0 AS vpc
ARG REGION
ENV region=us-east-1
LABEL owner=platform
WORKDIR /state
RUN echo ignored
"""
@pytest.fixture()
def project(tmp_path, monkeypatch):
"""An isolated project dir with compose.yml and a Novafile."""
proj = tmp_path / "proj"
proj.mkdir()
(proj / "compose.yml").write_text(COMPOSE)
(proj / "Novafile").write_text(NOVAFILE)
monkeypatch.chdir(proj)
return proj
@pytest.fixture()
def runner():
return CliRunner()
def _out(result) -> str:
return result.output + getattr(result, "stderr", "")
def test_config_prints_normalized_yaml(project, runner):
result = runner.invoke(cli, ["config"], env={"PROJECT": "demo"})
assert result.exit_code == 0, _out(result)
assert "services:" in result.output
assert "module: terraform-aws-modules/vpc/aws" in result.output
def test_config_interpolates_from_shell_env(project, runner, monkeypatch):
monkeypatch.setenv("REGION", "eu-west-1")
result = runner.invoke(cli, ["config"], env={"REGION": "eu-west-1", "PROJECT": "demo"})
assert result.exit_code == 0, _out(result)
assert "eu-west-1" in result.output
def test_config_env_file_precedence(project, runner, monkeypatch):
(project / ".env").write_text("REGION=from-dotenv\nPROJECT=from-dotenv\n")
(project / "extra.env").write_text("REGION=from-env-file\n")
monkeypatch.delenv("REGION", raising=False)
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config", "--env-file", "extra.env"])
assert result.exit_code == 0, _out(result)
# --env-file beats .env; shell env beats both (unset here).
assert "from-env-file" in result.output
assert "from-dotenv" not in result.output or "PROJECT" not in result.output
def test_config_dotenv_fallback(project, runner, monkeypatch):
(project / ".env").write_text("REGION=from-dotenv\nPROJECT=from-dotenv\n")
monkeypatch.delenv("REGION", raising=False)
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config"])
assert result.exit_code == 0, _out(result)
assert "from-dotenv" in result.output
def test_config_no_interpolate(project, runner):
result = runner.invoke(cli, ["config", "--no-interpolate"], env={"PROJECT": "x"})
assert result.exit_code == 0, _out(result)
assert "${REGION:-us-east-1}" in result.output
def test_config_required_var_missing(project, runner, monkeypatch):
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config"])
assert result.exit_code == 1, _out(result)
assert "project name is required" in _out(result)
def test_config_invalid_schema(project, runner):
(project / "compose.yml").write_text(
"services:\n a:\n module: hashicorp/vpc\n"
)
result = runner.invoke(cli, ["config"], env={"PROJECT": "x"})
assert result.exit_code == 1, _out(result)
assert "invalid module reference" in _out(result)
def test_config_missing_env_file_errors_cleanly(project, runner, monkeypatch):
"""A missing --env-file is a compose-style error, never a traceback (UX criterion)."""
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config", "--env-file", "nope.env"])
assert result.exit_code == 1, _out(result)
assert "no such file" in _out(result)
assert "Traceback" not in _out(result)
def test_config_missing_file(project, runner):
(project / "compose.yml").unlink()
result = runner.invoke(cli, ["config"])
assert result.exit_code == 1, _out(result)
assert "no configuration file found" in _out(result)
def test_build_prints_module_manifest(project, runner):
result = runner.invoke(cli, ["build"])
assert result.exit_code == 0, _out(result)
assert "module terraform-aws-modules/vpc/aws" in result.output
assert "version 5.0.0" in result.output
assert "stage vpc" in result.output
assert "arg REGION=(required)" in result.output
assert "workdir /state" in result.output
assert "Build manifest resolved: 1 stage(s)" in result.output
def test_build_warns_on_container_only_instructions(project, runner):
result = runner.invoke(cli, ["build"])
assert result.exit_code == 0, _out(result)
assert "WARNING" in _out(result)
assert "RUN is a container-only instruction" in _out(result)
def test_build_invalid_novafile(project, runner):
(project / "Novafile").write_text("FROM hashicorp/vpc\n")
result = runner.invoke(cli, ["build"])
assert result.exit_code == 1, _out(result)
assert "Error:" in _out(result)
assert "invalid module reference" in _out(result)
def test_build_missing_novafile(project, runner):
(project / "Novafile").unlink()
result = runner.invoke(cli, ["build"])
assert result.exit_code == 1, _out(result)
assert "no such file" in _out(result)
def test_build_with_explicit_novafile(project, runner):
alt = project / "Custom.Novafile"
alt.write_text("FROM hashicorp/consul/aws\n")
result = runner.invoke(cli, ["build", "-f", "Custom.Novafile"])
assert result.exit_code == 0, _out(result)
assert "hashicorp/consul/aws" in result.output
+45 -17
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
"""Tests for the nova.parsers package."""
+331
View File
@@ -0,0 +1,331 @@
"""Tests for the compose.yml loader schema (compose-indistinguishable subset)."""
import pytest
import yaml
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
MINIMAL = """
name: demo
services:
vpc:
module: terraform-aws-modules/vpc/aws
environment:
name: demo
"""
def test_minimal_file_parses():
spec = parse_compose(MINIMAL)
assert spec.name == "demo"
assert len(spec.services) == 1
svc = spec.services[0]
assert svc.name == "vpc"
assert svc.module is not None
assert svc.module.source == "terraform-aws-modules/vpc/aws"
assert svc.environment == (("name", "demo"),)
def test_providers_block_stored():
spec = parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws
providers:
aws:
region: us-east-1
profile: prod
"""
)
assert spec.providers == {"aws": {"region": "us-east-1", "profile": "prod"}}
def test_module_version_key_pins():
spec = parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws
version: "5.0.0"
"""
)
assert spec.services[0].module is not None
assert spec.services[0].module.version == "5.0.0"
def test_version_sugar_and_key_conflict():
with pytest.raises(ComposeParseError, match="version specified twice"):
parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws@5.0.0
version: "5.0.0"
"""
)
def test_environment_mapping_and_list_forms():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
environment:
K1: v1
K2: v2
b:
module: terraform-aws-modules/vpc/aws
environment:
- K3=v3
"""
)
assert spec.services[0].environment == (("K1", "v1"), ("K2", "v2"))
assert spec.services[1].environment == (("K3", "v3"),)
def test_vars_alias_accepted():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
vars:
K1: v1
"""
)
assert spec.services[0].environment == (("K1", "v1"),)
def test_vars_and_environment_conflict():
with pytest.raises(ComposeParseError, match="use only one"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
vars: {K: v}
environment: {K: v}
"""
)
def test_env_file_single_and_list():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
env_file: .env
b:
module: terraform-aws-modules/vpc/aws
env_file:
- one.env
- two.env
"""
)
assert spec.services[0].env_files == (".env",)
assert spec.services[1].env_files == ("one.env", "two.env")
def test_depends_on_short_and_long_forms():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
b:
module: terraform-aws-modules/vpc/aws
depends_on:
- a
c:
module: terraform-aws-modules/vpc/aws
depends_on:
a:
condition: module_deployed
"""
)
assert spec.services[1].depends_on == ("a",)
assert spec.services[2].depends_on == ("a",)
def test_depends_on_unknown_service():
with pytest.raises(ComposeParseError, match="unknown service 'ghost'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
depends_on:
- ghost
"""
)
def test_depends_on_self_is_error():
with pytest.raises(ComposeParseError, match="cannot depend on itself"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
depends_on:
- a
"""
)
def test_working_dir_profiles_provider():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
working_dir: /state/prod
profiles:
- prod
provider: AWS
"""
)
svc = spec.services[0]
assert svc.working_dir == "/state/prod"
assert svc.profiles == ("prod",)
assert svc.provider == "aws"
def test_invalid_provider_rejected():
with pytest.raises(ComposeParseError, match="provider must be one of"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
provider: openstack
"""
)
def test_build_form_defaults_and_explicit():
spec = parse_compose(
"""
services:
a:
build: {}
b:
build:
context: ./infra
novafile: Custom.Novafile
"""
)
assert spec.services[0].build is not None
assert spec.services[0].build.context == "."
assert spec.services[0].build.novafile == "Novafile"
assert spec.services[1].build.context == "./infra"
assert spec.services[1].build.novafile == "Custom.Novafile"
def test_module_and_build_both_rejected():
with pytest.raises(ComposeParseError, match="not both"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
build: {}
"""
)
def test_neither_module_nor_build_rejected():
with pytest.raises(ComposeParseError, match="requires a 'module'"):
parse_compose("services:\n a: {}\n")
def test_unknown_top_level_key():
with pytest.raises(ComposeParseError, match="top-level key 'networks'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
networks: {}
"""
)
def test_top_level_not_mapping():
with pytest.raises(ComposeParseError, match="expected a mapping"):
parse_compose("- just\n- a list\n")
def test_services_not_mapping():
with pytest.raises(ComposeParseError, match="'services' must be a mapping"):
parse_compose("services: []\n")
def test_unknown_provider_in_providers_block():
with pytest.raises(ComposeParseError, match="unknown provider 'openstack'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
providers:
openstack: {}
"""
)
def test_invalid_module_ref_surfaces_with_service():
with pytest.raises(ComposeParseError, match="service 'a': invalid module reference"):
parse_compose(
"""
services:
a:
module: hashicorp/vpc
"""
)
def test_interpolation_applied_to_values():
spec = parse_compose(
"""
name: ${PROJECT}
services:
a:
module: terraform-aws-modules/vpc/aws
environment:
region: ${REGION:-us-east-1}
""",
env={"PROJECT": "demo-project", "REGION": "eu-west-1"},
)
assert spec.name == "demo-project"
assert spec.services[0].environment == (("region", "eu-west-1"),)
def test_interpolation_disabled_when_env_none():
spec = parse_compose(
"services:\n a:\n module: terraform-aws-modules/vpc/aws\n"
" environment:\n region: ${REGION:-us-east-1}\n"
)
assert spec.services[0].environment == (("region", "${REGION:-us-east-1}"),)
def test_render_compose_round_trip():
spec = parse_compose(MINIMAL)
rendered = render_compose(spec)
assert rendered["name"] == "demo"
assert rendered["services"]["vpc"]["module"] == "terraform-aws-modules/vpc/aws"
assert rendered["services"]["vpc"]["environment"] == {"name": "demo"}
# Round trip: rendered dict re-parses to the same model.
reparsed = parse_compose(rendered)
assert reparsed.name == spec.name
assert reparsed.services[0].module == spec.services[0].module
assert reparsed.services[0].environment == spec.services[0].environment
def test_render_is_yaml_serializable():
spec = parse_compose(MINIMAL)
text = yaml.safe_dump(render_compose(spec), sort_keys=False)
assert "module: terraform-aws-modules/vpc/aws" in text
+83
View File
@@ -0,0 +1,83 @@
"""Tests for compose-style variable interpolation."""
import pytest
from nova.parsers.interp import InterpolationError, interpolate
def test_bare_var_set_and_unset():
assert interpolate("hello $NAME", {"NAME": "world"}) == "hello world"
assert interpolate("hello $NAME", {}) == "hello "
def test_braced_var_set_and_unset():
assert interpolate("${VAR}", {"VAR": "x"}) == "x"
assert interpolate("${VAR}", {}) == ""
def test_default_when_unset():
assert interpolate("${VAR:-fallback}", {}) == "fallback"
assert interpolate("${VAR:-fallback}", {"VAR": "set"}) == "set"
def test_default_when_empty():
assert interpolate("${VAR:-fallback}", {"VAR": ""}) == "fallback"
assert interpolate("${VAR-fallback}", {"VAR": ""}) == ""
def test_dash_default_only_when_unset():
assert interpolate("${VAR-fallback}", {}) == "fallback"
def test_required_error_with_message():
with pytest.raises(InterpolationError, match="VAR: region is required"):
interpolate("${VAR:?region is required}", {})
def test_required_error_when_empty():
with pytest.raises(InterpolationError, match="VAR"):
interpolate("${VAR:?}", {"VAR": ""})
def test_required_passes_when_set():
assert interpolate("${VAR:?need}", {"VAR": "ok"}) == "ok"
def test_question_mark_only_when_unset():
assert interpolate("${VAR?need}", {"VAR": ""}) == ""
with pytest.raises(InterpolationError):
interpolate("${VAR?need}", {})
def test_alternate_when_set():
assert interpolate("${VAR:+alt}", {"VAR": "set"}) == "alt"
assert interpolate("${VAR:+alt}", {}) == ""
assert interpolate("${VAR:+alt}", {"VAR": ""}) == ""
def test_plus_alternate_when_set_even_empty():
assert interpolate("${VAR+alt}", {"VAR": ""}) == "alt"
def test_dollar_dollar_literal():
assert interpolate("cost $$5", {}) == "cost $5"
assert interpolate("$$VAR", {}) == "$VAR"
def test_lone_dollar_literal():
assert interpolate("100% of $", {}) == "100% of $"
assert interpolate("a$1b", {}) == "a$1b"
def test_mixed_operators_in_one_string():
env = {"A": "1"}
assert interpolate("x${A}y${B:-z}w", env) == "x1yzw"
def test_invalid_braced_name():
with pytest.raises(InterpolationError, match="1BAD"):
interpolate("${1BAD}", {})
def test_no_interpolation_needed():
assert interpolate("plain text", {}) == "plain text"
+236
View File
@@ -0,0 +1,236 @@
"""Module reference resolver tests (task P02-01-01).
Pure unit tests for the Terraform module source grammar verified in
.ciagent/RESEARCH.md area 1: registry shorthand, private registry hosts,
public registry web-URL normalization, ``//subdir`` submodules, ``@version``
sugar, and local path sources — with the exact docker/compose-style error
messages for every rejected shape.
"""
import pytest
from nova.parsers.moduleref import (
ModuleRef,
ModuleRefError,
parse_module_ref,
)
# --- valid registry references -------------------------------------------------------
def test_public_registry_shorthand() -> None:
"""Three segments resolve to public shorthand with no host or version."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws",
submodule=None,
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_private_registry_four_segments() -> None:
"""Four segments carry a private registry hostname in host."""
ref = parse_module_ref("app.terraform.io/example-corp/k8s-cluster/azurerm")
assert ref == ModuleRef(
source="app.terraform.io/example-corp/k8s-cluster/azurerm",
submodule=None,
version=None,
is_local=False,
host="app.terraform.io",
)
assert ref.terraform_source == "app.terraform.io/example-corp/k8s-cluster/azurerm"
def test_public_registry_url_normalizes_to_shorthand() -> None:
"""The registry.terraform.io/<ns>/<name>/<provider> web URL drops its host."""
ref = parse_module_ref("registry.terraform.io/terraform-aws-modules/vpc/aws")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws",
submodule=None,
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_public_registry_modules_url_normalizes_to_shorthand() -> None:
"""The /modules/ web-URL form also normalizes to plain shorthand."""
ref = parse_module_ref("registry.terraform.io/modules/terraform-aws-modules/vpc/aws")
assert ref.source == "terraform-aws-modules/vpc/aws"
assert ref.host is None
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_submodule_marker() -> None:
"""// selects a submodule; it stays part of the emitted source."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws//modules/vpc-endpoint")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws//modules/vpc-endpoint",
submodule="modules/vpc-endpoint",
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws//modules/vpc-endpoint"
# --- version sugar -------------------------------------------------------------------
def test_version_sugar_plain() -> None:
"""@1.2.3 splits into version="1.2.3", leaving a clean source."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@1.2.3")
assert ref.source == "terraform-aws-modules/vpc/aws"
assert ref.version == "1.2.3"
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_version_sugar_pessimistic() -> None:
"""@~> 6.0 keeps the pessimistic operator in the version field."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@~> 6.0")
assert ref.version == "~> 6.0"
assert ref.source == "terraform-aws-modules/vpc/aws"
def test_version_sugar_comma_separated_range() -> None:
"""@>= 1.2, < 2.0 keeps the full constraint, spaces and all."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@>= 1.2, < 2.0")
assert ref.version == ">= 1.2, < 2.0"
assert ref.source == "terraform-aws-modules/vpc/aws"
# --- local sources --------------------------------------------------------------------
@pytest.mark.parametrize(
"path", ["./modules/vpc", "../shared/vpc", "/opt/nova/modules/vpc"]
)
def test_local_sources(path: str) -> None:
"""./ ../ and absolute paths stay as-is and never carry a version."""
ref = parse_module_ref(path)
assert ref == ModuleRef(
source=path, submodule=None, version=None, is_local=True, host=None
)
assert ref.terraform_source == path
def test_private_host_submodule_and_version_combo() -> None:
"""Private host + //submodule + @version all resolve together."""
ref = parse_module_ref(
"app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam@>= 1.2, < 2.0"
)
assert ref == ModuleRef(
source="app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam",
submodule="modules/iam",
version=">= 1.2, < 2.0",
is_local=False,
host="app.terraform.io",
)
assert ref.terraform_source == (
"app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam"
)
def test_surrounding_whitespace_stripped() -> None:
"""Leading/trailing whitespace never reaches the source."""
ref = parse_module_ref("\t terraform-aws-modules/vpc/aws \n")
assert ref.source == "terraform-aws-modules/vpc/aws"
# --- rejected references --------------------------------------------------------------
def test_two_segments_rejected() -> None:
"""The exact compose-style message for a 2-segment registry reference."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("hashicorp/vpc")
assert str(excinfo.value) == (
"invalid module reference 'hashicorp/vpc': "
"registry references need <namespace>/<name>/<provider>"
)
@pytest.mark.parametrize(
("ref", "reason"),
[
("app.terraform.io", "bare registry hostname without <namespace>/<name>/<provider>"),
("vpc", "registry references need <namespace>/<name>/<provider>"),
],
)
def test_one_segment_rejected(ref: str, reason: str) -> None:
"""Bare hostnames and bare names are not registry references."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == f"invalid module reference '{ref}': {reason}"
def test_five_segments_rejected() -> None:
"""Five segments overshoot the 4-segment private form."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("a/b/c/d/e")
assert str(excinfo.value) == (
"invalid module reference 'a/b/c/d/e': "
"registry references allow at most <hostname>/<namespace>/<name>/<provider>"
)
@pytest.mark.parametrize("ref", ["", " ", "\t"])
def test_empty_reference_rejected(ref: str) -> None:
"""Empty (or whitespace-only) references never parse."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == (
f"invalid module reference '{ref}': module source cannot be empty"
)
@pytest.mark.parametrize(
"path", ["./modules/vpc", "../shared/vpc", "/opt/nova/modules/vpc"]
)
def test_version_on_local_rejected(path: str) -> None:
"""Version pinning is a registry-only feature (exact message)."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(f"{path}@1.2.3")
assert str(excinfo.value) == (
f"invalid module reference '{path}@1.2.3': "
"local module sources do not support version pinning"
)
@pytest.mark.parametrize(
("version", "reason"),
[
("1.2.3.4.5", "invalid version constraint '1.2.3.4.5'"),
("foo", "invalid version constraint 'foo'"),
("", "invalid version constraint ''"),
],
)
def test_invalid_version_constraint_rejected(version: str, reason: str) -> None:
"""Non-constraint strings after @ are rejected with their own reason."""
ref = f"terraform-aws-modules/vpc/aws@{version}"
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == f"invalid module reference '{ref}': {reason}"
def test_double_slash_start_rejected() -> None:
"""A submodule marker without a leading source is invalid."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("//modules/vpc")
assert str(excinfo.value) == (
"invalid module reference '//modules/vpc': module source cannot start with '//'"
)
def test_empty_path_segment_rejected() -> None:
"""A trailing slash leaves an empty provider segment."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("terraform-aws-modules/vpc/aws/")
assert str(excinfo.value) == (
"invalid module reference 'terraform-aws-modules/vpc/aws/': "
"module source contains an empty path segment"
)
+217
View File
@@ -0,0 +1,217 @@
"""Tests for the Novafile parser (Dockerfile-compatible grammar)."""
import pytest
from nova.parsers.moduleref import ModuleRef
from nova.parsers.novafile import NovafileError, parse_novafile
def test_case_insensitive_instructions():
nv = parse_novafile(
"from terraform-aws-modules/vpc/aws\n"
"env REGION=us-east-1\n"
"label owner=platform\n"
)
assert nv.stages[0].module.source == "terraform-aws-modules/vpc/aws"
assert nv.stages[0].env == (("REGION", "us-east-1"),)
assert nv.stages[0].labels == (("owner", "platform"),)
def test_comments_and_blank_lines_skipped():
text = (
"# a comment\n"
"\n"
" # indented comment\n"
"FROM terraform-aws-modules/vpc/aws\n"
"# trailing comment\n"
)
nv = parse_novafile(text)
assert len(nv.stages) == 1
def test_line_continuations():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"ENV A=1 \\\n"
" B=2\n"
)
nv = parse_novafile(text)
assert nv.stages[0].env == (("A", "1"), ("B", "2"))
def test_env_legacy_form_preserves_inner_spaces():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"ENV MESSAGE hello world with spaces\n"
)
nv = parse_novafile(text)
assert nv.stages[0].env == (("MESSAGE", "hello world with spaces"),)
def test_env_quoted_values_with_escapes():
text = (
'FROM terraform-aws-modules/vpc/aws\n'
'ENV A="quoted value" B="say \\"hi\\"" C=plain\n'
)
nv = parse_novafile(text)
assert nv.stages[0].env == (
("A", "quoted value"),
('B', 'say "hi"'),
("C", "plain"),
)
def test_arg_with_and_without_default():
text = (
"ARG VERSION=1.2.3\n"
"FROM terraform-aws-modules/vpc/aws\n"
"ARG REGION\n"
)
nv = parse_novafile(text)
assert nv.global_args[0].name == "VERSION"
assert nv.global_args[0].default == "1.2.3"
assert nv.stages[0].args[0].name == "REGION"
assert nv.stages[0].args[0].default is None
def test_arg_semantics_globally_scoped():
text = (
"ARG VERSION=1.2.3\n"
"FROM terraform-aws-modules/vpc/aws\n"
"ARG REGION\n"
)
nv = parse_novafile(text)
assert [a.name for a in nv.global_args] == ["VERSION"]
assert [a.name for a in nv.stages[0].args] == ["REGION"]
def test_multi_stage_with_alias():
text = (
"FROM terraform-aws-modules/vpc/aws AS base\n"
"ENV A=1\n"
"FROM hashicorp/consul/aws AS consul\n"
"ENV B=2\n"
)
nv = parse_novafile(text)
assert len(nv.stages) == 2
assert nv.stages[0].name == "base"
assert nv.stages[0].env == (("A", "1"),)
assert nv.stages[1].name == "consul"
assert nv.stages[1].env == (("B", "2"),)
def test_workdir_accumulates_relative():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"WORKDIR /state\n"
"WORKDIR vpc\n"
"WORKDIR nested\n"
)
nv = parse_novafile(text)
assert nv.stages[0].workdir == "/state/vpc/nested"
def test_workdir_absolute_resets():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"WORKDIR /a/b\n"
"WORKDIR /fresh\n"
)
nv = parse_novafile(text)
assert nv.stages[0].workdir == "/fresh"
def test_container_only_instructions_warn():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"RUN echo hi\n"
"CMD [\"serve\"]\n"
"EXPOSE 8080\n"
)
nv = parse_novafile(text)
assert len(nv.warnings) == 3
assert "line 2: RUN is a container-only instruction" in nv.warnings[0]
assert "CMD" in nv.warnings[1]
assert "EXPOSE" in nv.warnings[2]
def test_copy_is_hard_error():
with pytest.raises(NovafileError, match="COPY is not supported in Novafiles"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nCOPY . /app\n")
def test_add_is_hard_error():
with pytest.raises(NovafileError, match="ADD is not supported in Novafiles"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nADD . /app\n")
def test_unknown_instruction():
with pytest.raises(NovafileError, match="unknown instruction FOO"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nFOO bar\n")
def test_first_instruction_must_be_from():
with pytest.raises(NovafileError, match="first instruction must be FROM"):
parse_novafile("ENV A=1\nFROM terraform-aws-modules/vpc/aws\n")
def test_empty_file():
with pytest.raises(NovafileError, match="no instructions found"):
parse_novafile("# only comments\n\n")
def test_error_carries_filename_and_line():
with pytest.raises(NovafileError) as excinfo:
parse_novafile("FROM terraform-aws-modules/vpc/aws\nFOO x\n", "MyNovafile")
assert str(excinfo.value).startswith("MyNovafile:2:1:")
def test_invalid_module_ref_in_from_reports_line():
with pytest.raises(NovafileError) as excinfo:
parse_novafile("FROM hashicorp/vpc\n")
assert "invalid module reference" in str(excinfo.value)
assert "Novafile:1:1:" in str(excinfo.value)
def test_module_ref_parsed_in_from():
nv = parse_novafile("FROM terraform-aws-modules/vpc/aws@5.0.0\n")
assert isinstance(nv.stages[0].module, ModuleRef)
assert nv.stages[0].module.version == "5.0.0"
def test_from_without_ref():
with pytest.raises(NovafileError, match="FROM requires a module reference"):
parse_novafile("FROM\n")
def test_from_with_dangling_token():
with pytest.raises(NovafileError, match="FROM expects"):
parse_novafile("FROM terraform-aws-modules/vpc/aws extra\n")
def test_from_as_missing_name():
with pytest.raises(NovafileError, match="FROM expects"):
parse_novafile("FROM terraform-aws-modules/vpc/aws AS\n")
def test_env_before_from_is_error():
with pytest.raises(NovafileError, match="first instruction must be FROM"):
parse_novafile("ENV A=1\n")
def test_non_default_escape_directive_warns():
text = "# escape=`\nFROM terraform-aws-modules/vpc/aws\n"
nv = parse_novafile(text)
assert any("escape" in w for w in nv.warnings)
def test_multiple_env_pairs_one_line():
nv = parse_novafile(
"FROM terraform-aws-modules/vpc/aws\nENV A=1 B=2 C=3\n"
)
assert nv.stages[0].env == (("A", "1"), ("B", "2"), ("C", "3"))
def test_unterminated_quote():
with pytest.raises(NovafileError, match="unterminated quoted value"):
parse_novafile('FROM terraform-aws-modules/vpc/aws\nLABEL A="open\n')