Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack
- **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget.
- **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`).
- **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan.
- **Ansible export.** The Ansible tab (`views/AnsibleView.tsx`, lazy-loaded) previews (YAML/INI highlighting and README rendering from the tokenizers in `views/ansible/syntax.ts`, rendered as React elements, never as HTML strings; colors are the `--color-code-*` theme tokens) and zips (`io/ansibleZip.ts`, JSZip on demand) what `deriveAnsible(plan)` in `src/derive/ansible/` generates: `inventory.yaml` (functional groups `leaves`, `spines`, `mgmtservers` … with a child group per partition, leaves split into one group per rack), `group_vars` / `host_vars` for the metal-roles partition roles (`sonic-config`, not the deprecated `sonic`; `metal-core`, which reads `lo` / `asn`; `mgmt-server`, `dhcp`, `metal-bmc`, `pixiecore`, `image-cache`), the playbooks, `requirements.yaml` (roles and collections) and a README. `src/derive/devices.ts` is the single source of hostnames and ASNs (numbering model in its header, from the metal-stack network docs: leaves unique, spines and exits shared); `src/derive/ip/deviceAddresses.ts` hands out loopbacks, management addresses, per-leaf PXE networks (metal-core CIDRs) and transfer addresses from the infra subnets, which carry a `kind` for that. `src/derive/ports.ts` lays out each switch's ports once for both NOS paths: 4x25G breakouts for 2x25G server ports on the leaves' first 100G ports, uplinks on the last ports of their speed, downlinks from the first, in plan order, with speed, MTU (9216 fabric, 9000 servers and mgmt) and RS-FEC on server ports, and the BGP ports; from the catalog's SONiC port maps (`ports[].sonic`, from sonic-buildimage's `port_config.ini`; only add maps from there, `sonicPortNames()` returns null otherwise). metal-api and NSQ are addressed under `metal_control_plane_ingress_dns` (`api.<domain>`, `<domain>:4150`, the control plane role defaults), the pixiecore API at the mgmt server's router ID, and the BMC superuser follows go-hal's per-vendor name (`BMC_SUPERUSER` in the catalog). Switches follow their partition's NOS (`FabricConfig.nos`, inventory groups `edgecore_sonic` / `broadcom_sonic`): Edgecore SONiC through metal-roles' `sonic-config` (`sonic_config_*`), Broadcom Enterprise SONiC through the Dell collection `dellemc.enterprise_sonic` over httpapi (`derive/ansible/enterpriseSonic.ts`): one `sonic_<module>` variable per module holding its `config`, one task per module called directly in the deploy playbooks (tagged with its topic), `replaced` for plan-described topics, checked against the collection's argspecs, plus an SSH play for DNS and the leaves' split routing mode. The leaves' FRR is always metal-core's; every other BGP switch gets FRR from exactly one source (`nos.test.ts`), and every leaf carries PXE VLAN 4000 with DHCP relayed to the mgmt servers, whose `dhcp_subnets` lease each leaf's PXE network. What the plan cannot know is written as `CHANGE_ME` via `AnsibleOut.todo()` and listed as a placeholder; `Plan.deployment` (`model/deployment.ts`) holds the few settings the export needs. Every generated variable must be listed in `model/ansibleRoles.ts`, which mirrors the metal-roles READMEs at a pinned version (`ansible.test.ts` enforces it); update it from there, don't invent variables. YAML goes through the comment-capable writer in `derive/ansible/yaml.ts`, which follows YAML 1.1 quoting (`"on"`, `"yes"`), as Ansible and GitHub read it. `derive/ansible/ci.ts` adds the pipelines (`Plan.deployment.ci.platform`: GitLab CI by default, GitHub Actions, both or none): a check on every push (inventory, `--syntax-check`, no `CHANGE_ME` left in `inventories/`) and manual deploys per partition in `DEPLOY_STEPS` order (mgmt servers, mgmt network, prod network) on a runner tagged per partition, one resource group / concurrency group each. Credentials stay CI secrets that Ansible reads through `ANSIBLE_PRIVATE_KEY_FILE` / `ANSIBLE_VAULT_PASSWORD_FILE`. The settings card shows only what differs per installation and has no default (release, control plane domain); everything with a default (public name and NTP servers as in mini-lab / metal-roles, inventory, role versions, the pipeline platform and its settings) sits in its Advanced section.
- **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `<Icon>` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline and in topology nodes, 12 px in rack slots; color follows the text.
- **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change.
- **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message.
Expand Down
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<p align="center">
Plan a <a href="https://metal-stack.io">metal-stack</a> installation in the browser:<br />
configure the production and management networks, see the resulting topology and rack elevations,<br />
and get an orderable hardware bill of materials.
get an orderable hardware bill of materials and an Ansible deployment with CI/CD pipelines.
</p>

<p align="center">
Expand Down Expand Up @@ -74,6 +74,22 @@ consistent — you edit the plan, everything else follows from it.
loopbacks, PXE, management and transfer networks — from the plan's switches and servers.
Overlapping or misaligned ranges are reported like any other issue, and the address plan
exports as CSV.
- **Ansible export** — an inventory, group and host variables for the
[metal-roles](https://github.com/metal-stack/metal-roles) partition roles (sonic-config,
metal-core, mgmt-server, dhcp, metal-bmc, pixiecore, image-cache) and the playbooks that apply
them, laid out like a metal-stack deployment repository. Derived from the plan and the IP plan:
hostnames, ASNs (leaves unique, spines and exits shared), loopbacks, management addresses,
per-leaf PXE networks with DHCP relay, DHCP ranges, transfer networks and each switch's ports:
4x25G breakouts for 25G servers, speed, MTU and FEC, and the BGP ports (uplinks on the last
ports, from the SONiC port maps in the catalog). Switches follow their partition's NOS:
Edgecore SONiC through metal-roles' sonic-config, Broadcom Enterprise SONiC through the
`dellemc.enterprise_sonic` collection, one module call per topic. Only the metal-stack release and the
control plane domain need entering (metal-api and NSQ are reached under it); name and NTP
servers default to public ones, and everything else with a default sits under Advanced. What
the plan cannot know, the secrets and certificates, is marked `CHANGE_ME` and listed. A GitLab
CI pipeline (or GitHub Actions workflows) checks every push, refusing leftover placeholders,
and deploys each partition by hand, in order, on a runner inside the partition. Every file is
previewed with YAML highlighting (the README rendered) and the whole set downloads as a zip.
- **Prices** — an optional price book (kept in the browser, separate from the plan, importable
and exportable as JSON) turns the BOM into a cost estimate with line totals, category
subtotals and a grand total.
Expand Down Expand Up @@ -123,6 +139,13 @@ With derivation shown: every quantity broken down by central rack and rack group

![BOM](docs/screenshots/bom.png)

### Ansible deployment

The Redundant template with a release and control plane domain entered: a leaf's host variables
with its ASN, loopback, BGP ports and PXE network, and the secrets still to fill in on the right.

![Ansible deployment](docs/screenshots/ansible.png)

## Getting started

The planner runs at **[https://metal-stack.github.io/planner/](https://metal-stack.github.io/planner/)** — no install, no account, nothing leaves your browser.
Expand Down Expand Up @@ -167,9 +190,12 @@ The whole app operates on a single `Plan` document, described by Zod schemas in
| `src/derive/validate.ts` | validation issues |
| `src/derive/nodes.ts` | node tallies per rack, partition and plan |
| `src/derive/ip/` | CIDR arithmetic, the IP address plan and its validation |
| `src/derive/devices.ts` | named devices with hostnames and ASNs |
| `src/derive/ports.ts` | the BGP ports of every switch |
| `src/derive/ansible/` | the Ansible inventory, variables, playbooks and CI/CD |

Hardware facts — part numbers, port counts, height units, nodes per chassis, and metal-stack
compatibility — live in `src/model/catalog.ts`. The compatibility data mirrors the official
Hardware facts — part numbers, port counts and SONiC port names, height units, nodes per chassis,
and metal-stack compatibility — live in `src/model/catalog.ts`. The compatibility data mirrors the official
[hardware list](https://docs.metal-stack.io/docs/hardware).

State lives in a Zustand store with undo history and localStorage persistence
Expand All @@ -186,7 +212,8 @@ diagrams are plain SVG.
## Dependencies and advisories

Runtime dependencies are deliberately few: React, Zustand (with Zundo for undo), Zod, Lucide for
icons and ExcelJS for the xlsx export. Everything else is build tooling.
icons, ExcelJS for the xlsx export, and yaml and JSZip for the Ansible export (both loaded only
when the Ansible tab opens). Everything else is build tooling.

`npm audit` reports two moderate advisories against `uuid <11.1.1`
([GHSA-w5hq-g745-h8pq](https://github.com/advisories/GHSA-w5hq-g745-h8pq)), reached through
Expand Down
23 changes: 22 additions & 1 deletion THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Build-time tooling is not listed: it never reaches a user.

Regenerate with `npm run notices`; CI checks that it stays current.

88 packages.
89 packages.

> **Note.** The following declare no license and ship no license file:
> `buffers`.
Expand Down Expand Up @@ -3558,6 +3558,27 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

## yaml 2.9.1

License: ISC
Homepage: https://eemeli.org/yaml/

```text
Copyright Eemeli Aro <eemeli@gmail.com>

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
```

## zip-stream 4.1.1

License: MIT
Expand Down
Binary file added docs/screenshots/ansible.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/screenshots/plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
},
"dependencies": {
"exceljs": "^4.4.0",
"jszip": "^3.10.2",
"lucide-react": "^1.45.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"yaml": "^2.9.1",
"zod": "^4.6.1",
"zundo": "^2.3.0",
"zustand": "^5.0.15"
Expand Down
11 changes: 10 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react'
import { lazy, Suspense, useEffect, useMemo } from 'react'
import { countIssues, validatePlan } from './derive/validate'
import { useHistory, usePlanStore, type View } from './store/planStore'
import BomView from './views/BomView'
Expand All @@ -12,12 +12,16 @@ import TopologyView from './views/TopologyView'
import logoUrl from './assets/metal-stack-logo.svg'
import { ACTION_ICON, Icon, TAB_ICON } from './views/icons'

// The Ansible tab (and its YAML writer) loads on first use.
const AnsibleView = lazy(() => import('./views/AnsibleView'))

const tabs: { view: View; label: string }[] = [
{ view: 'plan', label: 'Plan' },
{ view: 'topology', label: 'Topology' },
{ view: 'racks', label: 'Racks' },
{ view: 'ips', label: 'IPs' },
{ view: 'bom', label: 'BOM' },
{ view: 'ansible', label: 'Ansible' },
]

const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform)
Expand Down Expand Up @@ -128,6 +132,11 @@ export default function App() {
{activeView === 'racks' && <RackLayoutView />}
{activeView === 'ips' && <IpView />}
{activeView === 'bom' && <BomView />}
{activeView === 'ansible' && (
<Suspense fallback={null}>
<AnsibleView />
</Suspense>
)}
</main>
<Toaster />
</div>
Expand Down
Loading
Loading