Skip to content

feat(scenarios): add test_apt_cacher and test_apt_mirror dual-lan scenarios - #79

Closed
t0kubetsu wants to merge 130 commits into
devfrom
feat/local-apt-mirror
Closed

feat(scenarios): add test_apt_cacher and test_apt_mirror dual-lan scenarios#79
t0kubetsu wants to merge 130 commits into
devfrom
feat/local-apt-mirror

Conversation

@t0kubetsu

Copy link
Copy Markdown
Contributor

Closes #78
Related to range42/range42-catalog#162

Summary

Adds two reference scenarios generated by r42playbooks new to validate the local APT mirror and APT cacher catalog box templates on a dual-LAN topology.

  • test_apt_cacherapt-cacher-ng transparent proxy on lan1 (vmbr150/192.168.150.x), Debian + Ubuntu client VMs on lan2 (vmbr151/192.168.151.x)
  • test_apt_mirror — nginx-served full apt-mirror on lan1, same client layout on lan2
  • .gitignore — add Python build artifact patterns (.venv/, *.egg-info/, .coverage, __pycache__/, etc.) and .claude

Both scenarios were generated with:

r42playbooks new <name> \
  --subnet dual-lan \
  --box "<server>:subnet=lan1" \
  --box "debian-jump:subnet=lan2" \
  --box "ubuntu-jump:subnet=lan2" \
  --catalog ../range42-catalog

Test plan

  • Run test_apt_cacher.setup.sh on a Proxmox node — VMs provision and Debian/Ubuntu VMs resolve packages via lan1 cache
  • Run test_apt_mirror.setup.sh on a Proxmox node — VMs provision and Debian/Ubuntu VMs resolve packages via lan1 mirror
  • Regenerate each scenario from its scenario.r42.yml with r42playbooks new --force — output is identical

t0kubetsu and others added 30 commits May 15, 2026 10:44
…es isolation

Implements the network isolation design from issue #20.

Adds `scenarios/demo_lab_network/` — a self-contained scenario based on
demo_lab that enforces zone separation between the admin (vmbr142) and
CTF (vmbr144) bridges via iptables FORWARD chain rules on the Proxmox host.

New: 05_network_isolation/stage_00/proxmox_forward_rules.yml
  - Installs iptables-persistent on pve01
  - ACCEPT: admin → ctf (management access)
  - ACCEPT: ctf → wazuh :1514/:1515 (agent reporting)
  - DROP:   ctf → admin (zone isolation)
  - DROP:   ctf → vmbr0/WAN (air-gap)
  - Rules are idempotent and persist across reboots via netfilter-persistent

All rules implemented as Ansible tasks (not Python API calls), per
hyde-repo's recommendation in the issue discussion.

Two-zone design (admin/ctf) chosen over three-zone; the dedicated
management bridge alternative is documented in README.md as a future option.

Closes #20
ansible_connection: local on the proxmox host targets the deployer machine,
which doesn't have iptables. Switch to proxmox-cli (SSH to pve01 as root)
where iptables is available.
…d packages can reach internet

Before this fix, the CTF→WAN FORWARD DROP rule was already in place when the
CTF VMs were deployed, blocking DNS and NTP during cloud-init and package
installation. This caused the deployment to hang (30-60s DNS timeouts) and
ultimately fail at the NTP sync step.

New lift/restore pattern:
  1. lift_ctf_airgap.yml removes the DROP rule before CTF infrastructure runs
  2. 04_ctf_infrastructure/_main.yml runs with internet access
  3. 05_network_isolation/_main.yml re-installs the DROP rule after setup

Both main.yml and main_vms_only.yml updated. lift_ctf_airgap.yml handles
both DROP and REJECT variants (idempotent, state=absent).
…, tcpdump)

INSTALL_PACKAGES_UTILS_NETWORK was hardcoded to NO for the vuln-box stage,
blocking installation of nmap, tcpdump, net-tools and iputils-ping even though
iputils-ping was added to the role. Set to YES so all network diagnostic tools
are available on vuln-boxes.
…ir-gap rules

netfilter-persistent can save rules with a newline embedded in the comment
("r42: ctf\n  air-gap"), which ansible.builtin.iptables state=absent cannot
match. Add a second comment-based pass for duplicates, then a shell fallback
that loops iptables -D until no more DROP rules on vmbr144→vmbr0 remain.
Introduces the r42topo package skeleton in the playbooks repo: a pure,
framework-agnostic pydantic v2 core consumed by the backend API, r42topo's
own CLI/TUI, and the (rewrite-in-progress) range42 deployment CLI/TUI.

- core/models.py: Topology schema tree (Subnet/Zone/Box/Attachment/
  NetworkPolicyRef), extra=forbid, security deny-list guards
- core/constants.py: scenario-name regex (matches backend resolver, no dots),
  catalog-ref/bridge/IP patterns, vm_id bounds, octet rule, injection deny-list
- core/io.py: deterministic atomic JSON load/dump (sorted keys, round-trip)
- core/errors.py: framework-free exception hierarchy
- pyproject.toml: pydantic+pyyaml core deps; typer/textual/dev as extras
- tests: 14 tests, RED->GREEN, 94% coverage on core
- docs/r42topo-plan.md: full architecture + phased build order

Deploy path is _universal-only (rewrite-aligned): the compiler emits
topology.json + artifacts; deployment runs via _universal Plan B (external
prerequisite), not a materialized scenario dir.
Adds the catalog-template layer and global vm_id/IP allocation validation.

- core/catalog_models.py: BoxTemplate, NetworkPolicyTemplate (symbolic zones,
  services, allow/deny matrix, defaults), SubnetLayout — all extra=forbid
- core/catalog.py: load_catalog() resolves <category>/<id>/vX.Y.Z/template.yml,
  picks highest version, validates, records version+sha256; path posture mirrors
  backend checks_playbooks.py (regex + strict resolve + is_relative_to + symlink guard)
- core/idalloc.py: ReservedIndex parses JSONL _reserved.json; validate_allocation()
  enforces octet rule, intra-topology dup vm_id/IP, and cross-scenario collisions
  (own-scenario re-deploy allowed). Read-only/pure.
- core/constants.py: TEMPLATE_ID_RE, VERSION_DIR_RE, topology-layer dir/category names
- tests: +16 (catalog loader incl. shipped-template validation; idalloc rules)
- suite: 30 passing, 93% core coverage
First end-to-end vertical slice: author -> compile -> _universal.

- core/validate.py: semantic checks (zone/subnet refs, IP-in-subnet,
  dangling catalog refs)
- core/compiler/network_policy.py: compile symbolic policy + topology bindings
  into deterministically ordered FORWARD rules via weight bands (ESTABLISHED ->
  service -> zone-accept -> intra -> zone-drop -> air-gap -> default); plus
  lint_segmentation() fail-closed checks (FORWARD-only/host-SSH-safe, established
  precedes drops, no ACCEPT shadows a deny, air-gap present, terminal default-deny)
- core/compiler/{inventory,scenario_vms,stages}.py: hosts.yml (range42 nested
  groups), scenario_vms.json (demo_lab shape), per-zone attachment dispatch
  (template defaults + box attachments, deduped)
- core/compiler/__init__.py: compile_topology() orchestrator -> CompileResult
- core/extravars.py: resolve_universal_extravars() — typed r42_* allow-list
- api.py: load_catalog/author_topology/validate_topology/compile_topology/
  resolve_universal_extravars — pure adapter for backend + CLI/TUI consumers
- tests: +26 (56 total), 93% coverage

Verified: compiling against the REAL range42-catalog 05_topology_layer and
running the actual _universal stub (origin/feature/gamenet-authoring-v1) passes
green; generated FORWARD table reproduces demo_lab_network's isolation rules.
Thin frontends over the pure core, plus a shared scaffolding helper.

- core/scaffold.py: scaffold_topology() builds a minimal valid topology from a
  subnet_layout + policy (one box per role-matched zone, octet-rule vm_id/IP) —
  pure, reused by both CLI and TUI
- cli.py: Typer app — author / validate / compile / show (--rules); maps core
  errors to exit codes; console_script `r42topo`
- tui/controller.py: pure, framework-free façade (choices, scaffold, validate,
  save, summary, rules) — unit-tested without the event loop
- tui/app.py: Textual view over the controller; console_script `r42topo-tui`
- pyproject: add r42topo-tui entry point
- tests: +12 (68 total, 90% coverage) incl. CliRunner + headless Textual mount

Verified: `r42topo author --layout default-3zone --policy air-gap-ctf` against
the real catalog scaffolds a 3-box topology; `show --rules` renders the
compiled FORWARD table.
Security (from security-reviewer):
- C3 (CRITICAL): close the segmentation-linter bypass — wildcard-source DROP
  rules were skipped, so `accept ctf->admin` + `drop *->admin` passed clean.
  Rewrote lint_segmentation to check every compiled DROP (interface-aware, so
  air-gap rules don't false-positive) against earlier ACCEPTs; excludes the
  terminal catch-all. Added regression test (bypass caught, air-gap still clean).
- C1: deny-list now applied recursively to Attachment.params and
  NetworkPolicyRef.overrides (Jinja/SSTI surface) via reject_injection_nested
- C2: validate wan_interface (IFACE_RE) and service *_ip (ipaddress) overrides;
  missing/invalid service IP now fails closed instead of broadening the rule to
  "any destination". CompiledRule interface/port fields gain patterns.
- H1/H2/H3: injection guards on Topology.scenario/description/proxmox_node;
  PROXMOX_NODE_RE requires leading alnum (no empty/leading-dash)
- H4: extravars allow-list uses explicit raise, not assert (-O strips asserts)
- H5: air-gap zone absent from topology is now flagged (was silently skipped)
- M1/M2: MatrixRule src/dst patterns + comment guard; linter terminal-rule check
  covers reject as well as drop
- M4: CLI warns when --reserved is omitted (collision checks disabled)

Python quality (from python-reviewer):
- load_topology + api.author_topology wrap pydantic.ValidationError as
  TopologyError (no raw traceback leaks to the CLI)
- _fail typed NoReturn; schema_version pinned Literal[1]
- scenario_vms.json vms sorted by vm_id (full determinism)
- scaffold avoids dup vm_id/IP for repeated roles; drops redundant model_dump
- TUI handlers catch TopologyError/ValueError, not bare Exception
- idalloc docstring documents the TOCTOU window

Suite: 78 tests, 90% coverage.
ECC blueprint skill output — a cold-start plan for r42playbooks, the
msfvenom-style scenario generator (list catalog modules -> compose -> generate
a demo_lab-shaped scenarios/<name>/). 10 steps (S0 housekeeping, S1 rename
r42topo->r42playbooks, S2 spec model, S3 catalog pick/validate, S4 alloc+manifest,
S5b verbatim tree, S5a generated-from-manifest + API freeze, S6 CLI, S7 TUI,
S8 importable API, S9 ECC review). Adversarially reviewed (architect/opus);
CRITICAL+HIGH folded in: secrets/ symlink contract, generated-vs-copied
templates/*.j2, populate manifest templates[], resolved template-selection +
role->section decisions, name-reference (not copy) catalog contract.
Mechanical rename of the scenario-authoring package to its real identity
(what r42deploy/r42runtime will import). Behaviour unchanged.

- git mv r42topo -> r42playbooks (all modules + tui + tests imports)
- pyproject: name, readme, [project.scripts] (r42playbooks / r42playbooks-tui),
  packages.find include
- update docstrings, README, and the plan's REUSE table paths
- .gitignore module comment

Suite green (78 passed); import r42playbooks + cli/tui/api entrypoints OK.
Step 1 of docs/r42playbooks-plan.md.
Step 2 of docs/r42playbooks-plan.md. The reproducible msfvenom-style 'options'
artifact a user composes, written verbatim into every generated scenario.

- core/spec.py: ScenarioSpec + BoxSpec (extra='forbid', deny-list guards on
  free-text name/notes/vars), load_spec / dump_spec_atomic (YAML, deterministic).
- core/io.py: extract atomic_write_text helper (reused by spec IO); dump_topology
  now delegates to it (byte-identical output preserved).
- constants.py: BOX_COUNT_MAX bound on count expansion.
- BoxSpec.template_vm_id optional override (per plan §7.1, unblocks S4).
- tests/test_spec.py (15 cases) + conftest valid_spec_dict/spec_factory fixtures.

Full suite green.
Step 3 of docs/r42playbooks-plan.md. The list/validate source of truth.

- catalog.py: list_roles() scans 02_ansible_layer/**/roles/ names; list_containers()
  scans 03_container_layer/docker/_ctf/ for compose-bearing dirs (ref = path under
  _ctf/). Both read-only with symlink-escape guards; empty when a layer is absent.
- Catalog gains roles/containers sets, populated by load_catalog.
- validate_refs(spec, catalog): typo guard reporting unknown subnet_layout /
  network_policy / box template / role / container refs (gamification skipped).
- constants.py: 02/03 layer + compose-filename constants.
- tests/test_catalog_pick.py (10 cases) + fake_catalog fixture extended with 02/03
  layers (real range42-catalog is a separate gitignored repo, never in checkout).

Full suite green (103).
Step 4 of docs/r42playbooks-plan.md (Renderer A). Turn a ScenarioSpec into a
concrete VM list honouring the project invariants.

- core/templates_table.py: the fixed 12-row 9xxx Proxmox template table (verbatim
  from demo_lab manifest templates[]); select_template() picks lowest matching
  vm_id for a box spec, with explicit template_vm_id override (§7.1 / H2).
- core/allocate.py: place each box in its role's subnet, fill octets from a
  per-role base, assign vm_id=band*1000+octet (bands 1..8; 9xxx reserved for
  templates) so the octet rule holds by construction; expand count>1 to
  <template>-00..; merge template default_attachments + spec additions (names
  only, §2); skip ids/IPs owned by other scenarios in _reserved.json.
  manifest_dict/manifest_json emit the demo_lab shape with a populated
  templates[] (H1: old compiler hard-coded []).
- constants.py: ROLE_SUBNET_NAME / ROLE_BASE_OCTET / vm_id band bounds (team
  defaults to the student subnet per §7.1).
- tests/test_allocate.py (19 cases); fake_catalog gains a student-box template.

Full suite green (120).
Step 0 housekeeping: re-add /topology.json, /hosts.yml, /expanded.json scratch
ignores (present on feat/r42topo-canonical-schema, absent on this branch).
Plan §4.3 prerequisite: the explicit `find scenarios/demo_lab` baseline (110
files) the S5b/S5a golden tests assert the generated tree against. Captured now
so the renderer step has a frozen reference shape to mirror.
Render a composed Allocation + ScenarioSpec into scenarios/<name>/ mirroring
the demo_lab shape, emitting only the class-(B) files (plan §4.1): per-box
stage_00 clone playbooks, stage_01 plays that list catalog roles BY NAME
(the catalog↔playbooks name-reference contract, §2), per-box devkits,
per-section reinstall scripts, top-level setup/delete/reset scripts, the
class-B templates/ files, the vendored 01_init_proxmox/ template-creation
subtree (H3), and the originating scenario.r42.yml.

Resolved decisions (plan §8 mutation protocol, MUTATED 2026-06-03):
- H3: copy a single canonical 01_init_proxmox/ vendored as a package asset
  (sourced from blank_scenario_2_subnets, matching TEMPLATE_TABLE's vmbr140
  management subnet) into every scenario; main.yml imports its two playbooks.
- Richness: generate a uniform per-box structure only — NOT demo_lab's
  group playbooks / group devkits / _testing/ / builder_* (KISS/YAGNI).

Does NOT emit (left to S5a class-A): each section's _main.yml, templates/*.j2,
manifest/scenario_vms.json. Never creates secrets/ (deploy-time symlink, §4.2).

Pure + deterministic: all writes via core.io.atomic_write_text; output depends
only on the Allocation + spec. New: core/render.py, core/render_assets.py,
assets/scenario/01_init_proxmox/. tests/test_render.py (15 tests). 135 green.
…ss A)

Layer the class-(A) files that must reflect THIS composition (never copied from
demo_lab) on top of the S5b boilerplate:
- manifest/scenario_vms.json (vms[] + populated templates[], H1)
- templates/ansible-inventory.j2: per-composition groups (box.inventory_group)
  + member hosts r42.<vm_name>; proxmox/-cli groups kept verbatim
- templates/ssh-config.j2: one Host/Hostname block per VM (M5 naming) + a
  shared r42.* user/key/ProxyJump block
- each section's _main.yml: stage_00 imports with per-VM global_* overrides,
  then stage_01 imports

FREEZE the public API surface (gates S6 ∥ S7): api.render_scenario(spec, *,
catalog, dest, reserved=None) -> Path (allocates then renders), plus exported
allocate / load_spec / list_roles / list_containers / validate_refs /
ScenarioSpec. M5 invariant covered by a golden test: every concrete stage_01
`hosts: r42.<x>` resolves in the generated inventory.

New tests/test_render_classa.py (10 tests). 145 green.
Replace the legacy topology-compiler CLI (validate/compile/author/show on a
topology.json) with the scenario-generator surface over the frozen api.py:

- list <boxes|subnets|policies|roles|containers|scenarios> — enumerate pickable
  catalog modules, or existing generated scenarios under -o
- show <module> — auto-detect + describe one box/subnet/policy/role/container
- new <name> --subnet … --policy … --box admin-wazuh --box vuln-box:count=5
  [--spec scenario.r42.yml] [-o scenarios/] — compose a ScenarioSpec (flags or
  --spec, positional name wins) and render a deployable tree; prints the path

Thin shell only: validate_refs typo-guards before rendering, TopologyError ->
exit 1, no business logic in the CLI. The parked topology compiler stays
reachable via api.py for back-compat; it's just no longer a CLI subcommand
(pivot: r42playbooks is the generator, not the canonical engine).

tests/test_cli.py rewritten for the generator commands (11 tests). 156 green.
Replace the legacy topology-scaffold TUI with the msfvenom-style composer, all
logic in the pure (Textual-free) controller so it unit-tests headless:

- ScenarioComposerController: layouts/policies/box_templates/roles/containers
  choices; set_name/set_subnet/set_policy; add_box(template, count)/remove_box/
  clear_boxes; build_spec (raises TopologyError if incomplete/invalid);
  validate() (structural gaps + validate_refs); preview() (allocation preview);
  generate(dest) -> Path via api.render_scenario.
- ScenarioComposerApp: thin view — name + layout/policy selects, add-box row
  (template + count), Preview/Generate/Clear, output pane. Keeps #scenario /
  #output ids; errors surfaced in-pane.

tests/test_tui.py rewritten (8 tests: controller compose+validate+preview+
generate + mount smoke). core/ stays pure (no textual/typer imports). 155 green.
Make `import r42playbooks` drive generation by import alone (for r42deploy/
r42runtime and the range42 deployment CLI/TUI):

- __init__.py re-exports the frozen generator surface from api.py:
  load_catalog, list_roles, list_containers, validate_refs, load_spec,
  dump_spec_atomic, allocate, render_scenario, ScenarioSpec, Allocation,
  Catalog, ReservedIndex (+ legacy Topology, __version__).
- README rewritten generator-first: install extras, list/show/new CLI, TUI,
  and an import-only library example; documents the generated tree + the
  name-reference contract (secrets/ not generated).
- pyproject description updated (generator, not "topology compiler").

tests/test_package_api.py: pins the top-level export contract + import-only
roundtrip + core-error propagation (4 tests). 159 green.
ECC python-reviewer + code-reviewer + security-reviewer pass over S1–S8.
Fixes (CRITICAL: none; HIGH + security MEDIUMs resolved):

- render: STAGE01_PLACEHOLDER emitted a bare `[]` that `import_playbook`
  rejects ("a play must contain hosts/import_playbook/roles/tasks") — a no-role
  box would break the deploy. Now a valid no-op play (hosts + tasks: []).
- render: box `vars` from the spec were validated + carried to AllocatedBox but
  silently dropped — now emitted into the stage_01 play `vars:` block.
- render: SCENARIO_NAME_RE permits '/', which spawned malformed nested file
  paths (ctf/web1.setup.sh). File prefixes now use the leaf; dir tree keeps the
  full name. No write ever escaped dest ('..' was already deny-listed.)
- render_assets.fill(): raise on any leftover @@sentinel@@ (catches a misspelled
  key before it ships into a generated file).
- security: Attachment.catalog_ref had no deny-list (CATALOG_REF_RE admits '..');
  it is written verbatim as an Ansible role name. Added reject_injection guard.
- catalog: validate_refs now also checks each box template's default_attachments
  (the renderer emits them) — closes a silent typo-guard gap.
- catalog: resolved_version/hash raise CatalogNotFoundError not raw KeyError;
  _load_category narrows `except Exception` to pydantic ValidationError;
  _resolved is field(init=False).
- allocate: AllocatedBox.box_vars is now MappingProxyType (frozen-safe);
  manifest_dict -> dict[str, Any].
- cli: default --catalog ../range42-catalog (matches workspace layout, per
  user request); _build_spec uses try/else so `data` is always bound.
- spec: dump_spec_atomic/load_spec typed (Path); module-level Path import.
- tui: remove_box rebuilds the list immutably.
- api.render_scenario: docstring notes callers should validate_refs first.

Tests: +9 (placeholder no-op play, box_vars render, slash-name leaf prefix,
fill sentinel guard, catalog_ref traversal reject, validate_refs default-attach
gap, list scenarios CLI, tightened allocator raises to typed errors). 165 green.

Noted for a later step (pre-existing, out of S9 scope): TEMPLATE_TABLE does not
cover every real-catalog box spec (e.g. admin-wazuh 2cpu/4gb/32gb) — surfaces as
a clean ValidationError, not a crash.
t0kubetsu added 29 commits June 9, 2026 17:17
Job-level hashFiles() evaluates before checkout so the workspace is always
empty; replace with a step-level shell test writing to GITHUB_OUTPUT.
Also drop --profile=production (incompatible with org conventions) and add
.ansible-lint config with profile:basic.
…epo stubs

ansible-lint 6.x rejects syntax-check in skip_list; stub the catalog
and proxmox-controller roles with mock_roles so syntax-check passes
without cross-repo checkouts.
Role lives in range42-catalog which is not checked out in CI;
stub it so ansible-lint syntax-check passes.
- yaml[indentation]: fix 6-space vars_files indent in stage_00/mon_wazuh.yml
- schema[playbook]: remove null name key in stage_01/deployer_ui.yml
- yaml[truthy]: replace become: yes with become: true in stage_01/mon_wazuh.yml
- key-order[task]: move when: before block: in _r42_admin, _r42_student_box_group, _r42_vuln_box_group
- vars null: revert vars: {} -> vars: in vuln_box_02/03/04 stage_00 (had real vars below)
- vars null: fix vars: -> vars: {} in stage_00 files with comments-only after vars:
- add .ansible/roles/ stubs to work around ansible-lint 26.x mock_roles bug for 3-part dotted role names
….1.1

Every scenario (and the render_assets.py generator templates) hardcoded
vm_ci_dns_ips: "1.1.1.1". On lab networks that firewall 1.1.1.1, template
first-boot warm-up can't resolve apt mirrors and 'range42-context deploy'
hangs at the cloud-init auto-poweroff wait.

Use vm_ci_dns_ips: "{{ default_vm_ci_dns_ips | default('1.1.1.1') }}" (same
pattern as the adjacent vm_ci_user/vm_ci_password lines). The default is set
by range42-init.py from the Proxmox node resolver; 1.1.1.1 remains the
fallback for environments where it isn't set.
Scenarios consume vm_ci_dns_ips via {{ default_vm_ci_dns_ips | default('1.1.1.1') }}.
Define that default in the scenario templates so it lands in the right scope:
- ansible-vars.yml  -> group_vars/<scenario>/vars.yml (site.yml deploy path)
- vault-example.yml -> the operator-built vault (workspace deploy path, which is
  what range42-context deploy actually loads)

Ships 1.1.1.1 as the portable fallback; range42-init.py substitutes the detected
Proxmox node resolver at workspace creation.
Dual-lan test scenario exercising the new admin-rocketchat box template
(range42-catalog): a lan1 admin-rocketchat box + a lan2 debian-jump box,
generated by the scenario generator. For deploy-testing the Rocket.Chat
bootstrap (software.install.rocketchat) on the deployer.
Rebuild the aggregated reservation file from all per-scenario manifests so it
includes test_rocketchat (and back-fills demo_lab_network, test_apt_cacher,
test_apt_mirror, which were missing — the committed file was stale).

test_rocketchat VMs (1002/.150.2, 3002/.151.2) are collision-free. Pre-existing
cross-scenario collisions surfaced by the now-in-sync file (test_apt_cacher vs
test_apt_mirror, demo_lab vs demo_lab_network, blank_scenario_4 vs
demo_lab_network) are NOT addressed here — flagged for separate cleanup.
…lates

The template-create plays read {{ default_vm_ci_dns_ips | default('1.1.1.1') }},
but the generator's ANSIBLE_VARS_YML / VAULT_EXAMPLE_YML templates never defined
the var — so freshly generated scenarios fell back to 1.1.1.1. On networks where
1.1.1.1 is firewalled, cloud-init can't resolve, the template never auto-powers
off, and deploy hangs at "WAIT for cloud-init to auto-poweroff".

Define default_vm_ci_dns_ips in both generator templates (range42-init.py
substitutes the node resolver at workspace creation), matching the hand-fixed
scenarios. Also backfill the already-generated test_rocketchat scenario.
…rrored)

Template build IPs were derived as {template_subnet_prefix}.{box_octet}. Two
boxes in different lab subnets can legitimately share a host octet (e.g. dual-lan
.150.2 + .151.2), which mapped their two DISTINCT templates onto the same
template-subnet IP (both .140.2). The templates boot concurrently during
01_init_proxmox, so the shared IP collided on the bridge: the loser's cloud-init
couldn't reach the network, its apt-get update stalled, the VM never
auto-powered-off, and the deploy hung at "WAIT for cloud-init to auto-poweroff"
(intermittently small vs medium, an ARP race).

Allocate each distinct template a unique octet on the template subnet via
_next_free_octet (skipping the gateway) instead of mirroring the box octet.

- allocate.py: sequential collision-free template IPs (+ _TEMPLATE_IP_BASE)
- tests/test_allocate.py: regression — two boxes sharing an octet across subnets
  must yield distinct template IPs
- regenerate test_rocketchat (small template 9321 .140.2 -> .140.3) + _reserved.json
…nct template IPs)

Apply the allocate.py template-IP fix to the two apt scenarios: their templates
previously shared a build IP (box-octet mirroring), colliding on concurrent boot.
Regenerated so each scenario's templates get distinct template-subnet IPs
(cacher .140.2/.3; mirror .140.2/.3/.4) + refreshed _reserved.json.

Cross-scenario template-subnet overlap (distinct scenarios reusing low .140.x)
remains by design — scenarios deploy one at a time (delete-everything between),
so it does not cause the concurrent-boot collision that hung deploys.
…ir-gapped range)

Regenerate against the updated admin-rocketchat box template, which no longer
sets INSTALL_PACKAGES_NTP_AND_UPDATE_TIME. In an egress-filtered range udp/123
is blocked (even the Proxmox node shows clock unsynchronized), so the
basic_packages role's hard "wait for NTPSynchronized == yes" can never succeed
and failed stage_01. VMs take host (kvm-clock) time instead.
@t0kubetsu t0kubetsu closed this Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants