6 Commits

Author SHA1 Message Date
CIAgent 31edaf8e4c docs(P02): phase status — REQ-02/REQ-03 complete, shipped v0.1.2
---ci---
phase: 2
milestone: v0.1
status: complete
---/ci---
2026-10-14 23:04:40 +00:00
CIAgent ea5884c0f2 docs(P02): complete parsers phase
---ci---
phase: 2
milestone: v0.1
status: complete
requirements:
  covered: [REQ-02, REQ-03]
  partial: [REQ-01]
---/ci---
2026-10-14 23:04:20 +00:00
CIAgent 9d1116096b docs(P01): phase status — REQ-05 complete, REQ-01 partial, shipped v0.1.1
---ci---
phase: 1
milestone: v0.1
status: complete
---/ci---
2026-10-14 22:38:28 +00:00
CIAgent a6c5a1bb4f docs(P01): complete cli-skeleton phase
---ci---
phase: 1
milestone: v0.1
status: complete
requirements:
  covered: [REQ-05]
  partial: [REQ-01]
---/ci---
2026-10-14 22:38:14 +00:00
CIAgent 3e86118383 chore(P00): checkpoint — phase 0 complete, shipped v0.1.0
---ci---
phase: 0
milestone: v0.1
status: complete
---/ci---
2026-10-14 22:13:36 +00:00
CIAgent b624317048 docs(P00): complete pre-execution phase
---ci---
phase: 0
milestone: v0.1
status: complete
decisions:
  - id: D-001..D-028
    decision: phase-0 decision record carried forward from squashed history (gitea classification, v0.1 minor pin, go-live slug, single-project mode, 6-phase roadmap, secrets scopes, env-discovery split, adapter pattern, clarified scope D-009..D-022, research decisions D-023..D-027, mvp/ux gate D-028)
    rationale: squash merge collapses phase-0 commits; decision details live in .ciagent/{PROJECT,RESEARCH,GRILL,PLAN}.md committed in this tree
    confidence: 1.0
    alternatives: []
  - id: G-001..G-012
    decision: grill binding decisions carried forward (verdict proceed at 0.65; prerelease=true for v0.1.0..v0.1.6 per G-003; container artifact conditionality per G-004; mock-only terraform validation per G-005)
    rationale: details in .ciagent/GRILL.md committed in this tree
    confidence: 0.65
    alternatives: []
---/ci---
2026-10-14 22:12:59 +00:00
37 changed files with 4583 additions and 52 deletions
+64
View File
@@ -0,0 +1,64 @@
# Nova CLI — Architecture (P0 refined)
## Overview
nova-cli is a Python CLI that mirrors Docker / Docker Compose ergonomics for infrastructure
deployment. Commands flow from a docker-like command surface through file parsers (Novafile,
compose.yml) and an environment discovery layer into a pluggable IaC adapter (Terraform is the
first adapter) that deploys infrastructure modules to cloud providers (AWS, Azure, GCP). Policy
checks run through vendored c7n + c7n-left. Refined during phase-0 research (see RESEARCH.md).
## Module Layout (src-layout, single package `nova`)
| Path | Contents |
|------|----------|
| `src/nova/cli/` | click entry point (`nova.cli:main`), command groups (build, up, down, ps, ..., policy, env, version), docker-parity flag layer (`-f/-p/-d/--env-file/--profile`), help styling |
| `src/nova/parsers/` | `novafile.py` (Dockerfile-grammar parser: case-insensitive instructions, comments, `\` continuations, ENV k=v / k v, ARG, FROM...AS, LABEL, WORKDIR; BuildKit-style `Novafile:<l>:<c>` errors), `compose.py` (compose-spec schema subset: name, services, environment list/map, env_file, depends_on short/long, working_dir, profiles, providers block), `interp.py` (`${VAR}` `${VAR:-def}` `${VAR:?err}` `${VAR:+alt}` + `.env` loading), `moduleref.py` (registry shorthand `[host/]ns/name/provider[//subdir][@ver]`, registry URLs, local `./` `../`) |
| `src/nova/env/` | `git.py` (pure-python `.git` parsing: config INI, HEAD, loose refs + packed-refs, zlib commit objects; `git` subprocess fallback; auto-init with notice), `credentials.py` (gh hosts.yml, glab config.yml, AWS credentials file/env, ARM_* env + azure profile, GOOGLE_*/gcloud ADC; masked fingerprints), `pipeline.py` (GITHUB_ACTIONS / GITEA_ACTIONS / GITLAB_CI detection → PrefillContext), `forge.py` (stdlib-HTTP GitHub + Gitea clients; shared read surface, per-forge auth/pagination/payload normalization) |
| `src/nova/engine/` | deployment orchestration: dependency ordering from depends_on, lifecycle mapping (up = init+plan+apply, down = destroy, ps = state listing), project naming precedence, state dir (WORKDIR) management |
| `src/nova/iac/` | `adapter.py` (IaCAdapter protocol) + `terraform.py` (first adapter: shells out to the `terraform` binary; detects missing binary with install hint) |
| `src/nova/providers/` | provider engine: compose `providers:` block + discovered credentials → generated Terraform provider configuration |
| `src/nova/policy/` | c7n/c7n-left vendoring wrapper (pinned `c7n-left==0.3.39`), builtin policy pack (`src/nova/data/policies/`), `policy check` gate for `up` (`--skip-policy` bypass) |
| `src/nova/data/` | package data: builtin policy pack, version metadata |
## Components
| Component | Description | Boundaries | Depends On |
|-----------|-------------|------------|------------|
| Command Surface | Docker-ergonomic CLI commands (build, up, down, ps, stop, start, restart, rm, logs, config, policy, env, version) with compose-parity flags and help layout | Argument parsing and routing only; no IaC/provider logic | — |
| Novafile Parser | Dockerfile-analog parser; Terraform module registry paths replace container images; container-only instructions warn, COPY/ADD error | Produces module build definitions (stages, ARG defaults, ENV input defaults, WORKDIR state dir) | — |
| Compose Parser | compose.yml loader; compose-indistinguishable schema plus `module` (replaces image) and top-level `providers`; `${VAR}` interpolation with compose operator set and precedence (shell > --env-file > .env) | Produces module deployment specs; discovery mirrors compose (upward search, NOVA_COMPOSE_FILE, -f merge) | — |
| Environment Discovery | Git-context (repo detect/auto-create, remotes, commits), GitHub/Gitea API clients, credential discovery (gh/glab files, cloud creds/env), pipeline detection with PrefillContext | Read-only environment introspection; secrets masked; no deployment logic | — |
| IaC Adapter Interface | Protocol with `init / plan / apply / destroy / show` methods (Terraform first; other IaC types plug in later without redesign) | Contract only; engines implement it | — |
| Terraform Adapter | Implements the protocol by invoking the external `terraform` binary; generates module blocks (`source` + registry `version`) and reads `show -json` for ps | Terraform-specific; kept behind the interface; binary prerequisite detected, never bundled | IaC Adapter Interface |
| Provider Engine | Maps compose `providers:` + discovered credentials to provider-correct Terraform (aws, azurerm, google) | Provider targeting and credential injection only | Terraform Adapter, Compose Parser, Environment Discovery |
| Deployment Engine | Lifecycle semantics (up = init+plan+apply with approval unless -d; down = destroy, -v purges state), depends_on ordering, project naming | Orchestration only; no parsing, no provider details | Parsers, IaC Adapter |
| Policy Engine | Vendored c7n + c7n-left as pinned deps; `c7n-left run -p <policies> -d <tf-dir> --output json` via subprocess; violations block `up` | Policy evaluation; invoked from the command surface | Provider Engine (generated Terraform as input) |
| Release Engineering | Wheel + sdist + PyInstaller one-file linux binary + slim container; gitea release API (POST /repos/{owner}/{repo}/releases, multipart asset attach) | Packaging and release only | — |
## Provider Mapping
| compose `providers` key | Terraform provider source | Credential inputs (discovered) |
|------------------------|--------------------------|-------------------------------|
| `aws` | `hashicorp/aws` | AWS_ACCESS_KEY_ID/SECRET/SESSION_TOKEN env or `~/.aws/credentials` profile; AWS_PROFILE, region |
| `azure` | `hashicorp/azurerm` | ARM_SUBSCRIPTION_ID, ARM_TENANT_ID, ARM_CLIENT_ID, ARM_CLIENT_SECRET env; azureProfile.json for non-secret context |
| `gcp` | `hashicorp/google` | GOOGLE_APPLICATION_CREDENTIALS / gcloud ADC file; GOOGLE_PROJECT/GOOGLE_CLOUD_PROJECT, GOOGLE_REGION |
## Data Flow
User command -> Command Surface -> parse Novafile / compose.yml (interpolation over env + .env +
pipeline prefill) -> Environment Discovery (git context, credentials, pipeline PrefillContext,
forge clients) -> Deployment Engine (ordering, project name) -> Provider Engine (credentials ->
provider config) -> IaC Adapter Interface -> Terraform Adapter -> external `terraform` binary ->
cloud provider (AWS/Azure/GCP). Policy checks (c7n/c7n-left) gate `up` between plan and apply.
`nova env` reports discovery; `nova config` prints the normalized model; release engineering ships
binary + container via the gitea API.
## Build Order
1. Command Surface (P1)
2. Novafile + Compose Parsers (P2)
3. Environment Discovery (P3)
4. IaC Adapter Interface + Terraform Adapter + Provider Engine + Deployment Engine (P4)
5. Policy Engine (P5)
6. Release Engineering (P6)
+8
View File
@@ -0,0 +1,8 @@
{
"phase": 2,
"stage": "complete",
"milestone": "v0.1",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-15T01:40:00Z"
}
+177
View File
@@ -0,0 +1,177 @@
# CIAgent Grill Report
## Run: 2026-10-14 (mode: mechanical, focus: all)
### Verdict: Proceed (confidence: 0.65)
Pre-implementation review of nova-cli (codebase intentionally empty; implementation starts P1). For a
project with zero code, the evidence base is unusually strong: 27 recorded decisions with rationale,
alternatives, and confidence scores (2cc5e4f, 31722f5, f6954b6); research verified against primary
sources with a 10-row assumption table where every row carries a fallback (RESEARCH.md:336-350); an
explicit out-of-scope table (REQUIREMENTS.md:45-51); task-level done criteria (PLAN.md:5-9); and
per-phase ships under the sponsor-pinned v0.1 minor (D-002, D-019, D-020). The failure modes a status
report would hide are real and concentrated in three places — the release container artifact (cannot
be built in this environment), the never-exercised real-deployment path (mock-only), and release
hygiene on the phase patch line — all received forced dispositions, not scope cuts. No scope creep
detected between specify (2cc5e4f) and plan (50d99ed): REQ-01..11 are unchanged; clarify only narrowed.
Forcing questions scored: 40. Challenges raised: 10 (all forced to dispositions at >=0.60). Escalations: 0.
### Axis 1 — Business Case
- **Q1**: What problem does this solve, and is it still the top priority?
- Evidence: PROJECT.md:5-9; init spec 2cc5e4f. Answer: infra deployment with zero new ergonomics (docker mental model; Terraform module as unit). Unchanged since specify. Conf: 0.70.
- Resolved: sponsor-validated; no competing priority exists in-repo.
- **Q2**: Named sponsor who decides under pressure?
- Evidence: D-002 (2cc5e4f — user override of the computed v1.0 default); clarify session 31722f5: 14 binding scope decisions (D-009..D-022) in one pass. Conf: 0.75.
- Resolved: sponsor demonstrably arbitrates; "support" is evidenced by override behavior, not assertion.
- **Q3**: Impact if cancelled?
- Evidence: PROJECT.md:59 — "nova-cli is the command-line interface for nova-platform". Conf: 0.65.
- Resolved: not a zombie — the platform loses its only CLI front door.
- **Q4**: ROI against a counterfactual?
- Evidence: MISSING — no ROI/cost-benefit artifact anywhere. Answer: none computed; value is sponsor-attested ergonomic leverage. Conf: 0.60.
- Challenge -> G-012: no financial ROI required for internal tooling; go-live adoption review is the payback checkpoint.
### Axis 2 — Scope & Requirements
- **Q1**: Scope expanding, contracting, or stable?
- Evidence: REQ-01..11 identical in 2cc5e4f and 50d99ed; D-009 deferred run/exec/cp. Answer: monotonically contracted, then frozen. Conf: 0.85.
- Resolved: no creep against the D-002 v0.1 pin.
- **Q2**: Requirements owned and frozen — or a moving target? Is "practically indistinguishable" honest?
- Evidence: sponsor clarify 31722f5; but PROJECT.md:5 says "practically the same and almost indistinguishable" while D-010 hard-errors COPY/ADD, D-011 adds module/providers keys, D-015 makes stop/start/rm not-applicable. Conf: 0.75.
- Challenge -> G-002: the claim is one-directional and aspirational; the honest subset is the binding definition and must be enumerated in README + error messages.
- **Q3**: Explicit out-of-scope list?
- Evidence: REQUIREMENTS.md:45-51 (3-row table) + PROJECT.md:44-47. Answer: lifecycle gaps, non-Terraform adapters, run/exec/cp. Conf: 0.85.
- Resolved: someone can answer the question, in writing.
- **Q4**: Hidden requirements disclosed late?
- Evidence: terraform BUSL licensing surfaced pre-build (D-027, f6954b6); secrets masking (D-013); linux-only binary (D-017). Residual late-disclosure risk: gitea runner existence (A8 @0.75). Conf: 0.70.
- Resolved with note: A8 feeds the container-artifact challenge (G-004).
### Axis 3 — Architecture & Feasibility
- **Q1**: Validated by the builders, or only the sellers?
- Evidence: ARCHITECTURE.md refined during research (f6954b6, "P0 refined"); RESEARCH.md verified against HashiCorp/compose-spec/gh/act_runner/PyPI; persona territories match module layout (PERSONAS.md:76-81). Conf: 0.70.
- Resolved: builder == seller here (agent pipeline), so validation is external-source-based; this grill adds the adversarial pass.
- **Q2**: Integration surface enumerated?
- Evidence: ARCHITECTURE.md:24-37 — terraform subprocess, c7n-left subprocess, forge HTTP, credential chains, gitea release API. Each has a RESEARCH section with pitfalls (§1-10). Conf: 0.75.
- Resolved: surface is large but every edge is pre-verified with concrete failure modes.
- **Q3**: Existing system being replaced?
- Evidence: repo root contains only .ciagent/ + README.md — greenfield. Answer: nothing replaced; alternative is raw terraform CLI; no data migration. Conf: 0.80.
- Resolved.
- **Q4**: Inherited technical debt budgeted?
- Evidence: zero code debt (empty src/); carrying costs documented: c7n vendoring ~60-80 MB installed, binary +100-150 MB, container 200-300 MB (RESEARCH.md:286-288, §10). Conf: 0.75.
- Resolved: dependency weight is the accepted, documented cost.
### Axis 4 — People, Skills, Organization
- **Q1**: Key-person dependency?
- Evidence: config.json:15-21 (full autonomy); all five active personas are one agent (PERSONAS.md); planner subagent failed twice and was auto-recovered inline (50d99ed lessons). Conf: 0.60.
- Challenge -> G-010: maximal concentration — one executor. Mitigation: all state is committed artifacts; any agent instance can resume from git.
- **Q2**: Resources actually allocated?
- Evidence: P0 ran specify->clarify->research->plan in 51 minutes across 4 substantive commits (543975c -> 50d99ed, git log). Conf: 0.80.
- Resolved: 100% allocation, evidenced by velocity — not "in name only".
- **Q3**: Product owner with authority?
- Evidence: 31722f5 (14 decisions); D-002 user override of a computed default. Conf: 0.75.
- Resolved: single owner with demonstrated override authority; no committee.
- **Q4**: Building capability they don't have?
- Evidence: every unknown converted to verified fact pre-build — module grammar (D-023), c7n-left `-d` flag catch (D-025), commit.id vs commit.sha (D-026), packed-object fallback requirement (RESEARCH §8). Conf: 0.75.
- Resolved: research-first discipline closed the gaps before code exists.
### Axis 5 — Timeline & Estimates
- **Q1**: Deadline set before scope was understood?
- Evidence: no dates anywhere (ROADMAP.md, PLAN.md). Answer: no calendar deadline exists; execution is phase-gated. Conf: 0.65.
- Resolved: no reverse-engineered date pressure; slippage is visible per phase ship instead.
- **Q2**: Critical path and the 3-month pusher?
- Evidence: PLAN deps P2,P3->P4->P5->P6->P7; P4 is 5 tasks across 3 waves incl. adapter, 3 providers, engine, wiring (PLAN.md:85-99). Conf: 0.70.
- Resolved: P4 is the risk concentration; a provider-generation defect found late is the schedule-killer; mitigation = adapter conformance harness (P04-01-01) + E2E gates per wave.
- **Q3**: Evidence-based estimates?
- Evidence: MISSING — no effort/size estimates per task. Answer: tasks sized as single executor passes with explicit done criteria. Conf: 0.60.
- Challenge -> forced: estimates are absent by design (agent passes, not hours); progress unit = shipped phase patch (D-019/D-020); P4 wave-3 E2E flagged as the estimate-risk concentration.
- **Q4**: Working definition of done?
- Evidence: PLAN.md:5-9 must_haves; per-task Done: lines; phase must-haves; REQ->task traceability (PLAN.md:194-208); allow_skip=false (config.json:23). Conf: 0.85.
- Resolved: "done" is testable and enforced, not whatever the demo shows.
### Axis 6 — Budget & Financial Realism
- **Q1**: Spent vs remaining?
- Evidence: MISSING monetary budget. Answer: none exists; cost is pipeline compute/tokens. Conf: 0.60.
- Challenge -> forced: non-monetary internal project; P0 burn (~1 session) is the baseline; low materiality.
- **Q2**: Cost drivers not in the original budget?
- Evidence: RESEARCH.md:286-288 (29 packages, boto3/botocore, compiled tfparse wheel), §10 (onefile 1-3 s startup, 200-300 MB container). Conf: 0.75.
- Resolved: all predictable drivers documented before first spend.
- **Q3**: Burn rate and runway?
- Evidence: 4 substantive commits in 51 minutes (21:18 -> 22:09, git log). Conf: 0.60.
- Resolved: no runway constraint at current burn.
- **Q4**: Budget contingent on something unbudgeted?
- Evidence: A8 (gitea runner @0.75) with local scripts/release.sh + GITEA_TOKEN fallback (RESEARCH.md:332-334); release_blocking=false. Conf: 0.70.
- Resolved: release path survives the A8 failure mode without new spend.
### Axis 7 — Risks, Assumptions, Dependencies
- **Q1**: Top assumptions with evidence?
- Evidence: RESEARCH.md:336-350 — A1..A10, each with confidence and consequence-if-wrong + fallback. Conf: 0.80.
- Resolved: the best assumption discipline I have seen in a P0 artifact.
- **Q2**: External dependencies?
- Evidence: terraform binary (D-027, external prerequisite), PyPI pin (D-025), gitea API/runner (D-026, A8), cloud credential chains (RESEARCH §5). Conf: 0.75.
- Resolved: each has detection, hint, or fallback.
- **Q3**: The single kill-risk — mitigated or hoped away?
- Evidence: D-027 ("dev environment has no terraform binary") + PLAN.md:165 ("terraform mocked") — the generated-Terraform-to-real-terraform contract is never exercised before go-live. Kill-risk: adapter emits terraform real terraform rejects (or accepts wrongly). Conf: 0.60.
- Challenge -> G-005: environment-forced; compensating controls are verified grammar (§1, D-023), conformance harness, and documented first-real-apply as post-go-live validation.
- **Q4**: Pre-mortem done?
- Evidence: this grill + the assumption-consequence table (RESEARCH.md:336-350) which functions as one. Conf: 0.65.
- Resolved: this report completes it.
### Axis 8 — Governance & Decision-Making
- **Q1**: Decision-maker when executives disagree?
- Evidence: config.json:15-21 — full autonomy, threshold 0.6, hooks [deploy, delete_data, merge_to_main]; D-002 sponsor-override precedent. Conf: 0.70.
- Resolved: sponsor is arbiter; agent auto-decides >=0.6 and records in ---ci--- blocks.
- **Q2**: Cadence and escalation pattern?
- Evidence: stage pipeline specify->clarify->research->plan->grill->execute; max_revision_iterations=3; escalation hooks configured. Conf: 0.70.
- Resolved: per-stage gates; the observed planner failure (50d99ed) was auto-recovered and recorded transparently.
- **Q3**: Status-report omissions?
- Evidence: 50d99ed ---ci--- lessons honestly records a double subagent failure. MISSING: per-phase token/cost and coverage metrics. Conf: 0.65.
- Resolved with note: decisions transparent; operational metrics absent — add coverage % to phase ships.
- **Q4**: Agreed stop-the-project trigger?
- Evidence: MISSING — no explicit milestone kill criteria. Answer: implicit gates only (must-haves, allow_skip=false). Conf: 0.60.
- Challenge -> G-008: bind a standing stop trigger; ensure release_blocking=false (which masks release failures) can never mask a failed phase gate.
### Axis 9 — Adoption & Operational Readiness
- **Q1**: Who uses it, what changes, what's in it for them?
- Evidence: PROJECT.md:5,9; PLAN.md:149-161 user surface; README quickstart task P06-01-05. Answer: docker-fluent developers; they write Novafile/compose.yml instead of .tf; incentive = zero new ergonomics. Conf: 0.65.
- Resolved with note: no beta users planned before go-live; adoption is asserted, not evidenced.
- **Q2**: Ops involved now or handed a finished product?
- Evidence: the product is itself an infra tool; release-engineer persona owns operational artifacts from P6 start (PERSONAS.md:41-44). Conf: 0.60.
- Resolved-ish: no separate ops team exists; packaging owner is in the loop at build time.
- **Q3**: Rollback plan?
- Evidence: PLAN.md:178 (down -v reverse-order destroy, state purge); D-015 explicit not-applicable errors. MISSING: state-recovery documentation; remote state backends unaddressed. Conf: 0.60.
- Challenge -> G-009: terraform-native recovery from local WORKDIR state is the only path; must be documented; remote backends stay post-go-live.
- **Q4**: Success criteria validated with the judges?
- Evidence: sponsor-validated requirements (PROJECT.md:13-24); PLAN UX acceptance criteria; this grill binds go-live criteria. Conf: 0.65.
- Resolved: the sponsor is the judge and has seen the criteria; no external judges exist.
### Meta — Closing Review
- **Q1**: Auditor flags?
- Evidence: three material flags: (1) REQUIREMENTS.md:35 "Dockerfile-built container image" vs no docker daemon in dev env + A8 @0.75 (RESEARCH.md:325-326); (2) real deploy never exercised (D-027); (3) D-019 ships v0.1.0..v0.1.6 as releases while P1 commands emit "landing in P4" errors (PLAN.md:30). Conf: 0.65.
- Challenge -> G-004, G-005, G-003 respectively.
- **Q2**: What is the project NOT doing?
- Evidence: no remote-state backends (local WORKDIR state only), no real-cloud smoke, no macOS/Windows binaries (D-017 linux-only), no behavior benchmark against real docker compose, no token-cost tracking. Conf: 0.70.
- Resolved as flags: most consequential omission is remote state — acceptable under the deferred-lifecycle out-of-scope row, but it caps serious production adoption until post-go-live.
- **Q3**: Simplest 80% version?
- Evidence: derivable — parsers + lifecycle + AWS-only adapter + wheel-only releases (drop forge/pipeline/policy/Azure/GCP/binary/container). Conf: 0.70.
- Challenge -> G-011: the fallback exists and is real, but sponsor scope stands; reduction requires sponsor instruction, not agent initiative.
- **Q4**: What must be true for 90-day success — is it true today?
- Evidence: P0 velocity (51 min, 4 commits); conditions: c7n-left==0.3.39 resolves on target (D-025 verified via PyPI), mocks hold through P4, A8 true or release.sh fallback works. Conf: 0.70.
- Resolved: all conditions have recorded, verified fallbacks; nothing required is currently false.
### Binding Decisions
| ID | Axis | Decision | Confidence |
|-----|------|----------|-----------|
| G-001 | 2 | Scope frozen at sponsor-validated v0.1 (D-002); no growth detected specify->plan; the out-of-scope table binds all later phases | 0.85 |
| G-002 | 2 | "Practically indistinguishable" is a one-directional subset claim; D-010/D-011/D-015 deltas are the binding definition and must be enumerated in README + error messages | 0.75 |
| G-003 | 8 | Phase patches v0.1.0..v0.1.6 marked prerelease=true on gitea; v0.1.7 is the first stable go-live release (D-019 intact; gitea API supports prerelease, RESEARCH.md:321) | 0.70 |
| G-004 | 6/9 | REQ-06 container artifact = shipped Dockerfile + build script guaranteed; image build/push conditional on daemon or runner (A8); image outcome reported in every release summary — never silently absent | 0.60 |
| G-005 | 7 | Go-live ships with mock-only deployment validation (no terraform/cloud in dev env, D-027); first real apply is an explicit documented post-go-live validation step (README + release notes) | 0.60 |
| G-006 | 3 | Terraform stays external (BUSL, D-027/A5); never bundled in binary or container; install-hint error is the tested path; OpenTofu bundling is a post-go-live decision | 0.80 |
| G-007 | 6 | c7n-left==0.3.39 pin holds (D-025) with dot-notation builtin policies; binary bloat (+100-150 MB) accepted | 0.85 |
| G-008 | 8 | Standing stop trigger: 3 failed revision iterations on any task, or a Rethink grill verdict, halts the milestone and returns to the sponsor; release_blocking=false masks release failures only — never gate failures | 0.60 |
| G-009 | 9 | v0.1 is local-state only (WORKDIR state dir); README must document state location, terraform-native recovery, and down -v semantics; remote backends remain post-go-live scope | 0.65 |
| G-010 | 4 | Single-agent key-person risk accepted; the committed artifact trail + stage gates make the pipeline resumable by any agent instance | 0.65 |
| G-011 | Meta | 80% fallback (parsers + lifecycle + AWS-only adapter + wheel releases) recorded as contingency only; activation requires sponsor instruction | 0.70 |
| G-012 | 1 | No financial ROI computed; sponsor attestation + cancellation-impact test substitutes; post-go-live adoption review validates the case | 0.60 |
### Escalations
- None. All 10 challenges were forced to dispositions at >=0.60 confidence per the full-autonomy preference. The weakest bindings (G-004, G-005, G-008 at 0.60) should be re-checked at the P4 and P6 gates: G-004 at the P6 release dry-run, G-005 at the P4 adapter conformance pass, G-008 at every phase review.
+91
View File
@@ -0,0 +1,91 @@
---
# Nova CLI — Persona configuration (produced at end of P0 RESEARCH)
# Format: YAML frontmatter with active, phase_specific, reason fields.
personas:
lead-developer:
domain: coordination
active: true
phase_specific: false
reason: Task decomposition, territory arbitration, and phase coordination
frameworks: [pytest, click, pyyaml]
constraints: [docker-ergonomic-parity, vertical-slices, minimal-dependencies]
territory: ["src/nova/**", "tests/**", "pyproject.toml"]
backend-engineer:
domain: backend
active: true
phase_specific: false
reason: Environment discovery (git-context, forge clients, credentials, pipeline detection) and the IaC adapter + provider engine are backend services
frameworks: [click, pyyaml, urllib, zlib, subprocess]
constraints: [secrets-masked, read-only-introspection, adapter-behind-interface, stdlib-http]
territory: ["src/nova/env/**", "src/nova/iac/**", "src/nova/providers/**", "src/nova/engine/**", "tests/env/**", "tests/iac/**", "tests/providers/**", "tests/engine/**"]
cli-engineer:
domain: backend
active: true
phase_specific: false
reason: Custom persona — CLI-only project; owns the docker-ergonomic command surface, flag layer, and file parsers (UX-first)
frameworks: [click, pyyaml]
constraints: [ux-first, docker-flag-parity, compose-style-errors, minimal-dependencies]
territory: ["src/nova/cli/**", "src/nova/parsers/**", "tests/cli/**", "tests/parsers/**"]
policy-engineer:
domain: backend
active: true
phase_specific: false
reason: Custom persona — owns the vendored c7n/c7n-left policy engine and builtin policy pack (P5)
frameworks: [c7n, c7n-left, subprocess]
constraints: [pinned-vendor-versions, gate-up-unless-skip, dot-notation-resources]
territory: ["src/nova/policy/**", "src/nova/data/policies/**", "tests/policy/**"]
release-engineer:
domain: backend
active: true
phase_specific: false
reason: Custom persona — owns packaging, PyInstaller binary, container image, and gitea release automation (P6)
frameworks: [pyinstaller, setuptools, gitea-api]
constraints: [binary-plus-container-every-release, reproducible-release-script, no-secrets-in-artifacts]
territory: ["scripts/**", "build/**", "Dockerfile", ".gitea/workflows/**", "pyproject.toml", "tests/release/**"]
data-engineer:
domain: data
active: false
phase_specific: false
reason: Deactivated — no persistence layer, ORM, or schema migrations in nova-cli; file parsing is owned by cli-engineer
frameworks: []
constraints: []
territory: []
frontend-engineer:
domain: frontend
active: false
phase_specific: false
reason: Deactivated — no UI components, pages/, or frontend frameworks; nova-cli is a terminal CLI
frameworks: []
constraints: []
territory: []
---
# Nova CLI — Personas
## Roster rationale
- The four default personas were assessed against the actual project shape: a Python src-layout
CLI with no UI and no datastore. `frontend-engineer` is deactivated per the deactivation rule
(no React/Vue/Svelte files, no pages/components dirs, no frontend frameworks). `data-engineer`
is deactivated with cause: there is no persistence layer; compose/Novafile parsing is UX surface
owned by `cli-engineer`.
- Three custom personas were created for domains beyond the default four (activation rule):
`cli-engineer` (command surface + parsers, UX-first), `policy-engineer` (vendored c7n/c7n-left
gating), and `release-engineer` (binary + container release engineering).
- Frameworks were aligned to actual project dependencies (click, PyYAML, pytest; pinned
c7n-left==0.3.39 per RESEARCH.md) rather than static defaults.
- Territories were aligned to the ARCHITECTURE.md module layout (`src/nova/{cli,parsers,env,iac,
providers,engine,policy,data}`); no persona claims paths that do not exist in the plan.
- `pyproject.toml` is shared between `lead-developer` and `release-engineer` (packaging metadata);
conflicts resolve to `release-engineer` ownership for packaging changes, `lead-developer` for
dependency pins — recorded to prevent territory drift.
- No phase-specific personas were needed: P3 credential handling is covered by backend-engineer
under the `secrets-masked` constraint, and verification layers run STRIDE analysis on every
phase anyway.
## Execution order (domain priority)
coordination → backend → custom domains alphabetical (cli-engineer, policy-engineer, release-engineer).
Deactivated personas (data-engineer, frontend-engineer) receive no tasks; they are listed here with
`active: false` and the reason recorded above.
+208
View File
@@ -0,0 +1,208 @@
---
milestone: v0.1
milestone_type: feature
tag_line: "phase N ships v0.1.N; final phase patch v0.1.7 IS the go-live release (D-019)"
must_haves:
- ".venv/bin/python -m pytest -q is green across tests/cli, tests/parsers, tests/env, tests/iac, tests/providers, tests/engine, tests/policy, tests/release"
- "Every REQ-01..REQ-11 success criterion is demonstrated by at least one passing test or verifiable artifact"
- "nova --help, nova config, nova env git, nova policy version, nova version all work against a fixture project"
- "Wheel + dist/nova PyInstaller binary build and pass their smoke tests; scripts/release.sh --dry-run completes"
---
# Nova CLI — Milestone v0.1 Execution Plan
Vertical-slice plan for P1..P6. Each task is one executor pass, ends testable, maps to REQ IDs,
and is owned by one active persona (PERSONAS.md). Test command: `.venv/bin/python -m pytest`.
Task ID format: `P##-##-##` (phase-plan-task). Execution order: coordination -> backend ->
custom alphabetical (cli-engineer, policy-engineer, release-engineer).
## Phase 1 — CLI Skeleton & Docker-Ergonomic Command Surface
- Goal: installable `nova` CLI with the full docker compose lifecycle surface, routing wired, config discovery working
- Requirements: REQ-01, REQ-05
- Depends on: none
- Owner: cli-engineer
### Wave 1
- **P01-01-01** Scaffold Python package + packaging — create `pyproject.toml` (src-layout, `nova = nova.cli:main`, deps click+PyYAML, pytest/mypy config), `src/nova/__init__.py` (`__version__`), `src/nova/cli/__init__.py`, `src/nova/py.typed`. REQ-05. Done: `.venv/bin/pip install -e .` succeeds; `.venv/bin/python -c "import nova; assert nova.__version__"`.
- **P01-01-02** Click command surface — `src/nova/cli/main.py` + `src/nova/cli/commands/`: groups `build, up, down, ps, stop, start, restart, rm, logs, config` + `policy`, `env` groups + `version`; docker-parity options `-f/--file`, `-p/--project-name`, `-d/--detach`, `--env-file`, `--profile`; docker-style help. REQ-01. Done: `pytest tests/cli/test_main.py -q` green (registration, help shape, flag parsing).
- **P01-01-03** Config file discovery — `src/nova/cli/config_files.py`: compose.yaml > compose.yml preference, `-f` override (multi-file accept), `NOVA_COMPOSE_FILE` env, upward directory search; Novafile discovery (`./Novafile`, `-f` override). REQ-01. Done: `pytest tests/cli/test_config_files.py -q` green.
- **P01-01-04** Command behaviors + version — `src/nova/cli/commands/version.py` (package metadata); engine-pending commands validate args, run discovery, and emit precise compose-style errors naming the landing phase (P2 parsers / P4 engine) — deliberate, tested UX. REQ-01. Done: `pytest tests/cli/test_commands.py -q` green.
### Phase must-haves
- `nova --help` lists every lifecycle command with docker-parity flags
- `nova version` prints the package version
- `pytest tests/cli -q` green
## Phase 2 — Novafile + Compose.yml Parsers
- Goal: Novafile parses as a Dockerfile analog; compose.yml loads a compose-indistinguishable schema; `nova config` prints normalized config
- Requirements: REQ-02, REQ-03
- Depends on: P1
- Owner: cli-engineer
### Wave 1
- **P02-01-01** Module reference resolver — `src/nova/parsers/moduleref.py` + `tests/parsers/test_moduleref.py`: parse/normalize `[host/]ns/name/provider[//subdir][@ver]`, registry URLs → shorthand, local `./` `../` absolute; `@ver` sugar → version field (registry-only) per D-023; compose-style errors for 2-segment refs. REQ-02, REQ-04 (grammar basis). Done: `pytest tests/parsers/test_moduleref.py -q` green.
- **P02-01-02** Novafile parser — `src/nova/parsers/novafile.py` + `tests/parsers/test_novafile.py`: case-insensitive instructions, `#` comments, `\` continuations, `ENV k=v`/`ENV k v`, `ARG name[=default]`, `FROM <source> [AS name]`, `LABEL k=v`, `WORKDIR`; BuildKit-style `Novafile:<line>:<col>` errors; container-only instructions (RUN/CMD/ENTRYPOINT/HEALTHCHECK/EXPOSE) warn; COPY/ADD hard error; emits `NovafileModel` (stages, args, env defaults, labels, workdir). REQ-02. Done: `pytest tests/parsers/test_novafile.py -q` green.
### Wave 2
- **P02-01-03** Compose schema + interpolation + models — `src/nova/parsers/compose.py`, `src/nova/parsers/interp.py` (`${VAR}`, `${VAR:-def}`, `${VAR:?err}`, `${VAR:+alt}`, `.env` + `--env-file` loading, precedence shell > env-file > .env), `src/nova/parsers/models.py` (ComposeSpec, ServiceSpec, ProviderConfig dataclasses with validation; keys: name, services.module|build, environment map/list, env_file, depends_on short/long, working_dir, profiles, providers per D-011/D-024) + tests. REQ-03. Done: `pytest tests/parsers/test_compose.py tests/parsers/test_interp.py -q` green.
- **P02-01-04** Wire config/build commands — `src/nova/cli/commands/config.py` prints normalized compose (like `docker compose config`); `src/nova/cli/commands/build.py` parses Novafile → resolved module manifest; both use P1 discovery. Done: `pytest tests/cli/test_config_cmd.py -q` green; fixture compose.yml round-trips through `nova config`.
### Phase must-haves
- `pytest tests/parsers tests/cli -q` green
- `nova config` on a fixture project prints normalized, interpolated compose config
- Novafile parse errors carry `Novafile:<line>:<col>` positions
## Phase 3 — Environment Discovery Layer
- Goal: git-context, forge API clients, credential discovery, pipeline detection with env prefill
- Requirements: REQ-08, REQ-09, REQ-10, REQ-11
- Depends on: P1
- Owner: backend-engineer
### Wave 1
- **P03-01-01** Git context — `src/nova/env/git.py` + `tests/env/test_git.py`: pure-python `.git` parse (config INI, HEAD, refs/heads, packed-refs, loose zlib commit objects), `git` subprocess fallback for packed objects, repo detection walking up, auto-init with visible notice (D-021); GitContext (is_repo, remotes, branch, commit sha/message). REQ-09. Done: `pytest tests/env/test_git.py -q` green against fixture repos (real `git init` fixture + synthetic loose-object fixture).
- **P03-01-02** Credential discovery — `src/nova/env/credentials.py` + `tests/env/test_credentials.py`: gh `~/.config/gh/hosts.yml`, glab `~/.config/glab-cli/config.yml`, AWS `~/.aws/credentials` + `AWS_*` env, Azure `ARM_*`/`AZURE_*` env + profile existence, GCP gcloud ADC + `GOOGLE_*` env (D-013); CredentialReport with found/missing per source, values masked. REQ-10. Done: `pytest tests/env/test_credentials.py -q` green with monkeypatched HOME/env; output never contains raw secret values.
### Wave 2
- **P03-01-03** Pipeline detection + prefill — `src/nova/env/pipeline.py` + `tests/env/test_pipeline.py`: detect GITHUB_ACTIONS / GITEA_ACTIONS / GITLAB_CI; PrefillContext (repo slug, ref, sha, server URL, API URL, token hint, platform) per D-014/D-026; prefill values feed interpolation. REQ-11. Done: `pytest tests/env/test_pipeline.py -q` green.
- **P03-01-04** Forge clients — `src/nova/env/forge.py` + `tests/env/test_forge.py`: stdlib HTTP; GitHub (api.github.com, Bearer, `per_page`), Gitea (`<host>/api/v1`, `token` auth, `limit`, commit.id→sha normalization); read surface: get_repo, get_branch, list_commits, list_releases/latest_release (D-022). REQ-08. Done: `pytest tests/env/test_forge.py -q` green with mocked urlopen.
- **P03-01-05** `nova env` command group — `src/nova/cli/commands/env.py` + `tests/cli/test_env_cmd.py`: subcommands git (status/remotes/commit + `env git init`), credentials (masked report), pipeline (detected context), forge (connectivity/auth probe). REQ-09, REQ-10, REQ-11 surfaces. Done: `pytest tests/cli/test_env_cmd.py -q` green; `nova env git` in the nova-cli repo prints the origin remote.
### Phase must-haves
- `pytest tests/env tests/cli -q` green
- `nova env credentials` masks all secret values
- `nova env git` works on the nova-cli repository itself
## Phase 4 — IaC Adapter Interface + Terraform Adapter + Provider Engine
- Goal: modules deploy to AWS/Azure/GCP through compose-like lifecycle commands behind a pluggable adapter
- Requirements: REQ-04
- Depends on: P2, P3
- Owner: backend-engineer
### Wave 1
- **P04-01-01** Adapter interface — `src/nova/iac/adapter.py` + `tests/iac/test_adapter.py`: `IaCAdapter` protocol (`init, plan, apply, destroy, show`), `BackendContext` dataclass, conformance test harness (future IaC types implement it without redesign, D-008). Done: `pytest tests/iac/test_adapter.py -q` green (NullAdapter conformance).
- **P04-01-02** Provider engine — `src/nova/providers/{base,aws,azure,gcp}.py` + `tests/providers/test_providers.py`: compose `providers:` + CredentialReport → `required_providers` + provider blocks; aws→hashicorp/aws, azure→hashicorp/azurerm, gcp→hashicorp/google with credential env mapping (ARCHITECTURE.md table). REQ-04. Done: `pytest tests/providers/test_providers.py -q` green.
### Wave 2
- **P04-01-03** Terraform adapter — `src/nova/iac/terraform.py` + `tests/iac/test_terraform.py`: main.tf generation (terraform block, required_providers, provider configs, module blocks with source/version from ModuleRef), subprocess invocation (`init -input=false`, `plan -detailed-exitcode -out=`, `apply [-auto-approve]`, `destroy -auto-approve`, `show -json`), missing-binary error with install hint (D-027), JSON output parsing. REQ-04. Done: `pytest tests/iac/test_terraform.py -q` green (argv/cwd/env assertions + generated tf content with mocked subprocess).
- **P04-01-04** Deployment engine — `src/nova/engine/lifecycle.py` + `tests/engine/test_lifecycle.py`: depends_on topological order; `up` = init+plan+apply (approval unless `-d`; policy-gate hook point), `down` = reverse destroy (`-v` purges state dir), `ps` = aggregate `show -json` outputs, `logs` = apply log tail, `stop/start/restart/rm` = explicit not-applicable errors (D-015); project naming `-p` precedence. REQ-04. Done: `pytest tests/engine/test_lifecycle.py -q` green with a fake adapter.
### Wave 3
- **P04-01-05** Wire lifecycle commands — `src/nova/cli/commands/{up,down,ps,logs,stop,start,restart,rm}.py`: replace P1 pending errors with engine calls. Done: `pytest tests/cli -q` green; end-to-end: fixture compose.yml + mocked terraform → `nova up -d` succeeds and `nova ps` lists the service.
### Phase must-haves
- `pytest tests/iac tests/providers tests/engine tests/cli -q` green
- TerraformAdapter passes the adapter conformance harness
- End-to-end `up`/`ps`/`down` happy path with mocked terraform binary
## Phase 5 — Vendored c7n + c7n-left Policy Checks
- Goal: policy checks gate deployments from engines vendored into the CLI
- Requirements: REQ-07
- Depends on: P4
- Owner: policy-engineer
### Wave 1
- **P05-01-01** Policy engine — pin `c7n-left==0.3.39` (propagates `c7n==0.9.53`, D-025) in `pyproject.toml`; install into venv; `src/nova/policy/engine.py` + `tests/policy/test_engine.py`: policy pack resolution (user `.nova/policies/` > builtin `src/nova/data/policies/`), subprocess `c7n-left run -p <policies> -d <tf-dir> --output json`, violation parsing → PolicyResult. REQ-07. Done: `pytest tests/policy/test_engine.py -q` green; `.venv/bin/c7n-left --help` exits 0.
- **P05-01-02** Builtin pack + commands — `src/nova/policy/pack.py`, `src/nova/data/policies/*.yaml` (dot notation `terraform.<type>` per D-025; e.g. required tags, no unencrypted storage), `src/nova/cli/commands/policy.py` + `tests/policy/test_pack.py`: `nova policy check [target]`, `nova policy version` (reports vendored c7n/c7n-left versions). REQ-07. Done: `pytest tests/policy -q` green with mocked subprocess.
### Wave 2
- **P05-01-03** Deployment gate — `src/nova/engine/lifecycle.py` update + `tests/policy/test_gate.py`: policy check runs between plan and apply; violations block `up` with compose-style error; `--skip-policy` bypasses. REQ-07. Done: `pytest tests/policy/test_gate.py -q` green.
### Phase must-haves
- c7n + c7n-left vendored as pinned dependencies, importable in the venv
- `pytest tests/policy tests/engine -q` green
- policy gate blocks `up` on violations; `--skip-policy` proceeds
## Phase 6 — Release Engineering & Go-Live Prep
- Goal: every release ships the CLI binary plus the CLI in a container, reproducibly
- Requirements: REQ-06
- Depends on: P1, P2, P3, P4, P5
- Owner: release-engineer
### Wave 1
- **P06-01-01** Packaging finalize + wheel — `pyproject.toml` (include policy pack + py.typed as package data), `scripts/build.sh` (clean + build sdist/wheel); verify the wheel installs into a scratch venv and `nova version` runs from it. REQ-06. Done: build succeeds; scratch-venv smoke passes.
- **P06-01-02** PyInstaller binary — `build/nova.spec` + `scripts/build-binary.sh` → `dist/nova` one-file linux binary; smoke `dist/nova version`. REQ-06. Done: script completes; `dist/nova version` prints the version.
### Wave 2
- **P06-01-03** Container image definition — `Dockerfile` (multi-stage: builder installs wheel, runtime `python:3.11-slim` + nova, `ENTRYPOINT ["nova"]`), `scripts/docker-build.sh` (image tar when docker available), `tests/release/test_dockerfile.py` (structure/parse assertions — no docker daemon in dev env). REQ-06. Done: `pytest tests/release/test_dockerfile.py -q` green.
- **P06-01-04** Release pipeline — `scripts/release.sh`: pytest → wheel → binary → docker (if available) → gitea release create + asset upload via API (GITEA_TOKEN resolved only from `.ciagent/.env.secrets` / `.env*` files, bounded retries per D-020); `.gitea/workflows/release.yml` (on tag push, same pipeline); `tests/release/test_release_script.py` (dry-run assertions + YAML parse). REQ-06. Done: `pytest tests/release -q` green; `scripts/release.sh --dry-run` prints planned steps.
- **P06-01-05** README quickstart — `README.md`: install from release, Novafile + compose.yml example, `nova build/config/env credentials/up -d/ps/down -v` walkthrough, artifact inventory. REQ-01 surface. Done: quickstart commands verified against `nova --help`.
### Phase must-haves
- wheel + `dist/nova` binary build and pass smoke tests
- `scripts/release.sh --dry-run` completes end-to-end
- `.gitea/workflows/release.yml` parses as YAML; README quickstart present
## Final Phase (P7)
Orchestrated by the run workflow — no implementation tasks: multi-persona code review across the
milestone, project health audit, P0/P1 fixes in-phase, then milestone ship: merge phase/07 →
milestone/v0.1-go-live → main, tag `v0.1.7` (the go-live release per D-019), create the gitea
release with full milestone summary + artifacts, delete all milestone/phase branches, mark
REQ-01..REQ-11 complete in REQUIREMENTS.md, mark the milestone complete in ROADMAP.md.
## User-Facing Surface
The milestone's user-facing surface is the `nova` CLI (docker-ergonomic):
- Lifecycle commands mirroring docker compose: `nova build`, `nova up [-d]`, `nova down [-v]`,
`nova ps`, `nova stop`, `nova start`, `nova restart`, `nova rm`, `nova logs`, `nova config`
- Docker-parity flags: `-f/--file`, `-p/--project-name`, `-d/--detach`, `--env-file`, `--profile`
- Discovery: `nova env git [init]`, `nova env credentials`, `nova env pipeline`, `nova env forge`
- Policy: `nova policy check`, `nova policy version`; gate flag `--skip-policy` on `up`
- Meta: `nova version`, `nova --help` (docker-style help layout)
- Files: `Novafile` (Dockerfile analog), `compose.yml` (compose-indistinguishable), `.env`
- README quickstart documenting install + first deploy; shipped release artifacts: wheel, one-file
linux binary, container image definition + build script
## Happy Path
Written BEFORE execution; exercised end-to-end (terraform mocked) in P4/P5 tests:
1. `mkdir demo && cd demo` — developer writes `Novafile` (`FROM terraform-aws-modules/vpc/aws@5.0.0`)
and `compose.yml` (`services: vpc: {module: ./, environment: {name: demo}, provider: aws}`,
`providers: aws: {region: us-east-1}`)
2. `nova build` → parses Novafile, resolves the registry module ref, prints the module manifest
3. `nova config` → prints the normalized, interpolated compose config (like `docker compose config`)
4. `nova env credentials` → reports gh/glab/cloud credential sources, values masked
5. `nova up -d` → discovers compose.yml, orders services, generates Terraform (provider aws +
module block), runs policy check (c7n-left), then init + plan + apply through the Terraform
adapter; detached mode auto-approves; success message lists deployed services
6. `nova ps` → lists deployed services with outputs from `terraform show -json`
7. `nova logs <service>` → tails the adapter's apply log
8. `nova down -v` → destroys in reverse dependency order, purges state directory
Observable outcomes: each command prints docker-style progress and errors; a missing terraform
binary yields a precise install-hint error; policy violations block `up` unless `--skip-policy`.
## UX Acceptance Criteria
- `nova --help` and per-command help mirror docker compose structure and terminology
- Flags match docker parity: `-f`, `-p`, `-d`, `--env-file`, `--profile`, `-v` on down
- File errors are compose-style and name the file + reason (e.g. missing compose.yml, bad
Novafile line with `Novafile:<line>:<col>`)
- `nova config` output is valid, normalized YAML a docker compose user can read at a glance
- No secret value is ever printed: `nova env credentials` masks all discovered tokens/keys
- Missing external prerequisites (terraform, c7n-left) produce actionable errors with install
hints, never stack traces
- Command failures exit non-zero with concise docker-style messages; success output is terse
## Traceability
| REQ | Phase | Tasks |
|-----|-------|-------|
| REQ-01 | P1 (surface), P2 (config), P4 (wiring), P6 (README) | P01-01-02, P01-01-03, P01-01-04, P02-01-04, P04-01-05, P06-01-05 |
| REQ-02 | P2 | P02-01-01, P02-01-02, P02-01-04 |
| REQ-03 | P2 | P02-01-03, P02-01-04 |
| REQ-04 | P4 | P04-01-01, P04-01-02, P04-01-03, P04-01-04, P04-01-05 |
| REQ-05 | P1 | P01-01-01 (also every phase's pyproject/test discipline) |
| REQ-06 | P6 | P06-01-01, P06-01-02, P06-01-03, P06-01-04 |
| REQ-07 | P5 | P05-01-01, P05-01-02, P05-01-03 |
| REQ-08 | P3 | P03-01-04, P03-01-05 |
| REQ-09 | P3 | P03-01-01, P03-01-05 |
| REQ-10 | P3 | P03-01-02, P03-01-05 |
| REQ-11 | P3 | P03-01-03, P03-01-05 |
+69
View File
@@ -0,0 +1,69 @@
# Nova CLI
## Vision
Nova CLI brings Docker and Docker Compose ergonomics to infrastructure deployment. Instead of deploying containers, nova deploys infrastructure modules — referenced by Terraform module registry paths — to cloud providers (AWS, Azure, GCP). The Novafile (the Dockerfile analog) and compose.yml are practically the same and almost indistinguishable from their Docker counterparts, so developers use the mental model, file formats, and commands they already know.
## Core Value
Infrastructure deployment with zero new ergonomics: nova commands mirror their docker equivalents, but the unit of deployment is a Terraform module, not a container.
## Requirements
### Validated
- Complete adoption of Docker and Docker Compose ergonomics (commands, files, workflows)
- Novafile and compose.yml practically the same and almost indistinguishable from Docker equivalents, with Terraform module registry paths instead of containers
- Deploy infrastructure modules to AWS, Azure, GCP
- Native GitHub and Gitea API support
- Local .git config support: detect whether in a git repo (create one if not); discover remotes and commits
- Credential discovery: gh / glab CLI stored credentials; AWS / Azure / GCP stored credentials or env variables
- Pipeline detection (e.g., GitHub Actions): retrieve env / rely on env to pre-fill missing data for infrastructure modules
- Vendored c7n + c7n-left into the CLI for policy checks
- Python implementation
- Every release includes the CLI binary plus the CLI deployed in a container
### Active
The v0.1 scope is clarified (D-009..D-022). Refined scope:
- Command surface mirrors the docker compose core lifecycle set: `build`, `up`, `down`, `ps`, `stop`, `start`, `restart`, `rm`, `logs`, `config`, plus `nova policy` (REQ-07), `nova env` (REQ-08..11 discovery), and `nova version` — with docker-parity flags (`-f/--file`, `-p/--project-name`, `-d/--detach`, `--env-file`, `--profile`). `docker run/exec/cp` semantics are deferred: containers are not the unit of deployment (D-009)
- Novafile supports a Dockerfile-compatible instruction subset: `FROM` (module source, `AS` alias), `ARG`, `ENV` (module input defaults), `LABEL`, `WORKDIR` (state directory), comments, line continuations; container-only instructions (`RUN`, `CMD`, `ENTRYPOINT`, `HEALTHCHECK`, `EXPOSE`) are accepted with warnings; `COPY`/`ADD` are hard errors (D-010)
- compose.yml keeps docker compose shape: top-level `name` and `services`, each service with `module` (replaces `image`) or `build` (Novafile), `environment` (module input vars), `env_file`, `depends_on`, `workdir`, `profiles`; top-level `providers` block (aws/azure/gcp) is the minimal extension; `${VAR}` / `${VAR:-default}` interpolation; `vars` accepted as documented alias of `environment` (D-011)
- Module references: local paths (`./`, `../`, absolute) and Terraform registry paths — shorthand `namespace/module/provider[//subpath][@version]` and full registry URLs — with version pinning via `version:` (D-012)
- Lifecycle semantics: `up` = init + plan + apply (interactive approval unless `-d`), `down` = destroy (`-v` purges state), `ps` = deployment/state listing, `build` = Novafile → resolved module manifest; `stop`/`start`/`restart` return explicit "not applicable to infrastructure modules" errors in v0.1 (D-015)
- Credential discovery is file + environment based only (gh/glab config files; AWS credentials file/env; Azure env/profile; GCP gcloud ADC/env); no OS keychain; secret values are never printed (D-013)
- Pipeline detection covers GitHub Actions, Gitea Actions, GitLab CI; CI env pre-fills forge context (repo, ref, sha, API base, token) (D-014)
- Native GitHub + Gitea API clients (stdlib HTTP) with read surface: repo info, branches, commits, releases (D-022)
- Git-context: pure-python `.git` parsing (config, HEAD, refs, commit objects) with `git` subprocess fallback; repo auto-creation only when a command requires git context and none exists, with visible notice (D-021)
- c7n + c7n-left vendored as pinned Python dependencies of the CLI; `nova policy check` gates deployments (skippable via `--skip-policy`) (D-018)
- Python 3.10+ (3.11 dev), click + PyYAML, pyproject packaging, console entry point `nova` (D-016)
- Every release ships: wheel + sdist + PyInstaller one-file binary (linux amd64) + container image built from a Dockerfile; distributed via Gitea releases (D-017)
- Tag line: all phases ship patches on `v0.1.x`; the final phase patch IS the go-live release (D-019, per D-002 "remain on this minor until go-live")
### Out of Scope
- Full infrastructure lifecycle support — known gaps are handled in a future milestone after go-live
- Non-Terraform IaC adapters — the interface/adapter pattern ships in v0.1; additional IaC types (e.g., Pulumi) are future work
## Constraints
- Code must be Python
- Novafile and compose.yml must remain practically the same and almost indistinguishable from Docker Compose
- The IaC engine must follow an interface/adapter pattern — Terraform is the first adapter; other IaC types must be pluggable later without redesign
- First milestone is v0.1 and the project remains on this minor until go-live
- Releases must always include the CLI binary and the CLI deployed in a container
## Context
nova-cli is the command-line interface for nova-platform. Hosted on gitea at git.coreci.dev (owner: coreci, repo: nova-cli). Milestone v0.1-go-live is the first milestone; phase 0 (pre-execution) captures the specify -> clarify -> research -> plan -> grill pipeline artifacts. Obvious gaps in full infrastructure lifecycle support are intentionally deferred to a post-go-live milestone.
## Key Decisions
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| Milestone v0.1, remain on minor until go-live | User-specified versioning strategy | Recorded in config.json; branch milestone/v0.1-go-live; tag line v0.1.x (D-002, D-019) |
| IaC interface/adapter pattern, Terraform first | Begin with Terraform, other IaC types later, without redesign | ARCHITECTURE.md defines the adapter boundary |
| Docker-ergonomic parity over lifecycle completeness | Known lifecycle gaps deferred post-go-live | Scope constraint for v0.1 |
| Environment discovery layer | git-context, forge APIs, credentials, pipeline env prefill as one subsystem | Phase 3 of the roadmap |
| Ship per phase, release best-effort (D-020) | config.ship: per_phase=true, allow_skip=false, max_release_retries=3, release_blocking=false | Pipeline ships v0.1.0..v0.1.7 without halting on release failure |
+67
View File
@@ -0,0 +1,67 @@
# Nova CLI — Requirements
## v1
### Ergonomics & Formats
- REQ-01: Completely adopt Docker and Docker Compose ergonomics — command structure and UX practically indistinguishable from `docker` / `docker compose`
- Success criteria: `nova` exposes the docker compose core lifecycle set (build, up, down, ps, stop, start, restart, rm, logs, config) with docker-parity flags (`-f`, `-p`, `-d`, `--env-file`, `--profile`); `--help` output mirrors docker compose help structure; config discovery follows compose rules (cwd, `-f` override, `NOVA_COMPOSE_FILE`, upward search)
- REQ-02: Novafile (the Dockerfile analog) — practically the same as a Dockerfile, except Terraform module registry paths replace container images
- Success criteria: Dockerfile-compatible line grammar (case-insensitive instructions, `#` comments, `\` continuations); FROM/ARG/ENV/LABEL/WORKDIR supported; registry paths/local paths in FROM; Dockerfile-style error messages with line numbers; container-only instructions warn, COPY/ADD error
- REQ-03: compose.yml — practically the same and almost indistinguishable from docker compose, deploying infrastructure modules
- Success criteria: YAML loads via docker-compose-shaped schema (name, services with `module`|`build`, environment, env_file, depends_on, workdir, profiles; providers block); `${VAR}`/`${VAR:-default}` interpolation; schema violations produce compose-style errors; `nova config` prints the normalized config like `docker compose config`
### Deployment
- REQ-04: Deploy infrastructure modules to cloud providers: AWS, Azure, GCP
- Success criteria: IaC adapter interface ships with Terraform as first adapter; provider engine generates provider-correct Terraform (aws, azurerm, google) from compose `providers` config + discovered credentials; `up/down/ps` run init/plan/apply/destroy/show through the adapter in dependency order; future IaC types implement the same interface without redesign
### Environment Discovery
- REQ-08: Native support for GitHub and Gitea APIs
- Success criteria: stdlib HTTP clients for GitHub (api.github.com) and Gitea (<host>/api/v1) with token auth from discovered credentials; read surface: repo info, branch, commits, releases; `nova env forge` reports connectivity/auth
- REQ-09: Local .git config support — determine whether the working directory is a git repo (create one if not); discover remotes and commits from the configured .git
- Success criteria: pure-python .git parsing (config remotes, HEAD, branch, commit hash/message) with `git` subprocess fallback; `nova env git` prints repo status, remotes, current commit; repo auto-created with visible notice when a command requires git context and none exists
- REQ-10: Credential discovery — gh and glab CLI stored credentials; AWS, Azure, GCP stored credentials or env variables
- Success criteria: discovers gh `hosts.yml` and glab `config.yml` tokens; AWS `~/.aws/credentials` + `AWS_*` env; Azure env + `~/.azure` profile; GCP gcloud ADC file + `GOOGLE_APPLICATION_CREDENTIALS`/env; `nova env credentials` reports sources as found/missing with masked values
- REQ-11: Pipeline detection — discover whether running in a CI pipeline (e.g., GitHub Actions); retrieve env / rely on env to pre-fill missing data for infrastructure modules
- Success criteria: detects GITHUB_ACTIONS, GITEA_ACTIONS, GITLAB_CI; extracts repo slug, ref, sha, server/API URLs, token into a prefill context consumed by compose interpolation and forge clients; `nova env pipeline` prints the detected context
### Implementation & Release
- REQ-05: Code must be Python
- Success criteria: pure Python 3.10+ implementation (3.11 dev environment); pyproject.toml packaging with `nova` console entry point; minimal deps (click, PyYAML); pytest suite green
- REQ-06: Every release includes the CLI binary plus the CLI deployed in a container
- Success criteria: release pipeline produces wheel + sdist + PyInstaller one-file linux binary + Dockerfile-built container image; smoke test runs the built binary (`nova version`); artifacts attach to the Gitea release; pipeline reproducible via `scripts/release.sh` and `.gitea/workflows/release.yml`
- REQ-07: Vendor c7n + c7n-left into the CLI for policy checks
- Success criteria: c7n and c7n-left ship as pinned dependencies of the nova package; `nova policy check` runs c7n-left against generated Terraform; policy violations block `up` unless `--skip-policy`; `nova policy version` reports vendored versions; builtin default policy pack ships with the package
## v2
- Additional IaC adapters beyond Terraform (e.g., Pulumi) via the IaC adapter interface
- Full infrastructure lifecycle gap-filling (post-go-live milestone)
- OS keychain credential discovery; expanded CI platform detection
## Out of Scope
| Feature | Reason |
|---------|--------|
| Full infrastructure lifecycle support | Deferred to a future milestone after go-live (user-specified) |
| Non-Terraform IaC adapters | The adapter interface ships in v0.1; other IaC types are future work |
| `docker run/exec/cp` command analogs | Container semantics do not map to infrastructure modules in v0.1 (D-009) |
## Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| 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 | complete |
| REQ-06 | P6 | pending |
| REQ-07 | P5 | pending |
| REQ-08 | P3 | pending |
| REQ-09 | P3 | pending |
| REQ-10 | P3 | pending |
| REQ-11 | P3 | pending |
+351
View File
@@ -0,0 +1,351 @@
# Nova CLI — Phase 0 Domain Research
Conclusion-oriented research for the v0.1 implementation; key external facts verified against
official sources (HashiCorp, Docker/compose-spec, gh/glab/act_runner source, Gitea docs, PyPI).
Verification date: 2026-10-14.
## 1. Terraform module source grammar
Facts (verified: developer.hashicorp.com/terraform/language/block/module):
- Public registry shorthand: `<namespace>/<name>/<provider>` — exactly 3 segments, e.g.
`terraform-aws-modules/vpc/aws`. 2 or 5 segments is an error.
- Private registry: `<hostname>/<namespace>/<name>/<provider>` (4 segments), e.g.
`app.terraform.io/example-corp/k8s-cluster/azurerm` (HCP Terraform) or any self-hosted registry
hostname. `localterraform.com` is a generic TFE hostname.
- Local paths: MUST start with `./` or `../` (or be absolute: `/` or drive letter; absolute is
discouraged). Local sources do not support `version`.
- Submodules: append `//subdir` to any package source, e.g. `hashicorp/consul/aws//modules/consul-cluster`,
`git::https://example.com/network.git//modules/vpc`. Query params go AFTER the subdir:
`git::...git//modules/vpc?ref=v1.2.0`.
- Version: a separate `version` argument in the module block (constraint string, registry sources
only): `version = "~> 6.0"`, `">= 1.2"`, `"1.2.3"`. There is NO `@version` suffix in Terraform's
grammar — `@x.y.z` is nova sugar that must be translated to the `version` field.
- Other source schemes nova should recognize but may defer: `git::` (+`?ref=`), `github.com/...`,
`bitbucket.org/...`, `git@github.com:...`, `s3::`, `gcs::`, `hg::`, https archive URLs.
- Provider source strings: `hashicorp/aws`, `hashicorp/azurerm`, `hashicorp/google`
(`<namespace>/<type>` in required_providers; fully qualified `registry.terraform.io/hashicorp/aws`).
Pitfalls:
- A 2-segment reference (`hashicorp/vpc`) is invalid in Terraform — reject with a clear error.
- `//` (double slash) selects a subdir; a single `/` does not. Naive path splitting breaks.
- `version` is only valid for registry sources; local/git sources ignore it (git uses `?ref=`).
- Full registry URLs (`registry.terraform.io/...`, `registry.terraform.io/modules/...`) are web URLs,
not source grammar — nova must normalize them to shorthand before emitting module blocks.
v0.1 implication: a ModuleRef resolver accepting shorthand `[host/]ns/name/provider[//subdir][@ver]`,
registry URLs (normalized to shorthand), and local `./` `../` absolute paths; emit Terraform module
blocks with `source` + `version` (registry only); map providers aws→hashicorp/aws,
azure→hashicorp/azurerm, gcp→hashicorp/google.
## 2. Dockerfile grammar essentials
Facts (docs.docker.com/reference/dockerfile):
- Instruction keywords are case-insensitive (conventionally uppercase). Leading whitespace on a
line is stripped before keyword matching.
- Comments: lines whose first non-whitespace char is `#`. No inline comments. Parser directives
(`# syntax=`, `# escape=`) are `#`-lines that must precede all instructions.
- Line continuations: `\` at end of line joins the next line. `\` inside double-quoted values is an
escape. (A `# escape=` directive can change the escape char — treat non-default as warning in v0.1.)
- `ENV key value` (legacy): exactly ONE pair; value is everything after the first space, inner
whitespace preserved, no quote processing. `ENV key=value [k2=v2 ...]`: multiple pairs, quotes and
`\` escapes supported.
- `ARG name[=default]` — no default means must be provided at build time. ARG before the first FROM
is only usable by FROM lines; ARG after FROM is stage-scoped.
- `FROM [--platform=<p>] <image> [AS <name>]`; image may be `name:tag` or `name@digest`; a later
`FROM <stage-alias>` reuses a previously aliased stage.
- `LABEL k=v [k2=v2 ...]` (equals form only). `WORKDIR /path` — relative paths accumulate on the
previous WORKDIR; supports variable interpolation.
- First instruction must be FROM (ARG allowed before it). Everything else is an error.
- Error style: BuildKit (current docker) reports `Dockerfile:<line>:<col>: parser: <message>` with a
source code-frame, e.g. `Dockerfile:4:1: parser: unknown instruction: FOO (did you mean FROM?)`.
Classic builder used `Dockerfile parse error line <N>: <message>`.
Pitfalls: the ENV legacy form's "value is the whole remainder" rule trips naive `split(' ')`
parsers; resolve continuations before tokenizing (a `#` line following a `\` is instruction data,
not a comment); track physical line numbers for messages.
v0.1 implication: a line-oriented parser — strip comments, join continuations, match the keyword
case-insensitively, keep 1-based line numbers, produce BuildKit-style errors
(`Novafile:<line>:<col>: ...`); ENV/ARG/LABEL/FROM/WORKDIR supported, container-only instructions
warn, COPY/ADD hard-error (D-010).
## 3. Compose file essentials for schema parity
Facts (verified against compose-spec schema + docs.docker.com):
- Top-level keys (compose-spec, verified): `name`, `services`, `include`, `volumes`, `networks`,
`configs`, `secrets` (plus newer `models`/`jobs`; `version` is obsolete — accept with warning).
**`profiles` is NOT top-level** — it is a service-level key only.
- Service keys (verified schema shapes): `image` (string) — nova's analog is `module`;
`build` (string context path or map {context, dockerfile, args}) — nova points at a Novafile dir;
`environment` (list `["K=V", "K"]` OR map `{K: V, K2: null}`; null/no value = resolve from env);
`env_file` (single string or list); `depends_on` — short form list of service names, long form map
`{svc: {condition: service_started|service_healthy|service_completed_successfully, required: bool
(default true), restart: bool|string}}`; `working_dir` (string); `profiles` (list; no-profile
services always active, services activate when ANY of their profiles is enabled).
- Interpolation (verified): `$VAR`, `${VAR}`, `${VAR:-default}` (default when unset OR empty),
`${VAR-default}` (unset only), `${VAR:?err}` / `${VAR?err}` (error when unset/empty),
`${VAR:+alt}` / `${VAR+alt}`. Applied to unquoted and double-quoted YAML scalars; single-quoted
are literal; unset vars interpolate to empty string.
- Interpolation source precedence (verified): shell env > `--env-file` files > default `.env` in the
project directory. `.env` syntax: `KEY=VAL`, `#` comments, single/double quotes (`'...'` literal),
escapes (`\n`, `\"`), inline comments after space; `=` or `:` delimiters.
- Config discovery (verified): without `-f`, Compose searches the working directory and traverses
parent directories looking for `compose.yaml` (preference: compose.yaml > compose.yml >
docker-compose.yaml > docker-compose.yml). `COMPOSE_FILE` env var = `-f` (multiple paths separated
by `COMPOSE_PATH_SEPARATOR`, default `:`). `--env-file` overrides the default `.env`.
- Project name precedence (verified): `-p` flag > `COMPOSE_PROJECT_NAME` > top-level `name:` >
basename of project directory > basename of cwd. Name charset: lowercase letters, digits, `-`,
`_`, must start with lowercase letter or digit.
- `docker compose config`: parses, validates, resolves interpolation, merges multiple `-f` files
(later files override/add), prints normalized canonical YAML. Flags: `--services`, `--profiles`,
`--format json`, `--no-interpolate`, `--environment`.
Pitfalls: `${VAR-default}` vs `${VAR:-default}` differ on empty strings — a common bug; multiple
`-f` files merge recursively, later wins; `depends_on.required: false` marks optional deps and
`service_completed_successfully` can gate sequential apply.
v0.1 implication: mirror compose discovery (upward search, `NOVA_COMPOSE_FILE` env, `-f` merge),
service-level profiles, full `${...}` operator set, and `nova config` printing the normalized model;
the only schema extensions are `module:` (instead of image) and the top-level `providers:` block.
## 4. Docker CLI command/flag ergonomics
Facts (verified: docs.docker.com/reference/cli/docker/compose):
- Nova's command set (D-009) matches real compose subcommands: build, up, down, ps, stop, start,
restart, rm, logs, config — all exist in docker compose v2. Nova adds `policy`, `env`, `version`
as top-level groups (docker has `docker compose version`).
- Top-level flags (before the subcommand, repeatable where noted): `-f/--file` (repeatable),
`-p/--project-name`, `--profile` (repeatable), `--env-file` (repeatable), `--project-directory`,
`--ansi`, `--parallel`, `--dry-run`.
- Subcommand flags worth mirroring: `up`: `-d/--detach`, `--build`, `--no-deps`, `--wait`,
`--force-recreate`, `--pull`; `down`: `-v/--volumes`, `--remove-orphans`, `-t`; `ps`: `-a`,
`--format (table|json)`; `logs`: `-f/--follow`, `--tail N`, `--since`, `--no-log-prefix`;
`rm`: `-f`, `-s/--stop`, `-v`; `build`: `--pull`, `--no-cache`, `--build-arg`; `config`:
`--services`, `--profiles`, `--format json`; `restart`/`stop`/`start`: `-t/--timeout`.
- Env var equivalents (verified): `COMPOSE_FILE`→`-f`, `COMPOSE_PROJECT_NAME`→`-p`,
`COMPOSE_PROFILES`→`--profile`, `COMPOSE_PARALLEL_LIMIT`. Nova analogs: `NOVA_COMPOSE_FILE`,
`NOVA_PROJECT_NAME`, `NOVA_PROFILES` (REQ-01 names NOVA_COMPOSE_FILE).
- Help style: `Usage: docker compose [OPTIONS] COMMAND [ARGS]` with Description/Options/Commands
sections, two-column command list. Click reproduces this layout with little tuning.
Pitfalls: `-v` is per-subcommand (down: volumes) — do NOT hoist it to nova's top level (docker's
top-level `-v` means version); docker compose v2 accepts `-f/-p` both before and after the
subcommand — mirror with group-level options.
v0.1 implication: a click group `nova` with group-level `-f/-p/--profile/--env-file` and
subcommand-level docker-parity flags; `--help` output mirrors docker compose structure.
## 5. Credential discovery formats
Facts (gh/glab verified from source; cloud providers verified from provider docs):
- gh CLI: `~/.config/gh/hosts.yml` (override: `GH_CONFIG_DIR`/`XDG_CONFIG_HOME`). YAML structure:
`hosts.<host>.oauth_token`, `hosts.<host>.user`, `hosts.<host>.users.<user>.oauth_token`
(verified in gh internal/config). Token precedence: `GH_TOKEN`/`GITHUB_TOKEN` (per-host:
`GH_ENTERPRISE_TOKEN`) > hosts.yml > OS keyring (out of scope, D-013; keyring-managed tokens are
absent from hosts.yml — report "stored in keyring, unreadable", not "missing").
- glab: `~/.config/glab-cli/config.yml` (override `GLAB_CONFIG_DIR`, then XDG; verified in glab
internal/config). Structure: top-level `hosts:` map; per-host keys `token` (aliases
`gitlab_token`/`oauth_token`), `job_token`, `user`, `api_host`, `use_keyring`. Env
`GITLAB_TOKEN`/`GITLAB_ACCESS_TOKEN` beats the file; `use_keyring: true` means keyring (report like gh).
- AWS: `~/.aws/credentials` INI with bare section headers (`[default]`, `[profile-name]`); keys
`aws_access_key_id`, `aws_secret_access_key`, `aws_session_token`. `~/.aws/config` uses
`[profile name]` sections (`region`). Path overrides: `AWS_SHARED_CREDENTIALS_FILE`,
`AWS_CONFIG_FILE`. Env: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`,
`AWS_PROFILE`, `AWS_REGION`/`AWS_DEFAULT_REGION`. The Terraform AWS provider honors the standard
chain: env > profile file > instance role. env credentials beat `AWS_PROFILE`.
- Azure (azurerm provider, verified): env `ARM_SUBSCRIPTION_ID`, `ARM_TENANT_ID`, `ARM_CLIENT_ID`,
`ARM_CLIENT_SECRET`, plus `ARM_USE_CLI/MSI/OIDC`, `ARM_ENVIRONMENT` and cert/oidc variants. az
profile `~/.azure/azureProfile.json` = best-effort non-secret context; MSAL token cache
`~/.azure/msal_token_cache.json` is encrypted in newer az — do not parse (ARM_* env is the secret source).
- GCP (google provider, verified): `GOOGLE_APPLICATION_CREDENTIALS` (path to SA key JSON or ADC
file), `GOOGLE_CREDENTIALS` (key content), `GOOGLE_PROJECT`/`GOOGLE_CLOUD_PROJECT`,
`GOOGLE_REGION`, `GOOGLE_ZONE`, `GOOGLE_OAUTH_ACCESS_TOKEN`. gcloud ADC:
`~/.config/gcloud/application_default_credentials.json` with `type: authorized_user`
(refresh_token) or `service_account` (private_key) or `external_account`. `CLOUDSDK_CORE_PROJECT`
supplies project for gcloud.
- All discovered secret values are masked in output (D-013): report `{source, found: bool,
fingerprint}` (e.g. last-4 or hash prefix), never the value.
Pitfalls: INI parsing of AWS files must tolerate `#`/`;` comments, spaces around `=`, and profiles
with hyphens; multiple gh users under one host; ADC file may be a SA key with a project_id field —
read project from the key when present.
v0.1 implication: per-provider discovery classes with a uniform `discover() -> CredentialResult`
(surface: source path/env var, found, masked fingerprint); resolution order mirrors each cloud's
own provider chain so `up` just injects the right env vars/files into the Terraform adapter.
## 6. CI environment detection
Facts (GitHub docs; Gitea act_runner source verified; GitLab docs):
- GitHub Actions: `GITHUB_ACTIONS=true`; context: `GITHUB_REPOSITORY` (owner/repo), `GITHUB_REF`
(`refs/heads/<b>` | `refs/tags/<t>` | `refs/pull/N/merge`), `GITHUB_REF_NAME` (short), `GITHUB_SHA`,
`GITHUB_SERVER_URL`, `GITHUB_API_URL` (https://api.github.com), `GITHUB_TOKEN`,
`GITHUB_EVENT_NAME`, `GITHUB_EVENT_PATH` (JSON payload), `GITHUB_ACTOR`, `GITHUB_RUN_ID`.
- Gitea Actions (verified in act_runner `act/runner/run_context.go`): sets `CI=true`,
`GITHUB_ACTIONS=true` plus the same GITHUB_* contract (`GITHUB_REPOSITORY`, `GITHUB_SHA`,
`GITHUB_REF`, `GITHUB_REF_NAME`, `GITHUB_EVENT_NAME`, `GITHUB_WORKFLOW`, `GITHUB_WORKSPACE`,
`GITHUB_SERVER_URL`, `GITHUB_API_URL`, `GITHUB_ACTOR`, `GITHUB_RUN_ID`); job token `GITEA_TOKEN`
(repo-scoped). `GITHUB_ACTIONS=true` alone therefore CANNOT distinguish Gitea from GitHub.
- GitLab CI: `GITLAB_CI=true`, `CI=true`; `CI_PROJECT_PATH` (owner/repo), `CI_COMMIT_SHA`,
`CI_COMMIT_REF_NAME` (short, no `refs/` prefix), `CI_SERVER_URL`, `CI_API_V4_URL`, `CI_JOB_TOKEN`.
Pitfalls: GITHUB_REF has a `refs/` prefix while GitLab's CI_COMMIT_REF_NAME does not — normalize;
Gitea may leave `GITHUB_API_URL` unset (runner deletes it when empty) — derive `<server>/api/v1`
from GITHUB_SERVER_URL.
v0.1 implication: a pipeline module producing a normalized PrefillContext
`{platform, repo_slug, branch/ref, sha, server_url, api_base, token}` consumed by compose
interpolation and the forge clients; Gitea API base = `GITHUB_SERVER_URL + /api/v1`.
## 7. GitHub vs Gitea REST API
Facts (Gitea auth/pagination verified at docs.gitea.com/development/api-usage):
- Base: GitHub `https://api.github.com` (GHES: `https://<host>/api/v3`); Gitea `<host>/api/v1`
(nova's forge: `https://git.coreci.dev/api/v1`). Swagger: `<host>/swagger.v1.json`.
- Auth headers: GitHub `Authorization: Bearer <token>` (classic `token <t>` also works); Gitea
`Authorization: token <TOKEN>` (API-key tokens; OAuth2 tokens use `Bearer`; Basic auth also
accepted; `token=`/`access_token=` query params exist but leak — do not use).
- Read surface (same path shapes, different field details):
- Repo: `GET /repos/{owner}/{repo}` — both return `full_name`, `default_branch`, `html_url`,
`clone_url`, `private`.
- Branch: `GET /repos/{owner}/{repo}/branches/{branch}` — GitHub: `commit.sha`; Gitea:
`commit.id` + `commit.message`/`commit.timestamp` (normalize `sha` vs `id`!).
- Commits: GitHub `...commits?sha=<ref>&per_page=N`, Gitea `...commits?sha=<ref>&limit=N` —
both list `{sha, commit.message, commit.author/committer, html_url}`.
- Releases: `GET /repos/{owner}/{repo}/releases` — both `{tag_name, name, draft, prerelease,
assets[]}`; GitHub also has `/releases/latest`.
- Pagination: GitHub `page`/`per_page` (max 100), `Link` header, `X-RateLimit-*` headers; Gitea
`page`/`limit` (server default `MAX_RESPONSE_ITEMS=50`), `Link` header + `x-total-count`.
- Token scopes: GitHub classic `repo` (or fine-grained Contents + Metadata); Gitea scoped tokens
`read:/write:repository`, `write:package`, `all` (verified); Gitea CI job tokens are repo-limited.
- Errors: both return JSON `{"message": ...}`; GitHub 403 with `X-RateLimit-Remaining: 0` for rate
limit; 401 for bad token on both.
Pitfalls: branch shape divergence (commit.sha vs commit.id) is the #1 normalization bug; GitHub
expects `Accept: application/vnd.github+json` (+ `X-GitHub-Api-Version: 2022-11-28`) for stable v3.
v0.1 implication: a stdlib-HTTP `ForgeClient` base with `GitHubClient`/`GiteaClient` subclasses
(urllib, D-022); normalize branch/commit/release payloads into common dataclasses; auth header and
pagination differ per forge.
## 8. .git internals for pure-python parsing
Facts:
- Repo detection: `.git/` directory, OR a `.git` FILE containing `gitdir: <path>` (worktrees,
submodules). No `.git` at all → nova may auto-create with `git init` subprocess + visible notice
(D-021).
- `.git/config`: INI. `[remote "origin"]` with `url = ...`, `fetch = +refs/heads/*:...`;
`[branch "main"]` with `remote`/`merge`. Parse with `configparser(strict=False, delimiters="=")`.
- `HEAD`: either `ref: refs/heads/main` (on-branch) or a raw 40-hex sha (detached).
`refs/remotes/origin/HEAD` may indicate the default remote branch.
- Refs: loose ref files `.git/refs/heads/<branch>` (content: 40-hex + newline); tags under
`refs/tags/`; `packed-refs` file lines `<sha> <refname>` with `^<peeled-sha>` lines directly after
annotated tags (header line `# pack-refs with: ...`). Loose refs override packed-refs.
- Loose objects: `.git/objects/<2-char>/<38-hex>`, zlib-compressed (stdlib `zlib.decompress`);
payload `<type> <size>\x00<content>`. Commit headers: `tree`, `parent` (0..n),
`author Name <email> <unix-ts> <±hhmm>`, `committer`, optional `gpgsig`, blank line, message.
Tag objects: `object`, `type commit`, `tag <name>`, `tagger`.
- Subprocess fallback is REQUIRED for packed objects (`.git/objects/pack/*.pack` — no stdlib
reader), worktrees/submodules, reftable, shallow clones, or any object missing from the loose
store. Fallbacks: `git rev-parse HEAD`, `git config --get remote.origin.url`, `git log -1`,
`git branch --show-current`.
Pitfalls: HEAD may point at a ref that only exists in packed-refs; commits in fresh clones are
almost always packed (loose-object parsing works best on locally-created commits); never crash a
deployment because git context is unavailable — degrade to "no git context".
v0.1 implication: `nova.env.git` = pure-python first (config, HEAD, loose refs/objects), subprocess
fallback on any miss; outputs a `GitContext{root, branch, head_sha, remote_url, remote_host}`
consumed by the forge client (remote host → gitea/github/gitlab classification).
## 9. c7n + c7n-left packaging
Facts (verified from PyPI metadata + wheel source):
- PyPI: `c7n` 0.9.53 (console script `custodian`) and `c7n-left` 0.3.39 (console script `c7n-left`,
entry `c7n_left.cli:cli`). c7n-left pins EXACT versions: `c7n==0.9.53`, `click==8.5.0`,
`python-hcl2==8.1.4`, `rich==14.3.4`, `tfparse==0.6.22`; requires-python `>=3.9.2,<4`.
- Invocation (verified from c7n_left/cli.py — NOTE: the Terraform dir flag is `-d`, not `-t`):
`c7n-left run -p <policy-dir> -d <terraform-dir> [--filters "k=v,glob"] [--warn-on "k=v"]`
`[--output cli|json] [--output-file F] [--output-query <jmespath>] [--var-file F ...]`
`[--summary policy|resource]`. Subcommands: `run`, `test`, `validate`. Any policy match → exit
code 1 (configurable via `--warn-on`).
- Policy file syntax (verified): YAML
`policies: [{name, resource: terraform.<tf_type>, metadata: {severity}, filters: [...]}]` —
resource form is DOT notation (`resource: terraform.aws_s3_bucket`); match_type splits on the
first `.` and fnmatches the remainder against the raw Terraform resource type. The underscore form
`terraform_aws_s3_bucket` does NOT match in 0.3.39.
- Vendoring weight (verified via pip --dry-run): c7n-left pulls 29 packages including
boto3/botocore (~60-80 MB installed), cryptography (C extension), tfparse (compiled Rust manylinux
wheel), rich, python-hcl2, jsonschema. The c7n-left wheel itself is 46 KB pure-python.
Pitfalls: nova must pin `c7n-left==0.3.39` exactly (its c7n pin propagates — an independent c7n pin
breaks resolution); tfparse is a compiled wheel (PyInstaller bundles its `.so`, but builds must be
linux amd64 — already the target; binary grows ~100-150 MB with c7n vendored); the
`resource: terraform_<type>` underscore form silently matches nothing — builtin policies must use
the dot form.
v0.1 implication: pin `c7n-left==0.3.39` as a nova dependency (D-018); `nova policy check` shells
out to the `c7n-left` console script (subprocess isolation) with `-p <builtin-or-user-policy-dir>
-d <generated-tf-dir> --output json`, blocks `up` on exit 1 unless `--skip-policy`; builtin policy
pack ships as nova package data (dot-notation resources); `nova policy version` reads
`importlib.metadata`.
## 10. Python distribution & release
Facts:
- pyproject.toml (setuptools, src-layout):
`[build-system] requires=["setuptools>=68"], build-backend="setuptools.build_meta"`;
`[project] name="nova-cli", requires-python=">=3.10", dependencies=["click>=8.1,<9",
"PyYAML>=6,<7", "c7n-left==0.3.39"]`; `[project.scripts] nova="nova.cli:main"`;
`[tool.setuptools.packages.find] where=["src"]`; package-data for the builtin policy pack.
Artifacts via `python -m build` (wheel + sdist).
- PyInstaller one-file: `pyinstaller --onefile --name nova --strip` from the project venv. Pitfalls:
console-script indirection (point PyInstaller at a real module function), c7n dynamic imports +
data files need `--collect-submodules`/`--collect-data c7n c7n_left`, tfparse `.so` is collected
from the venv, onefile self-extracts to a tempdir (1-3 s startup), no cross-compilation (build on
linux amd64 in python:3.11-slim to match target glibc).
- Container: `FROM python:3.11-slim`, `pip install --no-cache-dir dist/nova*.whl`,
`ENTRYPOINT ["nova"]` (~200-300 MB with c7n vendored). Do NOT bundle the Terraform binary (BUSL):
the adapter detects a missing binary and errors with an install hint.
- Gitea release API (auth verified): `POST /api/v1/repos/{owner}/{repo}/releases` with
`Authorization: token <GITEA_TOKEN>`, JSON `{tag_name, name, body, draft, prerelease,
target_commitish}` — the tag is auto-created if absent. Response contains `id`. Asset upload:
`POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets?name=<file>` as multipart/form-data field
`attachment`. Token scope: `write:repository` (releases live under the repository scope).
- Gitea also ships a container registry at `<host>/v2/<owner>/...` — an alternative to attaching
image tars to releases when the runner has no docker daemon (this dev environment has none).
Pitfalls: tag-less repo means the first `tag_name` (v0.1.0) is created from the release API — do not
`git tag` separately; gitea `MAX_RESPONSE_ITEMS=50` pagination on release/asset listings; the
binary must be smoke-tested (`nova version`) before attaching (REQ-06).
v0.1 implication: `scripts/release.sh` + `.gitea/workflows/release.yml` build wheel/sdist/binary/
container, then a stdlib-HTTP release step (reusing the gitea client) creates the release and
attaches artifacts; release failure retries 3x, never blocks the phase (D-020).
## Assumptions
| # | Assumption | Confidence | Consequence if wrong |
|---|------------|-----------|----------------------|
| A1 | Gitea Actions sets a `GITEA_ACTIONS` marker env (presence-based detection); robust fallback is `GITHUB_ACTIONS=true` + `GITHUB_SERVER_URL` host != github.com (act_runner's GITHUB_* contract is verified) | 0.80 | Detection falls back to server-URL host check; no functional loss |
| A2 | Gitea asset upload uses multipart field name `attachment` (per Gitea swagger, not re-verified live) | 0.85 | Release attach step fails; fix is a field rename, caught in P6 smoke test |
| A3 | Gitea branch payload uses `commit.id` (PayloadCommit) vs GitHub `commit.sha` | 0.85 | Branch listing shows empty sha; one-line normalization fix |
| A4 | Exact docker parse-error wording (`Dockerfile:<l>:<c>: parser: ...`, `unknown instruction: X`) for message parity | 0.70 | Error wording drifts from docker style; cosmetic only |
| A5 | Terraform binary is an external runtime prerequisite (BUSL; not bundled in nova's container) | 0.90 | If bundling were required, container must switch to OpenTofu — post-go-live decision |
| A6 | az CLI profile `~/.azure/azureProfile.json` is best-effort context only (no secret extraction); secrets come from ARM_* env | 0.70 | Azure credential discovery reports less context; no auth impact |
| A7 | `nova policy check` invokes the installed `c7n-left` console script via subprocess rather than programmatic import (import surface may change across 0.3.x) | 0.90 | Switch to programmatic entry — contained in one wrapper module |
| A8 | Gitea at git.coreci.dev has Actions enabled with a runner (release workflow depends on it) | 0.75 | Releases run locally via `scripts/release.sh` + GITEA_TOKEN instead |
| A9 | Compose file preference order compose.yaml > compose.yml > docker-compose.yaml > docker-compose.yml for nova's discovery (docs confirm compose.yaml preferred; exact fallback order beyond it is conventional) | 0.90 | A file with both names present resolves differently; warn on ambiguity |
| A10 | GitLab CI default variables as listed (CI_PROJECT_PATH, CI_API_V4_URL, ...) — stable for years | 0.95 | Detection misses GitLab prefill; low impact for a gitea-hosted project |
All other facts above were verified against primary sources during this research pass.
+49
View File
@@ -0,0 +1,49 @@
# Nova CLI — Roadmap
## Overview
Milestone v0.1-go-live: deliver Docker / Docker Compose ergonomics for deploying Terraform module registry paths to AWS, Azure, and GCP, with an environment discovery layer (git-context, forge APIs, credentials, pipeline detection), vendored policy checks (c7n + c7n-left), and full release engineering (binary + container). The project remains on the v0.1 minor line until go-live. This roadmap is initial and is refined by the roadmapper during /ci-run planning.
## Phases
### Phase 1 — CLI Skeleton & Docker-Ergonomic Command Surface
- 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: 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
### Phase 3 — Environment Discovery Layer
- Status: not_started
- Depends on: P1
- Requirements: REQ-08, REQ-09, REQ-10, REQ-11
- Success criteria: git repo detection (create one if absent); remote/commit discovery from .git config; native GitHub + Gitea API clients; gh/glab stored-credential discovery; AWS/Azure/GCP stored credentials or env variables; pipeline detection (e.g., GitHub Actions) with env-based prefill for infrastructure modules
### Phase 4 — IaC Adapter Interface + Terraform Adapter + Provider Engine
- Status: not_started
- Depends on: P2, P3
- Requirements: REQ-04
- Success criteria: IaC adapter interface defined; Terraform adapter implements it; modules deploy to AWS/Azure/GCP through compose-like lifecycle commands; adapter pattern allows future IaC types without redesign
### Phase 5 — Vendored c7n + c7n-left Policy Checks
- Status: not_started
- Depends on: P4
- Requirements: REQ-07
- Success criteria: c7n + c7n-left vendored into the CLI; policy checks execute against module specs and deployments
### Phase 6 — Release Engineering & Go-Live Prep
- Status: not_started
- Depends on: P1, P2, P3, P4, P5
- Requirements: REQ-06
- Success criteria: release pipeline ships the CLI binary + CLI container image to gitea releases; v0.1 go-live criteria met
+45
View File
@@ -0,0 +1,45 @@
{
"project": {
"name": "Nova CLI",
"slug": "nova-cli"
},
"projects": [],
"active_project": null,
"milestone": "v0.1",
"release": {
"forge": "gitea",
"base_url": "https://git.coreci.dev",
"owner": "coreci",
"repo": "nova-cli"
},
"autonomy": {
"level": "full",
"escalation_hooks": ["deploy", "delete_data", "merge_to_main"],
"decision_confidence_threshold": 0.6,
"max_revision_iterations": 3,
"clarify_budget": 10
},
"ship": {
"per_phase": true,
"allow_skip": false,
"max_release_retries": 3,
"release_blocking": false
},
"personas": {
"enabled": true,
"territory_enforcement": "warn"
},
"verification": {
"bdd_default": false
},
"mvp_ux": {
"enabled": true
},
"secrets": {
"env_file": ".ciagent/.env.secrets",
"scopes": {
"release": ["GITEA_TOKEN"],
"forge": ["GITHUB_TOKEN", "GITEA_TOKEN"]
}
}
}
-52
View File
@@ -1,52 +0,0 @@
# nova-cli CI — first gated push (task order coreci/nova-platform#3, hip c12363)
#
# Gate shape: fetch -> one-time runner-surface probe (diagnostics report,
# never gate) -> repo-shape validation (gates loud). The offline floor is
# honest: every stage that runs is a real check; nothing skipped is
# presented as passed.
name: CI
on:
push:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
runs-on: node
steps:
- name: "fetch (probe: stock actions/checkout@v4)"
id: actfetch
continue-on-error: true
uses: actions/checkout@v4
- name: fetch (git-clone fallback, run token)
if: ${{ steps.actfetch.outcome == 'failure' }}
run: |
set -euo pipefail
git clone --depth 1 "https://coreci-nova-po:${{ secrets.GITEA_TOKEN }}@git.coreci.dev/${{ github.repository }}.git" .
- name: diagnostics (runner surface probe - reports, does not gate)
run: |
set -u
echo "== workspace =="
ls -A
git log --oneline -1 || true
echo "== python =="
python3 --version
python3 -m pytest --version 2>/dev/null || echo "pytest: ABSENT"
python3 - <<'PY'
for m in ("yaml", "jsonschema", "boto3", "moto"):
try:
__import__(m)
print(m + ": OK")
except Exception as e:
print(m + ": ABSENT (%s)" % type(e).__name__)
PY
echo "== pypi reachability =="
timeout 15 pip download --no-deps --dest /tmp/piptest pytest >/dev/null 2>&1 && echo "pypi: REACHABLE" || echo "pypi: UNREACHABLE"
- name: gate (repo shape)
run: |
set -euo pipefail
test -s README.md
head -1 README.md | grep -q '^# '
echo "gate: README present, non-empty, title line OK"
+22
View File
@@ -0,0 +1,22 @@
# secrets
.env
.env.secrets
.env.*
# python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
# nova local state
.nova/
*.tfstate
*.tfstate.*
.terraform/
+25
View File
@@ -0,0 +1,25 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "nova-cli"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"click>=8.1",
"PyYAML>=6.0",
]
[project.scripts]
nova = "nova.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
+3
View File
@@ -0,0 +1,3 @@
"""nova - Docker-style ergonomics for infrastructure deployment."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
"""nova.cli - click command surface and console entry point."""
from nova.cli.main import main
__all__ = ["main"]
+11
View File
@@ -0,0 +1,11 @@
"""Command implementations for nova lifecycle commands.
P2 introduces the ``nova.cli.commands`` package (verifier P1 flag): ``config``
and ``build`` gain real parser-backed behavior here; the deployment engines
(``up``/``down``/``ps``/...) land in phase 4.
"""
from nova.cli.commands.build import run_build
from nova.cli.commands.config import run_config
__all__ = ["run_build", "run_config"]
+61
View File
@@ -0,0 +1,61 @@
"""``nova build`` — parse a Novafile and print the resolved module manifest."""
from __future__ import annotations
from pathlib import Path
import click
from nova.cli.config_files import NovaFileError, find_novafile
from nova.parsers.novafile import NovafileError, parse_novafile
def run_build(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Parse the Novafile, validate every stage, and print the module manifest."""
try:
novafile_path = find_novafile(Path.cwd(), Path(file[0]) if file else None)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
try:
parsed = parse_novafile(
novafile_path.read_text(encoding="utf-8"),
filename=str(novafile_path),
)
except NovafileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
for warning in parsed.warnings:
click.echo(f"WARNING: {warning}", err=True)
click.echo(f"[+] Building module manifest from {novafile_path}")
for stage in parsed.stages:
ref = stage.module
label = stage.name or "main"
click.echo(f" --> stage {label}")
click.echo(f" module {ref.source}")
if ref.version is not None:
click.echo(f" version {ref.version}")
if ref.submodule is not None:
click.echo(f" subdir {ref.submodule}")
if stage.args:
for arg in stage.args:
default = arg.default if arg.default is not None else "(required)"
click.echo(f" arg {arg.name}={default}")
for key, value in stage.env:
click.echo(f" env {key}={value}")
for key, value in stage.labels:
click.echo(f" label {key}={value}")
click.echo(f" workdir {stage.workdir}")
click.echo(
f"Build manifest resolved: {len(parsed.stages)} stage(s), "
f"{len(parsed.global_args)} global arg(s)"
)
+67
View File
@@ -0,0 +1,67 @@
"""``nova config`` — validate and print the normalized compose configuration."""
from __future__ import annotations
import os
from pathlib import Path
import click
import yaml
from nova.cli.config_files import (
NovaFileError,
find_compose_file,
load_env_files,
)
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
from nova.parsers.interp import InterpolationError
def _interpolation_env(
compose_dir: Path, env_files: tuple[str, ...], interpolate: bool
) -> dict[str, str] | None:
"""Build the interpolation env: .env, then --env-file overrides, then shell.
docker compose precedence: shell environment > --env-file > project .env.
Returns None when interpolation is disabled.
"""
if not interpolate:
return None
env: dict[str, str] = {}
project_dotenv = compose_dir / ".env"
if project_dotenv.is_file():
env.update(load_env_files([project_dotenv]))
for env_file in env_files:
env.update(load_env_files([Path(env_file)]))
env.update(dict(os.environ))
return env
def run_config(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
no_interpolate: bool,
) -> None:
"""Load, validate, and print the normalized compose configuration."""
try:
compose_path = find_compose_file(
Path.cwd(), tuple(Path(f) for f in file)
)
env = _interpolation_env(compose_path.parent, env_file, not no_interpolate)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
try:
spec = parse_compose(
compose_path.read_text(encoding="utf-8"),
filename=str(compose_path),
env=env,
)
except (ComposeParseError, InterpolationError) as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
click.echo(yaml.safe_dump(render_compose(spec), sort_keys=False))
+151
View File
@@ -0,0 +1,151 @@
"""Compose-style config file discovery and env-file loading (task P01-01-03).
Discovery rules mirror docker compose (verified against the compose-spec
research in .ciagent/RESEARCH.md, area 3):
1. explicit ``-f`` files — the first is the primary file, but every listed
path must exist;
2. the ``NOVA_COMPOSE_FILE`` environment variable — ``:``-separated paths,
validated like ``-f`` (the ``COMPOSE_FILE`` analog);
3. an upward directory search — walk from the working directory to the
filesystem root, checking ``compose.yaml`` before ``compose.yml`` in
each directory.
``find_novafile`` mirrors ``docker build`` instead: the Novafile is only
looked up in the working directory or at an explicitly given path, never
found by an upward search (Dockerfile parity).
All functions are pure: they take ``pathlib.Path`` arguments, read the
``NOVA_COMPOSE_FILE`` environment variable only as the documented
discovery input, and keep no global state.
"""
import os
from collections.abc import Sequence
from pathlib import Path
#: Environment variable holding ``:``-separated compose file paths.
NOVA_COMPOSE_FILE_ENV = "NOVA_COMPOSE_FILE"
#: Candidate compose file names, in per-directory preference order.
COMPOSE_FILE_NAMES: tuple[str, ...] = ("compose.yaml", "compose.yml")
#: Default build recipe file name (Dockerfile parity).
NOVAFILE_NAME = "Novafile"
class NovaFileError(RuntimeError):
"""A configuration file is missing or could not be located."""
def _resolve(workdir: Path, path: Path) -> Path:
"""Anchor a relative path to ``workdir``; absolute paths pass through."""
return path if path.is_absolute() else workdir / path
def _require_file(target: Path) -> None:
"""Raise the compose-style error when ``target`` is not a file."""
if not target.is_file():
raise NovaFileError(f"no such file: {target}")
def _primary_or_raise(resolved: Sequence[Path]) -> Path:
"""Return the first path, after validating that every path exists."""
for target in resolved:
_require_file(target)
return resolved[0]
def _from_env(workdir: Path) -> list[Path] | None:
"""Interpret ``NOVA_COMPOSE_FILE`` as a ``:``-separated path list.
Relative entries are anchored to ``workdir``. An unset, empty, or
all-separator value means "no override" and returns None so the
upward search can take over.
"""
raw = os.environ.get(NOVA_COMPOSE_FILE_ENV, "")
entries = [part for part in raw.split(":") if part]
if not entries:
return None
return [_resolve(workdir, Path(part)) for part in entries]
def _search_upward(workdir: Path) -> Path | None:
"""Walk from ``workdir`` to the filesystem root looking for compose files.
In each directory ``compose.yaml`` is preferred over ``compose.yml``;
only when neither exists does the search continue upward.
"""
current = workdir.resolve()
while True:
for name in COMPOSE_FILE_NAMES:
candidate = current / name
if candidate.is_file():
return candidate
if current.parent == current: # reached the filesystem root
return None
current = current.parent
def find_compose_file(workdir: Path, files: Sequence[Path] | None = None) -> Path:
"""Locate the primary compose file, docker-compose style.
Precedence: explicit ``files`` (first is primary, all must exist) >
``NOVA_COMPOSE_FILE`` (same validation) > upward search for
``compose.yaml``/``compose.yml``. Raises :class:`NovaFileError` when a
listed file is missing or no configuration file exists at all.
"""
if files:
return _primary_or_raise([_resolve(workdir, path) for path in files])
env_files = _from_env(workdir)
if env_files is not None:
return _primary_or_raise(env_files)
found = _search_upward(workdir)
if found is not None:
return found
raise NovaFileError("no configuration file found: specify a file with -f")
def find_novafile(workdir: Path, file: Path | None = None) -> Path:
"""Locate the Novafile with docker-build parity: no upward search.
``file`` (from ``build -f``) wins when given; otherwise only
``<workdir>/Novafile`` is considered — exactly like ``docker build``
looking at ``./Dockerfile`` and never in parent directories.
"""
target = _resolve(workdir, file) if file is not None else workdir / NOVAFILE_NAME
_require_file(target)
return target
def load_env_files(paths: Sequence[Path]) -> dict[str, str]:
"""Load ``KEY=VALUE`` env files in order, later files overriding earlier.
Blank lines and ``#`` comments are skipped; one pair of surrounding
single or double quotes is stripped from each value. A missing file
raises :class:`NovaFileError`.
"""
env: dict[str, str] = {}
for path in paths:
_require_file(path)
env.update(_parse_env_file(path))
return env
def _parse_env_file(path: Path) -> dict[str, str]:
"""Parse one .env-format file into a key/value mapping."""
parsed: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
parsed[key.strip()] = _unquote(value.strip())
return parsed
def _unquote(value: str) -> str:
"""Strip one pair of surrounding single or double quotes from a value."""
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
return value[1:-1]
return value
+358
View File
@@ -0,0 +1,358 @@
"""nova CLI entry point: root command group, version command, console script target.
This phase wires the docker compose-ergonomic command *surface*: every
lifecycle command parses its full flag set, resolves its configuration
file compose-style (``-f`` override, ``NOVA_COMPOSE_FILE``, upward
``compose.yaml``/``compose.yml`` search; ``build`` instead resolves its
Novafile, docker build parity), then reports the later phase that will
implement the behavior. Novafile/compose parsers land in phase 2,
environment discovery in phase 3, the IaC engine in phase 4, and policy
integration in phase 5.
"""
import importlib.metadata
from pathlib import Path
from typing import Callable, NoReturn, TypeVar
import click
import nova
from nova.cli.commands import run_build, run_config
from nova.cli.config_files import NovaFileError, find_compose_file, find_novafile
def _resolve_version() -> str:
"""Resolve the installed nova-cli version, falling back to the package constant."""
try:
return importlib.metadata.version("nova-cli")
except importlib.metadata.PackageNotFoundError:
return nova.__version__
# --- Phase-landing stub handlers -------------------------------------------------
# Exact stderr contract for surface-only commands, keyed by the phase in
# which the implementation lands. ``{command_name}`` is substituted per
# command: build/config -> phase 2 parsers, env group -> phase 3 discovery,
# lifecycle engines -> phase 4, policy group -> phase 5.
_PHASE_LANDING: dict[int, str] = {
2: "the '{command_name}' parser lands in phase 2 (Novafile + compose parsers)",
3: "nova {command_name} lands in phase 3 (environment discovery)",
4: "the '{command_name}' engine lands in phase 4 (IaC adapter)",
5: "nova {command_name} lands in phase 5 (vendored c7n + c7n-left)",
}
def _pending(command_name: str, landing: int) -> NoReturn:
"""Emit the phase-landing error for a surface-only command and exit 1.
click has already parsed and validated the full flag set by the time a
handler runs; handlers first run config discovery (P01-01-03), then
report where the behavior implementation lands.
"""
message = _PHASE_LANDING[landing].format(command_name=command_name)
click.echo(f"Error: {message}", err=True)
raise SystemExit(1)
def _require_compose_file(file: tuple[str, ...]) -> None:
"""Run compose-file discovery for a lifecycle command, hard-failing on error.
docker-compose parity: a missing configuration file is reported as
``Error: <message>`` on stderr with exit code 1, before the command
reports its phase landing. On success the resolved file is simply
validated — the engines that consume it land in later phases.
"""
try:
find_compose_file(Path.cwd(), tuple(Path(f) for f in file))
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
def _require_novafile(file: tuple[str, ...]) -> None:
"""Run Novafile discovery for ``build``, hard-failing on error.
Retained for the phase-4 engine wiring; ``build`` itself now performs
discovery in :mod:`nova.cli.commands.build`.
"""
try:
find_novafile(Path.cwd(), Path(file[0]) if file else None)
except NovaFileError as exc:
click.echo(f"Error: {exc}", err=True)
raise SystemExit(1)
# --- Root command group -----------------------------------------------------------
@click.group(
invoke_without_command=False,
no_args_is_help=True,
help="nova - Docker-style ergonomics for infrastructure deployment",
)
def cli() -> None:
"""nova - Docker-style ergonomics for infrastructure deployment."""
@cli.command()
def version() -> None:
"""Print the nova-cli version."""
click.echo(f"nova-cli v{_resolve_version()}")
# --- Shared lifecycle flags (docker compose parity) -------------------------------
_FC = TypeVar("_FC", bound=Callable[..., object])
def _lifecycle_options(
file_short: bool = True,
file_help: str = "Specify an alternate Novafile/compose file (repeatable)",
) -> Callable[[_FC], _FC]:
"""Build the decorator stack of compose-parity options for a lifecycle command.
``file_short=False`` omits the ``-f`` short flag. It is used by ``rm``,
where ``-f`` is ``--force`` -- the same resolution docker compose ships
for the collision, since ``docker compose rm -f`` means forced removal.
``file_help`` overrides the ``--file`` help text; ``build`` uses it to
document that its ``-f`` is the Novafile path (docker build parity),
unlike the compose-file meaning everywhere else.
"""
# click lists options in reverse application order in `--help`, so apply
# them back-to-front to display the spec order (file, project-name,
# detach, env-file, profile) above each command's extras.
def decorate(command: _FC) -> _FC:
file_decl: tuple[str, ...] = ("-f", "--file") if file_short else ("--file",)
command = click.option(
"--profile",
"profile",
multiple=True,
help="Specify a deployment profile to enable (repeatable)",
)(command)
command = click.option(
"--env-file",
"env_file",
multiple=True,
help="Specify an alternate environment file (repeatable)",
)(command)
command = click.option(
"-d",
"--detach",
"detach",
is_flag=True,
help="Detached mode: do not stream engine output",
)(command)
command = click.option(
"-p",
"--project-name",
"project_name",
help="Specify an alternate project name",
)(command)
command = click.option(
*file_decl,
"file",
multiple=True,
type=click.Path(),
help=file_help,
)(command)
return command
return decorate
# --- Lifecycle commands -----------------------------------------------------------
@cli.command()
@_lifecycle_options(file_help="Specify the Novafile to build from (accepts the Novafile path, Dockerfile -f parity)")
def build(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Create a module build from a Novafile."""
run_build(file, project_name, detach, env_file, profile)
@cli.command()
@_lifecycle_options()
def up(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Deploy infrastructure modules."""
_require_compose_file(file)
_pending("up", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"-v",
"--volumes",
"volumes",
is_flag=True,
help="Remove named volumes declared in the Novafile",
)
def down(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
volumes: bool,
) -> None:
"""Remove deployed infrastructure modules."""
_require_compose_file(file)
_pending("down", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"-a",
"--all",
"all",
is_flag=True,
help="Show all services, including stopped ones",
)
def ps(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
all: bool,
) -> None:
"""List deployed services."""
_require_compose_file(file)
_pending("ps", 4)
@cli.command()
@_lifecycle_options()
def stop(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Stop services."""
_require_compose_file(file)
_pending("stop", 4)
@cli.command()
@_lifecycle_options()
def start(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Start services."""
_require_compose_file(file)
_pending("start", 4)
@cli.command()
@_lifecycle_options()
def restart(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
) -> None:
"""Restart services."""
_require_compose_file(file)
_pending("restart", 4)
@cli.command()
@_lifecycle_options(file_short=False)
@click.option(
"-f",
"--force",
"force",
is_flag=True,
help="Force removal of service states",
)
def rm(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
force: bool,
) -> None:
"""Remove stopped service states."""
_require_compose_file(file)
_pending("rm", 4)
@cli.command()
@_lifecycle_options()
@click.argument("service", nargs=-1)
def logs(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
service: tuple[str, ...],
) -> None:
"""View output from services."""
_require_compose_file(file)
_pending("logs", 4)
@cli.command()
@_lifecycle_options()
@click.option(
"--no-interpolate",
"no_interpolate",
is_flag=True,
help="Print the configuration without interpolating variables",
)
def config(
file: tuple[str, ...],
project_name: str | None,
detach: bool,
env_file: tuple[str, ...],
profile: tuple[str, ...],
no_interpolate: bool,
) -> None:
"""Validate and view the compose file."""
run_config(file, project_name, detach, env_file, profile, no_interpolate)
# --- Placeholder command groups ---------------------------------------------------
@cli.group(invoke_without_command=True)
def policy() -> None:
"""Manage deployment policies."""
_pending("policy", 5)
@cli.group(invoke_without_command=True)
def env() -> None:
"""Inspect deployment environments."""
_pending("env", 3)
# Console entry point (`nova = "nova.cli:main"`): the click group object is
# itself callable, so main() dispatches exactly as cli() would. This keeps
# `nova.cli.main` a click.BaseCommand instance, which is also what the test
# suite asserts.
main: click.Group = cli
+32
View File
@@ -0,0 +1,32 @@
"""Parsers for Novafile and compose.yml, plus module-reference resolution."""
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
from nova.parsers.interp import InterpolationError, interpolate
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
from nova.parsers.models import BuildSpec, ComposeSpec, ServiceSpec
from nova.parsers.novafile import (
ArgDef,
Novafile,
NovafileError,
NovafileStage,
parse_novafile,
)
__all__ = [
"ArgDef",
"BuildSpec",
"ComposeParseError",
"ComposeSpec",
"InterpolationError",
"ModuleRef",
"ModuleRefError",
"Novafile",
"NovafileError",
"NovafileStage",
"ServiceSpec",
"interpolate",
"parse_compose",
"parse_module_ref",
"parse_novafile",
"render_compose",
]
+339
View File
@@ -0,0 +1,339 @@
"""compose.yml loader — compose-indistinguishable schema plus the nova `providers` block."""
from __future__ import annotations
from typing import Any, Mapping
import yaml
from nova.parsers.interp import InterpolationError, interpolate
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
from nova.parsers.models import BuildSpec, ComposeSpec, ServiceSpec
_SUPPORTED_TOP = ("name", "services", "providers")
_VALID_PROVIDERS = ("aws", "azure", "gcp")
class ComposeParseError(ValueError):
"""Raised when a compose file violates the nova compose schema."""
def _err(filename: str, message: str) -> ComposeParseError:
return ComposeParseError(f"{filename}: {message}")
def _as_str_pairs(
value: Any, filename: str, service: str, key: str
) -> tuple[tuple[str, str], ...]:
"""Normalize environment/vars mappings and KEY=VALUE lists to pairs."""
if isinstance(value, dict):
pairs = []
for k, v in value.items():
if not isinstance(k, str):
raise _err(
filename,
f"service '{service}': {key} keys must be strings",
)
pairs.append((k, "" if v is None else str(v)))
return tuple(pairs)
if isinstance(value, list):
pairs = []
for item in value:
if not isinstance(item, str) or "=" not in item:
raise _err(
filename,
f"service '{service}': {key} list entries must be 'KEY=VALUE'",
)
k, _, v = item.partition("=")
pairs.append((k, v))
return tuple(pairs)
raise _err(
filename,
f"service '{service}': {key} must be a mapping or a list of 'KEY=VALUE'",
)
def _as_str_list(
value: Any, filename: str, service: str, key: str
) -> tuple[str, ...]:
if isinstance(value, str):
return (value,)
if isinstance(value, list) and all(isinstance(v, str) for v in value):
return tuple(value)
raise _err(
filename, f"service '{service}': {key} must be a string or list of strings"
)
def _interpolate_mapping(data: Any, env: dict[str, str]) -> Any:
"""Recursively interpolate all string scalars in parsed YAML data."""
if isinstance(data, str):
return interpolate(data, env)
if isinstance(data, dict):
return {
_interpolate_mapping(k, env): _interpolate_mapping(v, env)
for k, v in data.items()
}
if isinstance(data, list):
return [_interpolate_mapping(v, env) for v in data]
return data
def parse_compose(
data: Mapping[str, Any] | str,
filename: str = "compose.yml",
env: Mapping[str, str] | None = None,
) -> ComposeSpec:
"""Parse compose content (YAML text or a pre-loaded mapping) into a ComposeSpec.
When env is None no interpolation runs; otherwise every string value is
interpolated against env before validation.
"""
if isinstance(data, str):
try:
loaded = yaml.safe_load(data)
except yaml.YAMLError as exc:
raise _err(filename, f"invalid YAML: {exc}") from exc
else:
loaded = data
if env is not None:
loaded = _interpolate_mapping(loaded, dict(env))
if not isinstance(loaded, dict):
raise _err(filename, "expected a mapping at top level")
for key in loaded:
if key not in _SUPPORTED_TOP:
raise _err(
filename,
f"top-level key '{key}' is not supported "
f"(supported: {', '.join(_SUPPORTED_TOP)})",
)
name = loaded.get("name")
if name is not None and not isinstance(name, str):
raise _err(filename, "'name' must be a string")
services_raw = loaded.get("services")
if not isinstance(services_raw, dict):
raise _err(filename, "'services' must be a mapping")
providers_raw = loaded.get("providers")
providers: dict[str, dict] = {}
if providers_raw is not None:
if not isinstance(providers_raw, dict):
raise _err(filename, "'providers' must be a mapping")
for key, value in providers_raw.items():
if key not in _VALID_PROVIDERS:
raise _err(
filename,
f"providers: unknown provider '{key}' "
f"(supported: {', '.join(_VALID_PROVIDERS)})",
)
if not isinstance(value, dict):
raise _err(filename, f"providers.{key} must be a mapping")
providers[key] = dict(value)
services: list[ServiceSpec] = []
for service_name, service_body in services_raw.items():
if not isinstance(service_body, dict):
raise _err(
filename, f"service '{service_name}' must be a mapping"
)
services.append(
_parse_service(service_name, service_body, filename)
)
spec = ComposeSpec(
name=name, services=tuple(services), providers=providers
)
_validate_dependencies(spec, filename)
return spec
def _parse_service(name: str, body: dict, filename: str) -> ServiceSpec:
for key in body:
allowed = (
"module",
"build",
"version",
"environment",
"vars",
"env_file",
"depends_on",
"working_dir",
"profiles",
"provider",
)
if key not in allowed:
raise _err(
filename,
f"service '{name}': key '{key}' is not supported",
)
module_raw = body.get("module")
build_raw = body.get("build")
if module_raw is not None and build_raw is not None:
raise _err(
filename, f"service '{name}': specify either 'module' or 'build', not both"
)
if module_raw is None and build_raw is None:
raise _err(
filename,
f"service '{name}': requires a 'module' (registry path) or 'build' "
f"(Novafile) key",
)
module: ModuleRef | None = None
if module_raw is not None:
if not isinstance(module_raw, str):
raise _err(filename, f"service '{name}': 'module' must be a string")
try:
module = parse_module_ref(module_raw)
except ModuleRefError as exc:
raise _err(filename, f"service '{name}': {exc}") from exc
build: BuildSpec | None = None
if build_raw is not None:
if not isinstance(build_raw, dict):
raise _err(filename, f"service '{name}': 'build' must be a mapping")
build = BuildSpec(
context=str(build_raw.get("context", ".")),
novafile=str(build_raw.get("novafile", "Novafile")),
)
version = body.get("version")
if version is not None:
if not isinstance(version, str):
raise _err(filename, f"service '{name}': 'version' must be a string")
if module is not None and module.version is not None:
raise _err(
filename,
f"service '{name}': version specified twice "
f"(both @{module.version} sugar and the version key)",
)
if module is not None:
try:
module = parse_module_ref(f"{module_raw}@{version}")
except ModuleRefError as exc:
raise _err(filename, f"service '{name}': {exc}") from exc
environment: tuple[tuple[str, str], ...] = ()
if "environment" in body and "vars" in body:
raise _err(
filename,
f"service '{name}': 'vars' is an alias of 'environment' — use only one",
)
env_key = "environment" if "environment" in body else (
"vars" if "vars" in body else None
)
if env_key is not None:
environment = _as_str_pairs(body[env_key], filename, name, env_key)
env_files: tuple[str, ...] = ()
if "env_file" in body:
env_files = _as_str_list(body["env_file"], filename, name, "env_file")
depends_on: tuple[str, ...] = ()
if "depends_on" in body:
dep_raw = body["depends_on"]
if isinstance(dep_raw, list):
if not all(isinstance(v, str) for v in dep_raw):
raise _err(
filename,
f"service '{name}': depends_on short form must be a list of "
f"service names",
)
depends_on = tuple(dep_raw)
elif isinstance(dep_raw, dict):
depends_on = tuple(dep_raw.keys())
else:
raise _err(
filename,
f"service '{name}': depends_on must be a list or a mapping",
)
working_dir = body.get("working_dir")
if working_dir is not None and not isinstance(working_dir, str):
raise _err(filename, f"service '{name}': 'working_dir' must be a string")
profiles: tuple[str, ...] = ()
if "profiles" in body:
profiles = _as_str_list(body["profiles"], filename, name, "profiles")
provider = body.get("provider")
if provider is not None:
if not isinstance(provider, str) or provider.lower() not in _VALID_PROVIDERS:
raise _err(
filename,
f"service '{name}': provider must be one of "
f"{', '.join(_VALID_PROVIDERS)}",
)
provider = provider.lower()
return ServiceSpec(
name=name,
module=module,
build=build,
version=None if version is None else version,
environment=environment,
env_files=env_files,
depends_on=depends_on,
working_dir=working_dir,
profiles=profiles,
provider=provider,
)
def _validate_dependencies(spec: ComposeSpec, filename: str) -> None:
names = {s.name for s in spec.services}
for service in spec.services:
for dep in service.depends_on:
if dep not in names:
raise _err(
filename,
f"service '{service.name}' depends on unknown service '{dep}'",
)
if service.name in service.depends_on:
raise _err(
filename,
f"service '{service.name}' cannot depend on itself",
)
def render_compose(spec: ComposeSpec) -> dict:
"""Render a ComposeSpec back to the normalized `docker compose config` dict."""
services: dict[str, dict] = {}
for service in spec.services:
rendered: dict[str, Any] = {}
if service.module is not None:
rendered["module"] = service.module.source
if service.module.version is not None:
rendered["version"] = service.module.version
if service.build is not None:
rendered["build"] = {
"context": service.build.context,
"novafile": service.build.novafile,
}
if service.environment:
rendered["environment"] = dict(service.environment)
if service.env_files:
rendered["env_file"] = list(service.env_files)
if service.depends_on:
rendered["depends_on"] = list(service.depends_on)
if service.working_dir is not None:
rendered["working_dir"] = service.working_dir
if service.profiles:
rendered["profiles"] = list(service.profiles)
if service.provider is not None:
rendered["provider"] = service.provider
services[service.name] = rendered
out: dict[str, Any] = {}
if spec.name is not None:
out["name"] = spec.name
out["services"] = services
if spec.providers:
out["providers"] = spec.providers
return out
+93
View File
@@ -0,0 +1,93 @@
"""Compose-style variable interpolation: ${VAR}, ${VAR:-def}, ${VAR:?err}, ${VAR:+alt}."""
from __future__ import annotations
import re
from typing import Mapping
_NAME = r"[A-Za-z_][A-Za-z0-9_]*"
_BRACED = re.compile(rf"\$\{{({_NAME})(?:(:?[-+?])([^}}]*))?\}}")
_BARE = re.compile(rf"\$({_NAME})")
class InterpolationError(ValueError):
"""Raised when a ${VAR:?} requirement is unmet or syntax is invalid."""
def _replace_bare(match: re.Match, env: Mapping[str, str]) -> str:
value = env.get(match.group(1))
return value if value is not None else ""
def _replace_braced(match: re.Match, env: Mapping[str, str]) -> str:
name, operator, operand = match.group(1), match.group(2), match.group(3)
value = env.get(name)
if operator is None:
return value if value is not None else ""
if operator in (":-", "-"):
# ":-" fires on unset OR empty; "-" fires only on unset.
if value is None:
return operand or ""
if value == "" and operator == ":-":
return operand or ""
return value
if operator in (":?", "?"):
if value is None or (value == "" and operator == ":?"):
message = operand or "required variable is missing"
raise InterpolationError(f"{name}: {message}")
return value
if operator in (":+", "+"):
# ":+" fires on set AND non-empty; "+" fires on set (even empty).
present = value is not None and (value != "" or operator == "+")
return operand or "" if present else ""
raise InterpolationError(f"unsupported interpolation operator {operator!r}")
raise InterpolationError(f"unsupported interpolation operator {operator!r}")
def interpolate(text: str, env: Mapping[str, str]) -> str:
"""Interpolate compose-style variables in text against env.
Supported: ${VAR}, $VAR, ${VAR:-def}, ${VAR-def}, ${VAR:?msg}, ${VAR?msg},
${VAR:+alt}, ${VAR+alt}, and $$ as an escaped literal dollar.
"""
out: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch != "$":
out.append(ch)
i += 1
continue
if i + 1 < n and text[i + 1] == "$":
out.append("$")
i += 2
continue
m = _BRACED.match(text, i)
if m:
out.append(_replace_braced(m, env))
i = m.end()
continue
if text.startswith("${", i):
# An unclosed or invalidly-named braced expression is an error,
# matching compose behavior.
end = text.find("}", i)
inner = text[i + 2 : end] if end != -1 else text[i + 2 :]
raise InterpolationError(
f"invalid interpolation syntax: ${{{inner}}}"
)
m = _BARE.match(text, i)
if m:
out.append(_replace_bare(m, env))
i = m.end()
continue
# A lone '$' followed by a non-name character stays literal.
out.append("$")
i += 1
return "".join(out)
+40
View File
@@ -0,0 +1,40 @@
"""Typed models for the parsed compose file (compose-indistinguishable subset)."""
from __future__ import annotations
from dataclasses import dataclass, field
from nova.parsers.moduleref import ModuleRef
@dataclass(frozen=True)
class BuildSpec:
"""A `build:` service source — the Novafile analog of a compose build context."""
context: str
novafile: str
@dataclass(frozen=True)
class ServiceSpec:
"""One deployable module under `services:`."""
name: str
module: ModuleRef | None
build: BuildSpec | None
version: str | None
environment: tuple[tuple[str, str], ...] = ()
env_files: tuple[str, ...] = ()
depends_on: tuple[str, ...] = ()
working_dir: str | None = None
profiles: tuple[str, ...] = ()
provider: str | None = None
@dataclass(frozen=True)
class ComposeSpec:
"""The whole compose.yml document."""
name: str | None
services: tuple[ServiceSpec, ...]
providers: dict[str, dict] = field(default_factory=dict)
+178
View File
@@ -0,0 +1,178 @@
"""Terraform module reference resolver (task P02-01-01).
Grammar (verified: developer.hashicorp.com/terraform/language/block/module;
see .ciagent/RESEARCH.md area 1):
- Public registry shorthand: ``<namespace>/<name>/<provider>`` — exactly
three segments, e.g. ``terraform-aws-modules/vpc/aws``.
- Private registry: ``<hostname>/<namespace>/<name>/<provider>`` — four
segments, e.g. ``app.terraform.io/example-corp/k8s-cluster/azurerm``.
- Full public registry web URLs — ``registry.terraform.io/<ns>/<name>/<provider>``
and ``registry.terraform.io/modules/<ns>/<name>/<provider>`` — are web
addresses, not source grammar; nova normalizes them to shorthand.
- Submodules: append ``//<subdir>`` to any registry source (query parameters
would come after the subdir; none are supported in v0.1).
- Local sources: MUST start with ``./`` or ``../`` (or be absolute paths);
local sources never support version pinning.
- Version: nova sugar ``@<version>`` — split into the separate ``version``
field, valid ONLY for registry sources, validated as a Terraform-style
constraint (``1.2.3``, ``~> 6.0``, ``>= 1.2, < 2.0`` — comma-separated
operator/version checks allowed; empty and non-matching strings rejected).
Terraform's own grammar has no ``@version`` suffix: :func:`parse_module_ref`
translates the sugar so callers can emit ``source`` plus ``version`` arguments.
"""
import re
from dataclasses import dataclass
#: Hostname of the public registry whose web URLs normalize to shorthand.
PUBLIC_REGISTRY_HOST = "registry.terraform.io"
#: Path element the public registry web UI inserts between host and module.
PUBLIC_REGISTRY_MODULES_PATH = "modules"
_NEEDS_THREE = "registry references need <namespace>/<name>/<provider>"
_AT_MOST_FOUR = "registry references allow at most <hostname>/<namespace>/<name>/<provider>"
_BARE_HOST = "bare registry hostname without <namespace>/<name>/<provider>"
# Version constraint grammar: comma-separated checks. Each check is an
# optional Terraform operator followed by a 1-3 part version with optional
# pre-release and metadata suffixes; whitespace is allowed around operators
# and separators (e.g. ">= 1.2, < 2.0", "~> 6.0").
_VERSION_OPERATOR = r"(?:~>|>=|<=|!=|=|>|<)"
_VERSION_NUMBER = r"\d+(?:\.\d+){0,2}"
_VERSION_SUFFIX = (
r"(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
)
_VERSION_CHECK = rf"{_VERSION_OPERATOR}?\s*{_VERSION_NUMBER}{_VERSION_SUFFIX}"
_VERSION_CONSTRAINT_RE = re.compile(rf"^\s*{_VERSION_CHECK}(?:\s*,\s*{_VERSION_CHECK})*\s*$")
# Windows drive-letter prefix, the other legal absolute local form.
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
class ModuleRefError(ValueError):
"""A module reference does not match the Terraform source grammar."""
@dataclass(frozen=True)
class ModuleRef:
"""A resolved Terraform module source address.
``source`` is the Terraform-legal string to emit — public shorthand,
private host form, or local path — including any ``//submodule`` part;
``version`` is nova's ``@`` sugar split off (registry sources only).
"""
source: str
submodule: str | None
version: str | None
is_local: bool
host: str | None
@property
def terraform_source(self) -> str:
"""The source string a Terraform module block accepts."""
return self.source
def _invalid(ref: str, reason: str) -> ModuleRefError:
"""Build the one-line docker/compose-style error naming ``ref``."""
return ModuleRefError(f"invalid module reference '{ref}': {reason}")
def _is_local_source(base: str) -> bool:
"""True for ``./``, ``../``, unix-absolute, and windows-drive sources."""
if base.startswith(("./", "../")):
return True
return base.startswith("/") or _WINDOWS_DRIVE_RE.match(base) is not None
def _is_valid_version_constraint(version: str) -> bool:
"""True for Terraform-style constraint strings (e.g. ``~> 6.0``)."""
return _VERSION_CONSTRAINT_RE.match(version) is not None
def _segment_reason(segments: list[str]) -> str:
"""The count-error reason for a wrong number of registry segments."""
if len(segments) == 1 and "." in segments[0]:
return _BARE_HOST
if len(segments) <= 2:
return _NEEDS_THREE
return _AT_MOST_FOUR
def _normalize_public_url(segments: list[str]) -> list[str]:
"""Drop the registry.terraform.io host and its /modules/ path element."""
segments = segments[1:]
if segments and segments[0] == PUBLIC_REGISTRY_MODULES_PATH:
segments = segments[1:]
return segments
def parse_module_ref(ref: str) -> ModuleRef:
"""Resolve a module reference into its Terraform-legal parts.
Accepts public registry shorthand, private registry hosts, full public
registry web URLs (normalized to shorthand), ``//subdir`` submodules,
``@version`` sugar, and local ``./``/``../``/absolute paths. Surrounding
whitespace is stripped.
Raises :class:`ModuleRefError` with a one-line docker/compose-style
message for anything outside the grammar: empty references, ``//`` at
the start, bare hostnames, 2-segment or 5+-segment registry paths,
version pinning on a local source, and empty or malformed version
constraints.
"""
text = ref.strip()
if not text:
raise _invalid(ref, "module source cannot be empty")
if text.startswith("//"):
raise _invalid(ref, "module source cannot start with '//'")
base, sugar, version_text = text.partition("@")
version: str | None = version_text.strip() if sugar else None
if _is_local_source(base):
if version is not None:
raise _invalid(ref, "local module sources do not support version pinning")
return ModuleRef(source=base, submodule=None, version=None, is_local=True, host=None)
package, marker, subdir = base.partition("//")
submodule = subdir if marker else None
if marker and not subdir:
raise _invalid(ref, "submodule '//' must be followed by a path")
segments = package.split("/")
if "" in segments:
raise _invalid(ref, "module source contains an empty path segment")
host: str | None
if len(segments) >= 2 and segments[0] == PUBLIC_REGISTRY_HOST:
segments = _normalize_public_url(segments)
if len(segments) != 3:
raise _invalid(ref, _segment_reason(segments))
host = None
elif len(segments) == 3:
host = None
elif len(segments) == 4:
host = segments[0]
else:
raise _invalid(ref, _segment_reason(segments))
if version is not None and not _is_valid_version_constraint(version):
raise _invalid(ref, f"invalid version constraint '{version}'")
source = "/".join(segments)
if submodule is not None:
source = f"{source}//{submodule}"
return ModuleRef(
source=source,
submodule=submodule,
version=version,
is_local=False,
host=host,
)
+310
View File
@@ -0,0 +1,310 @@
"""Novafile parser — Dockerfile-compatible grammar for infrastructure modules.
A Novafile is practically a Dockerfile: same line grammar, case-insensitive
instructions, comments, and backslash continuations. The FROM instruction
takes Terraform module references instead of container images (D-010).
"""
from __future__ import annotations
import posixpath
from dataclasses import dataclass
from nova.parsers.moduleref import ModuleRef, ModuleRefError, parse_module_ref
_CONTAINER_ONLY = ("RUN", "CMD", "ENTRYPOINT", "HEALTHCHECK", "EXPOSE")
_UNSUPPORTED = ("COPY", "ADD")
class NovafileError(ValueError):
"""Raised when a Novafile cannot be parsed. Messages are BuildKit-style."""
@dataclass(frozen=True)
class ArgDef:
"""An ARG definition; a missing default means the arg is required at build time."""
name: str
default: str | None
@dataclass(frozen=True)
class NovafileStage:
"""One build stage: a FROM instruction and everything that follows it."""
name: str | None
module: ModuleRef
args: tuple[ArgDef, ...] = ()
env: tuple[tuple[str, str], ...] = ()
labels: tuple[tuple[str, str], ...] = ()
workdir: str = "/"
@dataclass(frozen=True)
class Novafile:
stages: tuple[NovafileStage, ...]
global_args: tuple[ArgDef, ...] = ()
warnings: tuple[str, ...] = ()
def _join_continuations(
text: str, filename: str
) -> list[tuple[str, int]]:
"""Split text into logical lines, joining backslash continuations.
Returns (logical_line, first_physical_line_number) pairs. Comment lines
and blank lines are preserved here and skipped by the instruction loop.
"""
physical = text.splitlines()
out: list[tuple[str, int]] = []
idx = 0
while idx < len(physical):
line = physical[idx]
first = idx + 1
while line.endswith("\\") and idx + 1 < len(physical):
line = line[:-1] + physical[idx + 1]
idx += 1
if line.endswith("\\"):
# Trailing continuation with nothing to join.
line = line[:-1]
out.append((line, first))
idx += 1
return out
def _unquote(value: str, line: int, filename: str) -> str:
"""Strip one layer of double quotes, honouring backslash escapes."""
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
inner = value[1:-1]
return inner.replace('\\"', '"').replace("\\\\", "\\")
return value
def _parse_pairs(
remainder: str, line: int, filename: str
) -> list[tuple[str, str]]:
"""Parse `key=value key2="quoted value"` sequences (Dockerfile ENV/LABEL form)."""
pairs: list[tuple[str, str]] = []
i = 0
n = len(remainder)
while i < n:
while i < n and remainder[i].isspace():
i += 1
if i >= n:
break
eq = remainder.find("=", i)
if eq == -1 or eq == i:
raise NovafileError(
f"{filename}:{line}:1: expected key=value pairs, got: {remainder[i:]!r}"
)
key = remainder[i:eq]
if not key or not key.strip():
raise NovafileError(
f"{filename}:{line}:1: expected key=value pairs, got: {remainder[i:]!r}"
)
key = key.strip()
i = eq + 1
if i < n and remainder[i] == '"':
i += 1
buf: list[str] = []
closed = False
while i < n:
ch = remainder[i]
if ch == "\\" and i + 1 < n and remainder[i + 1] in ('"', "\\"):
buf.append(remainder[i + 1])
i += 2
elif ch == '"':
i += 1
closed = True
break
else:
buf.append(ch)
i += 1
if not closed:
raise NovafileError(
f"{filename}:{line}:1: unterminated quoted value for {key!r}"
)
value = "".join(buf)
else:
j = i
while j < n and not remainder[j].isspace():
j += 1
value = remainder[i:j]
i = j
pairs.append((key, value))
return pairs
def _parse_arg(remainder: str, line: int, filename: str) -> ArgDef:
eq = remainder.find("=")
if eq == -1:
name = remainder.strip()
if not name or name != remainder:
raise NovafileError(
f"{filename}:{line}:1: ARG expects <name>[=<default>], got: {remainder!r}"
)
return ArgDef(name=name, default=None)
name = remainder[:eq].strip()
raw_default = remainder[eq + 1 :].strip()
if not name:
raise NovafileError(
f"{filename}:{line}:1: ARG expects <name>[=<default>], got: {remainder!r}"
)
return ArgDef(
name=name, default=_unquote(raw_default, line, filename) if raw_default else None
)
def parse_novafile(text: str, filename: str = "Novafile") -> Novafile:
"""Parse Novafile content into a :class:`Novafile` model."""
global_args: list[ArgDef] = []
warnings: list[str] = []
stages: list[NovafileStage] = []
# Mutable per-stage accumulators.
cur: dict | None = None
def close_stage() -> None:
nonlocal cur
if cur is not None:
stages.append(
NovafileStage(
name=cur["name"],
module=cur["module"],
args=tuple(cur["args"]),
env=tuple(cur["env"]),
labels=tuple(cur["labels"]),
workdir=cur["workdir"],
)
)
cur = None
saw_instruction = False
for raw_line, line_no in _join_continuations(text, filename):
line = raw_line.strip()
if not line:
continue
if line.startswith("#"):
directive = line[1:].strip()
if directive.startswith("escape=") and directive != "escape=\\":
warnings.append(
f"line {line_no}: non-default escape directive is treated as "
f"backslash in v0.1"
)
continue
parts = line.split(None, 1)
instruction = parts[0].upper()
remainder = parts[1].strip() if len(parts) > 1 else ""
if not saw_instruction and instruction not in ("FROM", "ARG"):
raise NovafileError(
f"{filename}:{line_no}:1: first instruction must be FROM"
)
saw_instruction = True
if instruction == "FROM":
close_stage()
ref_text, alias = _split_from(remainder, line_no, filename)
try:
module = parse_module_ref(ref_text)
except ModuleRefError as exc:
raise NovafileError(f"{filename}:{line_no}:1: {exc}") from exc
cur = {
"name": alias,
"module": module,
"args": [],
"env": [],
"labels": [],
"workdir": "/",
}
elif instruction == "ARG":
argdef = _parse_arg(remainder, line_no, filename)
if cur is None:
global_args.append(argdef)
else:
cur["args"].append(argdef)
elif instruction == "ENV":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: ENV must appear after a FROM"
)
if remainder and "=" in remainder.split()[0]:
cur["env"].extend(_parse_pairs(remainder, line_no, filename))
else:
split = remainder.split(None, 1)
if len(split) == 1:
raise NovafileError(
f"{filename}:{line_no}:1: ENV requires a value, got: {remainder!r}"
)
key, value = split[0], split[1].strip()
cur["env"].append((key, value))
elif instruction == "LABEL":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: LABEL must appear after a FROM"
)
cur["labels"].extend(_parse_pairs(remainder, line_no, filename))
elif instruction == "WORKDIR":
if cur is None:
raise NovafileError(
f"{filename}:{line_no}:1: WORKDIR must appear after a FROM"
)
path = remainder.strip()
if not path:
raise NovafileError(
f"{filename}:{line_no}:1: WORKDIR requires a path"
)
if path.startswith("/"):
cur["workdir"] = posixpath.normpath(path)
else:
cur["workdir"] = posixpath.normpath(
posixpath.join(cur["workdir"], path)
)
elif instruction in _CONTAINER_ONLY:
warnings.append(
f"line {line_no}: {instruction} is a container-only instruction "
f"and is ignored for infrastructure modules"
)
elif instruction in _UNSUPPORTED:
raise NovafileError(
f"{filename}:{line_no}:1: {instruction} is not supported in "
f"Novafiles - modules replace container filesystems"
)
else:
raise NovafileError(
f"{filename}:{line_no}:1: unknown instruction {parts[0].upper()}"
)
close_stage()
if not stages:
raise NovafileError(f"{filename}:0:1: no instructions found")
return Novafile(
stages=tuple(stages),
global_args=tuple(global_args),
warnings=tuple(warnings),
)
def _split_from(
remainder: str, line: int, filename: str
) -> tuple[str, str | None]:
"""Split `FROM <module-ref> [AS <name>]` into (ref_text, alias)."""
if not remainder:
raise NovafileError(f"{filename}:{line}:1: FROM requires a module reference")
parts = remainder.split()
ref_text = parts[0]
alias: str | None = None
if len(parts) == 2:
raise NovafileError(
f"{filename}:{line}:1: FROM expects '<module> [AS <name>]', got: {remainder!r}"
)
if len(parts) >= 3:
if parts[1].upper() != "AS":
raise NovafileError(
f"{filename}:{line}:1: FROM expects '<module> [AS <name>]', "
f"got: {remainder!r}"
)
alias = " ".join(parts[2:])
if not alias:
raise NovafileError(f"{filename}:{line}:1: AS requires a stage name")
return ref_text, alias
View File
+293
View File
@@ -0,0 +1,293 @@
"""Validated command behaviors with precise phase-landing errors (task P01-01-04).
The phase-1 "must-have" behavior table, exercised end to end through the
CliRunner: every lifecycle command parses its flags, discovers its
configuration file compose-style through the CLI itself, and fails with
the exact error naming the phase where its implementation lands — exit
code 1 throughout, never a crash.
Table rows, in test order:
1. no config anywhere -> discovery error for every lifecycle command
2. compose.yml in cwd -> each command lands in its documented phase
3. compose.yml + Novafile -> build reaches the phase-2 parser
4. explicit -f missing -> -f wins over discovery and hard-fails
5. upward search -> compose.yaml in a parent dir is found via the CLI
6. compose.yaml preference over compose.yml
7. version -> the one exit-0 path, printing the version
8. policy/env groups -> phase 5 / phase 3 landings
9. help surface -> Usage + docker-parity options; 13 commands listed
10. exit codes -> every surface error is exactly exit 1
"""
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Protocol
import click
import click.testing
import pytest
from nova.cli import main
from nova.cli.config_files import find_compose_file
runner = click.testing.CliRunner()
#: The 10 lifecycle commands that carry the compose-parity flag set.
LIFECYCLE_COMMANDS: tuple[str, ...] = (
"build",
"up",
"down",
"ps",
"stop",
"start",
"restart",
"rm",
"logs",
"config",
)
#: Lifecycle commands resolving a compose file (build resolves a Novafile).
COMPOSE_COMMANDS: tuple[str, ...] = LIFECYCLE_COMMANDS[1:]
#: The complete command surface: 10 lifecycle + policy + env + version.
ALL_COMMANDS: tuple[str, ...] = (*LIFECYCLE_COMMANDS, "policy", "env", "version")
MINIMAL_COMPOSE = (
"name: demo\n"
"services:\n"
" vpc:\n"
" module: terraform-aws-modules/vpc/aws\n"
)
MINIMAL_NOVAFILE = "FROM terraform-aws-modules/vpc/aws\n"
@pytest.fixture(autouse=True)
def _no_compose_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep NOVA_COMPOSE_FILE out of every behavior test (discovery isolation)."""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
class ProjectFactory(Protocol):
"""Callable creating an isolated project dir and making it the cwd."""
def __call__(
self,
files: Mapping[str, str] | None = None,
subdir: str | None = None,
) -> Path:
...
@pytest.fixture
def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ProjectFactory:
"""Create a project dir under tmp_path and chdir into it (monkeypatch.chdir).
``files`` maps relative paths (nested paths create their parents) to
content, so a project can ship a compose file, a Novafile, or both.
``subdir`` makes that subdirectory of the project the cwd, for the
upward-discovery row. Returns the project root.
"""
def _project(
files: Mapping[str, str] | None = None,
subdir: str | None = None,
) -> Path:
root = tmp_path / "proj"
root.mkdir(exist_ok=True)
for rel, content in (files or {}).items():
target = root / rel
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
cwd = root / subdir if subdir is not None else root
cwd.mkdir(parents=True, exist_ok=True)
monkeypatch.chdir(cwd)
return root
return _project
def run(*args: str) -> click.testing.Result:
"""Invoke `nova <args...>` through the shared CliRunner."""
return runner.invoke(main, list(args))
def output(result: click.testing.Result) -> str:
"""Everything the command printed, on stdout and stderr alike.
click >= 8.2 always separates the streams, so both are concatenated;
on older click versions (merged streams) ``output`` already carries
stderr and the ``stderr`` access raises — same fallback as the
P01-01-02 helper in test_main.py.
"""
try:
return result.output + getattr(result, "stderr", "")
except ValueError:
return result.output
# --- Row 1: no configuration file anywhere -------------------------------------------
@pytest.mark.parametrize("command", COMPOSE_COMMANDS)
def test_no_config_compose_commands_report_missing_configuration(
project: ProjectFactory, command: str
) -> None:
"""With no config in cwd (or any parent), every compose command fails on discovery."""
project()
result = run(command)
assert result.exit_code == 1
assert "no configuration file found" in output(result)
def test_no_config_build_reports_missing_novafile(project: ProjectFactory) -> None:
"""build needs a Novafile and reports it missing, docker-build style."""
project()
result = run("build")
assert result.exit_code == 1
printed = output(result)
assert "no such file" in printed
assert "Novafile" in printed
# --- Rows 2-3: a config file lets discovery pass; each command lands in its phase ----
@pytest.mark.parametrize(
("command", "phase"),
[
("up", 4),
("down", 4),
("ps", 4),
("stop", 4),
("start", 4),
("restart", 4),
("rm", 4),
("logs", 4),
],
)
def test_with_compose_file_each_command_lands_in_its_phase(
project: ProjectFactory, command: str, phase: int
) -> None:
"""A minimal compose.yml in cwd: engine commands land in phase 4."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run(command)
assert result.exit_code == 1
assert f"phase {phase}" in output(result)
def test_with_compose_file_config_exits_zero(project: ProjectFactory) -> None:
"""Since P2, `config` validates and prints the normalized compose file."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run("config")
assert result.exit_code == 0
assert "module: terraform-aws-modules/vpc/aws" in output(result)
def test_build_with_novafile_prints_manifest(project: ProjectFactory) -> None:
"""Since P2, `build` parses the Novafile and prints the module manifest."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run("build")
assert result.exit_code == 0
assert "module terraform-aws-modules/vpc/aws" in output(result)
# --- Row 4: explicit -f with a missing file ------------------------------------------
@pytest.mark.parametrize("command", ("up", "ps"))
def test_explicit_f_with_missing_file_beats_discovery(
project: ProjectFactory, command: str
) -> None:
"""An explicit -f wins over discovery and hard-fails naming the missing file."""
project(files={"compose.yml": MINIMAL_COMPOSE})
result = run(command, "-f", "nope.yml")
assert result.exit_code == 1
printed = output(result)
assert "no such file" in printed
assert "nope.yml" in printed
# --- Rows 5-6: discovery reaches the right file through the CLI ----------------------
def test_upward_search_finds_parent_compose_via_cli(project: ProjectFactory) -> None:
"""From a subdirectory, `logs` finds compose.yaml in the project root."""
project(files={"compose.yaml": MINIMAL_COMPOSE}, subdir="sub")
result = run("logs")
assert result.exit_code == 1
assert "phase 4" in output(result)
def test_compose_yaml_preferred_over_compose_yml(project: ProjectFactory) -> None:
"""With both files present, the CLI discovers compose.yaml, not compose.yml."""
root = project(files={"compose.yaml": MINIMAL_COMPOSE, "compose.yml": MINIMAL_COMPOSE})
result = run("up")
assert result.exit_code == 1
assert "phase 4" in output(result)
assert find_compose_file(root) == root / "compose.yaml"
# --- Rows 7-8: version and the placeholder groups ------------------------------------
def test_version_exits_zero() -> None:
"""`nova version` is the one success path in phase 1."""
result = run("version")
assert result.exit_code == 0
assert "0.1.0" in output(result)
@pytest.mark.parametrize(
("group", "phase"),
[("policy", "phase 5"), ("env", "phase 3")],
)
def test_placeholder_groups_land_in_their_phases(group: str, phase: str) -> None:
"""`policy` alone lands in phase 5; `env` alone lands in phase 3."""
result = run(group)
assert result.exit_code == 1
assert phase in output(result)
# --- Row 9: help surface --------------------------------------------------------------
@pytest.mark.parametrize("command", LIFECYCLE_COMMANDS)
def test_lifecycle_help_shows_usage_and_parity_options(command: str) -> None:
"""Every lifecycle `--help` shows Usage plus the docker-parity option set."""
result = run(command, "--help")
assert result.exit_code == 0
printed = output(result)
assert "Usage:" in printed
assert "--project-name" in printed
def test_root_help_lists_all_thirteen_commands() -> None:
"""`nova --help` lists the full 13-command surface."""
assert len(ALL_COMMANDS) == 13
result = run("--help")
assert result.exit_code == 0
printed = output(result)
for command in ALL_COMMANDS:
# word-boundary match: "start" must not be satisfied by "restart"
assert re.search(rf"\b{re.escape(command)}\b", printed), command
# --- Row 10: exit codes are exactly 1 -------------------------------------------------
@pytest.mark.parametrize("command", ("up", "rm", "logs"))
def test_phase_landings_exit_exactly_one(project: ProjectFactory, command: str) -> None:
"""Landing errors are exit 1 precisely (rows 1-2 assert the same for no-config)."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run(command)
assert result.exit_code == 1
assert "lands in phase" in output(result)
@pytest.mark.parametrize("command", ("build", "config"))
def test_parser_commands_exit_zero(project: ProjectFactory, command: str) -> None:
"""Since P2, build/config succeed (exit 0) on a valid project."""
project(files={"compose.yml": MINIMAL_COMPOSE, "Novafile": MINIMAL_NOVAFILE})
result = run(command)
assert result.exit_code == 0
+161
View File
@@ -0,0 +1,161 @@
"""End-to-end CLI tests for `nova config` and `nova build` (parser-backed behavior)."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from nova.cli.main import cli
COMPOSE = """
name: demo
services:
vpc:
module: terraform-aws-modules/vpc/aws
environment:
region: ${REGION:-us-east-1}
name: ${PROJECT:?project name is required}
"""
NOVAFILE = """\
FROM terraform-aws-modules/vpc/aws@5.0.0 AS vpc
ARG REGION
ENV region=us-east-1
LABEL owner=platform
WORKDIR /state
RUN echo ignored
"""
@pytest.fixture()
def project(tmp_path, monkeypatch):
"""An isolated project dir with compose.yml and a Novafile."""
proj = tmp_path / "proj"
proj.mkdir()
(proj / "compose.yml").write_text(COMPOSE)
(proj / "Novafile").write_text(NOVAFILE)
monkeypatch.chdir(proj)
return proj
@pytest.fixture()
def runner():
return CliRunner()
def _out(result) -> str:
return result.output + getattr(result, "stderr", "")
def test_config_prints_normalized_yaml(project, runner):
result = runner.invoke(cli, ["config"], env={"PROJECT": "demo"})
assert result.exit_code == 0, _out(result)
assert "services:" in result.output
assert "module: terraform-aws-modules/vpc/aws" in result.output
def test_config_interpolates_from_shell_env(project, runner, monkeypatch):
monkeypatch.setenv("REGION", "eu-west-1")
result = runner.invoke(cli, ["config"], env={"REGION": "eu-west-1", "PROJECT": "demo"})
assert result.exit_code == 0, _out(result)
assert "eu-west-1" in result.output
def test_config_env_file_precedence(project, runner, monkeypatch):
(project / ".env").write_text("REGION=from-dotenv\nPROJECT=from-dotenv\n")
(project / "extra.env").write_text("REGION=from-env-file\n")
monkeypatch.delenv("REGION", raising=False)
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config", "--env-file", "extra.env"])
assert result.exit_code == 0, _out(result)
# --env-file beats .env; shell env beats both (unset here).
assert "from-env-file" in result.output
assert "from-dotenv" not in result.output or "PROJECT" not in result.output
def test_config_dotenv_fallback(project, runner, monkeypatch):
(project / ".env").write_text("REGION=from-dotenv\nPROJECT=from-dotenv\n")
monkeypatch.delenv("REGION", raising=False)
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config"])
assert result.exit_code == 0, _out(result)
assert "from-dotenv" in result.output
def test_config_no_interpolate(project, runner):
result = runner.invoke(cli, ["config", "--no-interpolate"], env={"PROJECT": "x"})
assert result.exit_code == 0, _out(result)
assert "${REGION:-us-east-1}" in result.output
def test_config_required_var_missing(project, runner, monkeypatch):
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config"])
assert result.exit_code == 1, _out(result)
assert "project name is required" in _out(result)
def test_config_invalid_schema(project, runner):
(project / "compose.yml").write_text(
"services:\n a:\n module: hashicorp/vpc\n"
)
result = runner.invoke(cli, ["config"], env={"PROJECT": "x"})
assert result.exit_code == 1, _out(result)
assert "invalid module reference" in _out(result)
def test_config_missing_env_file_errors_cleanly(project, runner, monkeypatch):
"""A missing --env-file is a compose-style error, never a traceback (UX criterion)."""
monkeypatch.delenv("PROJECT", raising=False)
result = runner.invoke(cli, ["config", "--env-file", "nope.env"])
assert result.exit_code == 1, _out(result)
assert "no such file" in _out(result)
assert "Traceback" not in _out(result)
def test_config_missing_file(project, runner):
(project / "compose.yml").unlink()
result = runner.invoke(cli, ["config"])
assert result.exit_code == 1, _out(result)
assert "no configuration file found" in _out(result)
def test_build_prints_module_manifest(project, runner):
result = runner.invoke(cli, ["build"])
assert result.exit_code == 0, _out(result)
assert "module terraform-aws-modules/vpc/aws" in result.output
assert "version 5.0.0" in result.output
assert "stage vpc" in result.output
assert "arg REGION=(required)" in result.output
assert "workdir /state" in result.output
assert "Build manifest resolved: 1 stage(s)" in result.output
def test_build_warns_on_container_only_instructions(project, runner):
result = runner.invoke(cli, ["build"])
assert result.exit_code == 0, _out(result)
assert "WARNING" in _out(result)
assert "RUN is a container-only instruction" in _out(result)
def test_build_invalid_novafile(project, runner):
(project / "Novafile").write_text("FROM hashicorp/vpc\n")
result = runner.invoke(cli, ["build"])
assert result.exit_code == 1, _out(result)
assert "Error:" in _out(result)
assert "invalid module reference" in _out(result)
def test_build_missing_novafile(project, runner):
(project / "Novafile").unlink()
result = runner.invoke(cli, ["build"])
assert result.exit_code == 1, _out(result)
assert "no such file" in _out(result)
def test_build_with_explicit_novafile(project, runner):
alt = project / "Custom.Novafile"
alt.write_text("FROM hashicorp/consul/aws\n")
result = runner.invoke(cli, ["build", "-f", "Custom.Novafile"])
assert result.exit_code == 0, _out(result)
assert "hashicorp/consul/aws" in result.output
+188
View File
@@ -0,0 +1,188 @@
"""Config file discovery tests (task P01-01-03).
Pure unit tests — no CliRunner, no CLI process: every discovery rule is
exercised directly against tmp_path directory trees, mirroring the
compose-spec rules verified in .ciagent/RESEARCH.md area 3.
"""
from pathlib import Path
import pytest
from nova.cli.config_files import (
NovaFileError,
find_compose_file,
find_novafile,
load_env_files,
)
NO_CONFIG = "no configuration file found: specify a file with -f"
@pytest.fixture(autouse=True)
def _no_compose_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep NOVA_COMPOSE_FILE out of every discovery test unless set explicitly."""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
def _write(path: Path, content: str = "# nova config\n") -> Path:
"""Create a config file (discovery only checks existence, parsers land in P2)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
# --- find_compose_file: explicit -f files ------------------------------------------
def test_explicit_files_first_is_primary(tmp_path: Path) -> None:
"""Explicit files: the first is primary once every listed file exists."""
first = _write(tmp_path / "one.yml")
second = _write(tmp_path / "two.yml")
assert find_compose_file(tmp_path, [first, second]) == first
def test_explicit_files_missing_raises(tmp_path: Path) -> None:
"""Every explicit file must exist; a missing one is a hard error."""
present = _write(tmp_path / "one.yml")
missing = tmp_path / "two.yml"
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(tmp_path, [present, missing])
assert str(excinfo.value) == f"no such file: {missing}"
def test_explicit_file_relative_resolves_against_workdir(tmp_path: Path) -> None:
"""Relative -f paths anchor to the workdir (click hands us raw strings)."""
_write(tmp_path / "compose.yaml")
assert find_compose_file(tmp_path, [Path("compose.yaml")]) == tmp_path / "compose.yaml"
# --- find_compose_file: NOVA_COMPOSE_FILE env var ----------------------------------
def test_env_var_single_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""NOVA_COMPOSE_FILE supplies the compose file without any directory search."""
custom = _write(tmp_path / "custom.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(custom))
assert find_compose_file(tmp_path) == custom
def test_env_var_multiple_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A `:`-separated NOVA_COMPOSE_FILE resolves with the first as primary."""
one = _write(tmp_path / "one.yml")
two = _write(tmp_path / "two.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", f"{one}:{two}")
assert find_compose_file(tmp_path) == one
def test_env_var_missing_path_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Env-var paths get the same existence validation as -f files."""
missing = tmp_path / "gone.yml"
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(missing))
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(tmp_path)
assert str(excinfo.value) == f"no such file: {missing}"
def test_explicit_files_beat_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Precedence: -f files win over NOVA_COMPOSE_FILE."""
explicit = _write(tmp_path / "explicit.yml")
from_env = _write(tmp_path / "from-env.yml")
monkeypatch.setenv("NOVA_COMPOSE_FILE", str(from_env))
assert find_compose_file(tmp_path, [explicit]) == explicit
# --- find_compose_file: upward directory search ------------------------------------
def test_upward_search_finds_parent_compose_yaml(tmp_path: Path) -> None:
"""Without -f/env, discovery walks up parent directories to a compose file."""
compose = _write(tmp_path / "a" / "compose.yaml")
nested = tmp_path / "a" / "b" / "c"
nested.mkdir(parents=True)
assert find_compose_file(nested) == compose
def test_upward_search_prefers_compose_yaml_in_same_dir(tmp_path: Path) -> None:
"""compose.yaml beats compose.yml within the same directory."""
yaml = _write(tmp_path / "compose.yaml")
_write(tmp_path / "compose.yml")
assert find_compose_file(tmp_path) == yaml
def test_upward_search_compose_yml_fallback(tmp_path: Path) -> None:
"""compose.yml alone is a valid hit when compose.yaml is absent."""
yml = _write(tmp_path / "compose.yml")
assert find_compose_file(tmp_path) == yml
def test_no_config_found_raises(tmp_path: Path) -> None:
"""Nothing anywhere up to the root: the exact compose-style error."""
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(NovaFileError) as excinfo:
find_compose_file(empty)
assert str(excinfo.value) == NO_CONFIG
# --- find_novafile (docker build parity) ---------------------------------------------
def test_novafile_default_in_workdir(tmp_path: Path) -> None:
"""Default lookup is <workdir>/Novafile, nothing else."""
novafile = _write(tmp_path / "Novafile", "FROM demo\n")
assert find_novafile(tmp_path) == novafile
def test_novafile_no_upward_search(tmp_path: Path) -> None:
"""Dockerfile parity: a Novafile in a parent directory is never found."""
_write(tmp_path / "Novafile", "FROM demo\n")
nested = tmp_path / "nested"
nested.mkdir()
with pytest.raises(NovaFileError) as excinfo:
find_novafile(nested)
assert str(excinfo.value) == f"no such file: {nested / 'Novafile'}"
def test_novafile_override(tmp_path: Path) -> None:
"""An explicit build -f path replaces the default Novafile location."""
_write(tmp_path / "Novafile", "FROM demo\n")
custom = _write(tmp_path / "Novafile.prod", "FROM demo\n")
assert find_novafile(tmp_path, Path("Novafile.prod")) == custom
# --- load_env_files ------------------------------------------------------------------
def test_env_file_parsing_comments_blanks_and_quotes(tmp_path: Path) -> None:
"""KEY=VALUE lines parse; comments/blanks skip; surrounding quotes strip."""
env_file = _write(
tmp_path / "test.env",
"# comment line\n"
"\n"
"PLAIN=value\n"
'DOUBLE="quoted value"\n'
"SINGLE='single value'\n"
"EMPTY=\n",
)
assert load_env_files([env_file]) == {
"PLAIN": "value",
"DOUBLE": "quoted value",
"SINGLE": "single value",
"EMPTY": "",
}
def test_env_files_later_overrides_earlier(tmp_path: Path) -> None:
"""Later files override earlier keys; untouched keys survive."""
first = _write(tmp_path / "first.env", "A=1\nB=keep\n")
second = _write(tmp_path / "second.env", "A=2\n")
assert load_env_files([first, second]) == {"A": "2", "B": "keep"}
def test_env_files_missing_raises(tmp_path: Path) -> None:
"""A missing env file is a hard error naming the path."""
missing = tmp_path / "nope.env"
with pytest.raises(NovaFileError) as excinfo:
load_env_files([missing])
assert str(excinfo.value) == f"no such file: {missing}"
+226
View File
@@ -0,0 +1,226 @@
"""CLI surface tests: docker-ergonomic command surface (task P01-01-02).
Every lifecycle command parses its full flag set (click rejects unknown
options with exit code 2), resolves its configuration file compose-style
(P01-01-03), then reports the later phase in which its implementation
lands with exit code 1.
"""
import os
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import click
import click.testing
import pytest
from nova.cli import main
runner = click.testing.CliRunner()
@contextmanager
def _project_dir(
tmp_path: Path,
files: tuple[str, ...] | dict[str, str] = ("compose.yml",),
) -> Iterator[Path]:
"""Run with cwd inside a fresh project dir containing the named files.
Since P01-01-03, lifecycle handlers discover a compose file (build: a
Novafile) against ``Path.cwd()`` before reporting their phase landing,
so the landing tests need a minimal project directory to land in.
``files=()`` yields an empty project — the no-config case. A dict maps
file names to explicit content.
"""
root = Path(tempfile.mkdtemp(dir=tmp_path))
if isinstance(files, dict):
for name, content in files.items():
(root / name).write_text(content, encoding="utf-8")
else:
for name in files:
(root / name).write_text("# minimal nova config\n", encoding="utf-8")
previous = Path.cwd()
os.chdir(root)
try:
yield root
finally:
os.chdir(previous)
def _visible_output(result: click.testing.Result) -> str:
"""Everything the command printed, on stdout and stderr alike.
click >= 8.2 always separates the streams and ``Result.output`` already
mixes them in terminal order; concatenating ``stderr`` as well keeps
older click versions (where ``output`` may be stdout-only and stderr is
accessed separately) working. See the P01-01-02 task note.
"""
try:
return result.output + getattr(result, "stderr", "")
except ValueError:
# click < 8.2 with merged streams: output already contains stderr.
return result.output
def test_help_lists_all_commands() -> None:
"""`nova --help` shows the full command surface."""
result = runner.invoke(main, ["--help"])
assert result.exit_code == 0
output = _visible_output(result)
assert "Usage:" in output
for command in (
"build",
"up",
"down",
"ps",
"stop",
"start",
"restart",
"rm",
"logs",
"config",
"policy",
"env",
"version",
):
assert command in output, f"`nova --help` should list `{command}`"
def test_flag_parsing_up(tmp_path: Path) -> None:
"""`up` accepts every compose-parity flag, then reports the phase-4 engine."""
with _project_dir(tmp_path, files=("a.yml", "b.yml")):
result = runner.invoke(
main,
[
"up",
"-d",
"-f",
"a.yml",
"-f",
"b.yml",
"-p",
"proj",
"--env-file",
"e1",
"--profile",
"p1",
],
)
assert result.exit_code == 1
output = _visible_output(result)
assert "phase 4" in output
assert "Error: the 'up' engine lands in phase 4 (IaC adapter)" in output
def test_down_volumes_flag(tmp_path: Path) -> None:
"""`down -v` parses the volumes flag, then reports the phase-4 engine."""
with _project_dir(tmp_path):
result = runner.invoke(main, ["down", "-v"])
assert result.exit_code == 1
assert "phase 4" in _visible_output(result)
def test_build_config_real_behavior(tmp_path: Path) -> None:
"""Since P2, `build` and `config` run the real parsers.
`build` prints the resolved module manifest (exit 0); `config` prints
the normalized compose YAML (exit 0). Phase-2 landing errors are gone.
"""
files = {
"Novafile": "FROM terraform-aws-modules/vpc/aws@5.0.0\n",
"compose.yml": (
"name: demo\n"
"services:\n"
" vpc:\n"
" module: terraform-aws-modules/vpc/aws\n"
),
}
with _project_dir(tmp_path, files=files):
result = runner.invoke(main, ["build"])
assert result.exit_code == 0
assert "module terraform-aws-modules/vpc/aws" in _visible_output(result)
result = runner.invoke(main, ["config"])
assert result.exit_code == 0
assert "module: terraform-aws-modules/vpc/aws" in _visible_output(result)
def test_policy_env_groups() -> None:
"""`policy`/`env` groups report their phase-5/phase-3 landings."""
result = runner.invoke(main, ["policy"])
assert result.exit_code == 1
assert (
"Error: nova policy lands in phase 5 (vendored c7n + c7n-left)"
in _visible_output(result)
)
result = runner.invoke(main, ["env"])
assert result.exit_code == 1
assert (
"Error: nova env lands in phase 3 (environment discovery)"
in _visible_output(result)
)
def test_version_still_works() -> None:
"""`nova version` still prints the installed version and exits 0."""
result = runner.invoke(main, ["version"])
assert result.exit_code == 0
assert "0.1.0" in _visible_output(result)
def test_lifecycle_extras_parse(tmp_path: Path) -> None:
"""Per-command extras parse cleanly (an unknown option would exit 2)."""
cases = [
["down", "-v", "--profile", "prod"],
["ps", "-a"],
["stop"],
["start"],
["restart"],
["rm", "-f"],
["rm", "--file", "nova.yml", "--force"],
["logs", "web", "db", "-d"],
]
for args in cases:
with _project_dir(tmp_path, files=("compose.yml", "nova.yml")):
result = runner.invoke(main, args)
assert result.exit_code == 1, args
output = _visible_output(result)
assert "Error:" in output, args
assert "lands in phase" in output, args
def test_config_extras_parse(tmp_path: Path) -> None:
"""config's extra flags parse cleanly; with no compose file it still errors."""
with _project_dir(tmp_path, files=()):
result = runner.invoke(
main, ["config", "--no-interpolate", "-p", "proj"]
)
assert result.exit_code == 1
assert "no configuration file found" in _visible_output(result)
def test_lifecycle_without_config_reports_missing_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Without any config file, discovery fails before the phase landing.
`up` finds no compose file anywhere up to the root (the exact
compose-style message); `build` finds no Novafile in the working
directory (docker build parity: no upward search).
"""
monkeypatch.delenv("NOVA_COMPOSE_FILE", raising=False)
with _project_dir(tmp_path, files=()) as project:
result = runner.invoke(main, ["up"])
assert result.exit_code == 1
output = _visible_output(result)
assert (
"Error: no configuration file found: specify a file with -f" in output
)
assert "phase 4" not in output
result = runner.invoke(main, ["build"])
assert result.exit_code == 1
assert f"no such file: {project / 'Novafile'}" in _visible_output(result)
+1
View File
@@ -0,0 +1 @@
"""Tests for the nova.parsers package."""
+331
View File
@@ -0,0 +1,331 @@
"""Tests for the compose.yml loader schema (compose-indistinguishable subset)."""
import pytest
import yaml
from nova.parsers.compose import ComposeParseError, parse_compose, render_compose
MINIMAL = """
name: demo
services:
vpc:
module: terraform-aws-modules/vpc/aws
environment:
name: demo
"""
def test_minimal_file_parses():
spec = parse_compose(MINIMAL)
assert spec.name == "demo"
assert len(spec.services) == 1
svc = spec.services[0]
assert svc.name == "vpc"
assert svc.module is not None
assert svc.module.source == "terraform-aws-modules/vpc/aws"
assert svc.environment == (("name", "demo"),)
def test_providers_block_stored():
spec = parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws
providers:
aws:
region: us-east-1
profile: prod
"""
)
assert spec.providers == {"aws": {"region": "us-east-1", "profile": "prod"}}
def test_module_version_key_pins():
spec = parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws
version: "5.0.0"
"""
)
assert spec.services[0].module is not None
assert spec.services[0].module.version == "5.0.0"
def test_version_sugar_and_key_conflict():
with pytest.raises(ComposeParseError, match="version specified twice"):
parse_compose(
"""
services:
vpc:
module: terraform-aws-modules/vpc/aws@5.0.0
version: "5.0.0"
"""
)
def test_environment_mapping_and_list_forms():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
environment:
K1: v1
K2: v2
b:
module: terraform-aws-modules/vpc/aws
environment:
- K3=v3
"""
)
assert spec.services[0].environment == (("K1", "v1"), ("K2", "v2"))
assert spec.services[1].environment == (("K3", "v3"),)
def test_vars_alias_accepted():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
vars:
K1: v1
"""
)
assert spec.services[0].environment == (("K1", "v1"),)
def test_vars_and_environment_conflict():
with pytest.raises(ComposeParseError, match="use only one"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
vars: {K: v}
environment: {K: v}
"""
)
def test_env_file_single_and_list():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
env_file: .env
b:
module: terraform-aws-modules/vpc/aws
env_file:
- one.env
- two.env
"""
)
assert spec.services[0].env_files == (".env",)
assert spec.services[1].env_files == ("one.env", "two.env")
def test_depends_on_short_and_long_forms():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
b:
module: terraform-aws-modules/vpc/aws
depends_on:
- a
c:
module: terraform-aws-modules/vpc/aws
depends_on:
a:
condition: module_deployed
"""
)
assert spec.services[1].depends_on == ("a",)
assert spec.services[2].depends_on == ("a",)
def test_depends_on_unknown_service():
with pytest.raises(ComposeParseError, match="unknown service 'ghost'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
depends_on:
- ghost
"""
)
def test_depends_on_self_is_error():
with pytest.raises(ComposeParseError, match="cannot depend on itself"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
depends_on:
- a
"""
)
def test_working_dir_profiles_provider():
spec = parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
working_dir: /state/prod
profiles:
- prod
provider: AWS
"""
)
svc = spec.services[0]
assert svc.working_dir == "/state/prod"
assert svc.profiles == ("prod",)
assert svc.provider == "aws"
def test_invalid_provider_rejected():
with pytest.raises(ComposeParseError, match="provider must be one of"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
provider: openstack
"""
)
def test_build_form_defaults_and_explicit():
spec = parse_compose(
"""
services:
a:
build: {}
b:
build:
context: ./infra
novafile: Custom.Novafile
"""
)
assert spec.services[0].build is not None
assert spec.services[0].build.context == "."
assert spec.services[0].build.novafile == "Novafile"
assert spec.services[1].build.context == "./infra"
assert spec.services[1].build.novafile == "Custom.Novafile"
def test_module_and_build_both_rejected():
with pytest.raises(ComposeParseError, match="not both"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
build: {}
"""
)
def test_neither_module_nor_build_rejected():
with pytest.raises(ComposeParseError, match="requires a 'module'"):
parse_compose("services:\n a: {}\n")
def test_unknown_top_level_key():
with pytest.raises(ComposeParseError, match="top-level key 'networks'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
networks: {}
"""
)
def test_top_level_not_mapping():
with pytest.raises(ComposeParseError, match="expected a mapping"):
parse_compose("- just\n- a list\n")
def test_services_not_mapping():
with pytest.raises(ComposeParseError, match="'services' must be a mapping"):
parse_compose("services: []\n")
def test_unknown_provider_in_providers_block():
with pytest.raises(ComposeParseError, match="unknown provider 'openstack'"):
parse_compose(
"""
services:
a:
module: terraform-aws-modules/vpc/aws
providers:
openstack: {}
"""
)
def test_invalid_module_ref_surfaces_with_service():
with pytest.raises(ComposeParseError, match="service 'a': invalid module reference"):
parse_compose(
"""
services:
a:
module: hashicorp/vpc
"""
)
def test_interpolation_applied_to_values():
spec = parse_compose(
"""
name: ${PROJECT}
services:
a:
module: terraform-aws-modules/vpc/aws
environment:
region: ${REGION:-us-east-1}
""",
env={"PROJECT": "demo-project", "REGION": "eu-west-1"},
)
assert spec.name == "demo-project"
assert spec.services[0].environment == (("region", "eu-west-1"),)
def test_interpolation_disabled_when_env_none():
spec = parse_compose(
"services:\n a:\n module: terraform-aws-modules/vpc/aws\n"
" environment:\n region: ${REGION:-us-east-1}\n"
)
assert spec.services[0].environment == (("region", "${REGION:-us-east-1}"),)
def test_render_compose_round_trip():
spec = parse_compose(MINIMAL)
rendered = render_compose(spec)
assert rendered["name"] == "demo"
assert rendered["services"]["vpc"]["module"] == "terraform-aws-modules/vpc/aws"
assert rendered["services"]["vpc"]["environment"] == {"name": "demo"}
# Round trip: rendered dict re-parses to the same model.
reparsed = parse_compose(rendered)
assert reparsed.name == spec.name
assert reparsed.services[0].module == spec.services[0].module
assert reparsed.services[0].environment == spec.services[0].environment
def test_render_is_yaml_serializable():
spec = parse_compose(MINIMAL)
text = yaml.safe_dump(render_compose(spec), sort_keys=False)
assert "module: terraform-aws-modules/vpc/aws" in text
+83
View File
@@ -0,0 +1,83 @@
"""Tests for compose-style variable interpolation."""
import pytest
from nova.parsers.interp import InterpolationError, interpolate
def test_bare_var_set_and_unset():
assert interpolate("hello $NAME", {"NAME": "world"}) == "hello world"
assert interpolate("hello $NAME", {}) == "hello "
def test_braced_var_set_and_unset():
assert interpolate("${VAR}", {"VAR": "x"}) == "x"
assert interpolate("${VAR}", {}) == ""
def test_default_when_unset():
assert interpolate("${VAR:-fallback}", {}) == "fallback"
assert interpolate("${VAR:-fallback}", {"VAR": "set"}) == "set"
def test_default_when_empty():
assert interpolate("${VAR:-fallback}", {"VAR": ""}) == "fallback"
assert interpolate("${VAR-fallback}", {"VAR": ""}) == ""
def test_dash_default_only_when_unset():
assert interpolate("${VAR-fallback}", {}) == "fallback"
def test_required_error_with_message():
with pytest.raises(InterpolationError, match="VAR: region is required"):
interpolate("${VAR:?region is required}", {})
def test_required_error_when_empty():
with pytest.raises(InterpolationError, match="VAR"):
interpolate("${VAR:?}", {"VAR": ""})
def test_required_passes_when_set():
assert interpolate("${VAR:?need}", {"VAR": "ok"}) == "ok"
def test_question_mark_only_when_unset():
assert interpolate("${VAR?need}", {"VAR": ""}) == ""
with pytest.raises(InterpolationError):
interpolate("${VAR?need}", {})
def test_alternate_when_set():
assert interpolate("${VAR:+alt}", {"VAR": "set"}) == "alt"
assert interpolate("${VAR:+alt}", {}) == ""
assert interpolate("${VAR:+alt}", {"VAR": ""}) == ""
def test_plus_alternate_when_set_even_empty():
assert interpolate("${VAR+alt}", {"VAR": ""}) == "alt"
def test_dollar_dollar_literal():
assert interpolate("cost $$5", {}) == "cost $5"
assert interpolate("$$VAR", {}) == "$VAR"
def test_lone_dollar_literal():
assert interpolate("100% of $", {}) == "100% of $"
assert interpolate("a$1b", {}) == "a$1b"
def test_mixed_operators_in_one_string():
env = {"A": "1"}
assert interpolate("x${A}y${B:-z}w", env) == "x1yzw"
def test_invalid_braced_name():
with pytest.raises(InterpolationError, match="1BAD"):
interpolate("${1BAD}", {})
def test_no_interpolation_needed():
assert interpolate("plain text", {}) == "plain text"
+236
View File
@@ -0,0 +1,236 @@
"""Module reference resolver tests (task P02-01-01).
Pure unit tests for the Terraform module source grammar verified in
.ciagent/RESEARCH.md area 1: registry shorthand, private registry hosts,
public registry web-URL normalization, ``//subdir`` submodules, ``@version``
sugar, and local path sources — with the exact docker/compose-style error
messages for every rejected shape.
"""
import pytest
from nova.parsers.moduleref import (
ModuleRef,
ModuleRefError,
parse_module_ref,
)
# --- valid registry references -------------------------------------------------------
def test_public_registry_shorthand() -> None:
"""Three segments resolve to public shorthand with no host or version."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws",
submodule=None,
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_private_registry_four_segments() -> None:
"""Four segments carry a private registry hostname in host."""
ref = parse_module_ref("app.terraform.io/example-corp/k8s-cluster/azurerm")
assert ref == ModuleRef(
source="app.terraform.io/example-corp/k8s-cluster/azurerm",
submodule=None,
version=None,
is_local=False,
host="app.terraform.io",
)
assert ref.terraform_source == "app.terraform.io/example-corp/k8s-cluster/azurerm"
def test_public_registry_url_normalizes_to_shorthand() -> None:
"""The registry.terraform.io/<ns>/<name>/<provider> web URL drops its host."""
ref = parse_module_ref("registry.terraform.io/terraform-aws-modules/vpc/aws")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws",
submodule=None,
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_public_registry_modules_url_normalizes_to_shorthand() -> None:
"""The /modules/ web-URL form also normalizes to plain shorthand."""
ref = parse_module_ref("registry.terraform.io/modules/terraform-aws-modules/vpc/aws")
assert ref.source == "terraform-aws-modules/vpc/aws"
assert ref.host is None
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_submodule_marker() -> None:
"""// selects a submodule; it stays part of the emitted source."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws//modules/vpc-endpoint")
assert ref == ModuleRef(
source="terraform-aws-modules/vpc/aws//modules/vpc-endpoint",
submodule="modules/vpc-endpoint",
version=None,
is_local=False,
host=None,
)
assert ref.terraform_source == "terraform-aws-modules/vpc/aws//modules/vpc-endpoint"
# --- version sugar -------------------------------------------------------------------
def test_version_sugar_plain() -> None:
"""@1.2.3 splits into version="1.2.3", leaving a clean source."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@1.2.3")
assert ref.source == "terraform-aws-modules/vpc/aws"
assert ref.version == "1.2.3"
assert ref.terraform_source == "terraform-aws-modules/vpc/aws"
def test_version_sugar_pessimistic() -> None:
"""@~> 6.0 keeps the pessimistic operator in the version field."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@~> 6.0")
assert ref.version == "~> 6.0"
assert ref.source == "terraform-aws-modules/vpc/aws"
def test_version_sugar_comma_separated_range() -> None:
"""@>= 1.2, < 2.0 keeps the full constraint, spaces and all."""
ref = parse_module_ref("terraform-aws-modules/vpc/aws@>= 1.2, < 2.0")
assert ref.version == ">= 1.2, < 2.0"
assert ref.source == "terraform-aws-modules/vpc/aws"
# --- local sources --------------------------------------------------------------------
@pytest.mark.parametrize(
"path", ["./modules/vpc", "../shared/vpc", "/opt/nova/modules/vpc"]
)
def test_local_sources(path: str) -> None:
"""./ ../ and absolute paths stay as-is and never carry a version."""
ref = parse_module_ref(path)
assert ref == ModuleRef(
source=path, submodule=None, version=None, is_local=True, host=None
)
assert ref.terraform_source == path
def test_private_host_submodule_and_version_combo() -> None:
"""Private host + //submodule + @version all resolve together."""
ref = parse_module_ref(
"app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam@>= 1.2, < 2.0"
)
assert ref == ModuleRef(
source="app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam",
submodule="modules/iam",
version=">= 1.2, < 2.0",
is_local=False,
host="app.terraform.io",
)
assert ref.terraform_source == (
"app.terraform.io/example-corp/k8s-cluster/azurerm//modules/iam"
)
def test_surrounding_whitespace_stripped() -> None:
"""Leading/trailing whitespace never reaches the source."""
ref = parse_module_ref("\t terraform-aws-modules/vpc/aws \n")
assert ref.source == "terraform-aws-modules/vpc/aws"
# --- rejected references --------------------------------------------------------------
def test_two_segments_rejected() -> None:
"""The exact compose-style message for a 2-segment registry reference."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("hashicorp/vpc")
assert str(excinfo.value) == (
"invalid module reference 'hashicorp/vpc': "
"registry references need <namespace>/<name>/<provider>"
)
@pytest.mark.parametrize(
("ref", "reason"),
[
("app.terraform.io", "bare registry hostname without <namespace>/<name>/<provider>"),
("vpc", "registry references need <namespace>/<name>/<provider>"),
],
)
def test_one_segment_rejected(ref: str, reason: str) -> None:
"""Bare hostnames and bare names are not registry references."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == f"invalid module reference '{ref}': {reason}"
def test_five_segments_rejected() -> None:
"""Five segments overshoot the 4-segment private form."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("a/b/c/d/e")
assert str(excinfo.value) == (
"invalid module reference 'a/b/c/d/e': "
"registry references allow at most <hostname>/<namespace>/<name>/<provider>"
)
@pytest.mark.parametrize("ref", ["", " ", "\t"])
def test_empty_reference_rejected(ref: str) -> None:
"""Empty (or whitespace-only) references never parse."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == (
f"invalid module reference '{ref}': module source cannot be empty"
)
@pytest.mark.parametrize(
"path", ["./modules/vpc", "../shared/vpc", "/opt/nova/modules/vpc"]
)
def test_version_on_local_rejected(path: str) -> None:
"""Version pinning is a registry-only feature (exact message)."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(f"{path}@1.2.3")
assert str(excinfo.value) == (
f"invalid module reference '{path}@1.2.3': "
"local module sources do not support version pinning"
)
@pytest.mark.parametrize(
("version", "reason"),
[
("1.2.3.4.5", "invalid version constraint '1.2.3.4.5'"),
("foo", "invalid version constraint 'foo'"),
("", "invalid version constraint ''"),
],
)
def test_invalid_version_constraint_rejected(version: str, reason: str) -> None:
"""Non-constraint strings after @ are rejected with their own reason."""
ref = f"terraform-aws-modules/vpc/aws@{version}"
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref(ref)
assert str(excinfo.value) == f"invalid module reference '{ref}': {reason}"
def test_double_slash_start_rejected() -> None:
"""A submodule marker without a leading source is invalid."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("//modules/vpc")
assert str(excinfo.value) == (
"invalid module reference '//modules/vpc': module source cannot start with '//'"
)
def test_empty_path_segment_rejected() -> None:
"""A trailing slash leaves an empty provider segment."""
with pytest.raises(ModuleRefError) as excinfo:
parse_module_ref("terraform-aws-modules/vpc/aws/")
assert str(excinfo.value) == (
"invalid module reference 'terraform-aws-modules/vpc/aws/': "
"module source contains an empty path segment"
)
+217
View File
@@ -0,0 +1,217 @@
"""Tests for the Novafile parser (Dockerfile-compatible grammar)."""
import pytest
from nova.parsers.moduleref import ModuleRef
from nova.parsers.novafile import NovafileError, parse_novafile
def test_case_insensitive_instructions():
nv = parse_novafile(
"from terraform-aws-modules/vpc/aws\n"
"env REGION=us-east-1\n"
"label owner=platform\n"
)
assert nv.stages[0].module.source == "terraform-aws-modules/vpc/aws"
assert nv.stages[0].env == (("REGION", "us-east-1"),)
assert nv.stages[0].labels == (("owner", "platform"),)
def test_comments_and_blank_lines_skipped():
text = (
"# a comment\n"
"\n"
" # indented comment\n"
"FROM terraform-aws-modules/vpc/aws\n"
"# trailing comment\n"
)
nv = parse_novafile(text)
assert len(nv.stages) == 1
def test_line_continuations():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"ENV A=1 \\\n"
" B=2\n"
)
nv = parse_novafile(text)
assert nv.stages[0].env == (("A", "1"), ("B", "2"))
def test_env_legacy_form_preserves_inner_spaces():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"ENV MESSAGE hello world with spaces\n"
)
nv = parse_novafile(text)
assert nv.stages[0].env == (("MESSAGE", "hello world with spaces"),)
def test_env_quoted_values_with_escapes():
text = (
'FROM terraform-aws-modules/vpc/aws\n'
'ENV A="quoted value" B="say \\"hi\\"" C=plain\n'
)
nv = parse_novafile(text)
assert nv.stages[0].env == (
("A", "quoted value"),
('B', 'say "hi"'),
("C", "plain"),
)
def test_arg_with_and_without_default():
text = (
"ARG VERSION=1.2.3\n"
"FROM terraform-aws-modules/vpc/aws\n"
"ARG REGION\n"
)
nv = parse_novafile(text)
assert nv.global_args[0].name == "VERSION"
assert nv.global_args[0].default == "1.2.3"
assert nv.stages[0].args[0].name == "REGION"
assert nv.stages[0].args[0].default is None
def test_arg_semantics_globally_scoped():
text = (
"ARG VERSION=1.2.3\n"
"FROM terraform-aws-modules/vpc/aws\n"
"ARG REGION\n"
)
nv = parse_novafile(text)
assert [a.name for a in nv.global_args] == ["VERSION"]
assert [a.name for a in nv.stages[0].args] == ["REGION"]
def test_multi_stage_with_alias():
text = (
"FROM terraform-aws-modules/vpc/aws AS base\n"
"ENV A=1\n"
"FROM hashicorp/consul/aws AS consul\n"
"ENV B=2\n"
)
nv = parse_novafile(text)
assert len(nv.stages) == 2
assert nv.stages[0].name == "base"
assert nv.stages[0].env == (("A", "1"),)
assert nv.stages[1].name == "consul"
assert nv.stages[1].env == (("B", "2"),)
def test_workdir_accumulates_relative():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"WORKDIR /state\n"
"WORKDIR vpc\n"
"WORKDIR nested\n"
)
nv = parse_novafile(text)
assert nv.stages[0].workdir == "/state/vpc/nested"
def test_workdir_absolute_resets():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"WORKDIR /a/b\n"
"WORKDIR /fresh\n"
)
nv = parse_novafile(text)
assert nv.stages[0].workdir == "/fresh"
def test_container_only_instructions_warn():
text = (
"FROM terraform-aws-modules/vpc/aws\n"
"RUN echo hi\n"
"CMD [\"serve\"]\n"
"EXPOSE 8080\n"
)
nv = parse_novafile(text)
assert len(nv.warnings) == 3
assert "line 2: RUN is a container-only instruction" in nv.warnings[0]
assert "CMD" in nv.warnings[1]
assert "EXPOSE" in nv.warnings[2]
def test_copy_is_hard_error():
with pytest.raises(NovafileError, match="COPY is not supported in Novafiles"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nCOPY . /app\n")
def test_add_is_hard_error():
with pytest.raises(NovafileError, match="ADD is not supported in Novafiles"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nADD . /app\n")
def test_unknown_instruction():
with pytest.raises(NovafileError, match="unknown instruction FOO"):
parse_novafile("FROM terraform-aws-modules/vpc/aws\nFOO bar\n")
def test_first_instruction_must_be_from():
with pytest.raises(NovafileError, match="first instruction must be FROM"):
parse_novafile("ENV A=1\nFROM terraform-aws-modules/vpc/aws\n")
def test_empty_file():
with pytest.raises(NovafileError, match="no instructions found"):
parse_novafile("# only comments\n\n")
def test_error_carries_filename_and_line():
with pytest.raises(NovafileError) as excinfo:
parse_novafile("FROM terraform-aws-modules/vpc/aws\nFOO x\n", "MyNovafile")
assert str(excinfo.value).startswith("MyNovafile:2:1:")
def test_invalid_module_ref_in_from_reports_line():
with pytest.raises(NovafileError) as excinfo:
parse_novafile("FROM hashicorp/vpc\n")
assert "invalid module reference" in str(excinfo.value)
assert "Novafile:1:1:" in str(excinfo.value)
def test_module_ref_parsed_in_from():
nv = parse_novafile("FROM terraform-aws-modules/vpc/aws@5.0.0\n")
assert isinstance(nv.stages[0].module, ModuleRef)
assert nv.stages[0].module.version == "5.0.0"
def test_from_without_ref():
with pytest.raises(NovafileError, match="FROM requires a module reference"):
parse_novafile("FROM\n")
def test_from_with_dangling_token():
with pytest.raises(NovafileError, match="FROM expects"):
parse_novafile("FROM terraform-aws-modules/vpc/aws extra\n")
def test_from_as_missing_name():
with pytest.raises(NovafileError, match="FROM expects"):
parse_novafile("FROM terraform-aws-modules/vpc/aws AS\n")
def test_env_before_from_is_error():
with pytest.raises(NovafileError, match="first instruction must be FROM"):
parse_novafile("ENV A=1\n")
def test_non_default_escape_directive_warns():
text = "# escape=`\nFROM terraform-aws-modules/vpc/aws\n"
nv = parse_novafile(text)
assert any("escape" in w for w in nv.warnings)
def test_multiple_env_pairs_one_line():
nv = parse_novafile(
"FROM terraform-aws-modules/vpc/aws\nENV A=1 B=2 C=3\n"
)
assert nv.stages[0].env == (("A", "1"), ("B", "2"), ("C", "3"))
def test_unterminated_quote():
with pytest.raises(NovafileError, match="unterminated quoted value"):
parse_novafile('FROM terraform-aws-modules/vpc/aws\nLABEL A="open\n')
+23
View File
@@ -0,0 +1,23 @@
"""Package acceptance tests: version metadata and CLI entry point."""
import importlib.metadata
import click
import nova
import nova.cli
def test_package_exposes_version_constant() -> None:
"""The package exposes its version as the __version__ constant."""
assert nova.__version__ == "0.1.0"
def test_installed_distribution_version_matches() -> None:
"""Installed distribution metadata reports the same version."""
assert importlib.metadata.version("nova-cli") == "0.1.0"
def test_cli_main_is_click_base_command() -> None:
"""The nova entry point (nova.cli:main) is a click command object."""
assert isinstance(nova.cli.main, click.BaseCommand)