From 7163f7d62d9248a367223e9bc26fe4d5d3f60fb4 Mon Sep 17 00:00:00 2001 From: Ben Duncan <5178243+benduncan@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:31:07 -0500 Subject: [PATCH] feat(oci): run Spinifex on Oracle Cloud, and fix the public-address and NAT gateway lifecycle (#1108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ship uninstall-spx.sh and fix the OCI bring-up path Adds scripts/uninstall-spx.sh, the inverse of setup.sh: it removes the software where node-reset.sh clears state and leaves the node installed. An operator who installed with `curl ... | bash` had no teardown path at all. State teardown is delegated to node-reset.sh rather than reimplemented, and setup.sh now ships both to /usr/local/share/spinifex so they are reachable without a checkout. It refuses on a multi-node cluster and --yes does not override that: a flag meaning "don't ask me" must not also mean "ignore a safety property". /var/lib/spinifex and every apt package are kept unless asked for, and both opt-ins confirm separately even under --yes. Proved by a full uninstall/reinstall round trip on dev-oci, which found two bugs neither prod nor dev-prod can reach, because both are ISO-installed: node-reset.sh exited 2, silently, at its last step and after the wipe. setup.sh creates the spinifex *group* and the spinifex-* service users but never a *user* named spinifex, so `getent passwd spinifex` exits 2 and pipefail carried it into set -e. The uninstaller broke openvswitch-switch on its first run by removing the socket-perms helper while leaving the drop-in referencing it, failing the unit at step EXEC with status 203. Fixed on both sides: the uninstaller removes drop-ins it wrote into other units' namespaces and restarts anything it failed, and setup-ovn.sh writes ExecStartPost=- so a missing helper can never stop another package's daemon. setup-ovn.sh also learns the shape OCI needs: when --wan-bridge names a Linux bridge it links it to OVS with a veth pair instead of enslaving the NIC, so the VNIC keeps its MAC identity and netplan keeps owning the addresses. It now fails on OVS datapath errors instead of reporting success, and the --management demotion guard keys on /etc/default/ovn-central rather than on database presence, which the ovn-central package creates on every node. Tests: scripts/uninstall-spx_test.sh, wired into make test-build-scripts along with shellcheck over the four scripts that ship to every node. * feat: allocate external IPs from the Oracle Cloud API An OCI VNIC drops any packet whose source is not a registered private IP object on it, so an external pool cannot be range math there — the address has to be created through the provider before it exists at all. Add source = "oci" and the allocator behind it. One allocation is two OCI objects: a secondary private IP that rides the wire, and a RESERVED public IP attached to it that the internet reaches. Allocate returns the public address because that is what AWS semantics are about, and records the pair so the host plumbing and release path can find the private half. The OCI objects are created before the binding is written, and dropped after it on release — both orders chosen so a crash leaves a discoverable leak rather than a record that lies. Reconcile squares the two on startup and is required, not optional: a leaked reserved public IP bills and holds one of the 50 per-region slots with nothing referencing it. It never touches an object without our display-name prefix, because the reference host carries operator-created secondaries on the same VNIC. Resolve the VNIC from an interface name by MAC through IMDS rather than by Oracle's VNIC ordering, which is not a documented contract. Generalise ExternalIPAM.EnableDHCP into InstallAllocator so the OCI wiring is not a second single-purpose installer. * fix: refuse source = "oci" outside routed mode, and cover the SDK path An OCI VNIC accepts exactly one MAC, its own, and delivers every inbound frame to it regardless of which of its addresses the packet is for. Measured on the reference host: an internet source reaches 10.200.0.144 as router-MAC to VNIC-MAC, the public address never appears on the wire at all, and every off-subnet destination resolves to the same next hop. Pool mode puts a per-VPC gateway LRP MAC and a per-rule external NAT MAC on the uplink and ARPs for the address, which policy.go:50 justifies with "the Linux bridge learns the per-rule external MAC on the veth port like any other MAC". An OCI VNIC does not learn MACs; it accepts one. So a pool-mode guest's egress is dropped and its ingress never reaches OVN. Routed mode keeps the address on the host, which already wears the VNIC's MAC. Pinning every LRP to that MAC does not rescue pool mode: they share one external logical switch and OVN resolves inbound frames by an L2 lookup on it, so duplicate MACs work for one VPC and silently misroute past that. The refusal names the MAC reason, because a refusal that only says "use nat mode" reads as arbitrary and gets worked around. Also test the SDK request shapes against a local endpoint. Two of them are wrong in ways reading does not catch: pagination stopping early reports a partial VNIC to reconcile, which then drops live bindings, and a nil privateIpId on detach is omitted from the body entirely, so OCI reads the update as a no-op, returns 200, and leaves the address attached and billing. * fix: install host firewall rules where a distro's catch-all reject cannot precede them Oracle's Ubuntu cloud image ships a catch-all REJECT in both INPUT and FORWARD. Two Spinifex datapaths were silently dead behind it on any such host. EnsureNATEgressRules probed with -C and appended with -A, so its ACCEPT rules landed after the REJECT and never matched: 24113 packets on the reject, 0 on both accepts, and guests had no egress. The comment named the case it did not handle, anticipating a FORWARD policy of DROP — which an append does survive — rather than an explicit REJECT rule, which it cannot. Insert at the head, and drain existing copies first, because a rule an earlier build appended still satisfies -C and would otherwise stay stranded across an upgrade. Nothing opened INPUT for the IMDS endpoints at all. The responder binds .254 and .253 per instance on the ime- devices, so guest requests are locally delivered and meet INPUT. Requests arrived and no reply ever left; cloud-init spent 490s timing out and reported the endpoint as disabled. With one wildcarded accept the same image boots in 18.6s and finds its datasource. The natuplink e2e check had the same blind spot the code did, asserting -C and therefore presence but never position. It now asserts the accepts precede the first reject. * feat: OCI address pairs, and an IMDS remap so the host keeps its own metadata An OCI external address is two objects, not one: a RESERVED public IP that OCI 1:1-NATs upstream and never puts on the wire, and a secondary private IP on the VNIC that the wire actually carries. Every AWS-facing record holds the public half; every datapath object must hold the private one. The mapping goes in policy.natManager and nowhere else. AddEIP, DeleteEIP, PruneOrphanEIPs and the HostEIPBinder all converge there, and reconcile rebuilds the rule set from AWS-facing intent on every pass — so a correctly mapped rule installed further upstream is swept on the next reconcile and replaced with one matching an address that never arrives. The seam is a DatapathResolver defaulting to identity, so every non-OCI deployment is unchanged. A resolve failure is fatal: falling back to the public address would install a rule that reads correctly in ovn-nbctl and blackholes. ocinet.Lookup is the OCI side — read-only, KV-backed, no client and no credentials, because vpcd is a different process from the daemon that owns the allocator and the bindings record is all either would consult. Two live bugs the in-memory test store was hiding, both found by making the fake report ErrNotFound the way the real store does: - KVStore.Mutate could not create, so every pool failed its own first allocation after OCI had already created and billed the pair. - Reconcile treated a missing record as a failure, aborting the one pass that exists to find an object created before its binding was written. Guest IMDS must stay on 169.254.169.254, which is also where a cloud host's own metadata service lives — the first guest launch made it a local address and took the host's metadata and DNS with it. The datapath is OpenFlow steered, so the guest-facing addresses stay put and only the host-side endpoint address moves, with a PREROUTING DNAT on ime-+ translating and conntrack un-NATing the reply. Off by default; identity when unset. The IMDS firewall rules move to vpcd. They are wildcarded over every endpoint rather than per-launch work, and spinifex-daemon holds no iptables grant and no CAP_NET_ADMIN by design, so attempting them aborted every launch. * fix: name the real constructor in the IMDS bind-address error, and mark the NAT datapath test in-package The error string pointed at DefaultHostBindAddrs, which does not exist — a caller following it would have looked for a symbol that was never written. nat_datapath_test.go reuses nat_test.go's in-package fixtures, so it carries the //test:in-package marker the package check asks for. * docs: index what each e2e suite proves Written because a datapath comment was read as an end-to-end behavioural claim and the suite that already answered it was found only after the wrong answer had been given. TestVPCEgressPaths/NATBaseline is the authority on private subnet egress, and nothing pointed at it. * fix: source-route an EIP's reply out the VNIC that holds it An EIP's reply carries the EIP as its source. On a host that pins its uplink with a policy rule, the reply took the main table instead and left by the wrong interface — which a cloud VNIC drops, because the address is registered on a different one. Ingress reached the guest and nothing came back. Every bind now mirrors the uplink's rule onto the EIP. The selector is the rule rather than the interface: both OCI VNICs sit in one subnet, so "the interface owning the EIP's subnet" names two of them, and two candidate rules is a refusal rather than a guess. A host with no source routing gets no rule and no change. * docs: name the setup-ovn recipe routed mode actually needs on OCI The OCI guide told operators to run `--wan-bridge=br-wan`, which is mutually exclusive with `--nat-uplink` and deletes the spx-nat transit veth routed mode runs on. The node then refuses to bring the uplink up, with an error naming the flag the guide should have used. Several messages also claimed routed mode is outbound-only with no public IPs. That stopped being true when the routed EIP path landed, and it reads as "what you are about to do cannot work" during the one init the OCI path documents. * feat: let routed NAT form a multi-node cluster, and carry every pool through formation Lifts the --nodes >= 2 refusal on --external-mode=nat. The refusal carried no technical justification -- it was a v1 scope decision -- but routed NAT is the only datapath a cloud VNIC can carry, so "routed NAT is single-node" had become "Spinifex on a cloud host is single-node". It is not fixed by lifting it. Measured on three OCI nodes: the cluster forms, all chassis register and guest-to-guest traffic runs at 7.1-7.7 Gbit/s, but every node claims every EIP, nodes disagree on the VPC's gateway LRP, and only the gateway chassis reaches its own guests. init now says so on stderr rather than refusing, because the refusal made the defects unreachable and therefore unfixable. Distributing the EIP datapath is the actual fix. Lifting it exposed a second defect that stops formation outright. NetworkConfig held nine flattened Pool* scalars and the init node set PoolName = "wan" unconditionally; routed NAT renders two pools and the one that matters -- nat-transit, carrying the gateway-LRP range -- was dropped. Each joiner got a single pool named "wan" holding the transit gateway and the detected WAN prefix, and the leader re-rendered its own config from the same struct, so all three nodes were wrong. Pools is now []admin.PoolData in render order, and PoolData gains json tags because it is on the wire now rather than a template input. * feat(install-node): add --external-mode so a cluster can be formed in routed NAT install-node.sh could not express routed NAT at all. It required --external-pool/--external-gateway/--external-prefix-len and passed --external-mode=pool to every spx admin init unconditionally, so the only supported way to form a routed-NAT cluster was by hand. --external-mode nat refuses the three pool flags rather than ignoring them, and threads --nat-uplink into setup-ovn.sh -- without it the WAN interface check rejects a physical NIC during formation, which is correct in pool mode and wrong in routed mode, where nothing is bridged. * fix(ocinet): stop each node deleting its peers' OCI addresses on startup Reconcile compares the binding record against the private IPs on one VNIC. The record is one key per pool and therefore cluster-wide, so on more than one node every peer's binding looks exactly like a binding whose objects OCI has lost. Each node dropped them, and direction 1 on the owning node then collected the now-unclaimed objects as leaks -- deleting the reserved public IP and private IP of a running instance. Observed on the three-node OCI cluster: node2's startup pass logged "collected a leaked address" for an address it had allocated ten minutes earlier and "dropped a stale binding" for one living on node3's VNIC. The damage did not stop at the OCI objects. With the binding gone, Lookup.DatapathIP returns the public address unchanged, so the OVN dnat_and_snat row carries the public address instead of the private half. Guest egress then leaves the VNIC sourced from a reserved public IP, which OCI drops because it enforces the registered source per VNIC -- and EnsureEIPSourceRoute no-ops too, since a public address shares no subnet with any host address. One destroyed binding takes out both directions. Binding.VNICID already exists for exactly this ("how a node that comes back from the dead tells mine from moved on without me") and Reconcile never consulted it. A binding is now only dropped when it names this node's VNIC; one naming no VNIC cannot be attributed and is left alone rather than guessed at. * fix(host): find the EIP's source-routing table when the uplink is held as a /32 uplinkSourceRuleFor identified the interface owning an EIP's subnet by finding a host address configured with a prefix that contains it. On OCI both VNICs sit in one subnet, so br-wan has to hold its address as a /32 -- a /23 there puts the subnet's on-link route in the main table at metric 0 and takes the whole subnet off the primary VNIC. A /32 contains nothing but itself, so no rule was ever found, no source rule was installed, and the reply left by the primary VNIC where OCI dropped it for carrying an unregistered source. The address is not the only record of who owns the subnet. The rule names a table, and that table holds the on-link route. Consulted as a fallback, only when the address prefixes matched nobody, so a host that already resolved this the direct way keeps its existing answer -- prod and dev-prod are unaffected. Only scope-link routes count. A default route covers every address and would make every source-routing table an equally good answer. * docs(oci): correct the multi-node story and name the oci_vnic_id constraint The nat-mode warning said EIPs and default egress were both broken on more than one node. Default egress is fine: delete a guest's dnat_and_snat row and it reaches 1.1.1.1 in 2.3 ms from a guest on any of three nodes. Only the public-IP path was broken, and the three defects behind it are now fixed. What remains is not a bug but the shape of the datapath: routed NAT centralises the EIP path on the VPC's gateway chassis, ext-shared is a localnet so the other nodes cannot reach it, and OCI drops any frame whose source is not registered on the VNIC it leaves by. So every node's pool must pin oci_vnic_id to the gateway chassis's VNIC rather than resolving its own. Say so in §5.3, where an operator writing the pool will read it, and in §9, where the cost is that a gateway change kills every public IP until the pools are repointed. The single-node cap itself is gone from the doc: a three-node cluster has been formed and measured, and the warning proceeds rather than refusing. * feat(nat): distribute the routed-NAT EIP path so it works on more than one node Routed NAT processed every EIP on the VPC's gateway chassis, and ext-shared is a localnet, so the transit segment a /32 route pointed into existed only on that one node. Measured on three nodes: all three claimed all three EIPs, the nodes disagreed on the gateway LRP, and only guests on the gateway chassis were reachable. The mechanism to fix it is not new. Pool mode already distributes EIPs by putting external_mac and logical_port on the dnat_and_snat row and letting OVN's ARP responder answer for the external IP on the external segment. Routed mode has an external segment too, the spx-nat veth pair, and it being per node is the distribution: only the node running the instance can answer ARP on it. So a routed rule is now distributed on the same terms as a pool one, and its host route becomes on-link on the transit veth instead of via a gateway LRP that may be on another chassis. bindHostEIP stops demanding that address for a distributed rule, which is what previously failed the bind on every node but one. vpcd plumbs only the ENIs local OVS reports as its own, asked of OVS rather than the Southbound DB because the question is about this host. The gateway LRP keeps its transit-pool address. Default egress routes through it and works across three nodes today, so making it link-local as pool mode does would break a working path to fix one distribution has already fixed. Pool and static are unmoved, and a test asserts that against the same input rather than leaving it to inspection. A rule with no MAC stays centralised, so a cluster part-way through an upgrade is never in a state neither shape handles. Beads: mulga-opyl7, mulga-la0pe * fix(nat): make routed-EIP host plumbing work on every node Three guests on three OCI nodes, one guest per node: only one was reachable. Two defects, both invisible on a single node. The transit veth took a random MAC per node, but OVN holds one MAC binding for the transit nexthop across the whole cluster — so every chassis addressed its egress to whichever node seeded the binding, and the other kernels dropped it at L2. The pair is node-local, so one locally-administered address everywhere is unambiguous; setup-ovn.sh pins it at creation and in the netdev unit, and EnsureUplinkPort repairs a veth made before that. Host routes and proxy-ARP also rode the leader-gated reconcile pass, so a node that never won the lease never plumbed its own guests' EIPs. BindHostEIPs does that half on its own loop on every node; Bind already leaves alone an EIP whose ENI is elsewhere, so two nodes never contend. An unresolvable address skips the sweep rather than shrinking it. Pool and static are untouched: the new pass is a no-op outside routed mode, and a test asserts it. Also adds --teardown and --dry-run to setup-ovn.sh, for re-running it on a node that is already tangled. It deliberately spares the NB/SB databases, /etc/spinifex, the system-id and the packages, and refuses to del-br a Linux bridge. * fix(nat): assert the transit MAC where vpcd actually runs it EnsureUplinkPort is never called in production — host.Wiring is constructed nowhere — so the repair landed in a path the deployed daemon does not take, and three nodes came back with three different MACs. vpcd re-asserts it beside the NAT egress rules instead, which is the routed-NAT branch that does run on every start. * fix(vpcd): make uplink-mode detection say what it saw, and check it detectBridgeMode logged only the branch it took, so a node detected as veth when it was wired for nat looked identical to one that was right. It now reports both probes every time, errors when a node carries both uplink veths (set up twice, and --teardown is the way out), and errors when external_mode and the wiring disagree — the datapath follows the wiring, so the config silently loses that argument. An explicitly configured bridge_mode gets the same cross-check, since nothing else ever compared it against the wiring. The vpc-networking doc now opens nat with "start with pool unless one of these applies" and gains a section on nat across more than one node; the admin warning for --external-mode=nat on two or more nodes says what was verified by hand and what is still uncovered. * test(nat): prove routed NAT delivers each node's own guests, on a cluster natuplink ran single-node only, which is exactly where the two defects that broke three nodes are invisible. It now admits ModeMultinode, and phase 9 takes a second shape there: a guest per node in a spread placement group, and for each one — its public IP plumbed by the node running it and by no other node, the route on-link with proxy-ARP answered there, a dnat_and_snat row carrying external_mac and logical_port, a TCP handshake in from another node, and the guest's own console verdict for egress. Every node's transit veth is checked against the one cluster-wide MAC before any guest boots. Single-node phase 9 gains the same row and on-link assertions, so the shape is held on both topologies. Two gates come from what three OCI nodes actually do: every node holds a gateway_chassis row for every VPC (the active one is the highest priority) and every node installs the VPC ingress route, so only phase 7 needs gating — it runs where the gateway port and the guest are both local, and logs which one is missing otherwise. Cell 20 is real-multi x nat with its own nightly step, leaving the permutation cells' suite set alone. * test(nat): run the VPC egress paths in nat mode, not pool mode only The single suite gated TestVPCEgressPaths and TestWANEgressSMTPBlock on external_mode = "pool", with a comment saying nat clusters have no EIP/NAT-GW support. They have had EIPs since the routed path landed, so the gate now asks whether the cluster can allocate public addresses at all — pool, or nat with a non-transit pool configured. The nat-single cell runs TestVPCEgressPaths beside natuplink, which is the only nightly coverage of a private subnet's isolation and its NAT gateway in routed mode. natuplink never creates a private subnet. * docs(config): list nat among external_mode values * fix(ci): renumber the multi-node nat cell off the bare-metal job Cell 20 is already the bare-metal single-node job, gated on the same cells input but declared in the workflow rather than e2e-cells.json. A matrix entry with the same number runs two unrelated jobs under one name, and naming 20 in a dispatch selects both. * feat(images): add Oracle Linux 10.1 and 9.8, and pin their catalog digests Oracle publishes its qcow2 checksums inline on an HTML page and ships no sums file, so the four new entries carry the expected digest in the catalog rather than a URL to fetch one from. Images gains ChecksumDigest for that, and the catalog import path verifies against it and says which of the two sources it used, so a recorded digest never reads as a fetched sums file. Pinning is safe here and only here: each URL names an immutable build (b291, b293, b178, b182) and never moves. aarch64 has a -kvm-cloud- build alongside the plain -kvm- one; the cloud build is the cloud-init image and is the one that matches x86_64's -kvm-. TestImageCatalogReachable HEADs every URL in the catalog from the general single-node suite. These artifacts live on hosts nobody here controls, so an upstream that retires a build breaks `spx admin images import` for a customer and nothing else in the suite would notice. It also asserts that every entry sets exactly one checksum source, and that no pinned entry points at a "latest" URL whose bytes would change under it. * fix(formation): carry bridge_mode onto the leader and every joiner applyNetworkConfig copied external_mode, external_interface and the pools out of the formation NetworkConfig but never bridge_mode, so a multi-node cluster wrote a spinifex.toml without the key while single-node init wrote it. Every node then fell back to autodetecting a mode that init had pinned. Nightly cell 30 caught it: TestNATUplink failed at the config phase on a spinifex.toml that did not contain bridge_mode = "nat". Deriving it from the mode rather than adding a NetworkConfig field keeps the leader and the joiners on the same rule single-node init already uses. * feat(ui): show how to ssh into an instance, and which user to use An instance detail page gave the addresses and the key pair name but never the login user, which is the one part of the command nobody can derive. It belongs to the AMI, not to Spinifex: nothing in the launch path creates or renames an account, so stock cloud-init applies the distro's own default_user. Oracle Linux is cloud-user, Debian is debian, Ubuntu is ubuntu, and guessing from the distro name gets two of those three wrong. The card resolves the user from the AMI's name and description, and an unrecognised image yields a placeholder rather than a guess, because a wrong username in a copy-paste command is worse than an honest gap. A private-only instance is called out as reachable from inside the VPC only, rather than presented as a command that will simply time out. * fix(nat): plumb host state for a NAT gateway's public address AddNATGateway installed the OVN snat row and stopped there. On a routed-NAT node the host is what delivers a public address, so an egressing private guest left with a source the host held no route back to, and the masquerade rule — which matches the transit 100.127.0.0/24 alone — never saw the packet. The gateway's address needs the same /32 route into OVN, proxy-ARP and FORWARD accepts an EIP gets. hostBindSpecs unions floatingIPSpecs with one deduped spec per NAT gateway address and feeds BindHostEIPs. It is kept apart from floatingIPSpecs deliberately: pruneOrphanEIPs and addLive read that set as the complete list of addresses allowed to hold a dnat_and_snat, and a gateway holds an snat, so widening it would stop the sweep reclaiming a stale row on an address later reused as a gateway's. AddNATGateway also binds directly, on both the create and idempotent-skip paths, so a fresh gateway is not dark for up to 30s waiting on the host EIP pass — and so a reboot that keeps the OVN row still replays the volatile half. Documents NAT gateways in nat mode in vpc-networking, and says at the head of that section that nat is a supported mode covered by nightly cells 19 and 30. * docs(oci): document Oracle Linux as a guest image, and that its user is cloud-user Four catalog entries, all UEFI. The login user is the one that catches people: opc is the default on the images Oracle publishes into OCI itself, while these are the generic KVM cloud images from yum.oracle.com, whose cloud-init default_user is cloud-user. Verified on 10.1 and 9.8 — opc, oracle and ec2-user are all refused. Also records why these four pin a checksum digest inline (Oracle ships no sums file, and each URL names an immutable build), why arm64 takes the -kvm-cloud- build, and that SELinux ships enforcing. * fix(reconcile): stop reading an unreadable intent as an empty one A wedged JetStream KV returned no keys for the ENI bucket while writes were still landing. loadPorts mapped that to an empty port map, pruneOrphanPorts read every guest LSP as an orphan against it, and every guest on the node lost DHCP and IMDS while the API kept answering and the instances stayed "running". The existing re-read guard did not help: it re-reads through the same path. Three defences, because no single one covers every shape of the fault. A failed kv.Get now fails the pass instead of dropping the record. Dropping one VPC narrows localVPCs, which filters out every ENI in it, which the sweep reads as permission to delete every guest port in that VPC — one failed read, same outcome, and it needs no exotic KV state. Unmarshal failures stay skips: that is a poison record rather than an unhealthy store, and failing on it would wedge reconcile forever. The port sweep now asks local OVS which taps are plugged in here and never deletes a port that has one. A running guest's port cannot be an orphan whatever intent says, and OVS is the one liveness signal that does not come through the KV read path the sweep is otherwise entirely driven by. Wired from vpcd; unwired callers fall back to the guard below. An unreadable OVS skips the sweep rather than deciding on intent alone. Each sweep also refuses outright when the keep set it would delete against holds nothing at all — no ENIs, no SGs, or no ports and no external IPs. That is the case local taps cannot cover, because the drift sweep is leader-gated and lists NB cluster-wide, so the leader holds no tap for another node's guest. The cost is real: a genuinely orphaned row now survives until one live record of its class exists. Leaking a stale row is the recoverable half. Three existing prune tests drove their fixtures to a wholly empty intent, which is now the refusal case; they keep one live record of the class so they still prove the prune. The Get-failure path has no unit test — there is no jetstream.JetStream fake in the tree and an embedded server cannot be made to list a key it will then refuse. * docs(oci): separate the two address quotas, and the principal that can read them The regional reserved-public-IP ceiling is tenancy-wide and does not grow with node count, so a cluster hits it before the per-VNIC 64, which does. Records the commands that read both, and that a detached reserved public IP still bills. Reading limits or usage needs a tenancy-level grant, which cannot be compartment-scoped the way the node's policy is. That is a second, read-only principal kept off every node, not an addition to SpinifexOperators. * docs(oci): give quotas their own troubleshooting section, and say we enforce none Spinifex hardcodes no address ceiling: it attempts the allocation and maps OCI's own refusal to InsufficientAddressCapacity. So a quota is raised with Oracle and nothing here is reconfigured or restarted — which is what an operator needs to know before it bites, not after. Separates the two quotas by how they scale: the regional reserved-public-IP default is tenancy-wide and raisable by ticket, the per-VNIC 64 is per node and is not. * fix(oci): follow a guest to its new node when its EIP moves OCI delivers a public address to whichever VNIC holds the private IP behind it, and nothing ever moved that private IP. Binding.VNICID was written once at allocate time, as the VNIC of whichever queue-group daemon happened to serve the AWS request, and never updated — so an address landed on the right node only by chance, and a guest that stopped and started elsewhere went dark while every OVN row, host route and firewall rule on the new node was correct. ClaimLocalAddresses closes the gap from the node that should hold the address. Local OVS decides ownership, the same authority the host EIP binder already trusts for the same question, and UpdatePrivateIp with a new VnicId does the move in one server-side call. The private IP OCID survives it, so the reserved public IP attached to it follows without being touched and the customer's address never changes. A loop per node, not leader-gated: the question is about this host's guests, and a node that never wins the reconcile lease would otherwise never claim the addresses of the instances it is running. * fix(oci): record which ENI an EIP is associated with An EIP is allocated bare and attached by a later AssociateAddress, so the OCI binding written at allocate time named no ENI and nothing ever filled it in. The address then stayed on whichever node happened to serve the allocation: the affinity pass could follow an auto-assigned address, which is allocated with its ENI, but never an EIP — the case customers actually use. BindOwner is on an optional OwnerTracker interface rather than Allocator, because only OCI has a notion of an address being delivered to one node. A static or DHCP address is on the wire everywhere its node can reach, those allocators do not implement it, and ExternalIPAM treats a pool that does not as a success with nothing to do. The same gap made the release owner guard inert for EIPs: it compares against a binding ENI that was always empty, so a stale teardown could free an address already handed to another instance. * fix(ec2): report the address an instance is actually reachable on Three faults kept DescribeInstances answering with the address launch assigned, whatever happened afterwards. The launch-time stamp lives on the stored ec2.Instance, and the stopped path only declined to add an address rather than removing one, so a stopped instance went on advertising an address it had released and that may already belong to someone else. The projection now clears it, and the NIC association with it, working on its copy so a restart can still report the address. The running path filled the public address only when it was nil, which the stamp never is, so an EIP could not be reported however correctly it was recorded. The runtime address now wins. And nothing told the rest of the system about an association: AssociateAddress wrote neither the ENI record, which is what IMDS and the network reconcile read, nor the instance record, which is what a describe answers from. The ENI write is direct; the instance write is a fan-out, because the queue-group worker that serves AssociateAddress is rarely the node running the instance. * fix(net): take the host EIP plumbing off a node its guest has left Bind refuses an EIP whose ENI is on another chassis; the prune beside it kept anything still wanted cluster-wide. A guest that moved therefore left the old node holding the /32 route into OVN and the proxy-ARP entry on the uplink, with nothing that would ever remove them. On a shared segment that node goes on answering ARP for an address another node is now serving. The two halves now ask the same question by the same authority, in the loop that already runs per node. An unreadable local OVS is not a yes: tearing down a live address on a guess would fire everywhere at once, so it keeps the binding and the next pass tries again. A binder with no chassis test behaves exactly as before, and a centralised EIP is exempt because it hairpins through the gateway chassis and every node plumbs it legitimately. * fix(net): refuse to free an Elastic IP on behalf of a departing interface Releasing an instance's public address on teardown releases it by address, scoped to the ENI holding it. Since an association now writes the EIP onto the instance record, that address is the EIP — so terminating an instance gave a customer's Elastic IP back to the pool while describe-addresses still reported it as theirs, free to be handed to another guest. The existing ownership guard cannot catch this. It only fires when the stored allocation already names an ENI, and an EIP is allocated bare and associated later, so the stored ENI is empty and the guard passes straight through. The distinction that holds is who is asking. ReleaseAddress is the address's own owner and names no interface; instance teardown names the interface it is tearing down. So a release that names an owner may free an auto-assigned address and never an EIP, which is enforced in each of the three allocators. DHCP needed the scope carried on the wire, since it releases by address and resolves the lease afterwards. Absent on an older caller, which reads as false and keeps the previous behaviour. Worst on the two providers where it is not recoverable: on DHCP the lease is the only thing holding the address and the server promises nothing about handing it back, and on OCI the address is a reserved public IP object that the release destroys outright. * test(e2e): prove an Elastic IP survives a stop and start Instance teardown releases the public address by value, and since the association writes the EIP onto the instance record, that release aims straight at the customer's Elastic IP. The allocator guards now refuse it, but nothing end to end proved the address comes back on a restart. The step goes inside EIPFlip, which already runs last on the shared public guest for the same reason a stop is safe there: the address the guest returns on is the allocation's, not the pool's. It runs on the static cells and the DHCP ones alike, and on DHCP it doubles as the only outside view of the lease still renewing while the guest is down. * feat: release an auto-assigned public address on stop, take a new one on start AWS lends an instance a public address for as long as it runs and gives it a different one on the next start. Spinifex kept the address across a stop, which holds a pool slot for a stopped instance and, on a DHCP pool, keeps renewing a lease nothing is using. A stop now offers the address back through the instance cleaner, which refuses anything an Elastic IP is behind — the EIP association record answers that, the same question an ENI delete already asks — and records on the VM that the start owes it a new one. A host drain keeps the address: Restore relaunches the guest on the next boot, and changing its address under it is an outage nobody asked for. The start allocates before the launch, so an exhausted pool refuses the start rather than booting a guest the customer cannot reach, adopts an Elastic IP associated while the instance was stopped instead of assigning a second address beside it, and hands the address back if the launch then fails. e2e: a StopStartAddress stage on the public guest asserts a stopped instance reports no address and comes back on a different one from a static pool, and a StopStartKeepsTheEIP step asserts the opposite for an Elastic IP. * docs: restructure the OCI guide around two deployment paths, with diagrams The guide had grown into a numbered wall with no shape: no frontmatter, no contents, and a reader with an OCI account had no way to tell where to start. It now follows the multi-node install template — overview, prerequisites, then the deployment path — and splits that path in two: Terraform for one node or three, which is the preferred route, and a manual single-node walkthrough for an instance that already exists. Both meet at one shared configuration section rather than repeating it. Two ASCII diagrams carry what the prose could not: one OCI instance with the whole AWS surface inside it, and three nodes with the same surface distributed, showing which plane each VNIC carries and how an address follows a guest that moves. The limits section was materially out of date and said so in the places that mattered most. Multi-node is no longer experimental, public addresses are no longer pinned to one node — each allocates on its own VNIC and the affinity pass moves the private half to wherever the guest is — and an auto-assigned address is now returned to OCI on stop, so a stopped fleet stops paying for addresses. What changed is recorded rather than quietly edited. The Terraform configuration moves in from the monorepo as scripts/terraform/oci-spx, so the guide points at something in this repository. Also marks the two new lifecycle test files in-package, with the reason each one needs to be: both substitute unexported dependencies. * docs: rework the OCI guide — SVG diagrams, Terraform variables, iSCSI and br-wan Replaces the two ASCII diagrams with SVGs in the house style, one for a single node and one for three. Renames the two deployment sections to Terraform deployment and Manual deployment, documents what the Terraform builds per node and every variable worth setting, and explains that the data volume is an iSCSI attachment rather than a disk. The manual path now covers the iSCSI login, the fstab entry and the netplan source routing that Terraform writes, and states what br-wan is for on a routed-NAT node: it names the VNIC for the OCI allocator and holds the registered addresses, but is never bridged into OVS. Opens with what Spinifex is and why OCI is worth running it on. * fix: make gateway convergence work on a multi-node routed-NAT cluster Three defects each stopped a multi-node cluster from ever attaching an internet gateway, and all three produced the same uninformative symptom: `terraform apply` sitting on `aws_internet_gateway.igw: Still creating...` forever while every service reported healthy. ovn-northd was given only its local OVSDB socket as a client connection. A RAFT follower does not forward writes -- it answers "not cluster leader, trying another server" -- so with one entry in the list, northd stops translating Northbound intent into Southbound flows the moment database leadership moves to another node. Measured on the OCI cluster: SB_Global.nb_cfg seventeen transactions behind NB_Global.nb_cfg, with no chassisredirect port for any VPC created after leadership moved. setup-ovn.sh wrote the member list only when the caller passed --ovn-remote, which only install-node.sh did; it now derives the list from the live RAFT membership otherwise, and says so when it finds a single member, because that node needs a second pass once the others join. The gateway datapath probe ran wherever the reconcile lease landed rather than on the chassis owning the port. Both probes leave from the local uplink, and under routed NAT the transit subnet exists identically on every node, so a remote probe reported a healthy gateway as dead and aimed a repair at a datapath the node does not own. GatewayPortLocal gates it. On OCI the probe then ARPed the tenant-visible public IP, which the cloud NATs and which appears on no interface anywhere. The reconciler now maps the address through the same DatapathResolver the NAT rules are built from. Also adds DescribeVpcEndpoints, which the AWS provider calls on every VPC delete to sweep GuardDuty-managed endpoints; an InvalidAction there failed the destroy and stranded the VPC. The OCI guide gains a Terraform section recording which shipped workbooks were validated against a three-node OCI cluster and which are still open. * docs: record the validated Terraform workbook results for the three-node OCI cluster Six of the seven workbooks run against the OCI cluster pass, including the three the section exists to prove: RDS, ECS and EKS. Records nginx-alb and the clean eks-quickstart re-run, and names the two lifecycle gaps that bit during the run so an operator recognises them rather than reading them as a broken workbook. * fix: route the NAT gateway's SNAT source through the datapath resolver AddNATGateway was the one NAT path that did not map its external address to the one that rides the wire. Where the public half of an allocation is NAT'd by the cloud it is on no interface anywhere, so every private-subnet guest egressed with a source that was dropped, while the gateway reported available and DNS kept working because the VPC resolver is host-local. The unmapped address also failed the host EIP sweep, which works in resolved addresses, so the bind was pruned as foreign on the next pass and reinstalled on the one after. The public half now survives as an external ID instead. Also restructures the OCI guide around numbered steps through to running Terraform, and moves the cloud-init and metadata sections into troubleshooting where they are read when something is wrong rather than followed when it is not. * fix: place a NAT gateway's OCI address on the node that forwards for it OCI drops a packet whose source is not a registered private IP on the VNIC it leaves from, so a NAT gateway's SNAT source has to be on the gateway chassis. Two independent placements decided those and nothing reconciled them: the address was created by whichever worker served AllocateAddress, while the chassis is a fixed ordering the reconcile pass asserts. On a three-node cluster that made private-subnet egress a coin flip, and the same Terraform passed or died depending on which node answered. The affinity pass already solves this for guests, by asking local OVS where the tap is. A NAT gateway has no tap, and it skipped anything with no ENI — which is exactly and only this address. The equivalent local fact is the VPC's chassisredirect binding, so ownership now has two answers rather than one, and an address that is neither a guest's nor a gateway's is still left alone. The VPC is recorded from the reconcile pass rather than from CreateNatGateway, because the answer is the gateway chassis and reconcile is what decides it. * fix: plumb a NAT gateway's host ingress only on its gateway chassis A NAT gateway's address has no ENI, so neither the bind's locality test nor the prune's could ever fire for it and every node installed its /32 route into OVN. Latent on bare metal, where pool addresses are disjoint from the node subnet; on a cloud that hands out the address from the nodes' own subnet it outranks the connected route and swallows node-to-node traffic. Give that address the same second authority the ocinet affinity pass has: the VPC's chassisredirect binding, read from the local ovn-controller. An unanswerable question still leaves the binding alone, so a single node whose gateway port is not yet claimed behaves exactly as before. The orphan EIP prune built its live set from ENI and user EIPs alone while also sweeping host ingress, so every drift pass tore down the ingress of every live NAT gateway and left it dark until the next host EIP tick. * fix: ask the gateway chassis only about a NAT gateway's address gatewayElsewhere skipped the NAT gateway marker it was named for and answered for every spec, so on a node that is not the VPC's gateway chassis every EIP — including a guest's, whose locality is its own port — was left unplumbed. Two of three nodes bound nothing. * test: assert the two private-subnet EKS workbooks in the tofu harness eks-https-ingress and eks-gitops-argocd put their workers in private subnets behind a NAT gateway, so a worker only registers Ready if its egress to the cluster endpoint and to ECR survives that path. That makes them the workbooks that catch a NAT gateway fault, and neither had an assertion, so neither could be run at all — run_workbook calls assert_ by construction. Both share eks-quickstart's readiness check, extracted unchanged. They also name the worker size node_instance_type rather than instance_type, and tofu rejects a -var for an undeclared variable outright, so the name is read from the config instead of kept in a list here. * test: count agents, not every node, when asserting EKS workers Ready The control plane is three K3s servers, so an unfiltered node count reaches a one-worker cluster's desired size before any worker joins — the assertion passed on exactly the failure it exists to catch, reporting "4/1 workers Ready". Filtering on the control-plane role label makes it 1/1, which is the number that says a node-group worker registered. Matters most for the two private-subnet workbooks, where the worker joining is the whole proof that egress through the NAT gateway works. * docs: record the two private-subnet EKS workbooks passing on OCI --- .github/assets/diagrams/oci-single-node.svg | 80 ++ .github/assets/diagrams/oci-three-node.svg | 116 ++ .github/e2e-cells.json | 11 + .github/workflows/e2e-nightly.yml | 61 +- Makefile | 17 +- cmd/spinifex/cmd/admin.go | 67 +- cmd/spinifex/cmd/admin_test.go | 67 +- cmd/spinifex/cmd/service.go | 2 + docs/admin/setting-up-your-cluster/README.md | 42 +- docs/compute/vpc-networking/README.md | 31 +- docs/coverage/README.md | 8 +- docs/coverage/coverage.json | 12 +- docs/coverage/ec2/README.md | 3 +- docs/coverage/internal-report.md | 8 +- docs/coverage/s3/README.md | 5 +- docs/oci-integration/README.md | 1005 +++++++++++++++++ docs/service-interfaces.yaml | 5 + docs/testing/e2e-coverage.md | 85 ++ go.mod | 1 + go.sum | 2 + scripts/README.md | 13 + scripts/dev-install.sh | 2 + scripts/install-node.sh | 87 +- scripts/node-reset.sh | 12 +- scripts/setup-ovn.sh | 311 ++++- scripts/setup-ovn_test.sh | 140 +++ scripts/setup.sh | 12 + scripts/terraform/oci-spx/.gitignore | 10 + scripts/terraform/oci-spx/README.md | 244 ++++ .../oci-spx/cloud-init/mount-data-volume.sh | 94 ++ .../oci-spx/cloud-init/open-service-ports.sh | 72 ++ .../oci-spx/cloud-init/setup-wan-bridge.sh | 215 ++++ .../oci-spx/cloud-init/user-data.yaml.tftpl | 50 + scripts/terraform/oci-spx/compute.tf | 139 +++ scripts/terraform/oci-spx/eip-move-test.sh | 236 ++++ scripts/terraform/oci-spx/identity.tf | 7 + scripts/terraform/oci-spx/network.tf | 148 +++ scripts/terraform/oci-spx/outputs.tf | 26 + scripts/terraform/oci-spx/provider.tf | 10 + scripts/terraform/oci-spx/requirements.txt | 2 + scripts/terraform/oci-spx/scripts/oci_env.py | 157 +++ .../oci-spx/terraform.tfvars.example | 12 + scripts/terraform/oci-spx/variables.tf | 167 +++ scripts/terraform/oci-spx/versions.tf | 4 + scripts/uninstall-spx.sh | 533 +++++++++ scripts/uninstall-spx_test.sh | 156 +++ spinifex/admin/admin.go | 26 +- spinifex/cloud/oci/apiclient_test.go | 219 ++++ spinifex/cloud/oci/client.go | 335 ++++++ spinifex/cloud/oci/client_test.go | 155 +++ spinifex/cloud/oci/export_test.go | 40 + spinifex/cloud/oci/fake.go | 334 ++++++ spinifex/cloud/oci/fake_test.go | 207 ++++ spinifex/cloud/oci/metadata.go | 141 +++ spinifex/cloud/oci/metadata_test.go | 127 +++ spinifex/cloud/oci/types.go | 109 ++ spinifex/config/config.go | 62 +- spinifex/config/config_test.go | 154 +++ spinifex/daemon/daemon.go | 94 ++ .../daemon/daemon_handlers_eni_public_ip.go | 49 + spinifex/daemon/vm_adapters.go | 52 +- spinifex/daemon/vpc_adapter.go | 6 + spinifex/formation/formation.go | 19 +- spinifex/gateway/ec2.go | 8 + spinifex/gateway/ec2/authz.go | 1 + spinifex/handlers/ec2/eip/service_impl.go | 97 +- spinifex/handlers/ec2/instance/projection.go | 19 +- .../ec2/instance/projection_public_ip_test.go | 94 ++ spinifex/handlers/ec2/instance/service.go | 10 + .../handlers/ec2/instance/service_impl.go | 125 ++ .../ec2/instance/service_impl_test.go | 6 + .../ec2/instance/start_public_ip_test.go | 202 ++++ spinifex/handlers/ec2/vpc/eni.go | 8 + spinifex/handlers/ec2/vpc/external_ipam.go | 80 +- spinifex/handlers/imds/constants.go | 30 + spinifex/handlers/imds/dns_forwarder.go | 34 +- spinifex/handlers/imds/service.go | 16 +- spinifex/handlers/imds/tap_responder.go | 15 +- spinifex/network/external/allocator.go | 14 + spinifex/network/external/dhcp/manager.go | 10 + spinifex/network/external/dhcp/nats_client.go | 6 +- .../network/external/dhcp/pool_allocator.go | 11 +- .../external/dhcp/pool_allocator_test.go | 2 +- .../external/dhcp/release_scope_test.go | 84 ++ spinifex/network/external/dhcp/wire.go | 6 + .../external/eip_release_guard_test.go | 75 ++ spinifex/network/external/ocinet/affinity.go | 183 +++ .../network/external/ocinet/affinity_test.go | 315 ++++++ spinifex/network/external/ocinet/allocator.go | 497 ++++++++ .../network/external/ocinet/allocator_test.go | 227 ++++ spinifex/network/external/ocinet/lookup.go | 100 ++ .../network/external/ocinet/lookup_test.go | 108 ++ .../network/external/ocinet/owner_test.go | 140 +++ spinifex/network/external/ocinet/reconcile.go | 153 +++ .../network/external/ocinet/reconcile_test.go | 229 ++++ spinifex/network/external/ocinet/store.go | 158 +++ spinifex/network/external/ocinet/wiring.go | 94 ++ spinifex/network/external/static_pool.go | 17 + spinifex/network/external/types.go | 23 + spinifex/network/host/eip_ingress.go | 79 +- spinifex/network/host/eip_ingress_test.go | 78 +- spinifex/network/host/eip_source_route.go | 209 ++++ .../network/host/eip_source_route_test.go | 225 ++++ spinifex/network/host/gateway_claim.go | 92 +- spinifex/network/host/imds_attach.go | 3 + spinifex/network/host/imds_bridge.go | 94 ++ spinifex/network/host/imds_bridge_test.go | 127 +++ spinifex/network/host/imds_datapath.go | 47 +- spinifex/network/host/natrules.go | 31 +- spinifex/network/host/natrules_test.go | 72 +- spinifex/network/host/ovs_local_port.go | 54 + spinifex/network/host/ovs_local_port_test.go | 69 ++ spinifex/network/host/routed.go | 33 + spinifex/network/host/routed_test.go | 54 + spinifex/network/policy/nat.go | 308 ++++- spinifex/network/policy/nat_binder_test.go | 213 ++++ spinifex/network/policy/nat_datapath_test.go | 185 +++ .../policy/nat_gateway_chassis_test.go | 104 ++ .../network/policy/nat_host_chassis_test.go | 102 ++ .../network/policy/nat_prune_guard_test.go | 80 ++ spinifex/network/policy/policy.go | 7 +- spinifex/network/reconcile/apply.go | 161 ++- .../reconcile/apply_eip_concurrency_test.go | 3 + .../reconcile/apply_gateway_claim_test.go | 88 +- .../reconcile/apply_natgw_live_test.go | 42 + .../reconcile/apply_prune_safety_test.go | 154 +++ spinifex/network/reconcile/apply_test.go | 150 ++- spinifex/network/reconcile/drift_test.go | 4 + spinifex/network/reconcile/intent.go | 28 +- spinifex/network/reconcile/intent_test.go | 61 + spinifex/network/reconcile/reconcile.go | 53 + .../reconcile/scenario_prune_live_test.go | 23 +- .../frontend/dist/assets/_auth-Cg86DRhs.js | 1 + .../frontend/dist/assets/_auth-DsyoN1M9.js | 1 - .../frontend/dist/assets/_id-BnUjyn0E.js | 1 - .../frontend/dist/assets/_id-CAKeaftF.js | 1 + .../frontend/dist/assets/_id-Czat7SAc.js | 2 + .../frontend/dist/assets/_id-U4BZhcdn.js | 2 - .../frontend/dist/assets/index-BrcYJ9RS.js | 11 - .../frontend/dist/assets/index-CV30i9r0.css | 2 + .../frontend/dist/assets/index-D7dQJPHv.js | 11 + .../frontend/dist/assets/index-z1WTrxZE.css | 2 - .../frontend/dist/assets/network-RAx0KOlf.js | 1 + .../dist/assets/waypoints-CMfoqtKm.js | 1 - .../dist/assets/waypoints-DE7eIn9V.js | 1 + .../spinifexui/frontend/dist/index.html | 9 +- .../frontend/src/lib/ssh-login.test.ts | 95 ++ .../spinifexui/frontend/src/lib/ssh-login.ts | 85 ++ .../(instances)/describe-instances/$id.tsx | 4 + .../ec2/-components/ssh-access-card.test.tsx | 59 + .../_auth/ec2/-components/ssh-access-card.tsx | 145 +++ spinifex/utils/images.go | 68 +- spinifex/utils/images_test.go | 59 + spinifex/vm/crash_recovery_test.go | 13 +- spinifex/vm/fakes_test.go | 28 +- spinifex/vm/instance_cleaner.go | 7 + spinifex/vm/record.go | 5 + spinifex/vm/record_test.go | 1 + spinifex/vm/shutdown.go | 32 + spinifex/vm/stop_public_ip_test.go | 122 ++ spinifex/vm/vm.go | 7 + spinifex/vpcd/bridge_detect_test.go | 115 ++ spinifex/vpcd/host_eip_loop.go | 48 + spinifex/vpcd/host_eip_loop_test.go | 95 ++ spinifex/vpcd/vpcd.go | 255 ++++- spinifex/vpcd/vpcd_eip_chassis_test.go | 109 ++ spinifex/vpcd/vpcd_nat_test.go | 4 +- spinifex/vpcd/vpcd_test.go | 12 +- tests/e2e/natuplink/natuplink_test.go | 424 ++++++- tests/e2e/run-tofu-examples-e2e.sh | 176 ++- tests/e2e/single/imagecatalog_test.go | 116 ++ tests/e2e/single/main_test.go | 67 +- tests/e2e/single/smtpblock_test.go | 4 +- tests/e2e/single/tests_test.go | 7 + tests/e2e/single/vpcegress_test.go | 142 ++- 175 files changed, 14704 insertions(+), 492 deletions(-) create mode 100644 .github/assets/diagrams/oci-single-node.svg create mode 100644 .github/assets/diagrams/oci-three-node.svg create mode 100644 docs/oci-integration/README.md create mode 100644 docs/testing/e2e-coverage.md create mode 100755 scripts/setup-ovn_test.sh create mode 100644 scripts/terraform/oci-spx/.gitignore create mode 100644 scripts/terraform/oci-spx/README.md create mode 100755 scripts/terraform/oci-spx/cloud-init/mount-data-volume.sh create mode 100755 scripts/terraform/oci-spx/cloud-init/open-service-ports.sh create mode 100755 scripts/terraform/oci-spx/cloud-init/setup-wan-bridge.sh create mode 100644 scripts/terraform/oci-spx/cloud-init/user-data.yaml.tftpl create mode 100644 scripts/terraform/oci-spx/compute.tf create mode 100755 scripts/terraform/oci-spx/eip-move-test.sh create mode 100644 scripts/terraform/oci-spx/identity.tf create mode 100644 scripts/terraform/oci-spx/network.tf create mode 100644 scripts/terraform/oci-spx/outputs.tf create mode 100644 scripts/terraform/oci-spx/provider.tf create mode 100644 scripts/terraform/oci-spx/requirements.txt create mode 100755 scripts/terraform/oci-spx/scripts/oci_env.py create mode 100644 scripts/terraform/oci-spx/terraform.tfvars.example create mode 100644 scripts/terraform/oci-spx/variables.tf create mode 100644 scripts/terraform/oci-spx/versions.tf create mode 100755 scripts/uninstall-spx.sh create mode 100755 scripts/uninstall-spx_test.sh create mode 100644 spinifex/cloud/oci/apiclient_test.go create mode 100644 spinifex/cloud/oci/client.go create mode 100644 spinifex/cloud/oci/client_test.go create mode 100644 spinifex/cloud/oci/export_test.go create mode 100644 spinifex/cloud/oci/fake.go create mode 100644 spinifex/cloud/oci/fake_test.go create mode 100644 spinifex/cloud/oci/metadata.go create mode 100644 spinifex/cloud/oci/metadata_test.go create mode 100644 spinifex/cloud/oci/types.go create mode 100644 spinifex/daemon/daemon_handlers_eni_public_ip.go create mode 100644 spinifex/handlers/ec2/instance/projection_public_ip_test.go create mode 100644 spinifex/handlers/ec2/instance/start_public_ip_test.go create mode 100644 spinifex/network/external/dhcp/release_scope_test.go create mode 100644 spinifex/network/external/eip_release_guard_test.go create mode 100644 spinifex/network/external/ocinet/affinity.go create mode 100644 spinifex/network/external/ocinet/affinity_test.go create mode 100644 spinifex/network/external/ocinet/allocator.go create mode 100644 spinifex/network/external/ocinet/allocator_test.go create mode 100644 spinifex/network/external/ocinet/lookup.go create mode 100644 spinifex/network/external/ocinet/lookup_test.go create mode 100644 spinifex/network/external/ocinet/owner_test.go create mode 100644 spinifex/network/external/ocinet/reconcile.go create mode 100644 spinifex/network/external/ocinet/reconcile_test.go create mode 100644 spinifex/network/external/ocinet/store.go create mode 100644 spinifex/network/external/ocinet/wiring.go create mode 100644 spinifex/network/host/eip_source_route.go create mode 100644 spinifex/network/host/eip_source_route_test.go create mode 100644 spinifex/network/host/ovs_local_port.go create mode 100644 spinifex/network/host/ovs_local_port_test.go create mode 100644 spinifex/network/policy/nat_datapath_test.go create mode 100644 spinifex/network/policy/nat_gateway_chassis_test.go create mode 100644 spinifex/network/policy/nat_host_chassis_test.go create mode 100644 spinifex/network/policy/nat_prune_guard_test.go create mode 100644 spinifex/network/reconcile/apply_natgw_live_test.go create mode 100644 spinifex/network/reconcile/apply_prune_safety_test.go create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/_auth-Cg86DRhs.js delete mode 100644 spinifex/services/spinifexui/frontend/dist/assets/_auth-DsyoN1M9.js delete mode 100644 spinifex/services/spinifexui/frontend/dist/assets/_id-BnUjyn0E.js create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/_id-CAKeaftF.js create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/_id-Czat7SAc.js delete mode 100644 spinifex/services/spinifexui/frontend/dist/assets/_id-U4BZhcdn.js delete mode 100644 spinifex/services/spinifexui/frontend/dist/assets/index-BrcYJ9RS.js create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/index-CV30i9r0.css create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/index-D7dQJPHv.js delete mode 100644 spinifex/services/spinifexui/frontend/dist/assets/index-z1WTrxZE.css create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/network-RAx0KOlf.js delete mode 100644 spinifex/services/spinifexui/frontend/dist/assets/waypoints-CMfoqtKm.js create mode 100644 spinifex/services/spinifexui/frontend/dist/assets/waypoints-DE7eIn9V.js create mode 100644 spinifex/services/spinifexui/frontend/src/lib/ssh-login.test.ts create mode 100644 spinifex/services/spinifexui/frontend/src/lib/ssh-login.ts create mode 100644 spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.test.tsx create mode 100644 spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.tsx create mode 100644 spinifex/vm/stop_public_ip_test.go create mode 100644 spinifex/vpcd/bridge_detect_test.go create mode 100644 spinifex/vpcd/host_eip_loop.go create mode 100644 spinifex/vpcd/host_eip_loop_test.go create mode 100644 spinifex/vpcd/vpcd_eip_chassis_test.go create mode 100644 tests/e2e/single/imagecatalog_test.go diff --git a/.github/assets/diagrams/oci-single-node.svg b/.github/assets/diagrams/oci-single-node.svg new file mode 100644 index 000000000..7049caffe --- /dev/null +++ b/.github/assets/diagrams/oci-single-node.svg @@ -0,0 +1,80 @@ + + + +SPINIFEX ON OCI — SINGLE NODE +one instance · routed NAT · real public addresses +One OCI instance, single Spinifex node + +INTERNET +AWS SDKs, SSH and guest egress all arrive here + +aws-cli / Terraform / boto3 → :9999 + +ssh → guest public IP + +guest egress ← same address + + +OCI VCN 10.200.0.0/16 · PUBLIC SUBNET 10.200.0.0/23 +virtual router 10.200.0.1 · internet gateway + +BM.Standard.E2.64 +spinifex-node-01 · FAULT-DOMAIN-1 +one host = one Spinifex region + + +VNIC 0 — PRIMARY +enp0s9 +10.200.0.252/23 · holds the default route +advertise IP · ovn-encap-ip · NATS · API :9999 + +VNIC 1 — SECONDARY +br-wan (Linux bridge) +10.200.1.233/32 + ip rule → table 200 +secondary private IPs ⇄ reserved public IPs + + +spx-nat-host ⇄ spx-nat-ovs +transit 100.127.0.0/24 · nft chain spinifex-nat-egress masquerades out br-wan + + +SPINIFEX CONTROL PLANE +every service is one binary: spx service <name> + +awsgw :9999 + +daemon + +vpcd + +NATS + +predastore + +viperblock + +northstar + +ui :3000 +SigV4 in, NATS in the middle, QEMU/KVM · OVN/OVS · Predastore · Viperblock underneath + + + +TENANT VPCs — OVN br-int +geneve-local + +vpc-a 10.0.0.0/16 +i-… 10.0.1.4 → 150.230.13.131 + +vpc-b 172.31.0.0/16 +i-… 172.31.4.9 · private, egress only + +BLOCK VOLUME — iSCSI +256 GB+ +169.254.2.2:3260 → /dev/sdb +/var/lib/spinifex +mounted by UUID, defaults,_netdev,nofail +viperblock · predastore · JetStream +A guest's public address is a real OCI reserved public IP, attached to a secondary private IP on br-wan. +Inbound and outbound are therefore the same address, and OCI's per-VNIC source check passes. + diff --git a/.github/assets/diagrams/oci-three-node.svg b/.github/assets/diagrams/oci-three-node.svg new file mode 100644 index 000000000..7d8f47fa7 --- /dev/null +++ b/.github/assets/diagrams/oci-three-node.svg @@ -0,0 +1,116 @@ + + + +SPINIFEX ON OCI — THREE NODES +three fault domains · one region · one API endpoint +Three OCI instances, redundant multi-node install + +INTERNET +one endpoint, whichever node answers + +aws-cli / Terraform / boto3 → :9999 + +ssh → guest public IP + +guest egress ← same address + + +OCI VCN 10.200.0.0/16 · PUBLIC SUBNET 10.200.0.0/23 +virtual router 10.200.0.1 · internet gateway + +spinifex-node-01 +FAULT-DOMAIN-1 +BM.Standard.E2.64 + +VNIC 0 10.200.0.252 +mesh · advertise · encap +VNIC 1 br-wan 10.200.1.233/32 +public IP pairs · table 200 + +awsgw + +daemon + +vpcd + +nats + +predastore + +viperblock + +i-23d2… 10.0.1.4 → 150.230.13.131 + +iSCSI /dev/sdb → /var/lib/spinifex + + +spinifex-node-02 +FAULT-DOMAIN-2 +BM.Standard.E2.64 + +VNIC 0 10.200.0.118 +mesh · advertise · encap +VNIC 1 br-wan 10.200.1.96/32 +public IP pairs · table 200 + +awsgw + +daemon + +vpcd + +nats + +predastore + +viperblock + +i-6b91… 10.0.1.9 → 137.23.9.125 + +iSCSI /dev/sdb → /var/lib/spinifex + + +spinifex-node-03 +FAULT-DOMAIN-3 +BM.Standard.E2.64 + +VNIC 0 10.200.0.41 +mesh · advertise · encap +VNIC 1 br-wan 10.200.1.12/32 +public IP pairs · table 200 + +awsgw + +daemon + +vpcd + +nats + +predastore + +viperblock + +i-0ae4… 10.0.2.6 → 150.230.4.88 + +iSCSI /dev/sdb → /var/lib/spinifex + +EAST–WEST, INSIDE THE VCN +no public address is involved + +Geneve UDP 6081 + +NATS JetStream R3 + +predastore RS(2,1) + +OVN Northbound raft + +OVSDB +Guest-to-guest traffic rides Geneve between the nodes' primary VNICs — it never leaves the subnet and never touches br-wan. + +A guest that moves takes its address with it. +The reserved public IP follows the secondary private IP to whichever node's br-wan the guest lands on — the tenant sees no change. +Three nodes is the smallest cluster that tolerates losing one: NATS JetStream keeps quorum, predastore RS(2,1) +keeps the data readable, and the OVN Northbound raft keeps a leader. One node per fault domain is what makes that real. + diff --git a/.github/e2e-cells.json b/.github/e2e-cells.json index 27d5e24a8..0649da823 100644 --- a/.github/e2e-cells.json +++ b/.github/e2e-cells.json @@ -143,5 +143,16 @@ "extra": "", "suite": "storagefault", "timeout_minutes": 100 + }, + { + "cell": 30, + "install": "binary", + "topo": "real-multi", + "net": "nat", + "host": "debian-13", + "arch": "x86_64", + "runner": "ci-multi", + "extra": "nat-multi", + "suite": "" } ] diff --git a/.github/workflows/e2e-nightly.yml b/.github/workflows/e2e-nightly.yml index bf38f3b89..4039720e8 100644 --- a/.github/workflows/e2e-nightly.yml +++ b/.github/workflows/e2e-nightly.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: inputs: cells: - description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection" + description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes" required: false permissions: @@ -278,8 +278,31 @@ jobs: rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \ if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc" + # natuplink proves a guest with a public IP reaches the internet. The two + # surfaces it never touches are a private subnet's isolation and its NAT + # gateway, and TestVPCEgressPaths is the authority on both — it skipped in + # nat mode until the fixture learned that routed NAT carries EIPs too. + - name: Run VPC Egress Paths in NAT mode + if: matrix.extra == 'nat-single' + working-directory: mulga/scripts/tofu-cluster + run: | + INVENTORY=$(tofu output -json inventory) + WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip') + SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path') + SSH_KEY="${SSH_KEY/#\~/$HOME}" + ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \ + "set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \ + echo '::group::suite single/TestVPCEgressPaths'; \ + date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-vpcegress-nat.start'; \ + GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \ + ARTIFACT_DIR='${ARTIFACT_DIR}' \ + ./single.test -test.v -test.timeout=30m -test.run '^TestVPCEgressPaths\$' \ + 2>&1 | tee '${ARTIFACT_DIR}/test-vpcegress-nat.log'; \ + rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \ + if [ \$rc -ne 0 ]; then echo 'vpcegress-nat: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc" + - name: Run Multi Node E2E - if: matrix.topo == 'real-multi' && matrix.suite == '' + if: matrix.topo == 'real-multi' && matrix.suite == '' && matrix.extra == '' working-directory: mulga/scripts/tofu-cluster run: | set -euo pipefail @@ -314,6 +337,40 @@ jobs: if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \ done; exit \$FAIL" + # The same suite as the nat-single cell, run against three nodes: there it + # proves routed NAT works at all, here that each node delivers its own + # guests' public IPs and no others — which one node cannot show. + - name: Run NAT Uplink Multi E2E + if: matrix.extra == 'nat-multi' + working-directory: mulga/scripts/tofu-cluster + run: | + set -euo pipefail + INVENTORY=$(tofu output -json inventory) + NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip') + SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path') + SSH_KEY="${SSH_KEY/#\~/$HOME}" + + NODES_CSV="" + NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length') + for i in $(seq 0 $((NODE_COUNT - 1))); do + ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip") + NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip" + done + + ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \ + "set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \ + if [ ! -f natuplink.test ]; then echo 'natuplink.test not in tests tar (branch predates suite) — skipping'; exit 0; fi; \ + echo '::group::suite natuplink'; \ + date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-natuplink.start'; \ + GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \ + SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \ + SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \ + ARTIFACT_DIR='${ARTIFACT_DIR}' \ + ./natuplink.test -test.v -test.timeout=30m \ + 2>&1 | tee '${ARTIFACT_DIR}/test-natuplink.log'; \ + rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \ + if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc" + # The dedicated suite cells only. Resolved once here rather than in each # step below, which would repeat the same three lines five times. - name: Resolve VM SSH details diff --git a/Makefile b/Makefile index 04066dbda..12494da9d 100644 --- a/Makefile +++ b/Makefile @@ -160,14 +160,27 @@ preflight: # Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that # ship on every node (unlike scripts/images/, kept in preflight: a wrong # restart decision here is an outage, not asset churn). +# +# NODE_SCRIPTS are the four that setup.sh installs into +# /usr/local/share/spinifex on every node. They are covered here for the same +# reason: between them they install the software, wire OVN, erase the state and +# remove the install, so a shellcheck warning in one is an outage or a wrong +# deletion, not a style note. +NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/node-reset.sh scripts/uninstall-spx.sh + test-build-scripts: @echo -e "\n....Running build/scripts/**/*_test.sh...." @for t in $$(find build/scripts -name '*_test.sh' | sort); do \ echo "-- $$t"; \ sh "$$t" || exit 1; \ done - @echo -e "\n....Running shellcheck over build/scripts/**/*.sh...." - shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) + @echo -e "\n....Running scripts/*_test.sh...." + @for t in $$(find scripts -maxdepth 1 -name '*_test.sh' | sort); do \ + echo "-- $$t"; \ + sh "$$t" || exit 1; \ + done + @echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...." + shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS) @echo " test-build-scripts ok" # E2E harness unit tests. Build-tagged `e2e` so they're skipped by the diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 600b46d80..ef2614f8a 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -496,6 +496,10 @@ func validateChecksumFlags(checksumPath string, checksumSet bool, localFile stri return nil } +// catalogPinnedDigestSource records that the expected digest came from this +// binary's catalog, so a recorded digest never reads as a fetched sums file. +const catalogPinnedDigestSource = "catalog:pinned" + // importSource is the file an import is about to extract, and what it is to be verified against. type importSource struct { imageFile, imageName, localFile string @@ -511,10 +515,23 @@ func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageD digest := ebsmetadata.ImageDigest{Filename: filepath.Base(src.imageFile)} switch sourceDigestMode(src.localFile, src.checksumPath, src.skipVerify) { case ebsmetadata.DigestCatalog: - if src.image.Checksum == "" || src.image.ChecksumType == "" { - fmt.Fprintf(errOut, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", src.imageName) + if src.image.ChecksumType == "" || (src.image.Checksum == "" && src.image.ChecksumDigest == "") { + fmt.Fprintf(errOut, "Catalog entry %q is missing Checksum/ChecksumDigest/ChecksumType; refusing import.\n", src.imageName) return digest, errors.New("catalog entry has no checksum") } + // A pinned digest is checked against this file rather than a sums file + // fetched from the same host that served the image. + if src.image.ChecksumDigest != "" { + actual, err := utils.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest) + if err != nil { + printChecksumError(errOut, src.imageFile, src.imageName, src.image, err) + return digest, err + } + fmt.Fprintf(out, "✅ Verified image checksum (%s) against the pinned catalog digest\n", src.image.ChecksumType) + digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, catalogPinnedDigestSource + digest.Verification = ebsmetadata.DigestCatalog + return digest, nil + } actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType) if err != nil { printChecksumError(errOut, src.imageFile, src.imageName, src.image, err) @@ -1283,7 +1300,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { detected, err := admin.DetectNetwork() if err != nil { fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err) - fmt.Fprintf(os.Stderr, " Use --external-mode=nat for outbound-only VMs on a non-bridgeable uplink, or specify --external-* flags manually.\n") + fmt.Fprintf(os.Stderr, " Use --external-mode=nat on a non-bridgeable uplink, or specify --external-* flags manually.\n") } else { detectedNet = detected @@ -1321,7 +1338,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { if externalMode == "" && !cmd.Flags().Changed("external-mode") { if isNonBridgeableUplink(detected.WAN.Name) { fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name) - fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n") + fmt.Fprintf(os.Stderr, " Use routed NAT mode instead:\n") fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n") fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n") os.Exit(1) @@ -1343,9 +1360,14 @@ func runAdminInit(cmd *cobra.Command, args []string) { // the transit segment claims externalGateway below. natPublicGateway := externalGateway if externalMode == "nat" { + // Verified on three OCI nodes 2026-09-26: each node delivers its own + // instances' public IPs, inbound and outbound. Still flagged because no + // nightly cell covers multi-node nat yet. if nodes >= 2 { - fmt.Fprintf(os.Stderr, "❌ Error: --external-mode=nat is single-node only (v1); use --nodes=1\n") - os.Exit(1) + fmt.Fprintf(os.Stderr, "⚠️ --external-mode=nat on %d nodes is NEW.\n", nodes) + fmt.Fprintf(os.Stderr, " Public IPs are delivered by the node running the instance; default egress stays on the VPC's gateway chassis.\n") + fmt.Fprintf(os.Stderr, " Verified by hand on three nodes, but not yet covered by a nightly cell.\n") + fmt.Fprintf(os.Stderr, " Check with: ip route show dev spx-nat-host — exactly one node should hold each public address.\n") } if !natPublicPool && (externalBindBridge != "" || gatewayIP != "") { fmt.Fprintf(os.Stderr, "❌ Error: --external-bind-bridge/--gateway-ip require --external-pool or --external-source in --external-mode=nat\n") @@ -1675,14 +1697,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { bootstrapSubnetId := utils.GenerateResourceID("subnet") bootstrapIgwId := utils.GenerateResourceID("igw") networkConfig.ExternalMode = externalMode - networkConfig.PoolName = "wan" - networkConfig.PoolSource = externalSource - networkConfig.PoolBindBridge = externalBindBridge - networkConfig.PoolStart = poolStart - networkConfig.PoolEnd = poolEnd - networkConfig.PoolGateway = externalGateway - networkConfig.PoolGatewayIP = gatewayIP - networkConfig.PoolPrefixLen = externalPrefixLen + networkConfig.Pools = externalPools networkConfig.PoolDNSServers = dnsServers networkConfig.BootstrapAccountId = admin.DefaultAccountID() networkConfig.BootstrapVpcId = bootstrapVpcId @@ -1835,7 +1850,7 @@ func runAdminInit(cmd *cobra.Command, args []string) { fmt.Printf(" Public pool: dhcp via %s\n", externalBindBridge) } } else { - fmt.Printf("\n📡 External networking: nat (routed, outbound-only — no public IPs/EIPs)\n") + fmt.Printf("\n📡 External networking: nat (routed; no public pool configured yet)\n") } fmt.Printf(" Transit: %s via %s (host masquerades out any uplink)\n", host.NATTransitCIDR, host.NATTransitHostEnd) fmt.Printf(" Host setup: ./scripts/setup-ovn.sh --nat-uplink (run before starting services)\n") @@ -3199,18 +3214,11 @@ func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkCon settings.IPSecEnabled = nc.IPSecEnabled settings.ExternalMode = nc.ExternalMode settings.PoolDNSServers = nc.PoolDNSServers + // Derived from the mode rather than carried in NetworkConfig, so the leader + // and every joiner pin the same value from the same rule as single-node init. + settings.BridgeMode = bridgeModeFor(nc.ExternalMode) if nc.ExternalMode != "" { - settings.Pools = []admin.PoolData{{ - Name: nc.PoolName, - Source: nc.PoolSource, - BindBridge: nc.PoolBindBridge, - Start: nc.PoolStart, - End: nc.PoolEnd, - Gateway: nc.PoolGateway, - GatewayIP: nc.PoolGatewayIP, - PrefixLen: nc.PoolPrefixLen, - DNSServers: nc.PoolDNSServers, - }} + settings.Pools = nc.Pools } settings.BootstrapAccountId = nc.BootstrapAccountId @@ -3911,7 +3919,12 @@ func resolveIfaceIP(iface string) string { func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Images, err error) { fmt.Fprintf(w, "Image integrity verification failed: %v\n", err) fmt.Fprintf(w, " file: %s\n", imageFile) - fmt.Fprintf(w, " source: %s\n", image.Checksum) + if image.ChecksumDigest != "" { + fmt.Fprintf(w, " source: %s (%s)\n", catalogPinnedDigestSource, image.ChecksumDigest) + fmt.Fprintf(w, " url: %s\n", image.URL) + } else { + fmt.Fprintf(w, " source: %s\n", image.Checksum) + } fmt.Fprintln(w) fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:") fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName) diff --git a/cmd/spinifex/cmd/admin_test.go b/cmd/spinifex/cmd/admin_test.go index 97f3b9d40..1a5394a13 100644 --- a/cmd/spinifex/cmd/admin_test.go +++ b/cmd/spinifex/cmd/admin_test.go @@ -738,21 +738,45 @@ func TestSpinifexTomlTemplate_ExternalPoolStaticSource(t *testing.T) { assert.Contains(t, content, `gateway = "192.168.1.1"`) } -// Formation joiners must receive PoolBindBridge from the init node so the +// Formation joiners must receive BindBridge from the init node so the // cluster-wide config reaches every chassis intact. func TestApplyNetworkConfig_PropagatesPoolBindBridge(t *testing.T) { settings := &admin.ConfigSettings{} nc := &formation.NetworkConfig{ - ExternalMode: "pool", - PoolName: "wan", - PoolSource: "dhcp", - PoolBindBridge: "br-wan", - PoolPrefixLen: 24, + ExternalMode: "pool", + Pools: []admin.PoolData{{ + Name: "wan", Source: "dhcp", BindBridge: "br-wan", PrefixLen: 24, + }}, } applyNetworkConfig(settings, nc) require.Len(t, settings.Pools, 1) assert.Equal(t, "dhcp", settings.Pools[0].Source) assert.Equal(t, "br-wan", settings.Pools[0].BindBridge) + assert.Empty(t, settings.BridgeMode, "bridged modes stay auto-detected, only nat is pinned") +} + +// Routed NAT renders two pools, and the transit one carries a gateway-LRP range +// that pool mode has no equivalent for. A joiner that received only the first, +// or lost the range, would allocate VPC gateway addresses from nowhere. +func TestApplyNetworkConfig_PropagatesEveryPool(t *testing.T) { + settings := &admin.ConfigSettings{} + nc := &formation.NetworkConfig{ + ExternalMode: "nat", + Pools: []admin.PoolData{ + {Name: "nat-transit", Gateway: "100.127.0.1", PrefixLen: 24, + GwLrpRangeStart: "100.127.0.16", GwLrpRangeEnd: "100.127.0.254"}, + {Name: "wan", Source: "static", Start: "192.168.1.150", End: "192.168.1.200", + Gateway: "192.168.1.1", PrefixLen: 24}, + }, + } + applyNetworkConfig(settings, nc) + require.Len(t, settings.Pools, 2) + assert.Equal(t, "nat", settings.BridgeMode, + "a joiner that loses bridge_mode falls back to autodetection for a mode init pinned") + assert.Equal(t, "nat-transit", settings.Pools[0].Name) + assert.Equal(t, "100.127.0.16", settings.Pools[0].GwLrpRangeStart) + assert.Equal(t, "100.127.0.254", settings.Pools[0].GwLrpRangeEnd) + assert.Equal(t, "wan", settings.Pools[1].Name) } func renderSingleNodePredastore(t *testing.T, settings admin.ConfigSettings) string { @@ -1405,7 +1429,36 @@ func TestImportSourceResolveDigest(t *testing.T) { var out, errOut bytes.Buffer _, err := importSource{imageFile: img, imageName: "debian-13-x86_64"}.resolveDigest(&out, &errOut) require.Error(t, err) - assert.Contains(t, errOut.String(), "missing Checksum/ChecksumType") + assert.Contains(t, errOut.String(), "missing Checksum/ChecksumDigest/ChecksumType") + }) + + // Oracle publishes its digests inline on a web page and ships no sums file, + // so a pinned digest is the only way its entries can import verified. + t.Run("catalog pinned digest verifies without fetching a sums file", func(t *testing.T) { + var out, errOut bytes.Buffer + got, err := importSource{ + imageFile: img, imageName: "oracle-10.1-x86_64", + image: utils.Images{ChecksumType: "sha256", ChecksumDigest: imgHex}, + }.resolveDigest(&out, &errOut) + require.NoError(t, err) + assert.Equal(t, ebsmetadata.ImageDigest{ + Algorithm: "sha256", Value: imgHex, Source: catalogPinnedDigestSource, + Verification: ebsmetadata.DigestCatalog, Filename: "image.raw", + }, got) + assert.Contains(t, out.String(), "pinned catalog digest") + }) + + t.Run("catalog pinned digest mismatch is refused and names the url", func(t *testing.T) { + var out, errOut bytes.Buffer + wrong := strings.Repeat("a", 64) + _, err := importSource{ + imageFile: img, imageName: "oracle-10.1-x86_64", + image: utils.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"}, + }.resolveDigest(&out, &errOut) + require.ErrorIs(t, err, utils.ErrChecksumMismatch) + assert.Contains(t, errOut.String(), catalogPinnedDigestSource) + assert.Contains(t, errOut.String(), "https://example.invalid/ol.qcow2") + assert.FileExists(t, img, "the downloaded file must be left in place") }) t.Run("unreadable image fails the unverified hash", func(t *testing.T) { diff --git a/cmd/spinifex/cmd/service.go b/cmd/spinifex/cmd/service.go index 140d57415..2026f1b4f 100644 --- a/cmd/spinifex/cmd/service.go +++ b/cmd/spinifex/cmd/service.go @@ -1032,6 +1032,8 @@ var vpcdStartCmd = &cobra.Command{ UnderlayMTU: clusterConfig.Network.UnderlayMTU, BlockedWANPorts: clusterConfig.Network.ResolvedBlockedWANPorts(), EgressBlockExemptVPCs: clusterConfig.Network.EgressBlockExemptVPCs, + IMDSHostMetaIP: clusterConfig.Network.IMDSHostMetaIP, + IMDSHostDNSIP: clusterConfig.Network.IMDSHostDNSIP, }) if err != nil { return fmt.Errorf("create vpcd service: %w", err) diff --git a/docs/admin/setting-up-your-cluster/README.md b/docs/admin/setting-up-your-cluster/README.md index e2fd9209b..3df98e4b7 100644 --- a/docs/admin/setting-up-your-cluster/README.md +++ b/docs/admin/setting-up-your-cluster/README.md @@ -1,7 +1,7 @@ --- title: "Setting Up Your Cluster" seoTitle: "Set Up Your First Spinifex Cluster — Spinifex Docs" -description: "Import an AMI, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance and connect to it on a fresh Spinifex cluster." +description: "Import an AMI and the RDS, ECS and EKS base images, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance on a fresh Spinifex cluster." category: "Admin" tags: - setup @@ -17,11 +17,12 @@ resources: # Setting Up Your Cluster -> Import an AMI, create a VPC with a public subnet, and launch your first instance. +> Import an AMI and the system base images, create a VPC with a public subnet, and launch your first instance. ## Table of Contents - [1. Import an AMI](#1-import-an-ami) + - [Option C: Import the system base images](#option-c-import-the-system-base-images) - [2. Create an SSH Key](#2-create-an-ssh-key) - [3. Create a VPC and Public Subnet](#3-create-a-vpc-and-public-subnet) - [4. Launch an Instance](#4-launch-an-instance) @@ -35,7 +36,7 @@ resources: ## Overview -This guide walks through the first steps on a freshly installed cluster: importing an AMI, creating an SSH key and a VPC with a public subnet, launching an instance, and connecting to it. +This guide walks through the first steps on a freshly installed cluster: importing an AMI and the system base images RDS, ECS and EKS need, creating an SSH key and a VPC with a public subnet, launching an instance, and connecting to it. ## Instructions @@ -82,6 +83,41 @@ Verify the import and note the AMI ID: AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text) ``` +### Option C: Import the system base images + +**RDS, ECS and EKS each launch from an image of their own, and none of them is imported for you.** Until these are in the catalog, `CreateDBInstance`, `CreateCluster` and `CreateNodegroup` all fail at the point they go looking for a node image — so a cluster without them has those three services present in the API and unusable in practice. Import them once, now, and the install is functional from the start. + +Run these one at a time rather than in parallel, so predastore is not taking four concurrent uploads: + +```bash +for img in spinifex-rds-postgres spinifex-rds-mariadb spinifex-ecs-node spinifex-eks-node; do + sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml +done +``` + +| Image | Serves | Contents | +| --- | --- | --- | +| `spinifex-rds-postgres` | RDS, `--engine postgres` | Alpine 3.24.1 + PostgreSQL 18 + `rds-init` | +| `spinifex-rds-mariadb` | RDS, `--engine mariadb` | Alpine 3.24.1 + MariaDB 11.8 + `rds-init` | +| `spinifex-ecs-node` | ECS container instances | Alpine 3.21.7 + containerd + ecs-agent | +| `spinifex-eks-node` | EKS control plane and nodegroups | Alpine 3.21.7 + K3s v1.32.5 + eks-token-webhook | + +All four boot **BIOS**, unlike the distro images, which boot UEFI. That is a property of how they are built and nothing you need to pass. + +If you run GPU workloads, add `spinifex-ecs-node-gpu` and `spinifex-eks-node-gpu` as well — Ubuntu 26.04 with the NVIDIA driver, selected automatically for a GPU instance type. They boot UEFI and they are large, so import them only if you have GPUs. + +> [!IMPORTANT] +> **These register under generic names, and that is correct.** `describe-images` shows `spinifex-eks-node` and `spinifex-ecs-node` both as `ami-alpine-3.21.7-x86_64`, and the two RDS images both as `ami-alpine-3.24.1-x86_64`. Nothing resolves them by name: ECS and EKS find theirs by the `spinifex:managed-by=ecs|eks` tag, and RDS by `spinifex:managed-by=rds` plus the `engine` and `engine-version` tags. **Verify the tags, not the names** — four rows with duplicate-looking names is what success looks like: +> +> ```bash +> aws ec2 describe-images \ +> --filters 'Name=tag-key,Values=spinifex:managed-by' \ +> --query 'Images[].[ImageId,State,Tags[?Key==`spinifex:managed-by`].Value|[0],Tags[?Key==`engine`].Value|[0]]' \ +> --output text +> ``` + +`spx admin images list --config /etc/spinifex/spinifex.toml` on a node shows the whole catalog, including the images not imported here. + ## 2. Create an SSH Key ### Option A: Import an existing key diff --git a/docs/compute/vpc-networking/README.md b/docs/compute/vpc-networking/README.md index 0b653c700..729f87972 100644 --- a/docs/compute/vpc-networking/README.md +++ b/docs/compute/vpc-networking/README.md @@ -161,6 +161,12 @@ Both support the same AWS features: public subnets, Elastic IPs, security groups ## `nat` — Shared SNAT (Simple) +**`nat` is a supported mode, not a fallback.** It is covered by the nightly e2e suite (cells 19 and 30, single- and multi-node) and it is the only mode a cloud-hosted node can run, so the OCI deployment depends on it. Read "specialised" below as "fewer deployments need it", never as "less finished". + +**Start with `pool` unless one of the reasons below applies.** Pool mode is the default, is what most of this document describes, and is what the AWS feature set was built against. `nat` is the specialised mode: it exists for uplinks that cannot be bridged, and it reaches AWS parity through host-side plumbing rather than by putting VM addresses on the wire. + +Reach for `nat` when the uplink refuses to carry anything but its own MAC and its own addresses — WiFi and WWAN, and **any cloud-hosted node**, where the VNIC drops a frame whose source address is not registered to it. On such a host `nat` is not a preference, it is the only mode that works at all; pool mode's localnet needs to announce VM addresses by ARP and a cloud VNIC will not carry them. See [oci-integration](../../oci-integration/README.md). + All VMs share a single external IP for outbound SNAT. By default there are no public IPs, no Elastic IPs, and no inbound from WAN — all subnets behave as private subnets with internet access. On routed-NAT nodes, adding a public pool restores full public IP parity (see below). > **Limitation (routed-NAT v1):** System instances (ECS/EKS/load-balancer @@ -244,6 +250,24 @@ gateway = "192.168.1.1" prefix_len = 24 ``` +### NAT gateways in NAT mode + +`CreateNatGateway` works here too, and draws its public address from the same public pool an EIP does. On the OVN side it is an `snat` row on the VPC router rewriting the private subnet's CIDR to that address — not the `dnat_and_snat` an EIP gets — so it is centralised on the VPC's gateway chassis by construction and there is no port or MAC to bind it to. + +**That address needs the same host plumbing as an EIP**, and on a routed-NAT node it is the host that delivers it: the `/32` route steering it into OVN, the proxy-ARP entry answering for it on the uplink, and the FORWARD accepts. Without them an egressing private instance leaves with a source address the host holds no route back to, while the masquerade rule — which matches the transit `100.127.0.0/24` alone — never sees the packet. Spinifex binds it when the gateway is created and re-asserts it on the host EIP pass, so no operator step is involved; `ip route show dev spx-nat-host` should list the gateway's public address alongside any EIPs. + +### NAT mode on more than one node + +`spx admin init --external-mode=nat --nodes N` works for N > 1, and each public IP is delivered by the node running its instance rather than by one gateway node. Three things make that true, and all three are automatic: + +- The `dnat_and_snat` row carries `external_mac` and `logical_port`, so OVN processes it on the instance's own chassis — the same mechanism pool mode has always used. It is safe here because `ext-shared` is a localnet, so the transit veth is node-local and only the node running the instance can answer for the address on it. +- Each node installs a host `/32` for its own instances' addresses and leaves the rest alone, asking local OVS which ENIs are plugged in here. Run `ip route show dev spx-nat-host` on each node: exactly one node should hold each public address. +- Every node's `spx-nat-host` carries the same MAC, `02:00:64:7f:00:01`. OVN keeps one MAC binding for the transit next hop across the whole cluster, so a per-node address would send every chassis's return traffic to whichever node wrote the binding last. `setup-ovn.sh` sets it and vpcd re-asserts it on every start. + +Default egress — an instance with no public IP — is still centralised on the VPC's gateway chassis, which is unchanged and is what the gateway LRP's transit address is for. + +**On a cloud host the address must also be registered on that node's VNIC.** The allocator runs per node and registers on the local VNIC, which is the right one exactly because the traffic now leaves there. `oci_vnic_iface` is the setting; `oci_vnic_id` is only needed to pin a pool to a specific VNIC and should not be set for this. + **Caveat — reaching an EIP from the spinifex host itself.** Host-sourced traffic enters OVN from the transit net, which is exempt from NAT (that is what makes the jumpbox pattern work) — so a host connection to an EIP that carries the transit source IP would skip DNAT. Spinifex stamps the EIP route with the uplink's LAN IP as source to avoid this, but if no uplink address can be determined, connect to the instance's **private IP** from the host instead. Other machines on the LAN are unaffected. ## Disabled (Empty/Omitted) @@ -263,6 +287,9 @@ VPC networking is overlay-only. No external connectivity. Instances can only com | DescribeInstances shows public IP | Yes | Yes | With public pool | No | | Admin must reserve IP range | Yes | No | Only static pool | No | | Needs router DHCP | No | Yes | Optional | No | +| Works on a non-bridgeable uplink | No | No | Yes | n/a | +| NAT gateways | Yes | Yes | With public pool | No | +| More than one node | Yes | Yes | Yes | Yes | If you start with `nat` and later need public subnets: on a bridgeable uplink switch to `pool` and define a range (or use `source = "dhcp"`); on a routed-NAT node just add a public pool alongside `nat-transit` — no data migration needed. @@ -460,8 +487,8 @@ external_mode = "pool" # "pool", "nat", or "" (disabled) | Value | Behavior | | -------------- | ----------------------------------------------------------------- | -| `"pool"` | Full public networking — public subnets, auto-assign, Elastic IPs | -| `"nat"` | Outbound-only SNAT — all VMs share one external IP | +| `"pool"` | **Default.** Full public networking — public subnets, auto-assign, Elastic IPs | +| `"nat"` | Outbound-only SNAT — all VMs share one external IP. Add a public pool for full parity. For uplinks that cannot be bridged, including every cloud host | | `""` / omitted | Overlay-only — no external connectivity | ## IP Pools: network.external_pools diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 04828d905..884439dc8 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -17,18 +17,18 @@ tags: ## Overview -The platform serves **391 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand. +The platform serves **389 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand. | Service | Operations | |---|---:| | [ACM](/coverage/acm) | 9 | -| [EC2](/coverage/ec2) | 126 | +| [EC2](/coverage/ec2) | 127 | | [ECR](/coverage/ecr) | 22 | | [ECS](/coverage/ecs) | 31 | | [EKS](/coverage/eks) | 36 | | [ELBv2](/coverage/elbv2) | 34 | | [IAM](/coverage/iam) | 76 | | [RDS](/coverage/rds) | 26 | -| [S3](/coverage/s3) | 26 | +| [S3](/coverage/s3) | 23 | | [STS](/coverage/sts) | 5 | -| **Total** | **391** | +| **Total** | **389** | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index adc187971..22acc4c5b 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -778,6 +778,7 @@ "DescribeVolumes", "DescribeVolumesModifications", "DescribeVpcAttribute", + "DescribeVpcEndpoints", "DescribeVpcs", "DetachInternetGateway", "DetachNetworkInterface", @@ -906,6 +907,7 @@ "DescribeVolumes", "DescribeVolumesModifications", "DescribeVpcAttribute", + "DescribeVpcEndpoints", "DescribeVpcs", "DetachInternetGateway", "DetachNetworkInterface", @@ -1240,7 +1242,6 @@ "DescribeVpcEndpointServiceConfigurations", "DescribeVpcEndpointServicePermissions", "DescribeVpcEndpointServices", - "DescribeVpcEndpoints", "DescribeVpcPeeringConnections", "DescribeVpnConnections", "DescribeVpnGateways", @@ -3048,18 +3049,15 @@ "DeleteObjects", "GetBucketLocation", "GetBucketTagging", - "GetBucketVersioning", "GetObject", "HeadBucket", "HeadObject", "ListBuckets", "ListMultipartUploads", - "ListObjectVersions", "ListObjects", "ListObjectsV2", "ListParts", "PutBucketTagging", - "PutBucketVersioning", "PutObject", "UploadPart", "UploadPartCopy" @@ -3076,18 +3074,15 @@ "DeleteObjects", "GetBucketLocation", "GetBucketTagging", - "GetBucketVersioning", "GetObject", "HeadBucket", "HeadObject", "ListBuckets", "ListMultipartUploads", - "ListObjectVersions", "ListObjects", "ListObjectsV2", "ListParts", "PutBucketTagging", - "PutBucketVersioning", "PutObject", "UploadPart", "UploadPartCopy" @@ -3127,6 +3122,7 @@ "GetBucketPolicyStatus", "GetBucketReplication", "GetBucketRequestPayment", + "GetBucketVersioning", "GetBucketWebsite", "GetObjectAcl", "GetObjectAttributes", @@ -3141,6 +3137,7 @@ "ListBucketInventoryConfigurations", "ListBucketMetricsConfigurations", "ListDirectoryBuckets", + "ListObjectVersions", "PutBucketAccelerateConfiguration", "PutBucketAcl", "PutBucketAnalyticsConfiguration", @@ -3158,6 +3155,7 @@ "PutBucketPolicy", "PutBucketReplication", "PutBucketRequestPayment", + "PutBucketVersioning", "PutBucketWebsite", "PutObjectAcl", "PutObjectLegalHold", diff --git a/docs/coverage/ec2/README.md b/docs/coverage/ec2/README.md index 8052afb82..040ef4b44 100644 --- a/docs/coverage/ec2/README.md +++ b/docs/coverage/ec2/README.md @@ -18,7 +18,7 @@ tags: ## Overview -Spinifex implements **126 operations** in the EC2 `2016-11-15` API model. +Spinifex implements **127 operations** in the EC2 `2016-11-15` API model. ### Spot Instances Are a Mock @@ -110,6 +110,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ | `DescribeVolumes` | | `DescribeVolumesModifications` | | `DescribeVpcAttribute` | +| `DescribeVpcEndpoints` | | `DescribeVpcs` | | `DetachInternetGateway` | | `DetachNetworkInterface` | diff --git a/docs/coverage/internal-report.md b/docs/coverage/internal-report.md index 43b208e2a..fe2dfaa5f 100644 --- a/docs/coverage/internal-report.md +++ b/docs/coverage/internal-report.md @@ -21,7 +21,7 @@ Pinned model source: `aws-sdk-go v1.55.8`. ## ec2 -`2016-11-15` — 126 of 625 modelled operations implemented (20.2%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model. +`2016-11-15` — 127 of 625 modelled operations implemented (20.3%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model. ### Not implemented @@ -287,7 +287,6 @@ Pinned model source: `aws-sdk-go v1.55.8`. - `DescribeVpcEndpointServiceConfigurations` - `DescribeVpcEndpointServicePermissions` - `DescribeVpcEndpointServices` -- `DescribeVpcEndpoints` - `DescribeVpcPeeringConnections` - `DescribeVpnConnections` - `DescribeVpnGateways` @@ -970,7 +969,7 @@ option-groups: Option groups configure engine add-ons for engines this platform ## s3 -`2006-03-01` — 26 of 99 modelled operations implemented (26.3%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model. +`2006-03-01` — 23 of 99 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model. ### Not implemented @@ -1003,6 +1002,7 @@ option-groups: Option groups configure engine add-ons for engines this platform - `GetBucketPolicy` - `GetBucketPolicyStatus` - `GetBucketReplication` +- `GetBucketVersioning` - `GetBucketWebsite` - `GetObjectAcl` - `GetObjectAttributes` @@ -1017,6 +1017,7 @@ option-groups: Option groups configure engine add-ons for engines this platform - `ListBucketInventoryConfigurations` - `ListBucketMetricsConfigurations` - `ListDirectoryBuckets` +- `ListObjectVersions` - `PutBucketAcl` - `PutBucketAnalyticsConfiguration` - `PutBucketCors` @@ -1032,6 +1033,7 @@ option-groups: Option groups configure engine add-ons for engines this platform - `PutBucketOwnershipControls` - `PutBucketPolicy` - `PutBucketReplication` +- `PutBucketVersioning` - `PutBucketWebsite` - `PutObjectAcl` - `PutObjectLegalHold` diff --git a/docs/coverage/s3/README.md b/docs/coverage/s3/README.md index e63151c3e..dba82bee4 100644 --- a/docs/coverage/s3/README.md +++ b/docs/coverage/s3/README.md @@ -18,7 +18,7 @@ tags: ## Overview -Predastore implements **26 operations** in the S3 `2006-03-01` API model. +Predastore implements **23 operations** in the S3 `2006-03-01` API model. ### Predastore serves this endpoint @@ -45,18 +45,15 @@ That behaviour is measured separately, against the `ceph/s3-tests` suite Ceph RG | `DeleteObjects` | | `GetBucketLocation` | | `GetBucketTagging` | -| `GetBucketVersioning` | | `GetObject` | | `HeadBucket` | | `HeadObject` | | `ListBuckets` | | `ListMultipartUploads` | -| `ListObjectVersions` | | `ListObjects` | | `ListObjectsV2` | | `ListParts` | | `PutBucketTagging` | -| `PutBucketVersioning` | | `PutObject` | | `UploadPart` | | `UploadPartCopy` | diff --git a/docs/oci-integration/README.md b/docs/oci-integration/README.md new file mode 100644 index 000000000..1eae89cb3 --- /dev/null +++ b/docs/oci-integration/README.md @@ -0,0 +1,1005 @@ +--- +title: "Spinifex on Oracle Cloud" +seoTitle: "Run Spinifex on Oracle Cloud Infrastructure — Spinifex Docs" +description: "Deploy Spinifex on OCI end to end with Terraform, single node or three, and give guests real public addresses through OCI's own API." +category: "Install" +tags: + - install + - oci + - oracle cloud + - terraform + - cluster +resources: + - title: "Multi-Node Install" + url: "/docs/install-multi-node" + - title: "VPC Networking" + url: "/docs/vpc-networking" + - title: "Host Firewall" + url: "/docs/host-firewall" + - title: "Spinifex Repository" + url: "https://github.com/mulgadc/spinifex" +--- + +# Running Spinifex on Oracle Cloud Infrastructure + +> Run the AWS surface — EC2, EBS, S3, VPC — inside your own OCI tenancy, with guests that get real, publicly reachable addresses through OCI's API. + +## Table of Contents + +- [Overview](#overview) +- [Why run Spinifex on OCI](#why-run-spinifex-on-oci) +- [What OCI changes](#what-oci-changes) +- [How it fits together](#how-it-fits-together) +- [Prerequisites](#prerequisites) +- [Deploying](#deploying) + - [Step 1. Set your Terraform inputs](#step-1-set-your-terraform-inputs) + - [Step 2. Build the infrastructure](#step-2-build-the-infrastructure) + - [Step 3. Install Spinifex and set up OVN](#step-3-install-spinifex-and-set-up-ovn) + - [Step 4. Form the cluster](#step-4-form-the-cluster) + - [Step 5. Configure Spinifex for OCI, start and verify](#step-5-configure-spinifex-for-oci-start-and-verify) + - [Step 6. Set up your cluster](#step-6-set-up-your-cluster) + - [Step 7. Run your existing Terraform against the cluster](#step-7-run-your-existing-terraform-against-the-cluster) + - [Step 8. Oracle Linux as a guest image](#step-8-oracle-linux-as-a-guest-image) +- [Verify end to end](#verify-end-to-end) +- [Troubleshooting](#troubleshooting) +- [Limits in this version](#limits-in-this-version) + +--- + +## Overview + +Spinifex is an open-source infrastructure platform that brings core AWS services to bare-metal, edge and on-prem environments. A node runs the Spinifex daemon and CLI, QEMU/KVM for guests, OVN/Open vSwitch for VPC networking, [Predastore](https://github.com/mulgadc/predastore) for S3-compatible object storage and [Viperblock](https://github.com/mulgadc/viperblock) for EBS-compatible block storage — and serves them all behind a SigV4 endpoint that the ordinary AWS SDKs and CLI talk to unmodified. + +This guide runs that same stack on Oracle Cloud Infrastructure. Everything in [Single-Node Install](/docs/install) and [Multi-Node Install](/docs/install-multi-node) still applies: one node is a working install, three is the minimum for a cluster that can lose a node, and formation, storage and OVN behave exactly as they do on hardware you own. What changes is the layer underneath — an OCI VCN is not an Ethernet segment, so the external datapath is wired differently, and public addresses come from OCI's API rather than from a range you write in a config file. + +**Read this guide end to end and you have a working cluster.** [Deploying](#deploying) is one sequence of seven steps, from an empty compartment to a node that can launch an instance with a publicly reachable address. It builds the infrastructure with Terraform, then installs and forms Spinifex the same way [Single-Node Install](/docs/install) and [Multi-Node Install](/docs/install-multi-node) do — those steps are inlined here, so you do not need to read three documents at once. The only choice to make is one node or three, and it is one line in Step 1. + +Every command and every output below was run against a live OCI tenancy, on both paths. + +## Why run Spinifex on OCI + +**Lower cost for the same shape of workload.** OCI's compute and block storage are priced well below the large US clouds for equivalent OCPU and IOPS, and Spinifex turns one large instance into a whole EC2/EBS/S3 surface — so a tenant's twenty small instances are twenty QEMU guests on hardware you are billed for once, not twenty billable cloud instances. + +**Egress stops being the line item that decides the architecture.** Oracle publishes a large monthly outbound-transfer allowance per tenancy and a per-GB rate beyond it that is roughly an order of magnitude below the majors — check the current pricing page rather than a number in a document, but the gap is the reason this integration exists. Traffic *between* guests never leaves the host or, on a cluster, never leaves the VCN: it is Geneve over the private plane, which is not billed as internet egress at all. + +**Portability — the workload stops being AWS-shaped and starts being yours.** The API your tenants code against is Spinifex's, so the same Terraform, the same SDK calls and the same AMIs run on OCI today, on bare metal in your own rack tomorrow, and at an edge site after that. Moving off AWS becomes a deployment decision rather than a rewrite, and moving *again* costs nothing extra. + +**You keep the parts of OCI that are worth keeping.** Bare-metal shapes, fault domains, block volumes with a chosen performance tier, and real public addresses out of OCI's own pool. Spinifex does not hide the cloud underneath it — it registers each guest's address with OCI so that guest is genuinely reachable on the internet, not behind a shared NAT. + +## What OCI changes + +> [!IMPORTANT] +> **Read this section before you provision anything.** OCI enforces addressing inside its virtual network in a way that makes one of Spinifex's three external modes unusable, and the choice is baked in at `spx admin init`. Getting it wrong produces a cluster that looks entirely healthy and drops every guest packet. + +Three properties of an OCI virtual network shape everything below. + +**OCI enforces the source IP per VNIC.** A packet leaving the instance with a source address that is not a registered private-IP object on that VNIC is dropped by the hypervisor. Measured, not inferred: from the same NIC with the same MAC, `ping -I 8.8.8.8` gets 0% loss and `ping -I 8.8.8.8` gets 100%. So an external address pool cannot be a range you write in a config file — **every address has to be registered with OCI before it works**, and registering it is exactly what this integration does. + +> Pinging the VCN router (`10.200.0.1`) proves nothing. It answers from any source address at ~0.08 ms because the hypervisor replies locally. Always test egress past the subnet. + +**A VNIC accepts one MAC; it does not learn MACs.** Every inbound frame arrives addressed to the VNIC's own MAC, whatever address the packet is for. Spinifex's default external datapath advertises each address by ARPing from a per-VPC router MAC onto a shared L2 segment, which needs a segment that learns MACs. OCI has none. Enforcement sits in the hypervisor on VM shapes and in the SmartNIC on bare metal, so there is no guest-side workaround. + +**A public IP is a second object, and it never appears on the wire.** An OCI public address is a RESERVED public IP that OCI 1:1-NATs upstream onto a **secondary private IP** on the VNIC. The instance only ever sees the private half. Spinifex handles this internally — AWS-facing APIs report the public address, the datapath uses the private one — but it explains why `ip addr` on the host never shows a customer's public IP, and why that is correct rather than a fault. + +### The consequence: routed mode is mandatory + +| External mode | What goes on the uplink | On OCI | +| --- | --- | --- | +| `pool` (`UplinkModePhysical`, `UplinkModeVeth`) | Per-VPC gateway MAC plus a per-rule external MAC, ARPed onto the segment | **Does not work.** Egress is dropped (wrong source MAC); ingress never reaches OVN (the frame is addressed to the VNIC MAC, which no router port owns). | +| `nat` (`UplinkModeRouted`) | Nothing. The host forwards over an RFC 6598 transit veth and sets no external MAC | **Works.** One MAC identity, and registered source addresses. | + +Spinifex **refuses** `source = "oci"` on a pool unless the node is in `nat` mode, and the refusal names this reason. See [VPC Networking → External modes](/docs/vpc-networking) for what each mode does off OCI. + +### Sizing + +**Bare metal is the recommendation.** `BM.Standard.E2.64` or similar gives you the hardware directly — no nested virtualisation, full NIC performance, and no hypervisor between your guests and the wire. + +| | Minimum | Recommended | +| --- | --- | --- | +| **Shape** | `VM.Standard.E6.Flex` | Bare metal, e.g. `BM.Standard.E2.64` | +| **OCPU** | 8 (16 vCPU) | 32+ | +| **RAM** | 32 GB | 128 GB+ | +| **Data volume** | 256 GiB block volume | 1 TB+ NVMe-backed | +| **VNICs** | 2 | 2 | +| **Nodes** | 1 | 3 — see [Cluster sizing](/docs/install-multi-node#cluster-sizing) | + +**Verify nested virtualisation before anything else on a VM shape.** Oracle's nested-KVM guidance names `VM.Standard3.Flex` (Intel) and `VM.Standard.E5.Flex` (AMD), and states Ampere shapes do not support it. `VM.Standard.E6.Flex` is **not** in that list but was measured working on 2026-09-24 (EPYC 9J45, `/dev/kvm` present, `svm` on all threads, `kvm_amd nested=1`). Treat an unlisted shape as unproven until you have checked: + +```bash +# If /dev/kvm is absent, stop — change shape, do not continue. +ls -l /dev/kvm +grep -oE 'vmx|svm' /proc/cpuinfo | sort -u +cat /sys/module/kvm_amd/parameters/nested 2>/dev/null \ + || cat /sys/module/kvm_intel/parameters/nested +``` + +**OCI has no Debian image.** Ubuntu LTS is the tested path — Ubuntu 26.04 LTS on kernel `7.0.0-1009-oracle` is what this integration was built and proven against. Anything in the tooling that assumes Debian is a known gap, not a supported configuration. + +--- + +## How it fits together + +### Single node — the whole AWS surface inside one OCI instance + +One bare-metal instance runs the control plane and every tenant guest. Customers reach the AWS APIs on the node's own address; their instances reach the internet through addresses registered on the node's second VNIC. + +

+ Single node on OCI — the instance inside the VCN public subnet, both VNICs, br-wan, the routed-NAT transit veth, the control plane, tenant VPCs and the iSCSI data volume +

+ +Two things in that picture are the whole integration. The guest's private address (`10.0.1.4`) never leaves the host — OVN and the host route both hold it. The address OCI delivers to is a **secondary private IP registered on VNIC 1**, appearing on `br-wan`, and the customer's public address (`150.230.13.131`) is a reserved public IP that OCI 1:1-NATs onto it upstream. `describe-instances` reports the public one; every datapath object holds the private one, which is why the two never appear together on the host. + +The third is storage. Every guest disk, every S3 object and the JetStream state all live under `/var/lib/spinifex`, which is an **OCI block volume attached over iSCSI**, not the boot disk. Size it for the guests you intend to run; a boot volume that quietly ends up carrying the stack is the most common way an install on OCI fails later rather than immediately. + +### Three nodes — the same surface, distributed + +Each node registers the addresses for **its own** guests on **its own** second VNIC, and the allocator runs per node, so nothing has to know another node's OCIDs. When a guest moves — a stop/start that lands elsewhere, or a host failure — the node it lands on claims the address's private half from the old VNIC through a single OCI call. The OCID and the public half are untouched, so the customer's address never changes. + +

+ Three nodes on OCI — three instances inside the VCN public subnet, one per fault domain, each with its own VNICs, addresses and iSCSI volume, joined east-west by Geneve, NATS, predastore and the OVN raft +

+ +The overlay, the object shards and the OVN databases all cross **VNIC 0**, inside the VCN. Size that plane, not the external one: guest-to-guest traffic between nodes is Geneve over the VCN, and it is the link every distributed layer shares. + +> [!NOTE] +> **The Spinifex region is your own naming and has nothing to do with the OCI region.** `ap-southeast-2` in `spinifex.toml` beside `ap-sydney-1` in OCI is correct and expected. Do not read one as evidence about the other. + +--- + +## Prerequisites + +Both paths need the same three things: an OCI compartment you can write to, an API key for Spinifex to allocate addresses with, and enough quota to hand out the addresses you plan to use. + +### Credentials — v1 uses an operator-provisioned API key + +**Instance principal is the better design and is not what v1 uses.** The instance certificate is served at `/opc/v2/identity/cert.pem` and the Go SDK can authenticate as the instance with no key material on disk — but it still needs a dynamic group and a policy authorising it, and on the reference tenancy it authenticated without being authorised for anything. **v1 requires an operator-provisioned API key**, deliberately, to keep the setup a single documented path. + +```bash +# On your workstation, not the node. +openssl genrsa -out ~/.oci/oci_api_key.pem 2048 +chmod 600 ~/.oci/oci_api_key.pem +openssl rsa -pubout -in ~/.oci/oci_api_key.pem -out ~/.oci/oci_api_key_public.pem +openssl rsa -pubout -outform DER -in ~/.oci/oci_api_key.pem \ + | openssl md5 -c # this is the fingerprint +``` + +Upload the public key to the user under **Identity → Users → API Keys**, and record the user OCID, the fingerprint and the tenancy OCID. + +### The IAM policy + +Spinifex calls exactly nine operations. Grant no more than these: + +| Operation | Why | +| --- | --- | +| `CreatePrivateIp` | Register the secondary private IP the datapath carries | +| `DeletePrivateIp` | Release it | +| `GetPrivateIp` | Poll for `AVAILABLE` after an asynchronous assign | +| `ListPrivateIps` | The startup reconcile, and the affinity pass that moves an address between nodes | +| `UpdatePrivateIp` | Reassign a private IP to this node's VNIC when a guest moves here | +| `CreatePublicIp` | Create the RESERVED public IP | +| `DeletePublicIp` | Release it | +| `GetPublicIp` / `GetPublicIpByPrivateIpId` | Poll lifecycle state, and reconcile a private IP back to its public half | +| `UpdatePublicIp` | Attach and detach the public IP from a private IP | + +The matching policy, scoped to the one compartment Spinifex allocates in: + +```text +Allow group SpinifexOperators to manage private-ips in compartment +Allow group SpinifexOperators to manage public-ips in compartment +Allow group SpinifexOperators to read vnics in compartment +Allow group SpinifexOperators to read subnets in compartment +Allow group SpinifexOperators to read vcns in compartment +``` + +**Scope it to a compartment, not the tenancy.** `manage public-ips` at tenancy level lets a compromised node consume the whole regional reserved-public-IP quota. + +**Do not grant `manage instances` or `manage vnics`.** Spinifex never creates, attaches or deletes a VNIC — it only adds addresses to a VNIC that already exists. A grant that allows VNIC lifecycle is strictly more than the integration can use. + +### Quotas to check before you size a deployment + +Two limits bound how many public addresses a deployment can hand out, and they do not scale the same way: + +| Quota | Default | Scope | Notes | +| --- | --- | --- | --- | +| **Reserved public IPs** | 50 | Per region, whole tenancy | Shared by every node, so it does **not** grow with node count. This is the wall you hit first. | +| **Secondary private IPs per VNIC** | 64 | Per VNIC, so per node | Not raisable. Three nodes gives you 192 — this is the limit that does scale. | + +A raise on the first is a support request, not a setting: Console → Governance & Administration → Limits, Quotas and Usage → "Request a service limit increase". Cite the exact limit name from `oci limits definition list`, not a name from documentation. + +```bash +oci limits definition list --service-name vcn --compartment-id +oci limits value list --service-name vcn --compartment-id +oci limits resource-availability get --service-name vcn \ + --limit-name --compartment-id +``` + +**These calls need a grant the node does not have, and must not be given.** Limits and usage are tenancy-level, which cannot be compartment-scoped the way the policy above insists the node's is. Create a **second, read-only principal** for an operator or a reporting job and keep it off every node: + +```text +Allow group SpinifexReporting to read limits in tenancy +Allow group SpinifexReporting to read usage-reports in tenancy +``` + +The second verb is what Cost Analysis and `oci usage-api usage-summary request-summarized-usages` read. Adding either to `SpinifexOperators` would hand every node tenancy-wide read, which is exactly what the node policy refuses. + +### The `oci` CLI + +**The `oci` CLI is not on the Ubuntu image**, and `apt` has no `python3-oci-cli` package. Install it into a virtualenv on your workstation, and on the node if you want to inspect allocations there: + +```bash +sudo apt-get update && sudo apt-get install -y python3-venv +python3 -m venv ~/.oci-cli-venv +~/.oci-cli-venv/bin/pip install --upgrade pip oci-cli +sudo ln -sf ~/.oci-cli-venv/bin/oci /usr/local/bin/oci +oci --version +``` + +**What the CLI is and is not for.** Spinifex's daemon uses the OCI **Go SDK** for every allocation — the CLI is not on that path. You need it for bootstrap and for operational inspection. Do not build automation that shells out to it during an allocation; allocations run inside a request budget that a forked Python process will not respect. + +--- + +## Deploying + +**Terraform builds the infrastructure; the standard Spinifex installer builds the node.** That split is deliberate — formation on OCI is the same path prod and bare metal take, and diverging it for one cloud would mean two formation paths to keep correct. + +There is one sequence of seven steps below, and the only thing that differs between a single node and a cluster is `node_count` in Step 1 and which variant of Steps 4 and 5 you follow. Decide now: + +| | Single node | Three nodes | +| --- | --- | --- | +| **`node_count`** | `1` | `3` | +| **Survives losing a node** | No | Yes — NATS keeps quorum, predastore RS(2,1) keeps the data readable, the OVN raft keeps a leader | +| **Public addresses available** | 64 — the per-VNIC secondary private IP limit | 192, three VNICs' worth, still bounded by the regional reserved-public-IP quota | +| **Fault domains** | One | Three, one per node, chosen by Terraform | +| **Use it for** | Evaluation, a lab, an edge site with one box | Anything you would be unhappy to lose | + +Two is not an option worth taking: it doubles the cost of a single node and gives you a cluster that cannot form a quorum. See [Cluster sizing](/docs/install-multi-node#cluster-sizing). + +The configuration lives in [`scripts/terraform/oci-spx`](../../scripts/terraform/oci-spx/README.md), which has the resource-by-resource architecture. + +### What Terraform builds, per node + +| Resource | Detail | +| --- | --- | +| `oci_core_instance` | One per `node_count`, round-robined across **fault domains** so three nodes land on three sets of racks rather than wherever placement puts them | +| Primary VNIC | Public IP, `hostname_label`, the host plane — SSH, the advertise address, the Geneve endpoint | +| `oci_core_vnic_attachment` | A **second VNIC** in the same subnet. Every Spinifex external address becomes a secondary private IP here at runtime | +| `oci_core_volume` | A block volume per node, `data_volume_size_in_gbs` at `data_volume_vpus_per_gb` | +| `oci_core_volume_attachment` | **`attachment_type = "iscsi"`**, with the Oracle Cloud Agent's Block Volume Management plugin enabled so the node logs the iSCSI session in itself | +| cloud-init | Partitions, formats, mounts and `fstab`s the volume; builds `br-wan` over the second VNIC; opens the service ports | + +**The data volume is iSCSI, and that is the detail that bites.** OCI presents the volume as an iSCSI target reachable at `169.254.2.2:3260` — attaching it in the API does not put a block device on the host. `is_agent_auto_iscsi_login_enabled` makes the Oracle Cloud Agent do the login, and because it does that *asynchronously*, the device is not there when cloud-init first runs. The mount script waits up to ten minutes for it, formats it **only if `blkid` reports no filesystem** (so a re-run cannot erase a populated volume), and writes an fstab entry by UUID: + +```text +UUID= /var/lib/spinifex ext4 defaults,_netdev,nofail 0 2 +``` + +`_netdev` is what orders the mount after the network, and therefore after `iscsid` has a session. `nofail` is what keeps a missing volume from wedging the boot. Mounting `/var/lib/spinifex` *itself* rather than somewhere else and symlinking is deliberate: viperblock, predastore and JetStream all land on the volume with no configuration pointing anywhere unusual. + +--- + +## Step 1. Set your Terraform inputs + +Prerequisites: Terraform, Python 3, OCI credentials in `~/.oci/config`, and an SSH public key. The Python helper builds and uses the configuration's own `.venv`. + +```bash +cd scripts/terraform/oci-spx +cat > terraform.auto.tfvars <<'EOF' +compartment_ocid = "ocid1.compartment.oc1..aaaa..." +deployment_name = "spinifex" + +# Shape and size. Bare metal is preferred; a flex VM is what the quota allows. +compute_shape = "VM.Standard.E6.Flex" +compute_ocpus = 8 # OCI counts an OCPU as a full core +compute_memory_in_gbs = 32 + +# 1 for a single node, 3 for a cluster. This is the only line that chooses. +node_count = 3 + +# The block volume behind /var/lib/spinifex, attached over iSCSI. +data_volume_size_in_gbs = 256 +data_volume_vpus_per_gb = 120 # 120 = Ultra High Performance + +node_client_cidr_allow_list = ["203.0.113.10/32"] +EOF +``` + +The variables worth knowing, all of which have defaults that build a working single node: + +| Variable | Default | What it decides | +| --- | --- | --- | +| `compartment_ocid` | — | **Required.** An OCID, not a name: this deploys into a compartment that already exists, because creating one needs tenancy-root rights the API user must not have | +| `deployment_name` | `spinifex` | Prefixes every resource and becomes the VCN `dns_label`, so 1–15 lowercase alphanumerics starting with a letter | +| `node_count` | `1` | Nodes, 1–25. `1` for a single node, `3` for a cluster. Each gets its own volume, its own second VNIC and its own fault domain | +| `compute_shape` | `VM.Standard.E6.Flex` | **`BM.*` for bare metal, `VM.*.Flex` for a VM.** A fixed bare-metal shape carries its own size, so `shape_config` is emitted only when the name contains `Flex` — set a BM shape and the two sizing variables below are ignored rather than rejected | +| `compute_ocpus` | `8` | OCPUs on a flex shape. Validated at ≥ 8 (16 vCPU), the minimum recommended for a node | +| `compute_memory_in_gbs` | `32` | GiB on a flex shape. Validated at ≥ 32 | +| `data_volume_size_in_gbs` | `256` | Size of each node's data volume. This is where guest disks, S3 objects and JetStream live — size it for the guests, not for the control plane | +| `data_volume_vpus_per_gb` | `120` | Performance tier, 0–120 VPUs/GB. 120 is Ultra High Performance; 10 is Balanced. Higher is more IOPS and more cost | +| `data_mountpoint` | `/var/lib/spinifex` | Where cloud-init mounts it. Change only if you know why | +| `vcn_cidr` | `10.200.0.0/22` | VCN address space. A `/22` leaves room for the secondary private IPs Spinifex registers per node | +| `public_subnet_cidr` | `10.200.0.0/23` | The subnet every node sits in, **on both VNICs**. Nodes serve the UI, the S3 gate and the AWS gateway directly, so they cannot live in a private subnet | +| `node_client_cidr_allow_list` | `["0.0.0.0/0"]` | Who may reach SSH and the service ports from the internet. **Change this** | +| `node_service_ports` | `[3000, 8443, 9999]` | Console, predastore gate, AWS gateway. SSH is opened separately | +| `ubuntu_version` | `26.04` | Canonical platform image version. The newest image for the shape and region is resolved at plan time rather than pinned to a regional OCID | +| `wan_bridge_name` | `br-wan` | The Linux bridge built over the second VNIC | +| `wan_bridge_mtu` | `9000` | The OCI VCN carries 9000 | + +> [!WARNING] +> **`node_client_cidr_allow_list` defaults to `0.0.0.0/0`.** Nodes sit in a public subnet with public addresses because they serve the UI, the S3 gate and the AWS gateway directly — there is no bastion. Restrict this before any real use. + +> [!NOTE] +> **Bare metal changes nothing else in this document.** Set `compute_shape = "BM.Standard.E2.64"` and the same config builds it: the two flex sizing variables stop applying, the shape's own CPU and memory take over, and every other step — the volume, the second VNIC, `br-wan`, the install — is identical. Nested virtualisation stops being a question, because there is no nesting. + +## Step 2. Build the infrastructure + +```bash +KEY=~/.ssh/oci-spx.pub +python3 scripts/oci_env.py --ssh-public-key-path "$KEY" -- terraform init +python3 scripts/oci_env.py --ssh-public-key-path "$KEY" -- terraform plan -out=oci-spx.tfplan +``` + +Read the saved plan, then apply **that exact plan** rather than re-planning: + +```bash +python3 scripts/oci_env.py --ssh-public-key-path "$KEY" -- terraform apply oci-spx.tfplan +``` + +**The apply ends with the addresses you need for every remaining step.** With `node_count = 3` it prints twenty-two resources and this: + +```text +Apply complete! Resources: 22 added, 0 changed, 0 destroyed. + +Outputs: + +compartment_name = "mulgadc" +compartment_ocid = "ocid1.compartment.oc1..aaaaaaaa6nkk...ztta" +hosts_file = < [!WARNING] +> Never commit the state file, a plan file, or key material. `.gitignore` covers `terraform.tfstate*`, `*.tfplan`, `*.auto.tfvars`, `terraform.tfvars` and `*.pem`. + +## Step 3. Install Spinifex and set up OVN + +Spinifex installs the same way here as anywhere else. Run this **on every node**: + +```bash +curl -fsSL https://install.mulgadc.com | bash +``` + +That puts the binary, the systemd units and the helper scripts in place and starts nothing. Then wire OVN, which is where the single-node and multi-node paths differ. + +Two flags are the same on every node and on both paths, and both are decisions you cannot change afterwards without re-doing this step: + +- **`--management` is load bearing.** Omitting it takes the compute-node branch, which stops *and disables* `ovn-central` — silently, leaving a node with no record of what it was meant to be. +- **`--nat-uplink`, not `--wan-bridge=br-wan`.** The two are mutually exclusive, and only `--nat-uplink` creates the `spx-nat` transit veth that routed mode runs on. `--wan-bridge` takes the veth-to-`br-ext` branch and deletes `spx-nat` on the way, after which `host.Routed.EnsureUplinkPort` refuses with *"not on OVS — run setup-ovn.sh --nat-uplink"*. + +**That does not make `br-wan` redundant.** It is still doing two jobs, neither of which involves OVS: + +1. It **names the VNIC** for the OCI allocator. `oci_vnic_iface = "br-wan"` in Step 6's pool config is resolved to a VNIC OCID **by MAC**, on the node it runs on — which is what lets one identical config file be correct on all three nodes. +2. It **holds the addresses.** Every external address Spinifex registers becomes a secondary private IP on that VNIC, appears on `br-wan`, and gets its own source-routing rule into table 200. The guests' own traffic reaches it through `spx-nat` and the host's `spinifex-nat-egress` masquerade. + +So a correct OCI node has `br-wan` as a **Linux** bridge with no OVS port at all, and `br-ext` as an OVS bridge whose ports are `spx-nat-ovs` and the patch to `br-int`. That asymmetry is the design, not a half-finished install. + +> If you suspect an OVS problem on this host, read `ovs-vsctl --columns=name,error list Interface` and the vswitchd log. **Never trust `ovs-vsctl`'s exit status** — it exits 0 even when the datapath rejected the device. + +### Single node + +```bash +sudo /usr/local/share/spinifex/setup-ovn.sh --management --nat-uplink +``` + +### Three nodes + +Servers 1 to 3 run a clustered **OVN database**, so VPC networking survives losing any one of them. **Node 1 creates the cluster and must finish first**; nodes 2 and 3 then join it. `--recreate-db` appears in all three because `ovn-central` starts a standalone database when the package installs, and a clustered one can only be created from scratch. + +Every address below is a **private** IP from the Terraform `nodes` output. + +```bash +# Run on every node, with the values from your own apply. +export SPINIFEX_NODE1=10.200.1.249 +export SPINIFEX_NODE2=10.200.0.201 +export SPINIFEX_NODE3=10.200.1.79 +export SPINIFEX_OVN_REMOTE=tcp:$SPINIFEX_NODE1:6642,tcp:$SPINIFEX_NODE2:6642,tcp:$SPINIFEX_NODE3:6642 +``` + +`--ovn-remote` is the whole member list, and **it is the same on all three nodes** — do not trim it to the peers. It is what `ovn-northd` dials, and an OVSDB RAFT follower does not forward writes: it answers *not cluster leader, trying another server*. A northd pointed at one member therefore works only while it happens to share a node with the database leader, and stops translating Northbound intent into Southbound flows the moment leadership moves. Nothing about that is visible from `systemctl` — every service stays `active` — and the symptom appears much later as a `terraform apply` hung on `aws_internet_gateway.igw: Still creating...` forever. + +**Node 1 — create the cluster:** + +```bash +sudo /usr/local/share/spinifex/setup-ovn.sh \ + --management --nat-uplink \ + --db-cluster-local-addr=$SPINIFEX_NODE1 \ + --ovn-remote=$SPINIFEX_OVN_REMOTE \ + --recreate-db \ + --encap-ip=$SPINIFEX_NODE1 +``` + +**Nodes 2 and 3 — join it,** after node 1 reports `=== OVN compute node setup complete ===`: + +```bash +# on node 2 +sudo /usr/local/share/spinifex/setup-ovn.sh \ + --management --nat-uplink \ + --db-cluster-local-addr=$SPINIFEX_NODE2 \ + --db-cluster-remote-addr=$SPINIFEX_NODE1 \ + --ovn-remote=$SPINIFEX_OVN_REMOTE \ + --recreate-db \ + --encap-ip=$SPINIFEX_NODE2 + +# on node 3 +sudo /usr/local/share/spinifex/setup-ovn.sh \ + --management --nat-uplink \ + --db-cluster-local-addr=$SPINIFEX_NODE3 \ + --db-cluster-remote-addr=$SPINIFEX_NODE1 \ + --ovn-remote=$SPINIFEX_OVN_REMOTE \ + --recreate-db \ + --encap-ip=$SPINIFEX_NODE3 +``` + +Each run ends with its own verification block. Node 1 reports `chassis count: 1`; nodes 2 and 3 report `0`, because a chassis is registered by `ovn-controller` a moment later and the script checks immediately. That is not a failure — confirm the real answer from node 1 once all three are done: + +```bash +sudo ovn-appctl -t /var/run/ovn/ovnnb_db.ctl cluster/status OVN_Northbound +sudo ovn-sbctl show +``` + +```text +Name: OVN_Northbound +Cluster ID: 7c7d (7c7d6d29-8243-4f82-8b4e-2848dbd5942e) +Server ID: 5d95 (5d952726-6ab5-4188-8eb9-b70acf37c374) +Address: tcp:10.200.1.249:6643 +Status: cluster member +Role: leader +Term: 1 +``` + +`Status: cluster member` with one leader, and a `Chassis` entry in `ovn-sbctl show` for each of the three. If `cluster/status` says the database is standalone, `--db-cluster-local-addr` did not reach it — re-run that node. + +Then confirm northd is actually translating, which `cluster/status` cannot tell you. Run this on any node: + +```bash +echo "NB $(sudo ovn-nbctl --no-leader-only get NB_Global . nb_cfg) / SB $(sudo ovn-sbctl --no-leader-only get SB_Global . nb_cfg)" +``` + +The two numbers must be equal, or within one of each other on a busy cluster. A gap that does not close within a few seconds means the active `ovn-northd` cannot write to the Northbound leader, and `/var/log/ovn/ovn-northd.log` will be looping on `clustered database server is not cluster leader; trying another server`. The fix is the `--ovn-remote` list above. + +## Step 4. Form the cluster + +Everything here is Spinifex's own formation, unchanged from bare metal except for two flags that OCI requires: + +- **`--external-mode=nat` is mandatory.** See [the consequence](#the-consequence-routed-mode-is-mandatory). `pool` mode produces a cluster that passes every health check and drops every guest packet. +- **`--ipsec=false`:** `openvswitch-ipsec` is masked on the OCI Ubuntu image while `spx admin init` defaults the flag to true. A cluster formed with it on will not bring its overlay up. + +Both are read from node 1 only. Joining nodes inherit the external mode, the transit pool and the IPsec posture, so they are chosen once. + +### Single node + +```bash +sudo spx admin init --node node1 --nodes 1 \ + --region ap-southeast-2 --az ap-southeast-2a \ + --external-mode=nat --ipsec=false +``` + +It finishes in a few seconds: + +```text +📡 External networking: nat (routed; no public pool configured yet) + Transit: 100.127.0.0/24 via spx-nat-host (host masquerades out any uplink) +✅ Created: spinifex.toml +🔧 Configuring AWS credentials... Profile: spinifex +✅ Host DNS: spx3.net + compute.internal -> 10.200.0.252:53 (northstar) +🎉 Spinifex initialization complete! + Advertise IP: 10.200.0.252 +``` + +"no public pool configured yet" is expected — the OCI pool is Step 6. + +### Three nodes + +Init and join run **concurrently**: init blocks until every node has joined. Start node 1, take the token from its output, then run both joins while it is still waiting. + +`--force` is in every command so the sequence is identical whichever way you installed. On a node Terraform just built there is nothing to lose either way; on a node that has been in service, joining discards its master key and orphans every volume sealed under it, and `--force` is the confirmation for that. + +**Node 1 — initialize:** + +```bash +sudo spx admin init --force \ + --node node1 --nodes 3 \ + --bind $SPINIFEX_NODE1 --cluster-bind $SPINIFEX_NODE1 \ + --port 4432 --region ap-southeast-2 --az ap-southeast-2a \ + --external-mode=nat --ipsec=false +``` + +It generates the keys and the CA, then stops and waits, printing the token: + +```text +📡 Formation server started on 10.200.1.249:4432 + Waiting for 2 more node(s) to join... + Token expires in 30m0s + + Other nodes should run: + sudo spx admin join --host 10.200.1.249:4432 --token spx_join_YHRL6y_yVP0E8Tc1 --node --bind +``` + +Take the **token** from that output, but run the commands below rather than the line it prints — they add `--force` and `--cluster-bind`. + +**Nodes 2 and 3 — join,** while init is still waiting: + +```bash +# on node 2 +sudo spx admin join --force \ + --node node2 --bind $SPINIFEX_NODE2 --cluster-bind $SPINIFEX_NODE2 \ + --host $SPINIFEX_NODE1:4432 --token \ + --region ap-southeast-2 --az ap-southeast-2a + +# on node 3 +sudo spx admin join --force \ + --node node3 --bind $SPINIFEX_NODE3 --cluster-bind $SPINIFEX_NODE3 \ + --host $SPINIFEX_NODE1:4432 --token \ + --region ap-southeast-2 --az ap-southeast-2a +``` + +Each join answers with the membership it now sees: + +```text +🎉 Node successfully joined cluster! + Cluster: spinifex (3 nodes) + Bind: 10.200.0.201 Advertise: 10.200.0.201 Loopback: 127.0.0.1 + Nodes: + - node2 (bind=10.200.0.201 advertise=10.200.0.201) + - node3 (bind=10.200.1.79 advertise=10.200.1.79) + - node1 (bind=10.200.1.249 advertise=10.200.1.249) +``` + +and node 1 unblocks: + +```text +🎉 Cluster formation complete! + Cluster: spinifex (3 nodes) + Region: ap-southeast-2 +``` + +Confirm the joiners inherited the OCI-critical settings before going on — this is cheap and catches a node that formed against the wrong leader: + +```bash +sudo grep -E 'external_mode|ipsec_enabled' /etc/spinifex/spinifex.toml +``` + +```text +ipsec_enabled = false +external_mode = "nat" +``` + +The join token expires 30 minutes after init; `--token-ttl 2h` if provisioning is slower than that. + +## Step 5. Configure Spinifex for OCI, start and verify + +**This is the step that is genuinely OCI-specific**, and it is identical on one node or three. Do all of it on **every** node before starting anything. + +### Discover the OCIDs + +```bash +# Instance, compartment and both VNICs, straight from the instance metadata. +curl -sH 'Authorization: Bearer Oracle' http://169.254.169.254/opc/v2/instance/ \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print("compartment:", d["compartmentId"]); print("instance:", d["id"])' + +curl -sH 'Authorization: Bearer Oracle' http://169.254.169.254/opc/v2/vnics/ \ + | python3 -c 'import json,sys +for v in json.load(sys.stdin): + print(v["macAddr"], v["vnicId"], v["subnetCidrBlock"], v.get("privateIp"))' +``` + +```text +compartment: ocid1.compartment.oc1..aaaaaaaa6nkk...ztta +instance: ocid1.instance.oc1.ap-sydney-1.anzxsljr6eq5...tkxdq + +02:00:17:01:9C:22 ocid1.vnic.oc1.ap-sydney-1.abzxsljrnat7...y6vw5q 10.200.0.0/23 10.200.0.252 +02:00:17:04:1A:57 ocid1.vnic.oc1.ap-sydney-1.abzxsljrjd7z...czgvua 10.200.0.0/23 10.200.1.233 +``` + +**You only need the compartment OCID for the config below** — the VNIC is resolved at runtime. Match a VNIC by MAC against `ip -br link show br-wan` if you want to confirm which is which; Oracle's VNIC ordering is not a documented contract, so "the second one" is not a selector. + +### Install the credentials on the node + +**The daemon cannot read `~/.oci/`.** Its systemd unit sets `ProtectHome=yes`, so a config under any home directory is invisible to it however the permissions read. Credentials go under `/etc/spinifex/`, on every node: + +```bash +sudo install -d -o root -g spinifex -m 0750 /etc/spinifex/oci +sudo install -o root -g spinifex -m 0640 ~/.oci/oci_api_key.pem /etc/spinifex/oci/oci_api_key.pem +sudo tee /etc/spinifex/oci/config >/dev/null <<'EOF' +[spinifex] +user=ocid1.user.oc1..aaaa... +fingerprint=aa:bb:cc:... +key_file=/etc/spinifex/oci/oci_api_key.pem +tenancy=ocid1.tenancy.oc1..aaaa... +region=ap-sydney-1 +EOF +sudo chown root:spinifex /etc/spinifex/oci/config +sudo chmod 0640 /etc/spinifex/oci/config +``` + +```text +drwxr-x--- 2 root spinifex 4096 Sep 26 03:48 . +-rw-r----- 1 root spinifex 303 Sep 26 03:48 config +-rw-r----- 1 root spinifex 1715 Sep 26 03:48 oci_api_key.pem +``` + +The profile name — `spinifex` here — is what `oci_config_profile` names below. Keep a copy at `~/.oci/config` too if you want the `oci` CLI on the node; that is a different consumer and it is not on any Spinifex code path. + +### Configure the OCI pool and the IMDS remap + +Both go in `/etc/spinifex/spinifex.toml`, on every node, with the **same text on each** — nothing here is per-node: + +```toml +[network] +external_mode = "nat" +imds_host_meta_ip = "169.254.42.254" +imds_host_dns_ip = "169.254.42.253" + +# Leave the nat-transit pool init wrote exactly as it is, and add this one. +[[network.external_pools]] +name = "oci-public" +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..aaaa..." +oci_vnic_iface = "br-wan" +oci_config_file = "/etc/spinifex/oci/config" +oci_config_profile = "spinifex" +dns_servers = ["169.254.169.253"] +``` + +Notes on the keys: + +- **Exactly one of `oci_vnic_id` and `oci_vnic_iface`**, never both — the config is rejected if you set both or neither. Two that disagreed would send allocations to a VNIC the datapath is not on, and OCI would drop the traffic without a word. +- **`oci_vnic_iface` is the right choice on a cluster**, because it resolves through instance metadata **by MAC** on the node it runs on. Naming either `br-wan` or the physical interface resolves to the same VNIC. Use `oci_vnic_id` only when you want to pin a specific VNIC on a single node. +- `oci_compartment_id` is the only other required key. `oci_subnet_id` is optional and defaults to the VNIC's own subnet; set it only when you want private IPs from a different subnet. `oci_config_file` defaults to `~/.oci/config` and `oci_config_profile` to `DEFAULT` — **on a node both need setting**, because the daemon cannot read a home directory. +- `oci_public_ip_pool` takes a BYOIP pool OCID. Accepted today so BYOIP is a config change later rather than a code change; see [Limits](#limits-in-this-version). +- `range_start`, `range_end`, `gw_lrp_range_*`, `bind_bridge` and `dhcp_mac` are **rejected** on an OCI pool. OCI owns the addresses; a range you wrote would be fiction. +- Leave the `nat-transit` pool alone. In routed mode the per-VPC gateway router addresses come from the RFC 6598 transit range, not from OCI — **only public addresses handed to guests consume an OCI address.** Fifty VPCs and three Elastic IPs cost three reserved public IPs, not fifty-three. +- **The IMDS pair is required on OCI**, and it is set both keys or neither — a half-configured pair is ignored and the node behaves as if the remap were off. [The next section](#the-169254169254-collision) is why it exists; `169.254.42.254` / `169.254.42.253` is the tested choice. + +### Start and verify + +On **every** node: + +```bash +sudo systemctl start spinifex.target +``` + +Then, from any one of them: + +```bash +sudo spx get nodes +``` + +```text +NAME | STATUS | ROLES | IP | REGION | AZ | UPTIME | VMs | SERVICES +node1 | Ready | nats:follower | 10.200.1.249 | ap-southeast-2 | ap-southeast-2a | 0m | 0 | nats,predastore,viperblock,daemon,awsgw,vpcd,ui +node2 | Ready | nats:follower | 10.200.0.201 | ap-southeast-2 | ap-southeast-2a | 0m | 0 | nats,predastore,viperblock,daemon,awsgw,vpcd,ui +node3 | Ready | nats:leader | 10.200.1.79 | ap-southeast-2 | ap-southeast-2a | 0m | 0 | nats,predastore,viperblock,daemon,awsgw,vpcd,ui +``` + +Every node listed, all `Ready`, exactly one `nats:leader`, and the same `SERVICES` on each. `spx top nodes` shows pooled capacity and what the cluster can launch right now; if it looks like one server rather than three, the others never joined. + +**Confirm the OCI allocator came up**, which `spx get nodes` cannot tell you: + +```bash +sudo journalctl -u spinifex-vpcd --since -5m | grep -i ocinet +``` + +```text +"msg":"ocinet resolved the external VNIC from its interface","pool":"oci-public","iface":"br-wan", + "vnic_id":"ocid1.vnic.oc1.ap-sydney-1.abzxsljrjwlf...kimuq","private_ip":"10.200.1.31","subnet":"10.200.0.0/23" +"msg":"OCI allocator ready","pool":"oci-public","collected":0,"stale_bindings":0,"skipped":1 +``` + +**`resolved the external VNIC` is the line that matters** — it proves the credentials work, the compartment is right, and `br-wan`'s MAC matched a real VNIC. A node missing it will accept `allocate-address` and fail it. + +> [!NOTE] +> On a cold cluster start you may instead see `OCI allocator reconcile failed … nats: no responders available for request` on some nodes. That is vpcd racing JetStream's KV at boot; the startup reconcile is skipped and not retried. It is harmless on a new cluster where nothing has been allocated, and it is tracked — restart `spinifex-vpcd` on that node to run it. The `resolved the external VNIC` line above is still the one that decides whether allocation works. + +## Step 6. Set up your cluster + +The cluster is running, but it holds nothing yet — no machine images, no networks, no instances. + +Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create an SSH key pair, create a VPC with a public subnet, and launch your first instance. It ends by arming the [host firewall](/docs/host-firewall), which is also where you re-arm it if you turned it off to form the cluster. + +--- + +## Step 7. Run your existing Terraform against the cluster + +This is the part worth pausing on. Everything above builds infrastructure **on** OCI using the OCI provider. Everything from here uses the **AWS** provider — unmodified, from the OpenTofu or Terraform registry — pointed at your own cluster. The same `aws_vpc`, `aws_instance`, `aws_db_instance`, `aws_ecs_service` and `aws_eks_cluster` resources a team already has in git apply against Spinifex on an OCI tenancy, with no rewriting and no OCI-specific module. + +That is the claim, so it is measured rather than asserted. What follows is a run of the workbooks we ship against a three-node Spinifex cluster on OCI VMs. + +### Pointing the provider at your cluster + +Three things differ from a provider block aimed at AWS, and only three: + +```hcl +provider "aws" { + region = "ap-southeast-2" + access_key = var.access_key + secret_key = var.secret_key + + endpoints { + ec2 = var.spinifex_endpoint # https://:9999 + s3 = var.predastore_endpoint # https://:8443 + iam = var.spinifex_endpoint + sts = var.spinifex_endpoint + } + + s3_use_path_style = true + skip_metadata_api_check = true + skip_region_validation = true +} +``` + +Credentials come from the `[spinifex]` profile that `spx admin init` writes into `~/.aws/credentials` on node 1. + +> **Use the node's private address, not its OCI reserved public IP.** The node certificate carries no SAN for the public address, because that address is never on the wire — OCI NATs it to a private one. An `aws` or `terraform` call to `https://:9999` fails TLS verification with *hostname doesn't match*. Until that is fixed, run Terraform from a node, or from anything else inside the VCN. + +### What we tested, and what happened + +Measured on 2026-09-26 against a three-node cluster of `VM.Standard.E6.Flex` instances in `ap-sydney-1`, running `spinifex v1.20.0-113`. Each workbook is a full `apply`, a functional assertion against the thing it built, and a `destroy`. + +| Workbook | What it exercises | Result | +| --- | --- | --- | +| `nginx-webserver` | VPC, subnet, IGW, security group, key pair, EC2 instance, public IP; HTTP 200 from the internet | **Passed** (86s) | +| `bastion-private-subnet` | Public and private subnets, NAT egress, a bastion, SSH hop to a private host | **Passed** (117s) | +| `rds-quickstart` | DB subnet group, parameter group, `aws_db_instance` (PostgreSQL), client instance, connection | **Passed** (296s) | +| `ecs-quickstart` | ECS cluster, task definition, service, container instances, ALB with a healthy target | **Passed** (166s) | +| `eks-quickstart` | EKS control plane, managed node group, `kubectl` against the cluster, nodes `Ready` | **Passed** (259s) | +| `nginx-alb` | Application Load Balancer across two subnets, two backends, health checks | **Passed** (123s) | +| `s3-webapp` | S3 bucket, IAM role and instance profile, IMDS-fetched credentials, upload from the guest | **Failed** — see below | +| `demo-app` | Container image for the EKS workbooks, pushed to ECR | **Passed** | +| `eks-https-ingress` | Private-subnet workers behind a NAT gateway, LBC addon, ACM certificate, HTTPS Ingress | **Passed** (252s) | +| `eks-gitops-argocd` | Private-subnet workers, Argo CD addon, EBS-CSI PersistentVolume, GitOps sync | **Passed** (225s) | + +Nine of the ten pass. RDS, ECS and EKS are the answer to the question this section exists to ask: an EKS control plane and a managed node group come up, `kubectl get nodes` reports them `Ready`, a PostgreSQL instance accepts connections, and an ECS service runs behind a load balancer with a healthy target. None of it knows it is running on someone else's cloud. + +The last two are the harder network. Their workers sit in private subnets and reach the cluster endpoint and ECR through a NAT gateway, so a worker registering `Ready` is the proof that the whole NAT path works — which on OCI it did not until three defects in the gateway's address handling were fixed. If those two pass and `eks-quickstart` also passes, NAT egress is sound. + +`s3-webapp` fails on the read-back rather than the create: the AWS provider issues an **S3 Control** `ListTagsForResource` for the bucket, an endpoint nothing serves, and the SDK builds its hostname by prefixing the account ID onto the host — which cannot resolve against an IP. The bucket, the IAM role and the instance are all created correctly first. This is not OCI-specific — it fails the same way on bare metal — and it is a known defect we are fixing. Until then, either pin the AWS provider to 5.x or set `skip_requesting_account_id = true` in the provider block, and buckets apply cleanly. + +Two things bit during the run that are worth knowing before you hit them, neither of which is a reason the workbooks do not work: + +- A `spinifex-daemon` restart while a node group is creating is terminal for that node group — `NodeCreationFailure: create interrupted by daemon restart`, with no retry. +- A `terraform destroy` immediately followed by a `terraform apply` of the same EKS cluster name can fail with `ResourceInUseException` while `DescribeCluster` and `ListClusters` both report the cluster gone, so there is nothing to wait on. Leave a minute between the two. + +--- + +## Step 8. Oracle Linux as a guest image + +Spinifex ships four Oracle Linux entries in its image catalog, so a customer on OCI can run the same distro their Oracle support contract covers. They import exactly like any other AMI — nothing about them is OCI-specific, and they run equally well on bare metal: + +| Catalog name | Release | Arch | Kernel | +| --- | --- | --- | --- | +| `oracle-10.1-x86_64` | Oracle Linux 10.1 | x86_64 | UEK 8 | +| `oracle-10.1-arm64` | Oracle Linux 10.1 | arm64 | UEK 8 | +| `oracle-9.8-x86_64` | Oracle Linux 9.8 | x86_64 | UEK 7 | +| `oracle-9.8-arm64` | Oracle Linux 9.8 | arm64 | UEK 7 | + +```bash +sudo spx admin images import --name oracle-10.1-x86_64 --config /etc/spinifex/spinifex.toml +aws ec2 describe-images --query 'Images[].[Name,State,BootMode]' --output text +``` + +All four boot **UEFI**, so launch them into a shape that boots UEFI — a BIOS-only instance type will not come up, and the failure looks like a hung boot rather than a rejected image. + +### The login user is `cloud-user`, not `opc` + +```bash +ssh -i path/to/key.pem cloud-user@ +``` + +**This is the one that catches people.** `opc` is the default user on the images Oracle publishes *into OCI itself*; these are the generic **KVM cloud images** from `yum.oracle.com`, whose cloud-init `default_user` is `cloud-user`. Verified on both releases: `opc`, `oracle` and `ec2-user` are all refused. The Spinifex UI's instance detail page shows the right user per AMI, so read it there rather than guessing from the distro. + +### Why these four pin a checksum digest inline + +Every other catalog entry verifies against the publisher's sums file. Oracle ships none — the digests are published as HTML on `yum.oracle.com/oracle-linux-templates.html` — so these four carry `ChecksumDigest` in `spinifex/utils/images.go` instead. That is safe **only** because each URL names an immutable build (`b291`, `b293`, `b178`, `b182`) and Oracle never moves one. A new point release is a new URL and a new digest, never an edit to an existing entry. + +On arm64 Oracle publishes a `-kvm-cloud-` build alongside a plain `-kvm-` one. The catalog takes `-kvm-cloud-`: it is the one with cloud-init, and so the one that matches x86_64's `-kvm-`. The plain arm64 build imports fine and then has no datasource, which surfaces as an instance with no key and no metadata rather than as an import error. + +### SELinux is enforcing + +Both releases ship `SELinux: enforcing`, unlike the Debian and Ubuntu images. That is the upstream default and Spinifex does not change it. It does not affect networking, IMDS or cloud-init — all verified working — but a workload that has only ever run on Debian may meet it for the first time here. + +--- + +--- + +## Verify end to end + +```bash +export AWS_PROFILE= + +aws ec2 allocate-address +aws ec2 run-instances --image-id --instance-type t3.micro --key-name \ + --subnet-id --associate-public-ip-address +aws ec2 describe-instances --query \ + 'Reservations[].Instances[].[InstanceId,PrivateIpAddress,PublicIpAddress,State.Name]' \ + --output text +``` + +Then, from the host: + +```bash +ping -c 3 +ssh -i ubuntu@ hostname +``` + +And from inside the guest — note that IMDS is **IMDSv2, so it needs a token**; an untokened request correctly returns 401: + +```bash +TOK=$(curl -s -X PUT http://169.254.169.254/latest/api/token \ + -H 'X-aws-ec2-metadata-token-ttl-seconds: 120') +curl -s -H "X-aws-ec2-metadata-token: $TOK" \ + http://169.254.169.254/latest/meta-data/instance-id +traceroute -n 8.8.8.8 +``` + +A healthy topology is three hops before OCI's network: the OVN router port, the transit gateway, then upstream. + +On a cluster, prove an address survives a move. `scripts/terraform/oci-spx/eip-move-test.sh` does the whole sequence — it asserts that an auto-assigned address is returned to OCI on stop and a different one comes back, then associates an Elastic IP, forces the guest to start on another node, and checks OCI's own record of which VNIC carries the private half: + +```bash +INSTANCE=i-... ./scripts/terraform/oci-spx/eip-move-test.sh +``` + +### What a correct node looks like + +Worth knowing so the asymmetry does not read as a fault: + +```bash +sudo ovn-nbctl lr-nat-list # dnat_and_snat holds the PRIVATE address +aws ec2 describe-instances ... # reports the PUBLIC address +ip route show | grep spx-nat-host # route to the PRIVATE address +``` + +**These disagreeing is the design.** The public address is 1:1-NATed by OCI upstream and never touches the wire, so every datapath object holds the private half while every AWS-facing record holds the public half. If OVN showed the public address, the rule would be matching something that never arrives. + +--- + +## Troubleshooting + +### Addresses and quotas + +**Spinifex enforces no limit of its own.** It attempts the allocation and reports what OCI says, so the ceiling you hit is always your tenancy's, never a number baked into the software. A quota is raised with Oracle, not reconfigured here, and there is nothing to restart afterwards. + +**`InsufficientAddressCapacity` on `allocate-address` is what exhaustion looks like.** It is the AWS error code for "out of addresses" and Spinifex returns it for either quota, mapped from OCI's own refusal. On a cluster the regional one is the likelier of the two. + +**A detached reserved public IP still bills.** `disassociate-address` returns the address to `AVAILABLE` without deleting it, exactly as an unassociated AWS Elastic IP behaves, and it keeps consuming your regional quota as well as your bill. `release-address` is what frees both. + +**A stopped instance costs nothing for its auto-assigned address.** Stopping an instance returns its auto-assigned address to the pool and deletes the reserved public IP object behind it, as AWS does; the start that follows creates a new one. An **Elastic IP** is yours and survives the stop untouched — that is the whole distinction between the two, and on OCI it is also the difference between a stopped fleet that bills for addresses and one that does not. + +### Datapath + +**Instance launches but the public address is unreachable.** In order: + +1. `sudo ovn-nbctl lr-nat-list ` — is there a `dnat_and_snat` row, and does it hold the *private* address? +2. `ip route get ` — does it route via the transit veth? +3. `sudo iptables -S FORWARD | grep spinifex-eip-ingress` — both directions present? +4. `oci network private-ip list --vnic-id ` — is the private address actually registered with OCI, **and on this node's VNIC**? If not, nothing else matters. + +**Egress works from one address and not another on the same NIC.** That is [source-IP enforcement](#what-oci-changes), and it means the address is not a registered private-IP object on the VNIC. It is never a routing problem on the host. + +**An address went dark after a guest moved node.** The affinity pass should have claimed it within 15 seconds. Check the new node's daemon journal for `ocinet affinity`, then confirm with step 4 above which VNIC OCI thinks holds the private half. + +### A node that was fine and then was not + +Terraform's cloud-init does the host jobs on each node — the iSCSI volume, `br-wan` and its source routing, the firewall. When one of them did not take, the symptom arrives much later and rarely looks like a networking or storage problem. This one command answers all of it: + +```bash +ssh -i ~/.ssh/oci-spx ubuntu@ ' + cloud-init status # done + sudo iscsiadm -m session # a session to 169.254.2.2:3260 + findmnt /var/lib/spinifex # mounted by UUID, with _netdev + ip -br addr show br-wan # UP, second VNIC address as a /32 + ip -br addr show enp1s0 # the bridge MEMBER holds NO address + ip route show default # still on the PRIMARY interface + ls -l /dev/kvm # present +' +``` + +Three answers are worth knowing in advance, because each looks wrong and is not: + +- **`br-wan` holds a `/32` and the default route is on the other interface.** That is how two VNICs share one subnet without the second stealing the first one's traffic, and it is what satisfies OCI's per-VNIC source check. `br-wan`'s address appears in `ip rule` instead of in the default route. +- **`br-wan`'s address is not one you chose.** It is the second VNIC's own private IP, assigned by OCI, and not predictable before the apply. +- **`enp1s0` carrying an address is the one real fault here.** The kernel derives an on-link route from it that beats the primary VNIC's, so every packet to another node leaves the wrong VNIC and OCI drops it. The node still forms a cluster and still reports `Ready` — formation uses outbound connections — and it surfaces later as `predastore … the stripe is short one holder` on the first AMI import. `sudo /usr/local/sbin/spinifex-setup-wan-bridge` repairs it in place. + +### Host + +**`RunInstances` returns `ServerInternal`.** Check the daemon journal for the real error — `journalctl -u spinifex-daemon --since -10m`. An OCI allocation failure surfaces this way. + +**The stack ends up on the boot disk and the node fills.** The data volume was mounted without `_netdev` and lost the race with `iscsid`. `findmnt /var/lib/spinifex` is the check; `ls` cannot tell the difference. + +### Guests cannot reach instance metadata + +**Guests boot but cloud-init hangs on metadata**, or the host stops answering its own. OCI uses `169.254.169.254` for its instance metadata, and so does every AWS-compatible guest — the host and the guests want the same address. + +Spinifex resolves this by moving the *host* side of the per-guest metadata link to a different link-local pair, so guests keep asking `169.254.169.254` exactly as they do on AWS. [Step 5](#step-5-configure-spinifex-for-oci-start-and-verify) sets `imds_host_meta_ip` and `imds_host_dns_ip` for you. **Set both or neither** — a half-configured pair is ignored and the node behaves as if the remap were off, which is what this symptom looks like. + +Check it after your first guest launches: + +```bash +ip route get 169.254.169.254 # your real NIC, not lo +curl -sH 'Authorization: Bearer Oracle' -o /dev/null -w '%{http_code}\n' \ + http://169.254.169.254/opc/v2/instance/ # 200 +ip -br addr show | grep ime- # holds 169.254.42.x, not .169.x +``` + +`ip route get 169.254.169.254` returning `dev lo` is the signature of a missing remap. The same signature can appear after a guest terminates, from a leftover `ime-` endpoint still holding the address; with the remap configured it is harmless, and removing the stale OVS port clears it. + +--- + +## Limits in this version + +- **Operator-provisioned API key**, not instance principal. The key is on each node's disk and its rotation is your responsibility. +- **One VNIC per pool.** A pool cannot grow past the per-VNIC secondary private IP limit of 64. Three nodes give you three pools' worth; a single node is capped at 64 addresses. +- **IPv4 only.** IPv6 on OCI is materially simpler — no pools, prefixes assign directly to VCNs — but is not implemented. +- **No BYOIP.** The integration accepts a public IP pool OCID in config so BYOIP becomes a config change rather than a code change, but the RIR validation and Oracle's own validation window both precede any use of it. +- **OCI Block Volumes are not an EBS provider.** They back `/var/lib/spinifex` and therefore viperblock, which captures most of the performance benefit, but there is no native OCI block provider. +- **Terraform builds infrastructure, not Spinifex.** Formation is still the standard install path, by design: it is the same path prod and bare metal take, and diverging it for one cloud would mean two formation paths to keep correct. + +### What is no longer a limit + +Recorded because earlier versions of this guide said otherwise: + +- **Multi-node works.** The `--nodes=1` cap on routed mode is gone, three-node formation and guest-to-guest traffic are proven on OCI, and the routed-NAT datapath is guarded nightly on three nodes. +- **Public addresses are not pinned to one node.** Each node allocates on its own VNIC, and an affinity pass reassigns an address's private half to whichever node the guest is on — one OCI call, keeping the OCID, so the customer's public address never changes. No node needs another node's VNIC OCID, and `oci_vnic_iface` is correct on every node. +- **Addresses are released when an instance stops.** An auto-assigned address goes back to the pool on stop and the reserved public IP object is deleted, so a stopped fleet stops costing you addresses. diff --git a/docs/service-interfaces.yaml b/docs/service-interfaces.yaml index 8691caf99..a8c86e3ca 100644 --- a/docs/service-interfaces.yaml +++ b/docs/service-interfaces.yaml @@ -86,6 +86,9 @@ services: # Cluster coordination subjects. - "ec2.ReserveClusterNode" - "ec2.FinalizeClusterInstances" + # An EIP association reaches whichever node holds the instance, which is + # rarely the node that served the request (fan-out, no queue group). + - "ec2.eni.public-ip-changed" - "spinifex.node.status" - "spinifex.node.vms" - "spinifex.nodes.discover" @@ -168,6 +171,8 @@ services: - "ebs.config.*" # → predastore (S3 / AMI / keypair store) - "s3.*" + # → every daemon, so the one holding the instance reports the new address + - "ec2.eni.public-ip-changed" # IAM lifecycle event - "iam.account.created" # → the in-guest rds-agent: set-password and the like, published on the diff --git a/docs/testing/e2e-coverage.md b/docs/testing/e2e-coverage.md new file mode 100644 index 000000000..014b323aa --- /dev/null +++ b/docs/testing/e2e-coverage.md @@ -0,0 +1,85 @@ +# What each E2E suite proves + +**This index exists to answer "does anything already test that?" before someone reasons it out from the code.** Comments describe intent when written and can go stale; these suites run against a live cluster and are the current answer on behaviour. + +Suites live in `tests/e2e//` and build to `tests/e2e/_bin/.test`. Every file carries `//go:build e2e`, so a default build skips them. The sets that decide what runs where are in `.github/scripts/suite-sets.sh`, and the nightly permutation matrix is `.github/e2e-cells.json`. + +| Set | Suites | +| --- | --- | +| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota storagefault` | +| `E2E_SUITES_MULTI` | `multinode lb cert quota storagefault` | +| `E2E_SUITES_NIGHTLY_SINGLE` | `single cert iam` | +| `E2E_SUITES_NIGHTLY_MULTI` | `multinode cert lb` | + +The nightly permutation sets are deliberately narrower than the full ones: those cells have a ~35 minute budget and exist to prove every install / network / host-OS combination boots and serves. `eks`, `ecs`, `rds` and `storagefault` each get a dedicated cell instead. + +## The suites + +### `single` — the broad single-node EC2/EBS/VPC surface + +The largest suite, 25 top-level tests. Notable entry points: + +- **`TestVPCEgressPaths`** — **the authority on subnet egress semantics.** One VPC with a public and a private subnet, sharing two guests across four stages: `PublicSubnetEgress` (IGW egress works), `RouteBeforeSubnet` (a regression guard for an IGW route installed on the main route table before the subnet exists), the NAT Gateway rounds, and `EIPFlip` (associating an Elastic IP onto a running instance). + - **`NATBaseline` hard-fails if a private-subnet guest reaches the internet with no NAT Gateway.** `NATGatewayUp` then proves egress appears and `NATGatewayDown` that it disappears. `SPINIFEX_VPCEGRESS_NAT_ROUNDS` repeats the up/down cycle for a soak run. + - **This is AWS parity for private subnets.** It ran in pool mode only until 2026-09-26: the fixture gated it on `external_mode = "pool"`, on the stale premise that nat clusters have no EIPs. They do when a public pool is configured, so the gate is now "can this cluster allocate public addresses" and the nat-single cell runs this test beside `natuplink`, which never creates a private subnet. +- `TestSGPolicyDatapath`, `TestSGReachabilityPolicy` — security groups as an actual datapath, not just API state. +- `TestWANEgressSMTPBlock` — egress port blocking. +- `TestInstanceMetadata` — IMDS from inside a guest. +- `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`. +- `TestNegativeErrorPaths` — the `8a`–`8h` AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, and so on). + +### `natuplink` — routed NAT (`external_mode = "nat"`), one node or three + +Runs **on** a node, shelling out to `ip`/`iptables`/`ovn-nbctl` locally. Skips unless `spinifex.toml` says `external_mode = "nat"`. Nine sequential phases: host wiring (transit veth, `ip_forward`, NAT egress rules), config, the EIP surface (enabled or disabled depending on whether a public pool exists), default-subnet public IP mapping, OVN gateway and SNAT on the transit net, instance egress **proven via the serial console**, host-to-guest Tier 1 ingress, a second VPC getting a unique transit gateway IP, and the EIP ingress lifecycle. + +**Phase 9 has two shapes.** On one node it is the EIP lifecycle: allocate, associate, host delivery plumbing, a TCP handshake to the address, a vpcd restart that must replay the plumbing, and disassociate tearing it down. On a cluster it is the distributed lane instead — a guest on every node, each public IP plumbed by the node running its guest and by **no other**, every transit veth carrying the one cluster-wide MAC, each `dnat_and_snat` row carrying `external_mac` and `logical_port`, inbound proven by a handshake and outbound by each guest's own console. **That lane is the regression guard for the two defects that made routed NAT work on one node only**, and neither is visible to a single-node run. + +Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally. + +Two nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap. + +### `multinode` — behaviour that only exists on more than one node + +`VPCSetup`, `SpansMultipleNodes`, `SpreadPlacement`, `EveryRunningInstanceReported`, `BastionSSH`, and its own NAT Gateway lane: `PreNATIsolation`, `NATGatewayInternet`, `NATCleanupOrdering`. + +### `iam` — everything IAM and STS + +Policy evaluation against live requests rather than unit-level: a deny applying to the next request without a new credential, resource-scoped grants naming one key pair, `aws:SourceIp` seeing the real client address, assumed-role sessions carrying role policies, and revocation stopping one key alone. + +### `cert` — the cluster CA and TLS identity + +CA installed and downloadable, fingerprint matches config, hostnames present in DNS SANs, OpenSSL verification, `curl` without a CA flag, and a shared issuer across nodes. + +### `lb` — load balancers (multi-node) + +Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `ModifyListener`, and NLB UDP. + +### `eks`, `ecs`, `ecr` — container services + +`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, and an IRSA pod wired with a projected SA token. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP. + +### `rds` — managed databases + +The create/describe/connect path against a live cluster: a DB VM that actually boots, TLS enforced with plaintext refused, clients connecting from the same and another subnet, class changes replacing the VM while keeping overrides, deferred parameter-group attachment windows, and delete-while-creating leaving nothing behind. + +### Storage and durability + +- **`storagefault`** — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. **The claim is about corruption, not availability.** SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node. +- **`partialblock`** — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest. +- **`storagegrowth`** — volume growth and image pull. Requires `external_mode=pool`; skips on a nat env, which has no EIP and therefore no guest SSH. +- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest reset once its budget expires. Proves a real reboot by requiring a *new* boot ID. +- **`diskperf`** — guest disk performance gate, pinned fio profile on a dedicated blank volume. Also requires `external_mode=pool` for guest SSH. + +### Other + +- **`quota`** — every enforced dimension refuses with the same `quotaExceeded` code; a launch is charged for every instance it asks for; one instance wider than the cap is refused. +- **`gpu`** — device visible in the guest, driver access, launch. +- **`bedrock`** — `ListFoundationModels`/`GetFoundationModel` against a running cluster. Wire-format parity is unit-tested, not here. +- **`ddil`** — Denied, Disrupted, Intermittent, Limited network scenarios. +- **`teardown`** — sweeps leftovers. +- **`harness`** — shared primitives, not a suite. `harness/vpc_egress.go` carries the VPC/subnet/IGW/NAT-gateway fixtures the egress scenarios build on. +- **`manifest-check`, `manifest-lint`** — manifest validation, not cluster behaviour. + +## Keeping this honest + +Add a row when a suite is added, and correct one when a suite's claim changes. An index that has drifted is worse than none, because it will be trusted. If a suite's package comment and this file disagree, check the test bodies — both are prose and either can be stale. diff --git a/go.mod b/go.mod index 67414910e..bd14152ff 100644 --- a/go.mod +++ b/go.mod @@ -160,6 +160,7 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/oracle/oci-go-sdk/v65 v65.126.0 // indirect github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index f84064489..cf44da415 100644 --- a/go.sum +++ b/go.sum @@ -392,6 +392,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/oracle/oci-go-sdk/v65 v65.126.0 h1:RuV0MEcLOOgNOBadYbbkUQriCK4Gm5348F/GdWvYPcI= +github.com/oracle/oci-go-sdk/v65 v65.126.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ= github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG7acT8gf4= github.com/ovn-kubernetes/libovsdb v0.8.1/go.mod h1:ZlnHLzagmLOSvyd9qfxBIZp6wOSOw0IsRsc+6lNUGbU= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= diff --git a/scripts/README.md b/scripts/README.md index 2c66b5d97..48a992b3d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,6 +12,19 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati | `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. | | `clone-deps.sh` | Clones or updates the viperblock and predastore checkouts alongside spinifex for cross-repo development. | +## Teardown + +Both ship to `/usr/local/share/spinifex/` on every node, so they are available on a host installed with `curl … | bash` and not only from a checkout. They differ in what they take away: + +| Script | What it does | +|---|---| +| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. | +| `uninstall-spx.sh` | Removes the **software** — the inverse of `setup.sh`. Delegates the state teardown above to `node-reset.sh` rather than reimplementing it, then removes the binary, the nbdkit plugin, the systemd units and slices, the service users, the sudoers grant, the sysctl/logrotate/chrony/udev drop-ins, the `systemd-networkd` units, the host firewall policy and its loaded nft table, and the Spinifex CA from the host trust store. Verifies afterwards and exits non-zero naming anything that survived. | + +`uninstall-spx.sh` keeps `/var/lib/spinifex` and every apt package by default — uninstalling software should not destroy data, and a `curl … | bash` install lands on hosts that were already using openvswitch, libvirt and chrony. `--purge-data` and `--purge-deps` opt into each, and both confirm separately even under `--yes` (`SPX_PURGE_CONFIRM=destroy` for unattended data removal). `--dry-run` prints the whole plan and touches nothing. + +**It refuses on a multi-node cluster, and `--yes` does not override that.** Uninstalling one node of a running cluster strands its guests and leaves the survivors reconciling against a chassis that will never answer. Run `spx admin cluster shutdown` first, then the uninstaller on each node. + ## Dev environment | Script | What it does | diff --git a/scripts/dev-install.sh b/scripts/dev-install.sh index 408fc9160..72b8b961a 100755 --- a/scripts/dev-install.sh +++ b/scripts/dev-install.sh @@ -29,6 +29,8 @@ trap 'rm -rf "$STAGING"' EXIT cp "$PROJECT_ROOT/bin/spx" "$STAGING/spx" cp "$MULGA_ROOT/viperblock/lib/nbdkit-viperblock-plugin.so" "$STAGING/" cp "$PROJECT_ROOT/scripts/setup-ovn.sh" "$STAGING/" +cp "$PROJECT_ROOT/scripts/node-reset.sh" "$STAGING/" +cp "$PROJECT_ROOT/scripts/uninstall-spx.sh" "$STAGING/" mkdir -p "$STAGING/systemd" cp "$PROJECT_ROOT/build/systemd/"* "$STAGING/systemd/" mkdir -p "$STAGING/scripts" diff --git a/scripts/install-node.sh b/scripts/install-node.sh index 163e64fa5..6355565f1 100755 --- a/scripts/install-node.sh +++ b/scripts/install-node.sh @@ -18,9 +18,15 @@ # The first host is the init node; the rest join it. # # Options: -# --external-pool A-B Public IP range for instances (required) -# --external-gateway IP Gateway for that range (required) -# --external-prefix-len N Prefix length of the range's subnet (required) +# --external-mode M pool (default) or nat. pool bridges a WAN NIC and +# needs the three flags below. nat routes through the +# spx-nat veth and takes none of them — it is the only +# mode a cloud VNIC can carry, and its public +# addresses come from a provider pool added to +# spinifex.toml, not from this script. +# --external-pool A-B Public IP range for instances (required in pool mode) +# --external-gateway IP Gateway for that range (required in pool mode) +# --external-prefix-len N Prefix length of the range's subnet (required in pool mode) # --external-iface NAME WAN NIC for br-external (default: auto-detected) # --node-names A,B,C Cluster node names (default: node1..nodeN) # --lan-bridge NAME Bridge carrying internal cluster traffic @@ -67,6 +73,7 @@ SSH_USER="${SSH_USER:-spinifex}" IDENTITY="" HOSTS=() NODE_NAMES=() +EXT_MODE="pool" EXT_POOL="" EXT_GATEWAY="" EXT_PREFIX="" @@ -111,6 +118,7 @@ FIREWALL_PEERS="/etc/spinifex/firewall/peers.nft" while [[ $# -gt 0 ]]; do case "$1" in + --external-mode) EXT_MODE="$2"; shift 2 ;; --external-pool) EXT_POOL="$2"; shift 2 ;; --external-gateway) EXT_GATEWAY="$2"; shift 2 ;; --external-prefix-len) EXT_PREFIX="$2"; shift 2 ;; @@ -138,7 +146,7 @@ while [[ $# -gt 0 ]]; do --no-firewall) MANAGE_FIREWALL=false; shift ;; --yes|-y) ASSUME_YES=true; shift ;; --dry-run) DRY_RUN=true; shift ;; - -h|--help) sed -n '2,61p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,67p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "ERROR: unknown option: $1" >&2; exit 2 ;; *) HOSTS+=("$1"); shift ;; esac @@ -164,13 +172,30 @@ if [ "$N" -eq 2 ]; then log " for a fault-tolerant deployment." fi -for required in "--external-pool:$EXT_POOL" "--external-gateway:$EXT_GATEWAY" "--external-prefix-len:$EXT_PREFIX"; do - [ -n "${required#*:}" ] || fail "${required%%:*} is required. +case "$EXT_MODE" in + pool|nat) ;; + *) fail "--external-mode must be pool or nat, got: $EXT_MODE" ;; +esac + +# Routed mode's own addresses are the fixed transit /24 the veth carries, and any +# public addresses come from a pool in spinifex.toml — a provider pool on a cloud +# host. There is nothing for these three flags to describe, so passing them is a +# misunderstanding worth naming rather than ignoring. +if [ "$EXT_MODE" = "nat" ]; then + for unused in "--external-pool:$EXT_POOL" "--external-gateway:$EXT_GATEWAY" "--external-prefix-len:$EXT_PREFIX"; do + [ -z "${unused#*:}" ] || fail "${unused%%:*} is a pool-mode flag and --external-mode=nat ignores it. + Routed mode addresses instances from the transit /24; public addresses come + from a [[network.external_pools]] entry in spinifex.toml." + done +else + for required in "--external-pool:$EXT_POOL" "--external-gateway:$EXT_GATEWAY" "--external-prefix-len:$EXT_PREFIX"; do + [ -n "${required#*:}" ] || fail "${required%%:*} is required in --external-mode=pool. Instances get their public addresses from this range, and there is no safe default to guess. Example: --external-pool 216.218.163.101-216.218.163.110 \\ --external-gateway 216.218.163.97 --external-prefix-len 27" -done + done +fi case "$IPSEC" in on) IPSEC_ENABLED=true ;; @@ -178,9 +203,11 @@ case "$IPSEC" in *) fail "--ipsec must be on or off, got: $IPSEC" ;; esac -[[ "$EXT_POOL" =~ ^[0-9.]+-[0-9.]+$ ]] || fail "--external-pool must be START-END, got: $EXT_POOL" -[[ "$EXT_PREFIX" =~ ^[0-9]+$ ]] && [ "$EXT_PREFIX" -ge 1 ] && [ "$EXT_PREFIX" -le 32 ] || - fail "--external-prefix-len must be 1-32, got: $EXT_PREFIX" +if [ "$EXT_MODE" = "pool" ]; then + [[ "$EXT_POOL" =~ ^[0-9.]+-[0-9.]+$ ]] || fail "--external-pool must be START-END, got: $EXT_POOL" + [[ "$EXT_PREFIX" =~ ^[0-9]+$ ]] && [ "$EXT_PREFIX" -ge 1 ] && [ "$EXT_PREFIX" -le 32 ] || + fail "--external-prefix-len must be 1-32, got: $EXT_PREFIX" +fi AZ="${AZ:-${REGION}a}" @@ -337,7 +364,11 @@ for i in $(seq 0 $((N - 1))); do "$role" "${HOSTS[$i]}" "${NODE_NAMES[$i]}" "${WAN_IPS[$i]}" "${LAN_IPS[$i]}" "${VPC_IPS[$i]}" done echo "" -echo " Instance pool: $EXT_POOL via $EXT_GATEWAY/$EXT_PREFIX" +if [ "$EXT_MODE" = "nat" ]; then + echo " External: routed nat — transit /24 on spx-nat, public addresses from spinifex.toml" +else + echo " Instance pool: $EXT_POOL via $EXT_GATEWAY/$EXT_PREFIX" +fi echo "" if $WIPE; then echo " --wipe: this DESTROYS, on ALL $N hosts INCLUDING ${HOSTS[0]}:" @@ -505,6 +536,12 @@ done # --recreate-db means no stale chassis row survives to be orphaned by it. log "building the OVN database ($([ "$DB_NODES" -eq 3 ] && echo "RAFT across 3" || echo standalone))" +# Without this, setup-ovn.sh auto-detects the uplink, finds a physical NIC it +# refuses to bridge, and stops with a menu. Routed mode wants none of that: the +# transit veth is the uplink, and --nat-uplink is what builds and keeps it. +OVN_UPLINK_ARGS="" +[ "$EXT_MODE" = "nat" ] && OVN_UPLINK_ARGS="--nat-uplink" + if [ "$DB_NODES" -eq 3 ]; then # Computed before the database nodes are built, not after, because they need # it too. setup-ovn.sh writes ovn-northd's NB/SB connections from this list @@ -520,7 +557,7 @@ if [ "$DB_NODES" -eq 3 ]; then --db-cluster-local-addr=${LAN_IPS[0]} \ --lan-addr=${LAN_IPS[0]} \ --ovn-remote=$OVN_REMOTE \ - --recreate-db \ + --recreate-db $OVN_UPLINK_ARGS \ --encap-ip=${VPC_IPS[0]}" || fail "${HOSTS[0]}: could not create the OVN database cluster" log " ${HOSTS[0]} created the database cluster" @@ -531,14 +568,14 @@ if [ "$DB_NODES" -eq 3 ]; then --db-cluster-remote-addr=${LAN_IPS[0]} \ --lan-addr=${LAN_IPS[$i]} \ --ovn-remote=$OVN_REMOTE \ - --recreate-db \ + --recreate-db $OVN_UPLINK_ARGS \ --encap-ip=${VPC_IPS[$i]}" || fail "${HOSTS[$i]}: could not join the OVN database cluster" log " ${HOSTS[$i]} joined the database cluster" done else on "${HOSTS[0]}" "sudo $SETUP_OVN --management \ --node-name=${NODE_NAMES[0]} \ - --lan-addr=${LAN_IPS[0]} \ + --lan-addr=${LAN_IPS[0]} $OVN_UPLINK_ARGS \ --encap-ip=${VPC_IPS[0]}" || fail "${HOSTS[0]}: setup-ovn.sh failed" log " ${HOSTS[0]} is running a standalone database" @@ -549,7 +586,7 @@ fi for i in $(seq "$DB_NODES" $((N - 1))); do on "${HOSTS[$i]}" "sudo $SETUP_OVN \ --node-name=${NODE_NAMES[$i]} \ - --ovn-remote=$OVN_REMOTE \ + --ovn-remote=$OVN_REMOTE $OVN_UPLINK_ARGS \ --encap-ip=${VPC_IPS[$i]}" || fail "${HOSTS[$i]}: setup-ovn.sh failed" log " ${HOSTS[$i]} attached as a compute node" done @@ -574,12 +611,16 @@ init_args=( --region "$REGION" --az "$AZ" --token-ttl "$TOKEN_TTL" - --external-mode=pool - --external-source=static - --external-pool="$EXT_POOL" - --external-gateway="$EXT_GATEWAY" - --external-prefix-len="$EXT_PREFIX" + --external-mode="$EXT_MODE" ) +if [ "$EXT_MODE" = "pool" ]; then + init_args+=( + --external-source=static + --external-pool="$EXT_POOL" + --external-gateway="$EXT_GATEWAY" + --external-prefix-len="$EXT_PREFIX" + ) +fi [ -n "$EXT_IFACE" ] && init_args+=(--external-iface="$EXT_IFACE") [ -n "$EMAIL" ] && init_args+=(--email="$EMAIL") @@ -931,4 +972,8 @@ if $RUN_SMOKE; then fi echo "" -log "cluster formed: $N nodes, pool $EXT_POOL" +if [ "$EXT_MODE" = "nat" ]; then + log "cluster formed: $N nodes, routed nat" +else + log "cluster formed: $N nodes, pool $EXT_POOL" +fi diff --git a/scripts/node-reset.sh b/scripts/node-reset.sh index e0f9cea62..6576ba80b 100755 --- a/scripts/node-reset.sh +++ b/scripts/node-reset.sh @@ -358,8 +358,16 @@ fi # tree holding its own keys and configs. It is not user data — it is wreckage # from a misdetected layout — and leaving it invites a later dev-mode run to # adopt it. -DEV_ROOT="$(getent passwd spinifex | cut -d: -f6)/spinifex" -if [ -n "${DEV_ROOT#/spinifex}" ] && [ -d "$DEV_ROOT" ]; then +# +# `|| true` is load bearing. setup.sh creates the *group* spinifex and the eight +# spinifex-* service users, never a user named spinifex — that account comes from +# the ISO's interactive login. So on any curl|bash-installed node `getent passwd +# spinifex` exits 2, and under `set -euo pipefail` that took the whole script +# down at the last step, after the wipe had already happened. Prod and dev-prod +# are ISO-installed, which is why it stayed hidden. +DEV_HOME="$(getent passwd spinifex | cut -d: -f6 || true)" +DEV_ROOT="${DEV_HOME}/spinifex" +if [ -n "$DEV_HOME" ] && [ -d "$DEV_ROOT" ]; then log "removing the dev-layout fallback tree at $DEV_ROOT" run sudo rm -rf "$DEV_ROOT" fi diff --git a/scripts/setup-ovn.sh b/scripts/setup-ovn.sh index 50c1f607d..9b33f522d 100755 --- a/scripts/setup-ovn.sh +++ b/scripts/setup-ovn.sh @@ -20,8 +20,10 @@ # --dhcp Obtain gateway IP via DHCP on the WAN bridge interface # --nat-uplink Routed NAT mode: no WAN NIC is bridged. Creates br-ext # with a transit veth (spx-nat-host 100.127.0.1/24) and -# host masquerade rules; VMs get outbound-only WAN over -# any uplink (ethernet, WiFi, cellular, PPP). Pair with +# host masquerade rules; VMs reach the WAN over any +# uplink (ethernet, WiFi, cellular, PPP), and EIPs work +# once a public pool is configured. This is the only +# mode that works on a cloud VNIC. Pair with # `spx admin init --external-mode=nat`. # --mgmt-bridge=NAME OVS bridge for system-instance control plane (default: br-mgmt) # --mgmt-cidr=CIDR IPv4 CIDR to assign on the mgmt bridge (default: 10.15.8.1/24) @@ -48,6 +50,15 @@ # (which is every node — the ovn-central package starts # one on install). DISCARDS ALL LOGICAL NETWORK STATE: # safe on a fresh node, destroys every VPC on a live one. +# --teardown Remove the host wiring this script builds — veth pairs, +# systemd-networkd persistence, routed-NAT egress rules, +# the OVS bridges and the ovn-* external_ids — and exit. +# The way back to a node this script can be re-run on +# cleanly, because a second run only unpicks the mode it +# is switching away from. Leaves the NB/SB databases and +# every VPC in them, /etc/spinifex, and the system-id; +# erasing those is node-reset.sh and uninstall-spx.sh. +# --dry-run Print what --teardown would do and change nothing. # # Cluster Sizing — which DB topology to run: # @@ -102,7 +113,7 @@ # # No WAN bridge (overlay-only, no public subnet): # ./scripts/setup-ovn.sh --management --encap-ip=10.0.0.1 # -# # Non-bridgeable uplink (WiFi/cellular) — routed NAT, outbound-only VMs: +# # Non-bridgeable uplink (WiFi/cellular) — routed NAT: # ./scripts/setup-ovn.sh --management --nat-uplink # # # Standalone management node, NB/SB client listeners also on the LAN plane: @@ -142,6 +153,10 @@ OVN_DBDIR="${OVN_DBDIR:-/var/lib/ovn}" # system-id themselves before / after invoking setup-ovn.sh — they # don't need this script to do it for them. NODE_NAME="" +# --teardown removes the host wiring this script builds, so a re-run starts +# from a clean node instead of compounding whatever the last run left. +TEARDOWN=false +DRY_RUN=false # Parse arguments for arg in "$@"; do @@ -162,6 +177,8 @@ for arg in "$@"; do --db-cluster-remote-addr=*) DB_CLUSTER_REMOTE_ADDR="${arg#*=}" ;; --recreate-db) RECREATE_DB=true ;; --node-name=*) NODE_NAME="${arg#*=}" ;; + --teardown) TEARDOWN=true ;; + --dry-run) DRY_RUN=true ;; --help|-h) sed -n '3,/^set -e/{/^set -e/!p}' "$0" exit 0 @@ -173,6 +190,127 @@ for arg in "$@"; do esac done +# --- Teardown --- +# +# Removes the host wiring this script builds and nothing else. A node that has +# been set up twice with different --wan-bridge values carries both shapes at +# once, and the second run cannot unpick the first: it rips down only the mode +# it is switching away from, and only when it gets far enough to know the mode. +# This is the way back to a node the script can be run on cleanly. +# +# What it deliberately leaves: the NB/SB databases and every logical network in +# them, /etc/spinifex, the OVS system-id, and the packages. Erasing those is +# node-reset.sh and uninstall-spx.sh, and a teardown that took the control +# plane's state with it could not be used to re-run this script. +run() { + if [ "$DRY_RUN" = true ]; then + echo " would run: $*" + return 0 + fi + "$@" +} + +# True when $1 names an existing Linux (not OVS) bridge. +is_linux_bridge() { + [ -d "/sys/class/net/$1/bridge" ] +} + +# Prints the bridge $1 is enslaved to, or nothing. +iface_master() { + local m + m=$(ip -o link show "$1" 2>/dev/null | sed -n 's/.* master \([^ ]*\) .*/\1/p') + printf '%s' "$m" +} + +teardown() { + echo "Tearing down the OVN host wiring built by this script" + [ "$DRY_RUN" = true ] && echo " (dry run — nothing is changed)" + echo "" + + echo "Step 1: Removing veth pairs..." + # Deleting one end deletes the peer, so the OVS port has to go first or it + # is left in OVSDB naming a device the kernel no longer has. + for pair in "veth-wan-ovs veth-wan-br" "spx-nat-ovs spx-nat-host"; do + set -- $pair + if command -v ovs-vsctl >/dev/null 2>&1; then + run sudo ovs-vsctl --if-exists del-port "$1" 2>/dev/null || true + fi + if ip link show "$2" >/dev/null 2>&1; then + run sudo ip link del "$2" 2>/dev/null || true + echo " removed veth pair: $2 ↔ $1" + fi + done + + echo "" + echo "Step 2: Removing systemd-networkd persistence..." + for f in /etc/systemd/network/05-spinifex-ovs-internal.network \ + /etc/systemd/network/10-spinifex-mgmt.network \ + /etc/systemd/network/14-spinifex-br-wan.netdev \ + /etc/systemd/network/15-spinifex-veth-wan.netdev \ + /etc/systemd/network/15-spinifex-veth-wan.network \ + /etc/systemd/network/16-spinifex-veth-wan-ovs.network \ + /etc/systemd/network/17-spinifex-nat.netdev \ + /etc/systemd/network/17-spinifex-nat.network \ + /etc/systemd/network/18-spinifex-nat-ovs.network; do + if [ -f "$f" ]; then + run sudo rm -f "$f" + echo " removed $f" + fi + done + run sudo networkctl reload 2>/dev/null || true + + echo "" + echo "Step 3: Removing routed-NAT egress rules..." + # Drain by comment rather than by rule text: the rules were built from a + # CIDR and an uplink this run may not know, and deleting by spec would miss + # any installed against a different uplink. + if command -v iptables >/dev/null 2>&1; then + for table_chain in "nat POSTROUTING" "filter FORWARD"; do + set -- $table_chain + while iptables -t "$1" -S "$2" 2>/dev/null | grep -q 'spinifex-nat-egress'; do + rule=$(iptables -t "$1" -S "$2" 2>/dev/null | grep -m1 'spinifex-nat-egress' | sed 's/^-A /-D /') + [ -n "$rule" ] || break + # shellcheck disable=SC2086 + run sudo iptables -t "$1" $rule 2>/dev/null || break + echo " removed $1/$2 rule (spinifex-nat-egress)" + [ "$DRY_RUN" = true ] && break + done + done + fi + + echo "" + echo "Step 4: Removing OVS bridges and external_ids..." + if command -v ovs-vsctl >/dev/null 2>&1; then + # br-ext and br-int are this script's; a bridge the operator named with + # --wan-bridge is removed too, unless it is a Linux bridge, which this + # script never created and must not delete. + for br in br-ext br-int "$MGMT_BRIDGE" "$WAN_BRIDGE"; do + [ -n "$br" ] || continue + if is_linux_bridge "$br"; then + echo " left $br alone: it is a Linux bridge, not ours to delete" + continue + fi + if sudo ovs-vsctl br-exists "$br" 2>/dev/null; then + run sudo ovs-vsctl --if-exists del-br "$br" + echo " removed OVS bridge: $br" + fi + done + for key in ovn-bridge-mappings ovn-remote ovn-encap-ip ovn-encap-type; do + run sudo ovs-vsctl remove Open_vSwitch . external_ids "$key" 2>/dev/null || true + done + echo " cleared ovn-bridge-mappings, ovn-remote, ovn-encap-ip, ovn-encap-type" + fi + + echo "" + echo "Teardown complete. The NB/SB databases, /etc/spinifex and the OVS" + echo "system-id are untouched — re-run this script to wire the node again." +} + +if [ "$TEARDOWN" = true ]; then + teardown + exit 0 +fi + # OVSDB RAFT clustering runs the NB/SB DBs, so it only applies to management # (DB) nodes. A remote addr without a local addr is meaningless (nothing to # join with). Fail loudly rather than silently starting a single-node DB. @@ -205,7 +343,43 @@ LINUX_BRIDGE="" # Set when WAN_BRIDGE_MODE="veth" — the Linux bridge behin detect_wan_bridge() { # If --wan-bridge was explicitly given, use it if [ -n "$WAN_BRIDGE" ]; then + # A Linux bridge of this name is the veth case, not the OVS-bridge case, + # and it is checked first because it is the only arm that can silently + # produce a broken datapath. An OVS bridge cannot take over the name of + # an existing Linux bridge: ovs-vsctl exits 0, OVSDB accepts the rows, + # and ofproto then refuses with "could not add network device br-wan to + # ofproto (File exists)" — visible only in `ovs-vsctl show`. The + # auto-detect path below has always handled this correctly by building a + # veth pair; it was simply unreachable whenever the operator named the + # bridge, which is exactly what they must do when the default route is + # on a physical NIC. + if is_linux_bridge "$WAN_BRIDGE"; then + LINUX_BRIDGE="$WAN_BRIDGE" + WAN_BRIDGE="br-ext" + WAN_BRIDGE_MODE="veth" + echo " $LINUX_BRIDGE is a Linux bridge — linking it to OVS br-ext via a veth pair" + if [ -n "$WAN_IFACE" ]; then + echo " ignoring --wan-iface=$WAN_IFACE: $LINUX_BRIDGE already owns its ports" + WAN_IFACE="" + fi + return + fi + if [ -n "$WAN_IFACE" ]; then + # Enslaving an interface that another bridge already owns fails the + # same silent way. Refuse rather than build something that reports + # success and cannot forward. + local master + master=$(iface_master "$WAN_IFACE") + if [ -n "$master" ]; then + echo "ERROR: $WAN_IFACE is already enslaved to bridge '$master'." + echo " Adding it to an OVS bridge as well cannot work — ovs-vsctl will" + echo " exit 0 and the datapath will refuse the device." + echo " Either detach it first, or point --wan-bridge at '$master' to" + echo " link that bridge to OVS with a veth pair:" + echo " $0 --management --wan-bridge=$master" + exit 1 + fi WAN_BRIDGE_MODE="direct" elif sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then WAN_BRIDGE_MODE="existing" @@ -274,7 +448,7 @@ detect_wan_bridge() { echo " ./scripts/setup-ovn.sh --management --encap-ip=$wan_ip" echo "" echo " 4. Non-bridgeable uplink (WiFi/cellular/PPP) — routed NAT mode" - echo " (VMs get outbound-only internet, no public IPs):" + echo " (add a public pool for EIPs; without one, VMs get egress only):" echo " ./scripts/setup-ovn.sh --management --nat-uplink" echo " then: spx admin init --external-mode=nat" echo "============================================================" @@ -450,6 +624,8 @@ if [ "$MANAGEMENT" = true ]; then OVN_REMOTE_NB="${OVN_REMOTE//:6642/:6641}" OVN_CTL_OPTS="$OVN_CTL_OPTS --ovn-northd-nb-db=$OVN_REMOTE_NB --ovn-northd-sb-db=$OVN_REMOTE" echo " ovn-northd: NB=$OVN_REMOTE_NB SB=$OVN_REMOTE (RAFT member list)" + else + NORTHD_LOCAL_ONLY=true fi if [ -n "$OVN_DB_LISTEN_OPTS" ]; then @@ -536,6 +712,38 @@ EOF echo " DB storage: clustered (NB + SB verified)" fi + # An OVSDB RAFT follower does not proxy writes — it answers "not cluster + # leader; trying another server" — so a northd whose client list names one + # member works only while it shares a node with the leader. When it does not, + # NB->SB translation stops dead: nothing is logged above info, every service + # stays active, and the only evidence is SB_Global.nb_cfg falling behind + # NB_Global.nb_cfg. Derive the list from the live membership rather than + # trusting the caller to have passed --ovn-remote. + if [ "${NORTHD_LOCAL_ONLY:-false}" = true ] && [ -n "$DB_CLUSTER_LOCAL_ADDR" ]; then + NB_MEMBERS=$(sudo ovs-appctl -t /var/run/ovn/ovnnb_db.ctl \ + cluster/status OVN_Northbound 2>/dev/null \ + | grep -oE 'at tcp:[0-9.]+:' | sed 's/^at tcp://; s/:$//' | sort -u) + NB_LIST="" + SB_LIST="" + for addr in $NB_MEMBERS; do + NB_LIST="${NB_LIST:+$NB_LIST,}tcp:$addr:6641" + SB_LIST="${SB_LIST:+$SB_LIST,}tcp:$addr:6642" + done + if [ "$(printf '%s\n' "$NB_MEMBERS" | grep -c .)" -gt 1 ]; then + sudo sed -i "s|\"\$| --ovn-northd-nb-db=$NB_LIST --ovn-northd-sb-db=$SB_LIST\"|" \ + /etc/default/ovn-central + sudo systemctl restart ovn-northd + echo " ovn-northd: NB=$NB_LIST SB=$SB_LIST (derived from RAFT membership)" + else + echo " NOTE: this node is the only NB RAFT member, so ovn-northd is pointed at" >&2 + echo " itself. Once the other members join, re-run this script here (or" >&2 + echo " pass --ovn-remote with the full member list) — otherwise northd" >&2 + echo " silently stops translating NB to SB the first time NB leadership" >&2 + echo " moves off this node, and every VPC created after that never" >&2 + echo " attaches its internet gateway." >&2 + fi + fi + # Wait for the Southbound DB to be serving before ovn-controller (Step 5) # dials it. On a single node ovn-controller races a fresh SB RAFT election # with no join step to absorb the window — attaching before the leader is @@ -580,6 +788,30 @@ else # enough for any ovn-nbctl without an explicit --db to confirm against the # wrong database — a `--wait=hv` there can never be acknowledged and burns # its whole timeout instead. + # + # Refuse to demote a node this script previously set up as a management + # node. --management is the only record of the role and is re-asserted on + # every run, so a re-run that forgets it silently takes the cluster's + # database away. The symptom lands three layers off: nothing listens on + # 6641, and vpcd crash-loops on "connection refused" with no hint that this + # script caused it. Seen on mulga-poc at 15,061 restarts. + # + # The signal is /etc/default/ovn-central, not the presence of a database: + # the ovn-central *package* creates DBs on install, so every compute node + # has them and testing for them would refuse every legitimate compute node. + # Only the --management path below writes OVN_CTL_OPTS to that file. + if [ -f /etc/default/ovn-central ] && grep -q '^OVN_CTL_OPTS=' /etc/default/ovn-central 2>/dev/null; then + echo "ERROR: this node was configured as a management node by a previous run" + echo " (/etc/default/ovn-central holds OVN_CTL_OPTS), but --management was" + echo " not given. Re-running without it stops and disables ovn-central and" + echo " takes the control plane down." + echo "" + echo " If this is a management node, re-run with --management." + echo " If it is genuinely being demoted to a compute node:" + echo " sudo rm -f /etc/default/ovn-central" + exit 1 + fi + if systemctl is-enabled ovn-central >/dev/null 2>&1 || systemctl is-active ovn-central >/dev/null 2>&1; then sudo systemctl stop ovn-central ovn-northd ovn-ovsdb-server-nb ovn-ovsdb-server-sb 2>/dev/null || true @@ -808,6 +1040,11 @@ NETWORK # the masquerade rule below is the only host-side NAT state. NAT_TRANSIT_CIDR="100.127.0.0/24" NAT_TRANSIT_GW_CIDR="100.127.0.1/24" + # Same on every node. OVN keeps one MAC binding for the transit + # nexthop cluster-wide, so a random per-node MAC sends every + # chassis's egress to whichever node seeded it; the pair is + # node-local, so one address everywhere is unambiguous. + NAT_TRANSIT_HOST_MAC="02:00:64:7f:00:01" if ! sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE" @@ -822,6 +1059,7 @@ NETWORK else echo " veth pair already exists: spx-nat-host ↔ spx-nat-ovs" fi + sudo ip link set spx-nat-host address "$NAT_TRANSIT_HOST_MAC" sudo ip addr replace "$NAT_TRANSIT_GW_CIDR" dev spx-nat-host # Add the OVS end to br-ext @@ -842,6 +1080,7 @@ NETWORK [NetDev] Name=spx-nat-host Kind=veth +MACAddress=$NAT_TRANSIT_HOST_MAC [Peer] Name=spx-nat-ovs @@ -870,25 +1109,56 @@ NETWORK echo " wrote $NAT_NETDEV + $NAT_NETWORK + $NAT_OVS_NETWORK (transit veth persists on reboot)" # Kernel egress: masquerade the transit /24 out any uplink and - # accept forwarded transit traffic even under FORWARD-policy DROP. - # vpcd re-ensures these on every start; installing here too means - # the wiring is testable before services run. - sudo iptables -t nat -C POSTROUTING -s "$NAT_TRANSIT_CIDR" ! -d "$NAT_TRANSIT_CIDR" \ - -m comment --comment "spinifex-nat-egress" -j MASQUERADE 2>/dev/null || \ - sudo iptables -t nat -A POSTROUTING -s "$NAT_TRANSIT_CIDR" ! -d "$NAT_TRANSIT_CIDR" \ + # accept forwarded transit traffic. vpcd re-ensures these on every + # start; installing here too means the wiring is testable before + # services run. + # + # Insert at the head, never append: Ubuntu ships a catch-all REJECT + # in FORWARD, and an appended ACCEPT sits behind it and never + # matches. Drain first so an already-appended copy is repositioned + # rather than left stranded. Every spec carries our comment, so + # only our own rules are ever deleted. + nat_egress_rule() { # nat_egress_rule + local table="$1" chain="$2"; shift 2 + local i + for i in 1 2 3 4 5 6 7 8; do + sudo iptables -t "$table" -D "$chain" "$@" 2>/dev/null || break + done + sudo iptables -t "$table" -I "$chain" 1 "$@" + } + nat_egress_rule nat POSTROUTING -s "$NAT_TRANSIT_CIDR" ! -d "$NAT_TRANSIT_CIDR" \ -m comment --comment "spinifex-nat-egress" -j MASQUERADE - sudo iptables -C FORWARD -i spx-nat-host -s "$NAT_TRANSIT_CIDR" \ - -m comment --comment "spinifex-nat-egress" -j ACCEPT 2>/dev/null || \ - sudo iptables -A FORWARD -i spx-nat-host -s "$NAT_TRANSIT_CIDR" \ + nat_egress_rule filter FORWARD -i spx-nat-host -s "$NAT_TRANSIT_CIDR" \ -m comment --comment "spinifex-nat-egress" -j ACCEPT - sudo iptables -C FORWARD -o spx-nat-host -m conntrack --ctstate RELATED,ESTABLISHED \ - -m comment --comment "spinifex-nat-egress" -j ACCEPT 2>/dev/null || \ - sudo iptables -A FORWARD -o spx-nat-host -m conntrack --ctstate RELATED,ESTABLISHED \ + nat_egress_rule filter FORWARD -o spx-nat-host -m conntrack --ctstate RELATED,ESTABLISHED \ -m comment --comment "spinifex-nat-egress" -j ACCEPT echo " installed masquerade + forward rules for $NAT_TRANSIT_CIDR (comment: spinifex-nat-egress)" ;; esac + # --- Prove the bridge actually came up --- + # + # ovs-vsctl reports on the database, not the datapath. `add-br` and + # `add-port` exit 0 whenever OVSDB accepts the row, and ofproto can refuse + # the device afterwards for reasons ovs-vsctl never sees — a name already + # taken by a Linux bridge, an interface another bridge already owns. The + # error is recorded per-interface and surfaces only here. Without this scan + # the script reports a successful setup and vpcd fails minutes later with a + # message that points somewhere else entirely. + if [ -n "$WAN_BRIDGE_MODE" ] && command -v ovs-vsctl >/dev/null 2>&1; then + OVS_ERRORS=$(sudo ovs-vsctl --columns=name,error --format=table list Interface 2>/dev/null \ + | awk 'NR>2 && $0 ~ /could not|error/ && $0 !~ /\[\]/' || true) + if [ -n "$OVS_ERRORS" ]; then + echo "" + echo "ERROR: OVS accepted the configuration but the datapath refused it:" + echo "$OVS_ERRORS" | sed 's/^/ /' + echo "" + echo " The bridge exists in OVSDB and cannot forward. Nothing downstream" + echo " will work, and vpcd will fail with an unrelated-looking error." + exit 1 + fi + fi + # --- DHCP: obtain gateway IP for OVN SNAT --- if [ "$EXTERNAL_DHCP" = true ] && [ "$WAN_BRIDGE_MODE" = "nat" ]; then echo "" @@ -1246,9 +1516,16 @@ for unit in openvswitch-switch:/var/run/openvswitch/db.sock \ WAIT_GLOB="${unit#*:}" OVERRIDE_DIR="/etc/systemd/system/${UNIT}.service.d" sudo mkdir -p "$OVERRIDE_DIR" + # `ExecStartPost=-` so a failure here cannot stop the unit. This drop-in goes + # into another package's service, and tightening a socket mode is hardening, + # not a liveness requirement — it must never be the reason openvswitch-switch + # or ovn-controller will not start. Without the dash, removing the helper + # (an uninstall, a partial re-run that has not reached this step yet) takes + # OVS down across the whole host with `Failed at step EXEC`, which reads as + # an OVS fault and is nothing of the sort. sudo tee "$OVERRIDE_DIR/spinifex-perms.conf" >/dev/null <&1) && rc=0 || rc=$? +[ "$rc" = 0 ] && pass "--help exits 0" || fail "--help exited $rc, want 0" + +for flag in --teardown --dry-run --management --nat-uplink; do + case "$help_out" in + *"$flag"*) pass "--help documents $flag" ;; + *) fail "--help does not document $flag" ;; + esac +done + +# --teardown is the flag an operator reaches for when a node is already +# tangled, so the help has to say what it keeps. Somebody who reads it as +# "removes everything" will not run it, and will hand-edit OVS instead. +case "$help_out" in + *"NB/SB databases"*) pass "--help says what --teardown preserves" ;; + *) fail "--help does not say what --teardown preserves" ;; +esac + +# -------------------------------------------------------------------------- +# Teardown, dry run. Every mutation must go through run(), so a dry run can +# report the whole plan without performing any of it. +# -------------------------------------------------------------------------- +out=$("$SETUP" --teardown --dry-run 2>&1) && rc=0 || rc=$? +[ "$rc" = 0 ] && pass "--teardown --dry-run exits 0" || fail "--teardown --dry-run exited $rc, want 0" + +case "$out" in + *"nothing is changed"*) pass "dry run says it changes nothing" ;; + *) fail "dry run does not announce itself" ;; +esac + +# The four groups the teardown exists to remove. A silently skipped group is +# the failure mode that leaves a node still tangled after a clean-looking run. +for step in "Removing veth pairs" "Removing systemd-networkd persistence" \ + "Removing routed-NAT egress rules" "Removing OVS bridges"; do + case "$out" in + *"$step"*) pass "dry run covers: $step" ;; + *) fail "dry run skips: $step" ;; + esac +done + +# The OVS port goes before the veth: deleting one end of a pair deletes the +# peer, and an OVS port left naming a device the kernel no longer has is +# exactly the state a re-run cannot unpick. +port_line=$(printf '%s\n' "$out" | grep -n 'del-port veth-wan-ovs' | head -1 | cut -d: -f1 || true) +link_line=$(printf '%s\n' "$out" | grep -n 'ip link del veth-wan-br' | head -1 | cut -d: -f1 || true) +if [ -n "$port_line" ] && [ -n "$link_line" ] && [ "$port_line" -lt "$link_line" ]; then + pass "the OVS port is removed before its veth" +else + fail "veth teardown order is wrong (port=$port_line link=$link_line)" +fi + +# A dry run that reported a real mutation would be the worst kind of bug here. +if printf '%s\n' "$out" | grep -E '^[[:space:]]+(sudo|ip|ovs-vsctl|iptables) ' >/dev/null 2>&1; then + fail "dry run printed a bare command, not a 'would run:' line" +else + pass "every dry-run mutation is reported, not executed" +fi + +# -------------------------------------------------------------------------- +# Static checks on the teardown body. +# -------------------------------------------------------------------------- +# Comments and echoed prose name the things the teardown deliberately spares, +# so match against the commands alone or every such sentence reads as a hit. +body=$(sed -n '/^teardown() {/,/^}/p' "$SETUP") +body_cmds=$(printf '%s\n' "$body" | grep -vE '^[[:space:]]*(#|echo )' || true) + +# Deleting a Linux bridge would take the host's own addressing with it: on a +# cloud node br-wan is a netplan-owned Linux bridge holding the VNIC's address +# and the default route, and this script never created it. +case "$body" in + *is_linux_bridge*) pass "teardown refuses to delete a Linux bridge" ;; + *) fail "teardown does not check for a Linux bridge before del-br" ;; +esac + +# The databases are the control plane's state. A teardown that took them could +# not be used for its actual purpose, which is re-running this script. +for forbidden in "ovnnb_db" "ovnsb_db" "/etc/spinifex" "rm -rf"; do + case "$body_cmds" in + *"$forbidden"*) fail "teardown touches $forbidden" ;; + *) pass "teardown leaves $forbidden alone" ;; + esac +done + +# -------------------------------------------------------------------------- +# The transit veth MAC. +# -------------------------------------------------------------------------- +# OVN keeps one MAC binding for the transit nexthop across the cluster, so a +# random per-node MAC sends every chassis's egress to whichever node seeded it. +# The address must therefore be a literal the script sets, and it must survive a +# reboot or the node goes dark the next time systemd-networkd recreates the pair. +nat_mode_body=$(sed -n '/^ nat)/,/^ ;;/p' "$SETUP") + +case "$nat_mode_body" in + *'ip link set spx-nat-host address'*) pass "nat mode pins the transit host MAC" ;; + *) fail "nat mode does not set a MAC on spx-nat-host" ;; +esac + +case "$nat_mode_body" in + *'MACAddress=$NAT_TRANSIT_HOST_MAC'*) pass "the netdev unit persists the transit MAC" ;; + *) fail "the netdev unit does not persist the transit MAC" ;; +esac + +# The script and the Go constant must agree: vpcd repairs a veth made before +# this existed, and a disagreement would have the two fighting every pass. +script_mac=$(printf '%s\n' "$nat_mode_body" | sed -n 's/.*NAT_TRANSIT_HOST_MAC="\([^"]*\)".*/\1/p' | head -1) +go_mac=$(sed -n 's/.*NATTransitHostMAC = "\([^"]*\)".*/\1/p' "$SCRIPT_DIR/../spinifex/network/host/routed.go" | head -1) +if [ -n "$script_mac" ] && [ "$script_mac" = "$go_mac" ]; then + pass "the transit MAC matches host.NATTransitHostMAC" +else + fail "transit MAC disagrees: script=$script_mac go=$go_mac" +fi + +if [ "$fails" -ne 0 ]; then + echo " setup-ovn.sh: $fails failure(s)" + exit 1 +fi +echo " setup-ovn.sh ok" diff --git a/scripts/setup.sh b/scripts/setup.sh index df4c26eb4..2c0ff86c7 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -33,6 +33,7 @@ IPSEC_STATE_HELPER="/usr/local/lib/spinifex/spinifex-set-ipsec-state" # the only component that knows every node's resolved planes. FIREWALL_DIR="/etc/spinifex/firewall" FIREWALL_RULES="${FIREWALL_DIR}/spinifex.nft" +# shellcheck disable=SC2034 # part of the firewall dir layout; written by install-node.sh FIREWALL_PEERS="${FIREWALL_DIR}/peers.nft" FIREWALL_LOCAL="${FIREWALL_DIR}/local.nft" FIREWALL_OPEN="${FIREWALL_DIR}/open-ports.nft" @@ -896,6 +897,17 @@ install_files() { info " /usr/local/share/spinifex/setup.sh" fi + # Teardown scripts. An install delivered by `curl | bash` with no way to + # undo it is a gap in the product, not just in our tooling, and node-reset.sh + # ships alongside because uninstall-spx.sh delegates its state teardown to it + # rather than reimplementing the QEMU and JetStream waits. + for teardown in node-reset.sh uninstall-spx.sh; do + if [ -f "$EXTRACT_DIR/$teardown" ]; then + $SUDO install -m 0755 "$EXTRACT_DIR/$teardown" "/usr/local/share/spinifex/$teardown" + info " /usr/local/share/spinifex/$teardown" + fi + done + # microVM kernel + initramfs $SUDO install -d /usr/share/spinifex/microvm if [ -d "$EXTRACT_DIR/microvm" ]; then diff --git a/scripts/terraform/oci-spx/.gitignore b/scripts/terraform/oci-spx/.gitignore new file mode 100644 index 000000000..bf2f62a12 --- /dev/null +++ b/scripts/terraform/oci-spx/.gitignore @@ -0,0 +1,10 @@ +.terraform/ +.venv/ +__pycache__/ +terraform.tfstate +terraform.tfstate.* +*.tfplan +*.auto.tfvars +crash.log +terraform.tfvars +*.pem diff --git a/scripts/terraform/oci-spx/README.md b/scripts/terraform/oci-spx/README.md new file mode 100644 index 000000000..4383f8139 --- /dev/null +++ b/scripts/terraform/oci-spx/README.md @@ -0,0 +1,244 @@ +# oci-spx — Terraform for Spinifex on OCI + +Terraform configuration that builds the OCI infrastructure a Spinifex cluster runs on: a VCN with its gateways and subnets, one to N nodes with two VNICs each, and a data volume per node. No DRG, remote peering, local peering gateway, RZG, or other external network attachment is created. + +**This is a local fork of [`aszynkow/oci_mulgadc`](https://github.com/aszynkow/oci_mulgadc), modified for Spinifex.** It builds infrastructure only; Spinifex itself is installed afterwards by the existing deploy path (`update-nodes.sh`, `install-node.sh`), the same scripts that serve prod and dev-prod. The operator guide that wraps it is [`docs/oci-integration`](../../../docs/oci-integration/README.md); the plan is `docs/development/feature/oci-terraform-provisioning.md` in the mulga monorepo. + +## What This Repository Adds + +**It deploys into a compartment that already exists and creates no identity resources.** `compartment_ocid` is required. Upstream created a compartment, an IAM group and a root policy; those need tenancy-root rights, which is the opposite of the permission boundary this deployment argues for. + +Every resource is prefixed with `deployment_name` (default `spinifex`), so nothing here can collide with or be mistaken for something built by hand in the same compartment. The value becomes the VCN `dns_label`, so it is validated as 1-15 lowercase alphanumerics. + +- `spinifex-vcn` (`10.200.0.0/22`) with public and private subnets, IGW, NAT Gateway, Service Gateway, and separate public/private route tables. +- A public-subnet security list opening the whole VCN to itself and, separately, `node_client_cidr_allow_list` to everything — guest policy belongs to the customer's AWS security groups, not to OCI. +- A counted `VM.Standard.E6.Flex` node deployment using the latest compatible Canonical Ubuntu 26.04 image, with one data volume per node. +- A second VNIC per node in the same public subnet, which is where every Spinifex external address is registered at runtime. + +## How this differs from upstream + +| | Upstream | Here | +| --- | --- | --- | +| Shape | `BM.Standard.E2.64` | `VM.Standard.E6.Flex`, 8 OCPU / 32 GB | +| Node placement | Private subnet, no public IP | **Public subnet, public IP required** | +| Bastion | Standard Bastion in the private subnet | Removed | +| Compartment | Created, with an IAM group and root policy | **Existing**, by OCID; no identity resources | +| Vault | Imports the SSH private key as a secret | Removed | +| Resource names | `mulgadc-*` | `${deployment_name}-*`, default `spinifex-*` | +| VNICs per node | 1 | 2 | +| VCN | `/16` | `/22` | +| Data volume | 20 TiB | 256 GiB | + +## Quick Start + +Prerequisites: Terraform, Python 3, OCI credentials in `~/.oci/config`, and the public and private halves of the SSH key. The Python helper creates and uses this repository's isolated `.venv` and installs [requirements.txt](requirements.txt). + +```bash +cd scripts/terraform/oci-spx + +echo 'compartment_ocid = ""' > terraform.auto.tfvars + +python3 scripts/oci_env.py --ssh-public-key-path -- terraform init + +python3 scripts/oci_env.py --ssh-public-key-path -- terraform plan -out=mulgadc.tfplan +``` + +Review the saved plan, then apply that exact plan: + +```bash +python3 scripts/oci_env.py --ssh-public-key-path -- terraform apply mulgadc.tfplan +``` + +## Config and State + +| Item | Purpose | +| --- | --- | +| `scripts/oci_env.py` | Loads OCI profile inputs, discovers the tenancy home region, exports Terraform variables, and runs Terraform in the repository `.venv`. | +| `requirements.txt` | Python dependency set for the environment helper. | +| `terraform.tfvars.example` | Example non-secret Terraform inputs. Copy it to untracked `terraform.auto.tfvars` for local overrides. | +| `.venv/` | Repository-local virtual environment; generated locally and not committed. | +| `terraform.tfstate` | Terraform state; generated locally unless a remote backend is configured. Do not commit it. | + +The helper defaults to OCI profile `apacanzset03child03`. If it is absent and `apacanzset03child3` is locally configured, the helper explicitly falls back to that profile. Override either setting when needed: + +```bash +python3 scripts/oci_env.py --profile my-profile --region ap-sydney-1 -- terraform plan +``` + +`ssh_public_key_path` is what installs your key on each node. **`ssh_private_key_path` is unused** — the vault that imported it was removed, so Terraform never reads a private key and none can reach the state file. The helper still exports it, which is why the variable is still declared. + +> [!WARNING] +> Never commit the state file, a plan file, or key material. `.gitignore` covers `terraform.tfstate*`, `*.tfplan`, `*.auto.tfvars`, `terraform.tfvars` and `*.pem`. + +## Architecture + +```mermaid +flowchart TB + compartment["Existing compartment
var.compartment_ocid"] + + subgraph region["Selected workload region"] + subgraph vcn["spinifex-vcn · 10.200.0.0/22"] + igw["Internet Gateway
spinifex-igw"] + nat["NAT Gateway
spinifex-nat"] + sgw["Service Gateway
spinifex-sgw"] + public_rt["Public route table
0.0.0.0/0 → IGW"] + private_rt["Private route table
0.0.0.0/0 → NAT
Oracle Services → SGW"] + public_subnet["Public subnet
10.200.0.0/23"] + private_subnet["Private subnet · unused
10.200.2.0/24"] + public_sl["spinifex-public-sl
all from VCN · 22/3000/8443/9999 + ping from internet"] + private_sl["spinifex-private-sl
SSH 22 + ping from VCN only"] + + public_subnet --> public_rt --> igw + private_subnet --> private_rt + private_rt --> nat + private_rt --> sgw + public_sl --> public_subnet + private_sl --> private_subnet + end + + subgraph node_set["Counted node deployment · node_count (default 1)"] + node["VM.Standard.E6.Flex[n]
8 OCPU / 32 GB · Ubuntu 26.04"] + vnic1["Primary VNIC
SSH · advertise · Geneve"] + vnic2["Secondary VNIC
Spinifex external addresses"] + volume["256-GiB data volume[n]
iSCSI → /var/lib/spinifex"] + node --> vnic1 + node --> vnic2 + node --> volume + end + public_subnet --> vnic1 + public_subnet --> vnic2 + end + + compartment --> vcn +``` + +## Repository Layout + +```text +. +├── identity.tf # Lookup of the existing compartment +├── network.tf # VCN, gateways, routes, subnets, and security lists +├── compute.tf # Counted nodes, second VNICs, volumes, and iSCSI attachments +├── cloud-init/ # user_data template and the data-volume mount script +├── provider.tf # Single-region provider +├── variables.tf # Inputs and validations +├── outputs.tf # OCIDs and policy outputs +├── versions.tf # Terraform and provider version constraints +├── terraform.tfvars.example +├── requirements.txt +└── scripts/oci_env.py # Python environment and Terraform command helper +``` + +## Identity and regions + +**Everything is created in one region, in a compartment that already exists.** There is no aliased home-region provider and no identity resource, because creating a compartment, a group or a root policy needs tenancy-root rights. A profile scoped to a compartment fails those calls with `404-NotAuthorizedOrNotFound` against the home-region identity endpoint, which reads like a missing resource rather than a missing permission. + +`region` defaults to `ap-sydney-1`. Spinifex's own region is a separate setting that follows AWS naming and defaults to `ap-southeast-2`; seeing both is correct, not a mismatch. + +## Sizing + +`node_count` defaults to `1` and supports `1` through `25`. Each index produces a matching instance, second VNIC, data volume, and attachment; names include the index, for example `spinifex-node-01`. + +**8 OCPU (16 vCPU) and 32 GB RAM is the minimum recommended for a Spinifex node**, and is what the variables default to. OCI counts an OCPU as a full core, so 8 OCPU is 16 threads. The control plane, predastore, viperblock, NATS and OVN all share that before a single guest boots. + +**Bare metal is the preferred shape** — the whole host, no noisy neighbour, and nested virtualisation that is not a guest of a guest. Until the bare-metal quota in this tenancy is raised we deploy on `VM.Standard.E6.Flex`, which is known-good: nested KVM works on it, measured on `mulga-poc`. + +`shape_config` sizes a flex shape and is **rejected on a fixed bare-metal shape**, so `compute.tf` emits it only when the shape name contains `Flex`. Setting `compute_shape` to a `BM.*` shape is therefore a one-variable change, and `compute_ocpus` and `compute_memory_in_gbs` are then ignored. + +Each data volume defaults to `256` GiB at `120` VPUs/GB, the Ultra High Performance tier. It is a separate data volume: the boot volume comes from the image's default size and is not affected by `data_volume_size_in_gbs`. + +## The data volume and cloud-init + +`cloud-init/mount-data-volume.sh` runs once per boot from `user_data` and leaves the volume mounted at `data_mountpoint`, which defaults to `/var/lib/spinifex`. It mounts Spinifex's data directory itself rather than `/mnt/something` with a redirect, so it is one mount and no Spinifex config points anywhere unusual. + +It is safe to re-run. **It formats only a device with no filesystem on it**, and treats a `blkid` failure it does not recognise as fatal rather than assuming the device is blank. + +**`_netdev` is load bearing.** Without it the mount is attempted before `iscsid` has a session, and the boot either hangs or silently lands the whole stack on the small boot disk. That is what happened on `mulga-poc` before its fstab entry was corrected, and it was only caught because a benchmark looked wrong. The acceptance test is therefore a **reboot**, not a successful first boot — `findmnt /var/lib/spinifex`, never `ls`. + +The script mounts the device by name and never lets `mount(8)` resolve the mount point through fstab: the fstab entry exists for the next boot, not the current one. + +`device` is set on the attachment deliberately. A volume attached without one is named after the boot disk — `mulga-poc`'s symlink is `/dev/oracleoci/oraclevda1 → /dev/sdb1` — whereas asking for the path gives a stable `/dev/oracleoci/oraclevdb`, identical on every node. + +## The WAN bridge + +`cloud-init/setup-wan-bridge.sh` builds `br-wan`, the Linux bridge carrying Spinifex's external datapath, over the **secondary** VNIC: NIC enslaved, bridge MAC cloned from the VNIC, MTU 9000, and table-200 source routing. + +**Source routing is not optional on OCI.** OCI enforces the source address per VNIC, so a reply leaving through the primary from a secondary address is dropped on the wire. Test egress past the subnet — pinging the VCN router proves nothing, because the hypervisor answers it locally. + +**It writes `/etc/netplan/60-spinifex-wan.yaml` and never edits `50-cloud-init.yaml`.** cloud-init owns that file and regenerates it, so edits there are lost. netplan merges every file in the directory, which is why a separate higher-numbered file is the supported way to extend a cloud-init-managed network. + +**The VNIC's MAC and address come from IMDS at runtime, not from Terraform.** They cannot come from Terraform: the second VNIC is attached after the instance reaches RUNNING, so when `user_data` is written it does not exist yet. The script reads `/opc/v2/vnics/` and takes the first entry whose MAC is not that of the interface holding the default route. + +`spinifex-wan-bridge.service` carries it, with `ConditionPathExists=!` on the netplan file: a boot where the VNIC arrived late retries, and once configured the unit stops running because netplan owns the config. Seeing it `inactive` after a reboot is correct, not a failure. + +## The host firewall + +**The Ubuntu OCI image closes the ports a node serves.** Its INPUT chain ends in `REJECT --reject-with icmp-host-prohibited` with only `22`, ICMP, `lo` and ESTABLISHED above it, so `3000`, `8443` and `9999` are unreachable on a fresh instance. `cloud-init/open-service-ports.sh` inserts them **above** the REJECT — appending would put them where they can never match — and persists with `netfilter-persistent save`. + +> [!WARNING] +> **Never flush iptables on an OCI node.** The image's `InstanceServices` chain in OUTPUT is what permits iSCSI to `169.254.2.0/24:3260` and the metadata service. `iptables -F` takes `/var/lib/spinifex` and IMDS with it, and it surfaces later as a mount problem rather than a firewall one. + +FORWARD is left alone too: Spinifex adds its own `spinifex-nat-egress` rules there. + +`node_service_ports` drives the host firewall only, and that is the whole of what it drives. The OCI security list is open, deliberately — see below. + +### Three layers, and only one of them is the customer's + +| Layer | Guards | Who sets it | +| --- | --- | --- | +| OCI security list | The subnet | **Open.** Narrow `node_client_cidr_allow_list` to restrict who reaches the deployment at all | +| OVN security groups | Each guest | The customer, through the AWS API, exactly as on EC2 | +| Host firewall (`open-service-ports.sh`) | The node's own listeners | `node_service_ports` | + +**The OCI security list is open on purpose.** A guest's firewall is its AWS security group, and a customer who opens `443` on theirs expects `443` to work. They cannot see an OCI security list, let alone edit one. Enumerating guest ports there would mean a Terraform change per customer port, and the failure would present as "my security group does nothing" — among the hardest network faults to trace. The intra-VCN rule is kept separate from the internet rule so that narrowing the latter can never cut Geneve, OVN, NATS or predastore between nodes. + +## Network and Access + +**Spinifex nodes sit in the public subnet with public IPs, and this is a requirement rather than a convenience.** A node serves the UI on `3000`, the predastore gate on `8443` and the AWS gateway on `9999` to operators and customers directly; a node in a private subnet cannot do its job. There is no bastion. + +Each node gets two VNICs in that same subnet. The primary carries the host plane — SSH, the node's advertise address, and the Geneve overlay — and the secondary is where the OCI allocator registers every Spinifex external address as a secondary private IP. `VM.Standard.E6.Flex` allows two VNIC attachments in total, so that is the whole budget; Oracle documents up to 64 secondary private IPv4 objects per VNIC, which is the per-node ceiling on Elastic IPs. + +`skip_source_dest_check` is deliberately left off. Routed NAT masquerades to the VNIC's own address and an Elastic IP's private half is itself a registered address on the VNIC, so nothing Spinifex sends is a foreign source. + +The private subnet is **unused today** and kept only so it exists: Spinifex guests live on the OVN overlay and never occupy an OCI subnet. + +`node_client_cidr_allow_list` controls who may reach a node's SSH and service ports from the internet, and defaults to `0.0.0.0/0`. Restrict it before production use: + +```hcl +node_client_cidr_allow_list = ["203.0.113.10/32"] +``` + +## The API user this seeds + +The OCI provider integration needs credentials on each node so Spinifex can allocate addresses at runtime. **Grant that user only the operations Spinifex performs on addresses** — create/get/list/delete private IPs, create/get/update/delete public IPs — and nothing else. It is not a tenancy admin. Broader rights widen the blast radius of a node compromise for no benefit. + +## Common Commands + +Show the current outputs: + +```bash +terraform output +``` + +Run a plan through the Python helper: + +```bash +python3 scripts/oci_env.py --ssh-public-key-path -- terraform plan +``` + +Export the helper's variables into the current shell instead of wrapping every command: + +```bash +eval "$(python3 scripts/oci_env.py --shell --ssh-public-key-path )" +terraform plan +``` + +## Troubleshooting + +- **Missing SSH-path error:** pass `--ssh-public-key-path` to `oci_env.py`. The private-key option is optional and unused. +- **`404-NotAuthorizedOrNotFound` on a create:** this is a permission error wearing a not-found error's clothes. Check the profile can write to `compartment_ocid`. +- **Additional node does not have a data volume:** keep `node_count` within `1`–`25`; the instance, volume, and attachment use the same count. +- **Need to access a node:** SSH to its public IP with the key from `ssh_public_key_path`. `terraform output nodes` lists the names and addresses; `terraform output -raw hosts_file` writes the form `update-nodes.sh --hosts-file` accepts. +- **`shape_config` rejected on apply:** the shape is a fixed bare-metal one. Remove the size variables from your `.auto.tfvars`; they do not apply. +- **Second VNIC refused:** `VM.Standard.E6.Flex` allows two VNIC attachments and both are used here. A shape with a lower `max-vnic-attachments` cannot run Spinifex. +- **`/var/lib/spinifex` is not mounted after boot:** read `/var/log/cloud-init-output.log` for the `[spinifex-data-volume]` lines. If it says the device never appeared, the iSCSI session is the problem — check `iscsiadm -m session` and that the Block Volume Management agent plugin is enabled. Re-running `/usr/local/sbin/spinifex-mount-data-volume /dev/oracleoci/oraclevdb /var/lib/spinifex` is safe. diff --git a/scripts/terraform/oci-spx/cloud-init/mount-data-volume.sh b/scripts/terraform/oci-spx/cloud-init/mount-data-volume.sh new file mode 100755 index 000000000..51b21a6b2 --- /dev/null +++ b/scripts/terraform/oci-spx/cloud-init/mount-data-volume.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Prepares the OCI block volume that backs Spinifex's data directory, and makes +# the mount survive a reboot. Safe to re-run: it never formats a device that +# already holds a filesystem. +set -euo pipefail + +DEVICE="${1:?device path required}" +MOUNTPOINT="${2:?mount point required}" +FSLABEL=spinifex-data + +# Overridable so the fstab logic can be exercised against a scratch file rather +# than the host's real one. Nothing in production sets it. +FSTAB="${FSTAB:-/etc/fstab}" + +log() { printf '[spinifex-data-volume] %s\n' "$*"; } +die() { + log "ERROR: $*" + exit 1 +} + +# The Oracle Cloud Agent logs the iSCSI session in asynchronously, so the device +# is not present when cloud-init first runs. Wait for it instead of racing it. +wait_for_device() { + local i + for i in $(seq 1 120); do + if [ -b "$DEVICE" ]; then + log "$DEVICE appeared after $((i * 5))s" + return 0 + fi + sleep 5 + done + die "$DEVICE did not appear within 600s; check the iSCSI session with 'iscsiadm -m session'" +} + +# blkid reporting a TYPE is the only thing standing between a re-run and an erased +# data volume, so a failure to read the device is fatal rather than "assume blank". +format_if_blank() { + local fstype rc=0 + fstype="$(blkid -o value -s TYPE "$DEVICE" 2>/dev/null)" || rc=$? + + # blkid exits 2 for an unrecognised device, which is the genuinely blank case. + if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then + die "blkid failed on $DEVICE with status $rc; refusing to format" + fi + + if [ -n "$fstype" ]; then + log "$DEVICE already holds a $fstype filesystem; leaving it alone" + return 0 + fi + + log "no filesystem on $DEVICE; creating ext4" + mkfs.ext4 -q -L "$FSLABEL" "$DEVICE" +} + +# _netdev is load bearing. Without it the mount is attempted before iscsid has a +# session and the boot either hangs or silently lands the stack on the boot disk. +write_fstab_entry() { + local uuid entry + uuid="$(blkid -o value -s UUID "$DEVICE")" || die "no UUID on $DEVICE after formatting" + entry="UUID=$uuid $MOUNTPOINT ext4 defaults,_netdev,nofail 0 2" + + if grep -qF "$entry" "$FSTAB"; then + log "fstab already carries the entry for $uuid" + return 0 + fi + + # Drop any earlier entry for this mount point, so a re-created volume with a + # new UUID does not leave a stale line behind it. + sed -i.bak -E "\%^[^#][^[:space:]]*[[:space:]]+${MOUNTPOINT}[[:space:]]%d" "$FSTAB" + printf '%s\n' "$entry" >> "$FSTAB" + log "added $entry" +} + +main() { + wait_for_device + format_if_blank + mkdir -p "$MOUNTPOINT" + write_fstab_entry + + systemctl daemon-reload + + # Mount the device by name rather than letting mount(8) look the mount point up + # in fstab: the fstab entry exists for the next boot, not for this one. + if mountpoint -q "$MOUNTPOINT"; then + log "$MOUNTPOINT is already mounted" + else + mount -o defaults,_netdev,nofail "$DEVICE" "$MOUNTPOINT" + fi + + findmnt --noheadings --output SOURCE,TARGET,FSTYPE,OPTIONS "$MOUNTPOINT" \ + || die "$MOUNTPOINT is not mounted after mount(8) reported success" +} + +main "$@" diff --git a/scripts/terraform/oci-spx/cloud-init/open-service-ports.sh b/scripts/terraform/oci-spx/cloud-init/open-service-ports.sh new file mode 100755 index 000000000..b0ee3fa7a --- /dev/null +++ b/scripts/terraform/oci-spx/cloud-init/open-service-ports.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Opens what a Spinifex node serves: every protocol to its cluster peers, and the +# listed TCP ports to everyone else. The Ubuntu OCI image ships an INPUT chain +# that ends in REJECT with only 22, ICMP, lo and ESTABLISHED above it, so the +# console, the predastore gate and the AWS gateway are closed by default. +# +# Usage: spinifex-open-service-ports [port...] +set -euo pipefail + +log() { printf '[spinifex-service-ports] %s\n' "$*"; } +die() { + log "ERROR: $*" + exit 1 +} + +[ "$#" -gt 1 ] || die "usage: $(basename "$0") [port...]" + +PEER_CIDR="$1" +shift + +# Never flush. The image's InstanceServices chain in OUTPUT is what permits iSCSI +# to 169.254.2.0/24:3260 and the metadata service; clearing it takes the data +# volume and IMDS with it. FORWARD is left alone too -- Spinifex manages it. +insert_position() { + iptables -L INPUT --line-numbers -n \ + | awk '$2 == "REJECT" || $2 == "DROP" { print $1; exit }' +} + +open_port() { + local port="$1" pos + if iptables -C INPUT -p tcp -m state --state NEW -m tcp --dport "$port" -j ACCEPT 2>/dev/null; then + log "port $port is already open" + return 0 + fi + + pos="$(insert_position)" + if [ -n "$pos" ]; then + # Appending would land after the REJECT, where the rule can never match. + iptables -I INPUT "$pos" -p tcp -m state --state NEW -m tcp --dport "$port" -j ACCEPT + log "opened port $port at INPUT position $pos" + else + iptables -A INPUT -p tcp -m state --state NEW -m tcp --dport "$port" -j ACCEPT + log "opened port $port (appended; INPUT has no terminal REJECT)" + fi +} + +# A cluster talks on far more than the ports customers reach: the formation +# server, OVN's NB/SB and raft, NATS, predastore, viperblock and Geneve. Listing +# them would be a list to keep in step with the code, and the subnet holds only +# our own nodes, so the peer CIDR is the boundary -- exactly as the OCI security +# list treats it. Narrowing the internet rule can then never cut the cluster. +open_peers() { + if iptables -C INPUT -s "$PEER_CIDR" -j ACCEPT 2>/dev/null; then + log "peer CIDR $PEER_CIDR is already open" + return 0 + fi + iptables -I INPUT 1 -s "$PEER_CIDR" -m comment --comment spinifex-peers -j ACCEPT + log "opened all protocols from $PEER_CIDR at INPUT position 1" +} + +main() { + local port + open_peers + for port in "$@"; do + open_port "$port" + done + + netfilter-persistent save >/dev/null || die "netfilter-persistent save failed; rules would not survive a reboot" + log "saved to /etc/iptables/rules.v4" +} + +main "$@" diff --git a/scripts/terraform/oci-spx/cloud-init/setup-wan-bridge.sh b/scripts/terraform/oci-spx/cloud-init/setup-wan-bridge.sh new file mode 100755 index 000000000..fbe31fff4 --- /dev/null +++ b/scripts/terraform/oci-spx/cloud-init/setup-wan-bridge.sh @@ -0,0 +1,215 @@ +#!/bin/bash +# Builds the Linux bridge that carries Spinifex's external datapath over the +# secondary OCI VNIC. Discovers that VNIC from the instance metadata service, +# because Terraform attaches it after the instance is already RUNNING and its +# MAC and address are therefore unknown when user-data is written. +set -euo pipefail + +BRIDGE="${BRIDGE:-br-wan}" +MTU="${MTU:-9000}" +IMDS="${IMDS:-http://169.254.169.254/opc/v2}" + +# A file of our own. 50-cloud-init.yaml belongs to cloud-init, so anything added +# there is lost when it re-renders. netplan merges every file in the directory, +# which is also why strip_cloud_init_secondary below has work to do. +NETPLAN_FILE="${NETPLAN_FILE:-/etc/netplan/60-spinifex-wan.yaml}" + +log() { printf '[spinifex-wan-bridge] %s\n' "$*"; } +die() { + log "ERROR: $*" + exit 1 +} + +# The VNIC holding the default route is the host plane and must be left alone. +primary_mac() { + local dev + dev="$(ip -4 route show default | awk '{for (i=1;i "$NETPLAN_FILE" < /etc/cloud/cloud.cfg.d/99-spinifex-network.cfg + chmod 0644 /etc/cloud/cloud.cfg.d/99-spinifex-network.cfg +} + +main() { + wait_for_secondary + + local iface + iface="$(iface_for_mac "$WAN_MAC")" || die "no interface carries $WAN_MAC" + log "bridging $iface into $BRIDGE" + + strip_cloud_init_secondary + + local previous="" + [ -f "$NETPLAN_FILE" ] && previous="$(cat "$NETPLAN_FILE")" + write_netplan "$iface" + + if [ "$STRIPPED" = 0 ] && [ "$previous" = "$(cat "$NETPLAN_FILE")" ] && ip link show "$BRIDGE" >/dev/null 2>&1; then + log "$BRIDGE is already configured and unchanged" + return 0 + fi + + netplan apply + for _ in $(seq 1 30); do + ip -4 addr show "$BRIDGE" 2>/dev/null | grep -q "inet ${WAN_IP}/" && break + sleep 2 + done + + ip -br addr show "$BRIDGE" || die "$BRIDGE did not come up" + ip -4 addr show "$BRIDGE" | grep -q "inet ${WAN_IP}/" \ + || die "$BRIDGE is up but does not hold ${WAN_IP}" + log "table 200: $(ip route show table 200 | tr '\n' ';')" +} + +main "$@" diff --git a/scripts/terraform/oci-spx/cloud-init/user-data.yaml.tftpl b/scripts/terraform/oci-spx/cloud-init/user-data.yaml.tftpl new file mode 100644 index 000000000..cfb407cfa --- /dev/null +++ b/scripts/terraform/oci-spx/cloud-init/user-data.yaml.tftpl @@ -0,0 +1,50 @@ +#cloud-config +# Brings the node to the state a bare-metal Spinifex node is in after imaging. +# Installing Spinifex itself is the deploy path's job, not cloud-init's. + +write_files: + - path: /usr/local/sbin/spinifex-mount-data-volume + owner: root:root + permissions: "0750" + content: | + ${indent(6, mount_script)} + + - path: /usr/local/sbin/spinifex-setup-wan-bridge + owner: root:root + permissions: "0750" + content: | + ${indent(6, wan_bridge_script)} + + - path: /usr/local/sbin/spinifex-open-service-ports + owner: root:root + permissions: "0750" + content: | + ${indent(6, service_ports_script)} + + # The secondary VNIC is attached after the instance reaches RUNNING, so it can + # arrive late. This unit retries on the next boot if the first attempt missed it, + # and stops running once the netplan file it writes exists. + - path: /etc/systemd/system/spinifex-wan-bridge.service + owner: root:root + permissions: "0644" + content: | + [Unit] + Description=Spinifex WAN bridge over the secondary OCI VNIC + Wants=network-online.target + After=network-online.target + ConditionPathExists=!/etc/netplan/60-spinifex-wan.yaml + + [Service] + Type=oneshot + RemainAfterExit=yes + Environment=BRIDGE=${wan_bridge_name} + Environment=MTU=${wan_bridge_mtu} + ExecStart=/usr/local/sbin/spinifex-setup-wan-bridge + + [Install] + WantedBy=multi-user.target + +runcmd: + - [/usr/local/sbin/spinifex-mount-data-volume, "${data_device}", "${data_mountpoint}"] + - [/usr/local/sbin/spinifex-open-service-ports, "${peer_cidr}", ${service_ports}] + - [systemctl, enable, --now, spinifex-wan-bridge.service] diff --git a/scripts/terraform/oci-spx/compute.tf b/scripts/terraform/oci-spx/compute.tf new file mode 100644 index 000000000..cd5659893 --- /dev/null +++ b/scripts/terraform/oci-spx/compute.tf @@ -0,0 +1,139 @@ +data "oci_identity_availability_domains" "available" { + compartment_id = var.tenancy_ocid +} + +# ap-sydney-1 has exactly one availability domain, so spreading nodes across ADs +# is not an option there and fault domains are the whole of the hardware +# separation available. Each one is a different set of racks, power and network +# inside the AD, which is what a Spinifex node needs from its neighbours. +data "oci_identity_fault_domains" "available" { + compartment_id = var.compartment_ocid + availability_domain = local.compute_availability_domain +} + +# Uses the newest Ubuntu 26.04 platform image that OCI advertises for this +# shape and region at plan time, rather than pinning a regional image OCID. +data "oci_core_images" "ubuntu_2604" { + compartment_id = var.tenancy_ocid + operating_system = "Canonical Ubuntu" + operating_system_version = var.ubuntu_version + shape = var.compute_shape + sort_by = "TIMECREATED" + sort_order = "DESC" +} + +locals { + compute_availability_domain = data.oci_identity_availability_domains.available.availability_domains[0].name + ubuntu_image_id = data.oci_core_images.ubuntu_2604.images[0].id + + # shape_config sizes a flex shape and is rejected outright on a fixed bare-metal + # one, so it is emitted only when the shape name says Flex. + shape_is_flex = strcontains(var.compute_shape, "Flex") + + # Round-robin, so three nodes land on three fault domains rather than wherever + # OCI's placement happens to put them. Left to itself OCI spreads on a + # best-effort basis and says nothing about the result, which is not something + # a storage cluster should take on trust. + fault_domains = data.oci_identity_fault_domains.available.fault_domains[*].name + + # Every node has exactly one data volume, so every node names it the same. Asking + # for the path is what makes it stable: a volume attached without one is named + # after the boot disk (mulga-poc has oraclevda1 pointing at sdb1). + data_device = "/dev/oracleoci/oraclevdb" + + # Identical on every node, so a change to it replaces all of them together. + user_data = templatefile("${path.module}/cloud-init/user-data.yaml.tftpl", { + data_device = local.data_device + data_mountpoint = var.data_mountpoint + peer_cidr = var.vcn_cidr + service_ports = join(", ", [for p in var.node_service_ports : "\"${p}\""]) + wan_bridge_name = var.wan_bridge_name + wan_bridge_mtu = var.wan_bridge_mtu + mount_script = file("${path.module}/cloud-init/mount-data-volume.sh") + wan_bridge_script = file("${path.module}/cloud-init/setup-wan-bridge.sh") + service_ports_script = file("${path.module}/cloud-init/open-service-ports.sh") + }) +} + +resource "oci_core_instance" "mulgadc" { + count = var.node_count + availability_domain = local.compute_availability_domain + fault_domain = local.fault_domains[count.index % length(local.fault_domains)] + compartment_id = var.compartment_ocid + display_name = format("%s-node-%02d", var.deployment_name, count.index + 1) + shape = var.compute_shape + + dynamic "shape_config" { + for_each = local.shape_is_flex ? [1] : [] + content { + ocpus = var.compute_ocpus + memory_in_gbs = var.compute_memory_in_gbs + } + } + + metadata = { + ssh_authorized_keys = file(var.ssh_public_key_path) + user_data = base64encode(local.user_data) + } + + # Required for automatic UHP iSCSI multipath setup and login. + agent_config { + plugins_config { + name = "Block Volume Management" + desired_state = "ENABLED" + } + } + + # The host plane: SSH, the node's advertise address, and the Geneve overlay. + # Public, because a node serves the UI, predastore and the AWS gateway itself. + create_vnic_details { + subnet_id = oci_core_subnet.public.id + assign_public_ip = true + hostname_label = format("%snode%02d", var.deployment_name, count.index + 1) + } + + source_details { + source_type = "image" + source_id = local.ubuntu_image_id + } +} + +# Every Spinifex external address lives here as a secondary private IP, written by +# the OCI allocator at runtime. Same subnet as the primary, which mulga-poc proves +# works. E6.Flex allows two VNICs total, so this is the whole remaining budget. +resource "oci_core_vnic_attachment" "mulgadc_external" { + count = var.node_count + instance_id = oci_core_instance.mulgadc[count.index].id + + create_vnic_details { + subnet_id = oci_core_subnet.public.id + display_name = format("%s-node-%02d-external", var.deployment_name, count.index + 1) + assign_public_ip = true + + # Deliberately left at the default. Routed NAT masquerades to this VNIC's own + # address and an EIP's private half is a registered IP here, so nothing we + # send is a foreign source and skipping the check would buy nothing. + skip_source_dest_check = false + } +} + +# Backs /var/lib/spinifex, so viperblock, predastore and JetStream all land on it. +# OCI presents the device; partitioning, formatting and mounting remain an OS task. +resource "oci_core_volume" "mulgadc_data" { + count = var.node_count + availability_domain = local.compute_availability_domain + compartment_id = var.compartment_ocid + display_name = format("%s-node-%02d-data", var.deployment_name, count.index + 1) + size_in_gbs = var.data_volume_size_in_gbs + vpus_per_gb = var.data_volume_vpus_per_gb +} + +resource "oci_core_volume_attachment" "mulgadc_data" { + count = var.node_count + attachment_type = "iscsi" + # Required by OCI for Ultra High Performance iSCSI volume attachments. + device = local.data_device + is_agent_auto_iscsi_login_enabled = true + instance_id = oci_core_instance.mulgadc[count.index].id + volume_id = oci_core_volume.mulgadc_data[count.index].id +} diff --git a/scripts/terraform/oci-spx/eip-move-test.sh b/scripts/terraform/oci-spx/eip-move-test.sh new file mode 100755 index 000000000..7b21543fe --- /dev/null +++ b/scripts/terraform/oci-spx/eip-move-test.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# eip-move-test.sh — prove an EIP follows its guest across an OCI node move. +# +# Drives the three-node Terraform OCI cluster through the exact sequence that +# strands an address today: associate an EIP, stop the guest, force it to start +# on a different node, and assert the address still answers. The decisive check +# is not the ping — it is the OCI API's own record of which VNIC carries the +# private half, because a reachable address on the wrong VNIC is luck. +# +# Step 0b first proves the other half of the distinction on the same guest: the +# auto-assigned address it launched with is gone from the OCI API while it is +# stopped, and a different one comes back. That is the billing claim, asserted +# against OCI rather than against our own record of what we think we released. +# +# Read-write on dev-oci only. Never point this at prod or dev-prod. +set -euo pipefail + +KEY=${KEY:-$HOME/.ssh/oci-poc.pem} +OCI_BIN=${OCI_BIN:-$HOME/bin/oci} +OCI_PROFILE=${OCI_PROFILE:-apacanzset03child03} +SUBNET=${SUBNET:-ocid1.subnet.oc1.ap-sydney-1.aaaaaaaaimrhjgsqgrfg7bnka2d3nbx2n5ss7kurcwneon22rkclqf3ypfqa} + +# Public address -> node name. The control plane is reached through node01. +declare -A NODE_IP=( + [spinifexnode01]=149.118.68.212 + [spinifexnode02]=137.23.19.167 + [spinifexnode03]=137.23.14.3 +) +API_HOST=${API_HOST:-${NODE_IP[spinifexnode01]}} + +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +fail() { printf '\033[31mFAIL: %s\033[0m\n' "$*" >&2; FAILED=1; } +ok() { printf '\033[32mok: %s\033[0m\n' "$*"; } +FAILED=0 + +# Assertions take values rather than an exit status: `cond && ok || fail` runs +# the failure branch whenever ok's own status is non-zero, and passing $? through +# a helper only moves the same trap one line down. +eq() { # eq + if [[ $1 == "$2" ]]; then ok "$3 ($1)"; else fail "$3: got '$1', wanted '$2'"; fi +} +unset_or_none() { # unset_or_none + if [[ -z $1 || $1 == None ]]; then ok "$2"; else fail "$2: got '$1'"; fi +} +is_set() { # is_set + if [[ -n $1 && $1 != None ]]; then ok "$2"; else fail "$2: got '$1'"; fi +} + +on() { # on + local ip=$1; shift + timeout 60 ssh -i "$KEY" -o StrictHostKeyChecking=no -o BatchMode=yes "ubuntu@$ip" "$@" +} + +aws_spx() { # aws_spx — run the AWS CLI from node01 against the cluster + on "$API_HOST" "export AWS_PROFILE=spinifex AWS_CA_BUNDLE=/etc/spinifex/ca.pem; \ + aws --endpoint-url https://localhost:9999 --region ap-southeast-2 $*" +} + +# node_of — which node is the guest on right now +node_of() { + on "$API_HOST" "sudo spx get instances --config /etc/spinifex/spinifex.toml 2>/dev/null" \ + | awk -v id="$1" '$0 ~ id {print}' | grep -oE 'spinifexnode0[0-9]' | head -1 +} + +# private_half_of — the OCI private address this EIP rides the +# wire as. Read off the OVN row that stamps the public address, which is the +# one place the pairing is recorded outside the bindings bucket. Guessing from +# the host's /32 routes would be ambiguous: a node hosting the guest carries the +# auto-assigned address's private half on the same VNIC. +private_half_of() { + on "$API_HOST" "NB=\$(sudo grep -oP '^ovn_nb_addr\\s*=\\s*\"\\K[^\"]+' /etc/spinifex/spinifex.toml); \ + sudo ovn-nbctl --db=\"\$NB\" --bare --columns=external_ip \ + find NAT 'external_ids:spinifex\\:public_ip=\"$1\"'" 2>/dev/null | tr -d '\r' | head -1 +} + +# reserved_ip_ocid — the OCI public IP object behind an address, +# empty when OCI holds none. This is the object the tenancy is billed for, so +# it — not our own KV — is what answers whether a stop really gave it back. +reserved_ip_ocid() { + "$OCI_BIN" --profile "$OCI_PROFILE" network public-ip get \ + --public-ip-address "$1" --query 'data.id' --raw-output 2>/dev/null || true +} + +# state_of — the EC2 state name, for polling +state_of() { + aws_spx "ec2 describe-instances --instance-ids $1 \ + --query 'Reservations[0].Instances[0].State.Name' --output text" 2>/dev/null | tr -d '\r' +} + +# public_ip_of — what DescribeInstances reports, "None" when none +public_ip_of() { + aws_spx "ec2 describe-instances --instance-ids $1 \ + --query 'Reservations[0].Instances[0].PublicIpAddress' --output text" 2>/dev/null | tr -d '\r' +} + +# is_state — a predicate for wait_for +is_state() { [[ $(state_of "$1") == "$2" ]]; } + +# vnic_of_private — the OCI VNIC OCID currently carrying a private IP +vnic_of_private() { + "$OCI_BIN" --profile "$OCI_PROFILE" network private-ip list \ + --subnet-id "$SUBNET" --ip-address "$1" --query 'data[0]."vnic-id"' --raw-output 2>/dev/null +} + +# external_vnic_of — that node's br-wan (secondary) VNIC OCID +external_vnic_of() { + on "${NODE_IP[$1]}" 'curl -s -H "Authorization: Bearer Oracle" -L \ + http://169.254.169.254/opc/v2/vnics/ | python3 -c " +import sys,json +v=json.load(sys.stdin) +print(v[1][\"vnicId\"] if len(v)>1 else \"\")"' +} + +reachable() { ping -c4 -W2 "$1" >/dev/null 2>&1; } + +wait_for() { # wait_for + local secs=$1 desc=$2; shift 2 + local deadline=$(( SECONDS + secs )) + while (( SECONDS < deadline )); do + if "$@"; then ok "$desc"; return 0; fi + sleep 5 + done + fail "$desc (timed out after ${secs}s)" + return 1 +} + +INSTANCE=${INSTANCE:-} +ALLOC="" +cleanup() { + [[ -n $ALLOC ]] || return 0 + say "cleanup: releasing $ALLOC" + local assoc + assoc=$(aws_spx "ec2 describe-addresses --query \ + \"Addresses[?AllocationId=='$ALLOC'].AssociationId\" --output text" 2>/dev/null | tr -d '\r') + [[ -n $assoc && $assoc != None ]] && aws_spx "ec2 disassociate-address --association-id $assoc" >/dev/null 2>&1 + sleep 5 + aws_spx "ec2 release-address --allocation-id $ALLOC" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +[[ -n $INSTANCE ]] || { echo "set INSTANCE=i-... (a running guest with an ENI)" >&2; exit 2; } + +say "0. starting state" +ENI=$(aws_spx "ec2 describe-instances --instance-ids $INSTANCE \ + --query 'Reservations[0].Instances[0].NetworkInterfaces[0].NetworkInterfaceId' --output text" | tr -d '\r') +AUTO=$(public_ip_of "$INSTANCE") +echo "instance $INSTANCE on $(node_of "$INSTANCE"), eni $ENI, auto-assigned ${AUTO:-}" + +# An auto-assigned address is borrowed for as long as the guest runs. On OCI it +# is a reserved public IP object billed to the tenancy, so "released on stop" is +# a billing claim and OCI's own API is the only place it can be proved. +say "0b. an auto-assigned address goes back to OCI on stop" +if [[ -z $AUTO || $AUTO == None ]]; then + echo "skipped: guest has no auto-assigned address" +else + AUTO_OCID=$(reserved_ip_ocid "$AUTO") + is_set "$AUTO_OCID" "OCI holds a public IP object for $AUTO while running" + + aws_spx "ec2 stop-instances --instance-ids $INSTANCE" >/dev/null + wait_for 180 "instance stopped" is_state "$INSTANCE" stopped + unset_or_none "$(public_ip_of "$INSTANCE")" "stopped instance reports no PublicIpAddress" + unset_or_none "$(reserved_ip_ocid "$AUTO")" "OCI no longer bills a public IP for $AUTO" + + aws_spx "ec2 start-instances --instance-ids $INSTANCE" >/dev/null + wait_for 300 "instance running again" is_state "$INSTANCE" running + RESTARTED=$(public_ip_of "$INSTANCE") + is_set "$RESTARTED" "restarted instance has an auto-assigned address" + if [[ $RESTARTED == "$AUTO" ]]; then + fail "restart returned the same address $AUTO — the stop did not release it" + else + ok "restart returned a different address ($AUTO -> $RESTARTED)" + fi + is_set "$(reserved_ip_ocid "$RESTARTED")" "OCI holds a public IP object for $RESTARTED" +fi + +# After the restart above, so the move in step 4 is measured from where the +# guest actually is rather than from where it started the script. +START_NODE=$(node_of "$INSTANCE") +echo "instance now on $START_NODE" + +say "1. allocate and associate an EIP" +ALLOC=$(aws_spx "ec2 allocate-address --query AllocationId --output text" | tr -d '\r') +EIP=$(aws_spx "ec2 describe-addresses --allocation-ids $ALLOC --query 'Addresses[0].PublicIp' --output text" | tr -d '\r') +aws_spx "ec2 associate-address --allocation-id $ALLOC --network-interface-id $ENI" >/dev/null +echo "allocated $ALLOC = $EIP" +wait_for 300 "EIP $EIP answers on $START_NODE" reachable "$EIP" + +PRIVATE=$(private_half_of "$EIP") +echo "private half: ${PRIVATE:-}" +is_set "$PRIVATE" "the EIP resolves to an OCI private address" + +say "2. DescribeInstances must report the EIP, not the auto-assigned address" +eq "$(public_ip_of "$INSTANCE")" "$EIP" "DescribeInstances reports the EIP" + +say "3. stop the guest" +aws_spx "ec2 stop-instances --instance-ids $INSTANCE" >/dev/null +wait_for 180 "instance stopped" is_state "$INSTANCE" stopped + +unset_or_none "$(public_ip_of "$INSTANCE")" "stopped instance reports no PublicIpAddress" + +# The EIP's own object is the customer's and must outlive the stop — the exact +# opposite of what step 0b asserted for the borrowed address. +is_set "$(reserved_ip_ocid "$EIP")" "OCI still holds the reserved public IP for $EIP" + +STILL=$(aws_spx "ec2 describe-addresses --allocation-ids $ALLOC --query 'Addresses[0].AssociationId' --output text" | tr -d '\r') +is_set "$STILL" "EIP stays associated across the stop" + +say "4. force a start on a different node" +on "${NODE_IP[$START_NODE]}" 'sudo systemctl stop spinifex-daemon' +restore_daemon() { on "${NODE_IP[$START_NODE]}" 'sudo systemctl start spinifex-daemon' || true; } +trap 'restore_daemon; cleanup' EXIT + +aws_spx "ec2 start-instances --instance-ids $INSTANCE" >/dev/null +sleep 20 +NEW_NODE=$(node_of "$INSTANCE") +echo "now on $NEW_NODE (was $START_NODE)" +[[ $NEW_NODE != "$START_NODE" ]] || { fail "guest did not move; nothing to prove"; exit 1; } + +say "5. the decisive checks" +wait_for 240 "EIP $EIP answers after the move to $NEW_NODE" reachable "$EIP" + +if [[ -n ${PRIVATE:-} ]]; then + WANT=$(external_vnic_of "$NEW_NODE"); GOT=$(vnic_of_private "$PRIVATE") + eq "$GOT" "$WANT" "OCI carries $PRIVATE on $NEW_NODE's VNIC" +fi + +eq "$(public_ip_of "$INSTANCE")" "$EIP" "still reports the EIP after the move" + +say "6. restore" +restore_daemon +wait_for 120 "$START_NODE prunes its stale route for $PRIVATE" bash -c \ + "! timeout 60 ssh -i $KEY -o StrictHostKeyChecking=no ubuntu@${NODE_IP[$START_NODE]} \ + 'ip route show dev spx-nat-host' | grep -q '${PRIVATE:-__none__}'" + +(( FAILED )) && { echo; echo "RESULT: FAILED"; exit 1; } +echo; echo "RESULT: PASSED" diff --git a/scripts/terraform/oci-spx/identity.tf b/scripts/terraform/oci-spx/identity.tf new file mode 100644 index 000000000..ec38be120 --- /dev/null +++ b/scripts/terraform/oci-spx/identity.tf @@ -0,0 +1,7 @@ +# Upstream created the compartment, an IAM group and a root policy. We do not: +# those are tenancy-root resources, and the API user Spinifex runs as is scoped to +# a compartment on purpose. Deploying into an existing compartment is the whole +# point -- creating one needs rights no node should ever hold. +data "oci_identity_compartment" "mulgadc" { + id = var.compartment_ocid +} diff --git a/scripts/terraform/oci-spx/network.tf b/scripts/terraform/oci-spx/network.tf new file mode 100644 index 000000000..4692b9b21 --- /dev/null +++ b/scripts/terraform/oci-spx/network.tf @@ -0,0 +1,148 @@ +data "oci_core_services" "oracle_services_network" {} + +locals { + all_oracle_services = one([for s in data.oci_core_services.oracle_services_network.services : s if strcontains(s.name, "Services In Oracle Services Network")]) +} + +resource "oci_core_vcn" "mulgadc" { + compartment_id = var.compartment_ocid + display_name = "${var.deployment_name}-vcn" + dns_label = var.deployment_name + cidr_blocks = [var.vcn_cidr] +} +resource "oci_core_internet_gateway" "mulgadc" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-igw" + enabled = true +} +resource "oci_core_nat_gateway" "mulgadc" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-nat" +} +resource "oci_core_service_gateway" "mulgadc" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-sgw" + services { service_id = local.all_oracle_services.id } +} +resource "oci_core_route_table" "public" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-public-rt" + route_rules { + network_entity_id = oci_core_internet_gateway.mulgadc.id + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" + } +} +resource "oci_core_route_table" "private" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-private-rt" + route_rules { + network_entity_id = oci_core_nat_gateway.mulgadc.id + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" + } + route_rules { + network_entity_id = oci_core_service_gateway.mulgadc.id + destination = local.all_oracle_services.cidr_block + destination_type = "SERVICE_CIDR_BLOCK" + } +} + +# Private-subnet ingress is limited to traffic originating inside this VCN. +# Egress remains open so private workloads can use the NAT and Service Gateway. +resource "oci_core_security_list" "private" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-private-sl" + + ingress_security_rules { + protocol = "6" # TCP + source = var.vcn_cidr + + tcp_options { + min = 22 + max = 22 + } + } + + ingress_security_rules { + protocol = "1" # ICMP + source = var.vcn_cidr + + icmp_options { + type = 8 # Echo request (ping) + code = 0 + } + } + + egress_security_rules { + protocol = "all" + destination = "0.0.0.0/0" + } +} +# Deliberately permissive, and that is the design rather than a shortcut. +# +# A Spinifex guest's firewall is its AWS security group, enforced by OVN on the +# node. A customer who opens 443 on their own security group expects 443 to +# work, and they have no way to know an OCI security list exists, let alone to +# edit one. Enumerating ports here would mean every customer port needed a +# Terraform change, and the failure would present as "my security group does +# nothing" — which is the hardest kind of network fault to trace. +# +# So the layers are: OCI security list open, OVN security groups enforce the +# guest policy, and the host firewall (cloud-init/open-service-ports.sh) guards +# the node's own listeners. Narrow `node_client_cidr_allow_list` to restrict +# who can reach the deployment at all; the intra-VCN rule is separate so that +# narrowing it can never cut Geneve, OVN, NATS or predastore between nodes. +resource "oci_core_security_list" "public" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-public-sl" + + ingress_security_rules { + protocol = "all" + source = var.vcn_cidr + } + + dynamic "ingress_security_rules" { + for_each = var.node_client_cidr_allow_list + content { + protocol = "all" + source = ingress_security_rules.value + } + } + + egress_security_rules { + protocol = "all" + destination = "0.0.0.0/0" + } +} + +resource "oci_core_subnet" "public" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-public-subnet" + dns_label = "public" + cidr_block = var.public_subnet_cidr + route_table_id = oci_core_route_table.public.id + prohibit_public_ip_on_vnic = false + security_list_ids = [oci_core_security_list.public.id] +} +resource "oci_core_subnet" "private" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.mulgadc.id + display_name = "${var.deployment_name}-private-subnet" + dns_label = "private" + cidr_block = var.private_subnet_cidr + route_table_id = oci_core_route_table.private.id + prohibit_public_ip_on_vnic = true + # Retain the VCN default list and add the private workload rules. + security_list_ids = [oci_core_vcn.mulgadc.default_security_list_id, oci_core_security_list.private.id] +} + +# Deliberately absent: DRG, remote peering connection, local peering gateway, and RZG attachment. diff --git a/scripts/terraform/oci-spx/outputs.tf b/scripts/terraform/oci-spx/outputs.tf new file mode 100644 index 000000000..51c712520 --- /dev/null +++ b/scripts/terraform/oci-spx/outputs.tf @@ -0,0 +1,26 @@ +output "compartment_name" { value = data.oci_identity_compartment.mulgadc.name } +output "compartment_ocid" { value = var.compartment_ocid } +output "vcn_ocid" { value = oci_core_vcn.mulgadc.id } +output "subnet_ocids" { value = { public = oci_core_subnet.public.id, private = oci_core_subnet.private.id } } +output "gateway_ocids" { value = { internet_gateway = oci_core_internet_gateway.mulgadc.id, nat_gateway = oci_core_nat_gateway.mulgadc.id, service_gateway = oci_core_service_gateway.mulgadc.id } } +output "route_table_ocids" { value = { public = oci_core_route_table.public.id, private = oci_core_route_table.private.id } } +output "security_list_ocids" { value = { public = oci_core_security_list.public.id, private = oci_core_security_list.private.id } } +output "compute_ocids" { value = [for instance in oci_core_instance.mulgadc : instance.id] } +output "data_volume_ocids" { value = [for volume in oci_core_volume.mulgadc_data : volume.id] } + +# What the Spinifex deploy path consumes. The primary VNIC is the host plane, so +# these are the addresses update-nodes.sh and install-node.sh connect to. +output "nodes" { + value = [for instance in oci_core_instance.mulgadc : { + name = instance.display_name + public_ip = instance.public_ip + private_ip = instance.private_ip + fault_domain = instance.fault_domain + }] +} + +# Ready for `update-nodes.sh --hosts-file`, so the handoff between Terraform and +# the deploy scripts is a file rather than a copy-paste. +output "hosts_file" { + value = join("\n", [for instance in oci_core_instance.mulgadc : instance.public_ip]) +} diff --git a/scripts/terraform/oci-spx/provider.tf b/scripts/terraform/oci-spx/provider.tf new file mode 100644 index 000000000..894ac4a8a --- /dev/null +++ b/scripts/terraform/oci-spx/provider.tf @@ -0,0 +1,10 @@ +provider "oci" { + tenancy_ocid = var.tenancy_ocid + user_ocid = var.user_ocid + fingerprint = var.fingerprint + private_key_path = var.private_key_path + region = var.region +} + +# The aliased home-region provider is gone with the identity resources it served. +# Everything here now lives in one region, in an existing compartment. diff --git a/scripts/terraform/oci-spx/requirements.txt b/scripts/terraform/oci-spx/requirements.txt new file mode 100644 index 000000000..9c1d73332 --- /dev/null +++ b/scripts/terraform/oci-spx/requirements.txt @@ -0,0 +1,2 @@ +# Dependencies used only by scripts/oci_env.py in its isolated .venv. +oci>=2.160.0,<3.0.0 diff --git a/scripts/terraform/oci-spx/scripts/oci_env.py b/scripts/terraform/oci-spx/scripts/oci_env.py new file mode 100755 index 000000000..3d20ec8e7 --- /dev/null +++ b/scripts/terraform/oci-spx/scripts/oci_env.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Prepare OCI-profile Terraform environment variables in this repo's .venv. + +Examples: + eval "$(python3 scripts/oci_env.py --shell)" + python3 scripts/oci_env.py -- terraform plan -out=mulgadc.tfplan +""" + +from __future__ import annotations + +import argparse +import configparser +import os +from pathlib import Path +import shlex +import subprocess +import sys +import venv + + +REPOSITORY = Path(__file__).resolve().parent.parent +VENV_PYTHON = REPOSITORY / ".venv" / "bin" / "python" +REQUIREMENTS = REPOSITORY / "requirements.txt" +REQUESTED_PROFILE = "apacanzset03child03" +FALLBACK_PROFILE = "apacanzset03child3" + + +def ensure_venv() -> None: + """Create, populate, then re-execute from this repository's virtualenv.""" + if Path(sys.executable).resolve() == VENV_PYTHON.resolve(): + return + if not VENV_PYTHON.exists(): + venv.EnvBuilder(with_pip=True).create(VENV_PYTHON.parent.parent) + subprocess.check_call([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "-r", str(REQUIREMENTS)]) + os.execv(str(VENV_PYTHON), [str(VENV_PYTHON), str(Path(__file__).resolve()), *sys.argv[1:]]) + + +def load_profile(config_path: Path, requested_profile: str) -> tuple[str, dict[str, str]]: + parser = configparser.RawConfigParser() + parser.read(config_path) + profile = requested_profile + if not parser.has_section(profile): + if profile == REQUESTED_PROFILE and parser.has_section(FALLBACK_PROFILE): + print( + f"Profile {REQUESTED_PROFILE} not found; using {FALLBACK_PROFILE}.", + file=sys.stderr, + ) + profile = FALLBACK_PROFILE + else: + raise ValueError(f"OCI profile {profile!r} was not found in {config_path}") + + # Validate through the OCI SDK installed in the dedicated virtualenv. + import oci + + values = oci.config.from_file(str(config_path), profile) + required = ("tenancy", "user", "fingerprint", "key_file", "region") + missing = [key for key in required if not values.get(key)] + if missing: + raise ValueError(f"Missing OCI profile values: {', '.join(missing)}") + return profile, values + + +def tenancy_home_region(values: dict[str, str]) -> str: + """Read the home region from OCI rather than assuming the profile region.""" + import oci + + subscriptions = oci.identity.IdentityClient(values).list_region_subscriptions(values["tenancy"]).data + home = next((item.region_name for item in subscriptions if item.is_home_region), None) + if not home: + raise ValueError("OCI did not return a tenancy home region") + return home + + +def terraform_environment( + profile: str, + values: dict[str, str], + region_override: str | None, + ssh_public_key_path: str | None, + ssh_private_key_path: str | None, +) -> dict[str, str]: + environment = { + "OCI_CLI_PROFILE": profile, + "TF_VAR_tenancy_ocid": values["tenancy"], + "TF_VAR_user_ocid": values["user"], + "TF_VAR_fingerprint": values["fingerprint"], + "TF_VAR_private_key_path": values["key_file"], + "TF_VAR_region": region_override or values["region"], + "TF_VAR_home_region": tenancy_home_region(values), + } + if ssh_public_key_path: + key_path = Path(ssh_public_key_path).expanduser().resolve() + if not key_path.is_file(): + raise FileNotFoundError(f"SSH public key was not found at {key_path}") + environment["TF_VAR_ssh_public_key_path"] = str(key_path) + if ssh_private_key_path: + key_path = Path(ssh_private_key_path).expanduser().resolve() + if not key_path.is_file(): + raise FileNotFoundError(f"SSH private key was not found at {key_path}") + environment["TF_VAR_ssh_private_key_path"] = str(key_path) + return environment + + +def main() -> int: + arg_parser = argparse.ArgumentParser() + arg_parser.add_argument("--profile", default=REQUESTED_PROFILE) + arg_parser.add_argument("--region") + arg_parser.add_argument( + "--ssh-public-key-path", + help="Optional public-key path exported as TF_VAR_ssh_public_key_path.", + ) + arg_parser.add_argument( + "--ssh-private-key-path", + help="Optional private-key path exported as TF_VAR_ssh_private_key_path.", + ) + arg_parser.add_argument("--shell", action="store_true", help="Print shell exports for eval.") + arg_parser.add_argument("command", nargs=argparse.REMAINDER, help="Command to run with the environment; prefix with --.") + args = arg_parser.parse_args() + if args.shell and args.command: + arg_parser.error("--shell and a command cannot be used together") + + ensure_venv() + config_path = Path(os.environ.get("OCI_CONFIG_FILE", Path.home() / ".oci" / "config")) + if not config_path.is_file(): + raise FileNotFoundError(f"OCI configuration was not found at {config_path}") + profile, values = load_profile(config_path, args.profile) + environment = terraform_environment( + profile, + values, + args.region, + args.ssh_public_key_path, + args.ssh_private_key_path, + ) + + if args.shell: + for key, value in environment.items(): + print(f"export {key}={shlex.quote(value)}") + return 0 + + if not args.command: + print(f"Loaded OCI profile {profile} for region {environment['TF_VAR_region']}; virtualenv: {VENV_PYTHON.parent.parent}") + return 0 + + command = args.command[1:] if args.command[0] == "--" else args.command + if not command: + raise ValueError("A command is required after --") + child_environment = os.environ.copy() + child_environment.update(environment) + print(f"Running with OCI profile {profile} in {environment['TF_VAR_region']}: {' '.join(map(shlex.quote, command))}") + return subprocess.call(command, cwd=REPOSITORY, env=child_environment) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (FileNotFoundError, ValueError, subprocess.CalledProcessError) as error: + print(f"Error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/terraform/oci-spx/terraform.tfvars.example b/scripts/terraform/oci-spx/terraform.tfvars.example new file mode 100644 index 000000000..130524240 --- /dev/null +++ b/scripts/terraform/oci-spx/terraform.tfvars.example @@ -0,0 +1,12 @@ +# Example non-secret overrides. Copy to the untracked terraform.auto.tfvars. + +# Restrict who may reach a node's SSH and Spinifex service ports from the internet. +node_client_cidr_allow_list = ["203.0.113.10/32"] + +# 1 for the single-node replica, 3 for a cluster. +node_count = 1 + +# Bare metal is preferred; this is what we test on until the quota is raised. +compute_shape = "VM.Standard.E6.Flex" +compute_ocpus = 8 +compute_memory_in_gbs = 32 diff --git a/scripts/terraform/oci-spx/variables.tf b/scripts/terraform/oci-spx/variables.tf new file mode 100644 index 000000000..7dd8580ab --- /dev/null +++ b/scripts/terraform/oci-spx/variables.tf @@ -0,0 +1,167 @@ +variable "tenancy_ocid" { type = string } +variable "user_ocid" { type = string } +variable "fingerprint" { type = string } +variable "private_key_path" { type = string } +# OCI's own region name. Spinifex's region is a separate setting that follows AWS +# naming, so ap-sydney-1 here beside ap-southeast-2 in spinifex.toml is correct. +variable "region" { + type = string + default = "ap-sydney-1" +} +# Unused. Kept declared because scripts/oci_env.py discovers and exports it, and +# there are no identity resources left that need the home-region endpoint. +variable "home_region" { + description = "Unused. The tenancy home region, exported by the Python environment tool." + type = string + default = null + nullable = true +} +# Required, and deliberately an OCID rather than a name. This deploys into a +# compartment that already exists; creating one needs tenancy-root rights that the +# API user Spinifex runs as must not have, and a name lookup can match a deleted one. +variable "compartment_ocid" { + description = "OCID of the existing compartment to deploy into." + type = string +} + +# Prefixes every resource this config owns, so nothing it creates can be confused +# with -- or collide with -- anything built by hand in the same compartment. The +# VCN dns_label takes this value directly, so keep it short and alphanumeric. +variable "deployment_name" { + description = "Name prefix for every resource this configuration creates." + type = string + default = "spinifex" + + validation { + condition = can(regex("^[a-z][a-z0-9]{0,14}$", var.deployment_name)) + error_message = "deployment_name must be 1-15 lowercase alphanumeric characters starting with a letter, because it is used as the VCN dns_label." + } +} +variable "vcn_cidr" { + description = "VCN address space. A /22 leaves room for the per-node secondary private IPs Spinifex allocates for external addresses." + type = string + default = "10.200.0.0/22" +} +variable "public_subnet_cidr" { + description = "Subnet every Spinifex node sits in, on both of its VNICs. Nodes serve the UI, predastore and the AWS gateway directly, so they cannot live in a private subnet." + type = string + default = "10.200.0.0/23" +} +variable "private_subnet_cidr" { + description = "Unused by Spinifex today; guests live on the OVN overlay, not in an OCI subnet. Kept so the subnet exists if something later needs it." + type = string + default = "10.200.2.0/24" +} + +variable "compute_shape" { + description = "Compute shape for each Spinifex node. Bare metal is preferred; VM.Standard.E6.Flex is what we test on until the bare-metal quota is raised." + type = string + default = "VM.Standard.E6.Flex" +} + +# A flex shape carries no size of its own, so these are not tuning knobs -- without +# them the shape is unbuildable. Both are the minimum recommended for a node. +variable "compute_ocpus" { + description = "OCPUs per node on a flex shape. OCI counts an OCPU as a full core, so 8 OCPU is 16 vCPU. Ignored on a fixed bare-metal shape." + type = number + default = 8 + + validation { + condition = var.compute_ocpus >= 8 + error_message = "compute_ocpus must be at least 8 (16 vCPU): the minimum recommended for a Spinifex node." + } +} + +variable "compute_memory_in_gbs" { + description = "Memory per node in GiB on a flex shape. Ignored on a fixed bare-metal shape." + type = number + default = 32 + + validation { + condition = var.compute_memory_in_gbs >= 32 + error_message = "compute_memory_in_gbs must be at least 32: the minimum recommended for a Spinifex node." + } +} + +variable "node_count" { + description = "Number of Spinifex nodes, each with its own data volume. 1 for the single-node replica, 3 for a cluster." + type = number + default = 1 + + validation { + condition = var.node_count >= 1 && var.node_count <= 25 && floor(var.node_count) == var.node_count + error_message = "node_count must be a whole number between 1 and 25." + } +} + +variable "ubuntu_version" { + description = "Canonical Ubuntu platform image version." + type = string + default = "26.04" +} + +variable "data_volume_size_in_gbs" { + description = "Size in GiB of the data block volume that backs /var/lib/spinifex on each node." + type = number + default = 256 +} + +variable "data_mountpoint" { + description = "Where cloud-init mounts the data volume. Spinifex's data directory itself, so one mount rather than a mount plus a redirect, and no config points anywhere unusual." + type = string + default = "/var/lib/spinifex" +} + +variable "data_volume_vpus_per_gb" { + description = "Performance tier for each node's data volume in VPUs per GB." + type = number + default = 120 + + validation { + condition = var.data_volume_vpus_per_gb >= 0 && var.data_volume_vpus_per_gb <= 120 + error_message = "data_volume_vpus_per_gb must be between 0 and 120." + } +} + +variable "ssh_public_key_path" { + description = "Absolute path to the SSH public key installed on the Ubuntu instance." + type = string + default = null + nullable = true +} + +# Nothing consumes this. It stays declared because scripts/oci_env.py exports it, +# and an undeclared TF_VAR_ is easier to misread as a broken helper than as unused. +variable "ssh_private_key_path" { + description = "Unused. The vault that imported the private key was removed; no resource needs it." + type = string + default = null + nullable = true +} + +variable "node_client_cidr_allow_list" { + description = "CIDR blocks permitted to reach a node's SSH and Spinifex service ports from the internet." + type = list(string) + default = ["0.0.0.0/0"] +} + +# The host firewall only. The OCI security list is deliberately open, because a +# guest's policy is its AWS security group and OVN enforces that on the node — +# see the comment on oci_core_security_list.public. +variable "node_service_ports" { + description = "TCP ports the node itself serves: console, predastore gate, AWS gateway. SSH is always opened separately." + type = list(number) + default = [3000, 8443, 9999] +} + +variable "wan_bridge_name" { + description = "Linux bridge built over the secondary VNIC to carry the external datapath." + type = string + default = "br-wan" +} + +variable "wan_bridge_mtu" { + description = "MTU for the WAN bridge and its member interface. The OCI VCN carries 9000." + type = number + default = 9000 +} diff --git a/scripts/terraform/oci-spx/versions.tf b/scripts/terraform/oci-spx/versions.tf new file mode 100644 index 000000000..cec28cd1d --- /dev/null +++ b/scripts/terraform/oci-spx/versions.tf @@ -0,0 +1,4 @@ +terraform { + required_version = ">= 1.5.0" + required_providers { oci = { source = "oracle/oci", version = ">= 6.0.0" } } +} diff --git a/scripts/uninstall-spx.sh b/scripts/uninstall-spx.sh new file mode 100755 index 000000000..40e2ca788 --- /dev/null +++ b/scripts/uninstall-spx.sh @@ -0,0 +1,533 @@ +#!/bin/bash +# uninstall-spx.sh — remove a Spinifex install from this node. +# +# The inverse of setup.sh. Where node-reset.sh clears *state* and leaves the +# node installed and ready to re-init, this removes the *software*: the binary, +# the units, the service users, the helper scripts and the configuration setup.sh +# wrote. State teardown is delegated to node-reset.sh rather than reimplemented. +# +# Usage: +# sudo /usr/local/share/spinifex/uninstall-spx.sh [options] +# +# Options: +# --purge-data Also remove /var/lib/spinifex — volumes, S3 objects, JetStream. +# Off by default: uninstalling software should not destroy data. +# --purge-deps Also apt-remove the packages setup.sh installed. Names them and +# confirms first, even under --yes. Off by default because a +# curl|bash install lands on hosts that were already using them. +# --yes Skip the confirmation prompt. Does NOT cover --purge-data, +# --purge-deps, or the multi-node refusal. +# --dry-run Print the whole plan, touch nothing, exit 0. +# +# Environment: +# SPX_PURGE_CONFIRM=destroy Unattended confirmation for --purge-data only. +# SPX_ETC_DIR Override /etc/spinifex. Exists so the multi-node +# refusal can be tested against a fixture config. +# +# WHAT THIS REMOVES +# +# /usr/local/bin/spx, the nbdkit plugin, /usr/local/{share,lib}/spinifex, +# /usr/share/spinifex, every spinifex systemd unit and slice, the eight +# spinifex-* service users, the sudoers grant, the sysctl/logrotate/chrony/udev +# drop-ins, the host firewall policy and its loaded nft table, the OVS bridges +# and OVN databases, and the Spinifex CA from the host trust store. +# +# WHAT THIS LEAVES ALONE +# +# /var/lib/spinifex unless --purge-data. Every apt package unless --purge-deps. +# /swapfile, which setup.sh may have created but cannot be told apart from one +# the host already had. Anything the operator configured around us: netplan, +# /etc/iptables/rules.v4, hand-written bridges. +set -euo pipefail + +ETC_DIR="${SPX_ETC_DIR:-/etc/spinifex}" +DATA_DIR=/var/lib/spinifex +LOG_DIR=/var/log/spinifex +RUN_DIR=/run/spinifex +CONFIG_FILE="$ETC_DIR/spinifex.toml" +SHARE_DIR=/usr/local/share/spinifex +NODE_RESET="$SHARE_DIR/node-reset.sh" + +PURGE_DATA=false +PURGE_DEPS=false +ASSUME_YES=false +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --purge-data) PURGE_DATA=true ;; + --purge-deps) PURGE_DEPS=true ;; + --yes | -y) ASSUME_YES=true ;; + --dry-run) DRY_RUN=true ;; + -h | --help) + # Header comment block, minus the shebang. Taken up to the first + # non-comment line rather than by line number, which drifts. + sed -n '2,/^[^#]/p' "$0" | sed '/^[^#]/d; s/^# \?//' + exit 0 + ;; + *) + echo "ERROR: unknown option: $1" >&2 + exit 1 + ;; + esac + shift +done + +log() { echo "[uninstall-spx] $*"; } +run() { + if $DRY_RUN; then + echo " would run: $*" + return 0 + fi + "$@" +} +# Paths are removed through one helper so --dry-run reports them uniformly and +# an absent path is never an error. Uninstalling a partial install is the +# ordinary case, not an exception. +# +# The existence test goes through sudo, not a bare [ -e ]. /etc/sudoers.d is +# 0750 root:root, so an unprivileged test reports "absent" for a file that is +# plainly there and the dry run then under-reports what it is about to delete — +# which is the one thing a dry run must never do. +exists() { sudo test -e "$1" || sudo test -L "$1"; } +rm_path() { + for p in "$@"; do + exists "$p" || continue + if $DRY_RUN; then + echo " would remove: $p" + else + sudo rm -rf "$p" + fi + done +} + +# --------------------------------------------------------------------------- +# 1. Refuse on a multi-node cluster that is still up. +# +# Uninstalling one node of a running cluster strands its guests and leaves the +# survivors reconciling against a chassis that will never answer. This is a +# structural check, not a prompt, so --yes does not cover it: a flag meaning +# "don't ask me" must not also mean "ignore a safety property". +# --------------------------------------------------------------------------- +NODES=() +if [ -r "$CONFIG_FILE" ]; then + mapfile -t NODES < <(sudo grep -oP '^\[nodes\.\K[^.\]]+' "$CONFIG_FILE" 2>/dev/null | sort -u || true) +fi + +if [ "${#NODES[@]}" -gt 1 ]; then + cat >&2 </dev/null 2>&1; then + log " version: $(spx version 2>/dev/null | head -1 || echo unknown)" +fi +if [ -d "$DATA_DIR" ]; then + size=$(sudo du -sh "$DATA_DIR" 2>/dev/null | cut -f1 || true) + instances=$(sudo find "$DATA_DIR/instances" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l || true) + volumes=$(sudo find "$DATA_DIR/volumes" -maxdepth 1 -mindepth 1 2>/dev/null | wc -l || true) + if $PURGE_DATA; then + log " --purge-data: DESTROYING $instances instance(s), $volumes volume(s), ${size:-unknown} under $DATA_DIR" + else + log " keeping $DATA_DIR ($instances instance(s), $volumes volume(s), ${size:-unknown}) — pass --purge-data to remove it" + fi +fi +$PURGE_DEPS && log " --purge-deps: apt packages will be removed (confirmed separately below)" + +if ! $ASSUME_YES && ! $DRY_RUN; then + read -r -p "Remove the Spinifex install from this node? Type 'uninstall' to continue: " reply + [ "$reply" = "uninstall" ] || { log "aborted"; exit 1; } +fi + +# --purge-data is a second decision with a different blast radius, so it takes +# its own confirmation even when --yes was given for the uninstall itself. +# +# SPX_PURGE_CONFIRM=destroy is the unattended escape, matching node-reset.sh's +# SPX_RESET_CONFIRM and install-node.sh's SPX_WIPE_CONFIRM. It is deliberately a +# separate variable from --yes: a caller that wants no prompts still has to name +# the data destruction specifically, so no existing automation acquires it by +# adding --yes. +if $PURGE_DATA && ! $DRY_RUN; then + if [ "${SPX_PURGE_CONFIRM:-}" = "destroy" ]; then + log "SPX_PURGE_CONFIRM=destroy: proceeding without a prompt" + else + read -r -p "Also destroy every volume and object under $DATA_DIR? Type 'destroy' to continue: " reply + [ "$reply" = "destroy" ] || { log "aborted"; exit 1; } + fi +fi + +# --------------------------------------------------------------------------- +# 3. Stop everything, and confirm it stopped. +# +# `systemctl stop spinifex.target` returns once the target is inactive, which is +# not the same as its services being stopped: spinifex-shutdown.service is +# ordered After= the storage services, so their stop jobs queue behind its +# ExecStop. Removing the binary out from under a running service leaves it +# executing an unlinked image, and removing the users out from under one is +# worse. node-reset.sh and update-nodes.sh both carry this same wait. +# --------------------------------------------------------------------------- +log "stopping services" +run sudo systemctl stop spinifex.target 2>/dev/null || true +if ! $DRY_RUN; then + for unit in spinifex-predastore spinifex-viperblock spinifex-nats; do + if systemctl is-active --quiet "$unit"; then + sudo systemctl stop "$unit" || true + fi + done + elapsed=0 + while [ -n "$(pgrep -x spx || true)" ]; do + if [ "$elapsed" -ge 120 ]; then + echo "ERROR: spx still running after 120s:" >&2 + pgrep -ax spx >&2 || true + echo " Removing the install under a live service leaves it running an" >&2 + echo " unlinked binary. Stop them manually and re-run." >&2 + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) + done +fi + +# --------------------------------------------------------------------------- +# 4. Delegate state teardown to node-reset.sh. +# +# It handles the guests, the OVS bridges, the OVN databases and the JetStream +# store, and it carries correctness that is invisible in its code: QEMU matched +# as ^qemu-system because comm truncates at 15 characters, a wait for QEMU to +# exit rather than for the signal, and the firewall-mode stash that stops an +# interrupted run leaving the node unarmed. A second copy of that would drift, +# and the failure mode of the drift is data loss. +# +# --keep-data unless we are purging: node-reset's default is to wipe, ours is +# not, and the flag is how the two defaults are reconciled. +# --------------------------------------------------------------------------- +if [ -x "$NODE_RESET" ]; then + log "clearing node state via $NODE_RESET" + reset_args=(--yes) + $PURGE_DATA || reset_args+=(--keep-data) + $DRY_RUN && reset_args+=(--dry-run) + if ! sudo "$NODE_RESET" "${reset_args[@]}"; then + echo "ERROR: node-reset.sh failed; stopping before removing the install." >&2 + echo " The node still has its software, so it can be repaired and retried." >&2 + exit 1 + fi +else + log "NOTE: $NODE_RESET is not installed — skipping the state teardown" + log " guests, OVS bridges and OVN databases are left as they are" +fi + +# --------------------------------------------------------------------------- +# 5. Unload the host firewall before deleting the files behind it. +# +# The nft ruleset lives in the kernel; removing spinifex.nft does not unload it. +# A node that uninstalls Spinifex and keeps a default-deny input policy with no +# file behind it is unreachable after the next reboot and unexplainable to +# whoever inherits it. +# +# Only our own table and our own marked rules, never a global flush: setup.sh +# creates exactly `table inet spinifex_filter`, and vpcd writes iptables rules +# carrying the comments below. Anything else on this host belongs to someone +# else. +# --------------------------------------------------------------------------- +log "unloading the host firewall policy" +if command -v nft >/dev/null 2>&1; then + if sudo nft list table inet spinifex_filter >/dev/null 2>&1; then + run sudo nft delete table inet spinifex_filter + else + log " no inet spinifex_filter table loaded" + fi +fi +if command -v iptables-save >/dev/null 2>&1 && ! $DRY_RUN; then + for marker in spinifex-nat-egress spinifex-eip-ingress spinifex-imds spinifex-imds-remap; do + while read -r table rule; do + [ -n "$rule" ] || continue + # shellcheck disable=SC2086 + sudo iptables -t "$table" -D $rule 2>/dev/null || true + done < <(sudo iptables-save | awk -v m="$marker" ' + /^\*/ { t = substr($0, 2) } + $0 ~ m && /^-A/ { sub(/^-A /, ""); print t, $0 }') + done +elif $DRY_RUN; then + echo " would remove iptables rules commented spinifex-nat-egress / spinifex-eip-ingress / spinifex-imds / spinifex-imds-remap" +fi + +# --------------------------------------------------------------------------- +# 6. systemd units. +# --------------------------------------------------------------------------- +log "removing systemd units" +if ! $DRY_RUN; then + sudo systemctl disable --now spinifex.target 2>/dev/null || true + for u in /etc/systemd/system/spinifex-*.service /etc/systemd/system/spinifex-*.timer; do + [ -e "$u" ] || continue + sudo systemctl disable --now "$(basename "$u")" 2>/dev/null || true + done +fi +rm_path /etc/systemd/system/spinifex-*.service \ + /etc/systemd/system/spinifex-*.timer \ + /etc/systemd/system/spinifex.target \ + /etc/systemd/system/spinifex.slice \ + /etc/systemd/system/spinifex-system.slice \ + /etc/systemd/system/spinifex-guests.slice \ + /etc/systemd/system/system.slice.d/spinifex-reserve.conf + +# Drop-ins we wrote into *other people's* units. These are the dangerous ones: +# they are not in a spinifex-named path, so a namespace glob misses them, and +# each holds an ExecStartPost pointing at a helper under /usr/local/lib/spinifex +# that the step below removes. Left behind, openvswitch-switch fails to start +# with `Failed at step EXEC ... ovs-socket-perms.sh: No such file or directory` +# — the uninstall breaks OVS for whatever else on the host was using it. Found +# exactly that way on the first dev-oci round trip. +log "removing drop-ins written into other units" +rm_path /etc/systemd/system/openvswitch-switch.service.d/spinifex-perms.conf \ + /etc/systemd/system/ovn-controller.service.d/spinifex-perms.conf \ + /etc/systemd/system/ovn-controller.service.d/log-level.conf \ + /etc/systemd/system/openvswitch-ipsec.service.d/spinifex-perms.conf \ + /etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf +# Only if we emptied them — another package may own the same override dir. +for d in openvswitch-switch ovn-controller openvswitch-ipsec ovn-northd; do + dir="/etc/systemd/system/${d}.service.d" + if sudo test -d "$dir" && [ -z "$(sudo ls -A "$dir" 2>/dev/null)" ]; then + rm_path "$dir" + fi +done + +run sudo systemctl daemon-reload +run sudo systemctl reset-failed 2>/dev/null || true + +# The units we just un-broke may be sitting in a failed state from an earlier +# attempt. Restarting them here is not housekeeping: openvswitch-switch failing +# is how the operator discovers the drop-in problem, and leaving it failed after +# an uninstall that claims success is the same silent failure one step later. +if ! $DRY_RUN; then + for u in openvswitch-switch ovn-controller; do + if systemctl is-failed --quiet "$u" 2>/dev/null; then + log " restarting $u, which our drop-in had failed" + sudo systemctl reset-failed "$u" 2>/dev/null || true + sudo systemctl restart "$u" 2>/dev/null || \ + log " NOTE: $u did not restart — check it by hand" + fi + done +fi + +# strongswan's charon profile: a site-override file setup-ovn.sh writes whole +# (not appends to) granting reads on /etc/spinifex, which is about to not exist. +if sudo test -f /etc/apparmor.d/local/usr.lib.ipsec.charon; then + log "removing the AppArmor grant for /etc/spinifex" + rm_path /etc/apparmor.d/local/usr.lib.ipsec.charon + run sudo apparmor_parser -r /etc/apparmor.d/usr.lib.ipsec.charon 2>/dev/null || true +fi + +# --------------------------------------------------------------------------- +# 7. systemd-networkd units written by setup-ovn.sh. +# +# Without these the veth and transit-veth pairs are recreated on the next boot, +# long after the software that used them is gone. Removed by glob because the +# veth-wan set (15/16) and the routed-NAT set (17/18) are both ours and a +# hardcoded list has already missed one of them once. +# --------------------------------------------------------------------------- +if compgen -G "/etc/systemd/network/1[5-8]-spinifex-*" >/dev/null; then + log "removing systemd-networkd units" + rm_path /etc/systemd/network/1[5-8]-spinifex-* + run sudo networkctl reload 2>/dev/null || true +fi + +# --------------------------------------------------------------------------- +# 8. Installed files. +# +# The nbdkit plugin does not live under any spinifex-owned path — it goes into +# nbdkit's own plugin directory — so it is the one file here that is easy to +# leave behind. Ask nbdkit where that is rather than guessing, and fall back to +# the multiarch paths setup.sh uses when nbdkit has already been removed. +# --------------------------------------------------------------------------- +log "removing installed files" +PLUGINDIR=$(nbdkit --dump-config 2>/dev/null | sed -n 's/^plugindir=//p' || true) +if [ -z "$PLUGINDIR" ]; then + for d in /usr/lib/x86_64-linux-gnu/nbdkit/plugins /usr/lib/aarch64-linux-gnu/nbdkit/plugins; do + [ -d "$d" ] && PLUGINDIR="$d" && break + done +fi +[ -n "$PLUGINDIR" ] && rm_path "$PLUGINDIR/nbdkit-viperblock-plugin.so" + +rm_path /usr/local/bin/spx \ + /usr/local/share/spinifex \ + /usr/local/lib/spinifex \ + /usr/share/spinifex + +# --------------------------------------------------------------------------- +# 9. Drop-ins. Globbed, not listed: 99-spinifex-vpc.conf is written at runtime +# by the daemon through spinifex-set-endpoint-sysctl and does not appear +# anywhere in setup.sh, so an inventory built by reading the installer alone +# leaves behind the drop-in that changes forwarding and rp_filter. +# --------------------------------------------------------------------------- +log "removing configuration drop-ins" +rm_path /etc/sysctl.d/*spinifex* \ + /etc/sudoers.d/spinifex-network \ + /etc/chrony/conf.d/spinifex.conf \ + /etc/logrotate.d/spinifex \ + /etc/udev/rules.d/*spinifex* +run sudo sysctl --system >/dev/null 2>&1 || true + +# --------------------------------------------------------------------------- +# 10. Spinifex directories. +# +# node-reset.sh emptied these of files; what is left is the structure setup.sh +# built, which is install state and goes now. /var/lib/spinifex only under +# --purge-data — and even then, only if it is not a mountpoint, because on an +# ISO-installed node several of these are separate ZFS datasets and a +# mountpoint cannot be removed. +# --------------------------------------------------------------------------- +log "removing spinifex directories" +rm_path "$ETC_DIR" "$LOG_DIR" "$RUN_DIR" +if $PURGE_DATA; then + if mountpoint -q "$DATA_DIR" 2>/dev/null; then + log "NOTE: $DATA_DIR is a mountpoint — emptying it rather than removing it" + if ! $DRY_RUN; then + sudo find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true + else + echo " would empty: $DATA_DIR" + fi + else + rm_path "$DATA_DIR" + fi +else + log " keeping $DATA_DIR" +fi + +# --------------------------------------------------------------------------- +# 11. Service users and groups. +# +# After the directories, because a user's home directory is under them and +# userdel warns about a missing home. Order here is cosmetic, not correctness. +# --------------------------------------------------------------------------- +log "removing service users and groups" +for svc in nats gw daemon storage northstar viperblock vpcd ui; do + user="spinifex-${svc}" + if id -u "$user" >/dev/null 2>&1; then + run sudo userdel "$user" 2>/dev/null || log " NOTE: could not remove user $user" + fi +done +for grp in spinifex-viperblock spinifex; do + if getent group "$grp" >/dev/null 2>&1; then + run sudo groupdel "$grp" 2>/dev/null || log " NOTE: could not remove group $grp (still has members?)" + fi +done + +# --------------------------------------------------------------------------- +# 12. The CA in the host trust store. Leaving it means the host trusts a CA +# whose key no longer exists anywhere. +# --------------------------------------------------------------------------- +if [ -f /usr/local/share/ca-certificates/spinifex-ca.crt ]; then + log "removing the Spinifex CA from the host trust store" + rm_path /usr/local/share/ca-certificates/spinifex-ca.crt + run sudo update-ca-certificates >/dev/null 2>&1 || true +fi + +# --------------------------------------------------------------------------- +# 13. Swap is reported, never removed. +# +# setup_swap creates an 8G /swapfile with the fstab line `/swapfile none swap sw +# 0 0` — which is byte-identical to the line a host would already have if it +# provisioned its own swap the ordinary way. There is no marker, so there is no +# way to tell ours from theirs, and removing a host's swap because we might have +# made it is not a trade worth taking. Say it is there and let the operator +# decide. +# --------------------------------------------------------------------------- +if [ -f /swapfile ] && grep -q '^/swapfile ' /etc/fstab 2>/dev/null; then + log "NOTE: /swapfile is left in place. setup.sh may have created it, but the" + log " fstab line is indistinguishable from a host's own. To remove it:" + log " sudo swapoff /swapfile && sudo rm -f /swapfile" + log " then delete its line from /etc/fstab" +fi + +# --------------------------------------------------------------------------- +# 14. apt packages, only when asked, and only after naming them. +# +# This is the one irreversible thing in the script. On a dedicated node these +# are ours; on the host a curl|bash install actually lands on they are someone +# else's, and openvswitch/libvirt/qemu/chrony going away underneath a running +# workload is a far worse outcome than a few megabytes left behind. Confirmed +# separately even under --yes, and never autoremove, which is unbounded. +# --------------------------------------------------------------------------- +if $PURGE_DEPS; then + PACKAGES="nbdkit qemu-utils gdisk ovmf less libvirt-daemon-system libvirt-clients +pciutils jq ethtool netcat-openbsd unzip xz-utils +ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon +chrony nftables" + log "--purge-deps: these packages would be removed:" + # shellcheck disable=SC2086 + for p in $PACKAGES; do echo " $p"; done + log " NOT removed: curl, wget, file, iproute2, dhcpcd-base, qemu-system-* — too" + log " widely depended on to remove from a host we do not own." + if $DRY_RUN; then + echo " would run: apt-get remove -y " + else + read -r -p "Remove these packages? Type 'remove' to continue: " reply + if [ "$reply" = "remove" ]; then + # shellcheck disable=SC2086 + sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y $PACKAGES || \ + log "NOTE: apt-get remove reported errors; check the output above" + else + log " skipped" + fi + fi +fi + +# --------------------------------------------------------------------------- +# 15. Verify. An uninstaller that reports success over a surviving unit file is +# worse than one that refuses, because the next install silently inherits it. +# --------------------------------------------------------------------------- +if ! $DRY_RUN; then + log "verifying" + LEFT=() + for p in /usr/local/bin/spx /usr/local/share/spinifex /usr/local/lib/spinifex \ + /usr/share/spinifex "$ETC_DIR" "$LOG_DIR" \ + /etc/sudoers.d/spinifex-network /etc/logrotate.d/spinifex \ + /etc/chrony/conf.d/spinifex.conf; do + exists "$p" && LEFT+=("$p") + done + for p in /etc/systemd/system/spinifex-*.service /etc/systemd/system/spinifex*.slice \ + /etc/systemd/system/spinifex.target /etc/sysctl.d/*spinifex* \ + /etc/systemd/network/1[5-8]-spinifex-* \ + /etc/systemd/system/*.service.d/spinifex-perms.conf \ + /etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf \ + /etc/systemd/system/ovn-controller.service.d/log-level.conf; do + exists "$p" && LEFT+=("$p") + done + # A unit we broke and could not restart is a failed uninstall, not a wart. + for u in openvswitch-switch ovn-controller; do + systemctl is-failed --quiet "$u" 2>/dev/null && LEFT+=("$u.service is failed") + done + for svc in nats gw daemon storage northstar viperblock vpcd ui; do + id -u "spinifex-${svc}" >/dev/null 2>&1 && LEFT+=("user spinifex-${svc}") + done + if command -v nft >/dev/null 2>&1 && sudo nft list table inet spinifex_filter >/dev/null 2>&1; then + LEFT+=("nft table inet spinifex_filter") + fi + $PURGE_DATA && [ -e "$DATA_DIR" ] && ! mountpoint -q "$DATA_DIR" 2>/dev/null && LEFT+=("$DATA_DIR") + + if [ "${#LEFT[@]}" -gt 0 ]; then + echo "ERROR: the uninstall did not complete. Still present:" >&2 + printf ' %s\n' "${LEFT[@]}" >&2 + exit 1 + fi +fi + +log "done — Spinifex is removed from this node" +$PURGE_DATA || { [ -d "$DATA_DIR" ] && log " $DATA_DIR was kept (--purge-data removes it)"; } +$PURGE_DEPS || log " apt dependencies were kept (--purge-deps removes them)" +exit 0 diff --git a/scripts/uninstall-spx_test.sh b/scripts/uninstall-spx_test.sh new file mode 100755 index 000000000..779715bf2 --- /dev/null +++ b/scripts/uninstall-spx_test.sh @@ -0,0 +1,156 @@ +#!/bin/sh +# uninstall-spx_test.sh — tests for the shipped uninstaller. +# +# Everything here runs against the refusal and help paths, which return before +# the script touches the host. Nothing in this file may reach a path that +# removes anything: the script under test is the one that deletes the install, +# so a test that ran it for real would uninstall the machine running CI. +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +UNINSTALL="$SCRIPT_DIR/uninstall-spx.sh" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +fails=0 +pass() { echo " ok - $1"; } +fail() { echo " FAIL - $1"; fails=$((fails + 1)); } + +check() { # check + if [ "$2" = 0 ]; then pass "$1"; else fail "$1"; fi +} + +echo "-- uninstall-spx.sh" + +# -------------------------------------------------------------------------- +# Help and argument parsing. +# -------------------------------------------------------------------------- +help_out=$("$UNINSTALL" --help 2>&1) && rc=0 || rc=$? +check "--help exits 0" "$rc" + +# Every flag the parser accepts must appear in the help text. This is what +# catches a flag added to the case arms and never documented, which on a +# destructive script is how an operator finds --purge-data by accident. +for flag in --purge-data --purge-deps --yes --dry-run; do + case "$help_out" in + *"$flag"*) pass "--help documents $flag" ;; + *) fail "--help does not document $flag" ;; + esac +done + +# The header is printed up to the first non-comment line, so a truncated block +# means the range drifted. The last paragraph is the one that used to be cut. +case "$help_out" in + *"netplan"*) pass "--help prints the whole header" ;; + *) fail "--help output is truncated before the end of the header" ;; +esac + +case "$help_out" in + *'#'*) fail "--help leaves comment markers in the output" ;; + *) pass "--help strips comment markers" ;; +esac + +"$UNINSTALL" --not-a-real-flag >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$? +[ "$rc" = 1 ] && pass "unknown option exits 1" || fail "unknown option exited $rc, want 1" +grep -q "unknown option" "$TMP/err" \ + && pass "unknown option explains itself on stderr" \ + || fail "unknown option wrote nothing useful to stderr" + +# -------------------------------------------------------------------------- +# The multi-node refusal. +# +# Uninstalling one node of a running cluster strands its guests, so this is a +# structural refusal rather than a prompt. The property under test is that +# --yes does not cover it — a flag meaning "don't ask me" must not also mean +# "ignore a safety property". +# -------------------------------------------------------------------------- +mkcfg() { # mkcfg ... + dir="$1" + shift + mkdir -p "$dir" + { + echo '[network]' + for n in "$@"; do + echo "[nodes.$n]" + echo "advertise = \"10.0.0.1\"" + echo "[nodes.$n.daemon]" + done + } >"$dir/spinifex.toml" +} + +mkcfg "$TMP/three" node1 node2 node3 +mkcfg "$TMP/one" node1 + +for args in "" "--yes" "--yes --dry-run"; do + # shellcheck disable=SC2086 + SPX_ETC_DIR="$TMP/three" "$UNINSTALL" $args >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$? + label=${args:-"(no flags)"} + [ "$rc" = 1 ] \ + && pass "three-node config refuses with $label" \ + || fail "three-node config with $label exited $rc, want 1" + grep -q "3-node cluster" "$TMP/err" \ + && pass "refusal names the node count for $label" \ + || fail "refusal for $label did not name the node count" + grep -q "spx admin cluster shutdown" "$TMP/err" \ + && pass "refusal for $label says how to proceed" \ + || fail "refusal for $label does not say how to proceed" +done + +grep -q "node1 node2 node3" "$TMP/err" \ + && pass "refusal lists the configured nodes" \ + || fail "refusal does not list the configured nodes" + +# A single-node config must get past the refusal. Asserted by what it is NOT: +# reaching the refusal message. --dry-run keeps it from touching the host, and +# the exit status is left alone because the dry run's later steps depend on +# whatever is installed on the machine running this test. +SPX_ETC_DIR="$TMP/one" "$UNINSTALL" --dry-run >"$TMP/out" 2>"$TMP/err" || true +grep -q "node cluster" "$TMP/err" \ + && fail "single-node config was refused as multi-node" \ + || pass "single-node config is not refused" + +# -------------------------------------------------------------------------- +# The dry run's promise. +# +# --dry-run must print the plan and touch nothing, which holds only while every +# mutating command goes through run() or rm_path(). A bare `sudo rm` or `sudo +# systemctl` added later would ignore the flag and delete for real during what +# the operator asked to be a rehearsal. Guarded blocks are allowed, so lines +# inside an `if ! $DRY_RUN` are not the concern — an unguarded one is. +# -------------------------------------------------------------------------- +stray=$(awk ' + # The helpers themselves are where the guarded mutation is meant to live. + /^(run|rm_path)\(\) \{/ { helper = 1 } + helper { if (/^\}$/) helper = 0; next } + /if ! \$DRY_RUN/ || /\$DRY_RUN && / { guard = 1 } + /^fi$/ || /^else$/ { guard = 0 } + guard { next } + /^[[:space:]]*#/ { next } + /^[[:space:]]*(run|exists|rm_path)[[:space:]]/ { next } + /^[[:space:]]*sudo[[:space:]]+(rm|userdel|groupdel|systemctl[[:space:]]+(disable|stop|restart))/ { + printf "%d: %s\n", NR, $0 + } +' "$UNINSTALL") + +if [ -n "$stray" ]; then + fail "mutating commands outside run()/rm_path() and outside a \$DRY_RUN guard:" + echo "$stray" | sed 's/^/ /' +else + pass "every mutation goes through run()/rm_path() or a \$DRY_RUN guard" +fi + +# The nft teardown must name our own table. A global flush would take the +# host's other rules with it. +grep -q 'nft delete table inet spinifex_filter' "$UNINSTALL" \ + && pass "nft teardown deletes only inet spinifex_filter" \ + || fail "nft teardown does not delete inet spinifex_filter by name" +grep -qE 'nft[[:space:]]+(-[a-z][[:space:]]+)*flush ruleset' "$UNINSTALL" \ + && fail "nft teardown flushes the whole ruleset" \ + || pass "nft teardown never flushes the whole ruleset" + +# -------------------------------------------------------------------------- +if [ "$fails" -gt 0 ]; then + echo " $fails check(s) failed" + exit 1 +fi +echo " uninstall-spx.sh ok" diff --git a/spinifex/admin/admin.go b/spinifex/admin/admin.go index ce3489734..353af8d21 100644 --- a/spinifex/admin/admin.go +++ b/spinifex/admin/admin.go @@ -148,21 +148,23 @@ type ConfigSettings struct { } // PoolData is one [[network.external_pools]] block rendered into spinifex.toml. +// The json tags are load bearing: formation sends these to every joiner, so a +// renamed field silently drops a pool rather than failing to compile. type PoolData struct { - Name string // Pool name (e.g., "wan", "nat-transit") - Source string // IP source: "static" or "dhcp" - BindBridge string // Linux bridge / interface for upstream DORA (source=dhcp only) - Start string // First IP in range (static only) - End string // Last IP in range (static only) - Gateway string // WAN gateway IP - GatewayIP string // Explicit SNAT IP (overrides default of first IP in range) - PrefixLen int // Subnet prefix length (default 24) - DNSServers []string // DNS servers for VM DHCP (auto-detected from host) - DHCPMAC string // DHCP client MAC strategy: "derived" (default) or "interface" + Name string `json:"name"` // Pool name (e.g., "wan", "nat-transit") + Source string `json:"source,omitempty"` // IP source: "static" or "dhcp" + BindBridge string `json:"bind_bridge,omitempty"` // Linux bridge / interface for upstream DORA (source=dhcp only) + Start string `json:"start,omitempty"` // First IP in range (static only) + End string `json:"end,omitempty"` // Last IP in range (static only) + Gateway string `json:"gateway,omitempty"` // WAN gateway IP + GatewayIP string `json:"gateway_ip,omitempty"` // Explicit SNAT IP (overrides default of first IP in range) + PrefixLen int `json:"prefix_len,omitempty"` // Subnet prefix length (default 24) + DNSServers []string `json:"dns_servers,omitempty"` // DNS servers for VM DHCP (auto-detected from host) + DHCPMAC string `json:"dhcp_mac,omitempty"` // DHCP client MAC strategy: "derived" (default) or "interface" // GwLrpRangeStart/End reserve gateway-LRP IPs for OVN routers. When empty // the allocator auto-derives the top 16 host IPs of the pool subnet. - GwLrpRangeStart string - GwLrpRangeEnd string + GwLrpRangeStart string `json:"gw_lrp_range_start,omitempty"` + GwLrpRangeEnd string `json:"gw_lrp_range_end,omitempty"` } // PredastoreNodeConfig describes a single Predastore node for multi-node config generation. diff --git a/spinifex/cloud/oci/apiclient_test.go b/spinifex/cloud/oci/apiclient_test.go new file mode 100644 index 000000000..d91fba956 --- /dev/null +++ b/spinifex/cloud/oci/apiclient_test.go @@ -0,0 +1,219 @@ +package oci_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "testing" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" +) + +// capture records what the SDK actually put on the wire, which is the point of +// these tests: the request shape is the part of client.go that can be wrong in +// a way no amount of reading catches. +type capture struct { + method string + path string + query url.Values + body map[string]any +} + +// testClient wires the real SDK-backed client to a handler. The signing key is +// generated per test — the SDK refuses to build a client without one, and a +// throwaway key is enough because nothing verifies the signature here. +func testClient(t *testing.T, h http.HandlerFunc) (oci.Client, *[]capture) { + t.Helper() + var seen []capture + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := capture{method: r.Method, path: r.URL.Path, query: r.URL.Query()} + if b, _ := io.ReadAll(r.Body); len(b) > 0 { + _ = json.Unmarshal(b, &c.body) + } + seen = append(seen, c) + w.Header().Set("Content-Type", "application/json") + h(w, r) + })) + t.Cleanup(srv.Close) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + pemKey := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + provider := common.NewRawConfigurationProvider( + "ocid1.tenancy.oc1..t", "ocid1.user.oc1..u", "ap-sydney-1", + "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99", string(pemKey), nil) + + c, err := oci.NewTestClient(provider, strings.TrimPrefix(srv.URL, "https://"), srv.Client()) + require.NoError(t, err) + return c, &seen +} + +// OCI pages list responses and a VNIC can hold 64 addresses. A client that +// ignored opc-next-page would report a partial VNIC to reconcile, which then +// treats the addresses it did not see as absent — and drops live bindings. +func TestListPrivateIPsFollowsEveryPage(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("page") { + case "": + w.Header().Set("opc-next-page", "page2") + _, _ = w.Write([]byte(`[{"id":"p1","ipAddress":"10.200.0.100","vnicId":"v1"}]`)) + case "page2": + w.Header().Set("opc-next-page", "page3") + _, _ = w.Write([]byte(`[{"id":"p2","ipAddress":"10.200.0.101","vnicId":"v1"}]`)) + default: + _, _ = w.Write([]byte(`[{"id":"p3","ipAddress":"10.200.0.102","vnicId":"v1"}]`)) + } + }) + + got, err := c.ListPrivateIPs(context.Background(), "v1") + require.NoError(t, err) + require.Len(t, got, 3, "pagination stopped early — reconcile would treat the rest as absent") + assert.Equal(t, "10.200.0.102", got[2].Address.String()) + assert.Len(t, *seen, 3) + assert.Equal(t, "v1", (*seen)[0].query.Get("vnicId")) +} + +func TestListPrivateIPsStopsOnASinglePage(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"id":"p1","ipAddress":"10.200.0.100","vnicId":"v1"}]`)) + }) + + got, err := c.ListPrivateIPs(context.Background(), "v1") + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Len(t, *seen, 1, "a response with no next page must not be re-fetched") +} + +// The bug this guards against is silent: a nil *string is omitted from the JSON +// body, so OCI would read the update as "change nothing", return 200, and leave +// the public IP attached. The address would keep billing and keep pointing at a +// released instance. +func TestDetachPublicIPSendsAnExplicitEmptyPrivateIP(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"q1","ipAddress":"203.0.113.10","lifecycleState":"UNASSIGNED"}`)) + }) + + got, err := c.DetachPublicIP(context.Background(), "q1") + require.NoError(t, err) + assert.False(t, got.IsAssigned()) + + require.Len(t, *seen, 1) + body := (*seen)[0].body + raw, present := body["privateIpId"] + require.True(t, present, "privateIpId was omitted, so OCI reads the update as a no-op and the address stays attached") + assert.Empty(t, raw) +} + +func TestAttachPublicIPNamesTheTargetPrivateIP(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"q1","privateIpId":"p2","lifecycleState":"ASSIGNED"}`)) + }) + + got, err := c.AttachPublicIP(context.Background(), "q1", "p2") + require.NoError(t, err) + assert.True(t, got.IsAssigned()) + assert.Equal(t, "p2", (*seen)[0].body["privateIpId"]) +} + +// An EIP has to outlive its association, so the create must ask for RESERVED. +// An EPHEMERAL public IP would vanish when the instance released it, which is +// not what AWS AllocateAddress promises. +func TestCreatePublicIPAsksForAReservedLifetime(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"q1","ipAddress":"203.0.113.10","lifetime":"RESERVED","lifecycleState":"ASSIGNING"}`)) + }) + + got, err := c.CreatePublicIP(context.Background(), "comp1", "p1", "spinifex-eipalloc-1") + require.NoError(t, err) + assert.Equal(t, oci.LifetimeReserved, got.Lifetime) + assert.False(t, got.IsAssigned(), "a create returns ASSIGNING; the caller must poll") + + body := (*seen)[0].body + assert.Equal(t, "RESERVED", body["lifetime"]) + assert.Equal(t, "comp1", body["compartmentId"]) + assert.Equal(t, "p1", body["privateIpId"]) +} + +// OCI picks the address unless we name one, and the allocator relies on that: +// naming one would mean guessing an address OCI may already have handed out. +func TestAssignPrivateIPOmitsTheAddressWhenWeDoNotChooseIt(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"p1","ipAddress":"10.200.0.183","vnicId":"v1"}`)) + }) + + got, err := c.AssignPrivateIP(context.Background(), "v1", netip.Addr{}, "spinifex-eipalloc-1") + require.NoError(t, err) + assert.Equal(t, "10.200.0.183", got.Address.String()) + + body := (*seen)[0].body + assert.Equal(t, "v1", body["vnicId"]) + assert.Equal(t, "spinifex-eipalloc-1", body["displayName"]) + _, present := body["ipAddress"] + assert.False(t, present, "an omitted address is how OCI is asked to choose") +} + +// A 404 from the real transport has to reach the caller as ErrNotFound, or +// reconcile cannot tell "already gone" from "the API is broken" and would stop +// mid-pass rather than finishing the cleanup. +func TestServiceErrorsFromTheWireClassify(t *testing.T) { + c, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"code":"NotAuthorizedOrNotFound","message":"Authorization failed or requested resource not found."}`)) + }) + + _, err := c.GetPublicIP(context.Background(), "q1") + require.Error(t, err) + assert.ErrorIs(t, err, oci.ErrNotFound) +} + +func TestQuotaErrorsFromTheWireClassify(t *testing.T) { + c, _ := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"LimitExceeded","message":"public IP limit reached"}`)) + }) + + _, err := c.CreatePublicIP(context.Background(), "comp1", "p1", "spinifex-1") + require.Error(t, err) + assert.ErrorIs(t, err, oci.ErrLimitExceeded) +} + +func TestDeleteAndUnassignReachTheRightRoutes(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + require.NoError(t, c.DeletePublicIP(context.Background(), "q1")) + require.NoError(t, c.UnassignPrivateIP(context.Background(), "p1")) + + require.Len(t, *seen, 2) + assert.Equal(t, http.MethodDelete, (*seen)[0].method) + assert.Contains(t, (*seen)[0].path, "q1") + assert.Equal(t, http.MethodDelete, (*seen)[1].method) + assert.Contains(t, (*seen)[1].path, "p1") +} + +func TestGetPublicIPByPrivateIPIDAsksByPrivateIP(t *testing.T) { + c, seen := testClient(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":"q1","privateIpId":"p1","lifecycleState":"ASSIGNED"}`)) + }) + + got, err := c.GetPublicIPByPrivateIPID(context.Background(), "p1") + require.NoError(t, err) + assert.Equal(t, "q1", got.ID) + assert.Equal(t, "p1", (*seen)[0].body["privateIpId"]) +} diff --git a/spinifex/cloud/oci/client.go b/spinifex/cloud/oci/client.go new file mode 100644 index 000000000..a3e4836bd --- /dev/null +++ b/spinifex/cloud/oci/client.go @@ -0,0 +1,335 @@ +package oci + +import ( + "context" + "fmt" + "net/netip" + "os" + "path/filepath" + "strings" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/common/auth" + "github.com/oracle/oci-go-sdk/v65/core" +) + +// Client is the narrow surface the allocator needs. It is expressed in this +// package's own types rather than the SDK's so the SDK stays confined to this +// file and the fake in fake.go is a map rather than a mock framework. +type Client interface { + // AssignPrivateIP creates a secondary private IP on vnicID. addr may be + // the zero Addr to let OCI choose from the subnet, which is the normal + // case — we do not pick addresses, OCI does. + AssignPrivateIP(ctx context.Context, vnicID string, addr netip.Addr, displayName string) (PrivateIP, error) + // MovePrivateIP reassigns an existing secondary private IP to vnicID, + // which must be in the same subnet. One call, applied server-side, and the + // OCID survives it — so the reserved public IP attached to this private IP + // stays attached and the customer's address never changes. Detaching and + // re-creating would lose both. + MovePrivateIP(ctx context.Context, privateIPID, vnicID string) (PrivateIP, error) + // UnassignPrivateIP deletes a secondary private IP. Deleting a primary is + // refused by OCI, which is the behaviour we want. + UnassignPrivateIP(ctx context.Context, privateIPID string) error + GetPrivateIP(ctx context.Context, privateIPID string) (PrivateIP, error) + ListPrivateIPs(ctx context.Context, vnicID string) ([]PrivateIP, error) + + // CreatePublicIP creates a RESERVED public IP in compartmentID and, when + // privateIPID is non-empty, attaches it in the same call. + CreatePublicIP(ctx context.Context, compartmentID, privateIPID, displayName string) (PublicIP, error) + // AttachPublicIP moves an existing reserved public IP onto privateIPID. + AttachPublicIP(ctx context.Context, publicIPID, privateIPID string) (PublicIP, error) + // DetachPublicIP unassigns without deleting, so the address is kept. + DetachPublicIP(ctx context.Context, publicIPID string) (PublicIP, error) + DeletePublicIP(ctx context.Context, publicIPID string) error + GetPublicIP(ctx context.Context, publicIPID string) (PublicIP, error) + // GetPublicIPByPrivateIPID returns the public IP attached to a private IP, + // or ErrNotFound when it has none. + GetPublicIPByPrivateIPID(ctx context.Context, privateIPID string) (PublicIP, error) +} + +// apiClient is the real implementation over the OCI Go SDK. +type apiClient struct { + net core.VirtualNetworkClient +} + +var _ Client = (*apiClient)(nil) + +// NewInstancePrincipalClient authenticates as the instance itself, using the +// certificate the metadata service serves at /opc/v2/identity/cert.pem. No key +// material on disk and nothing to rotate, which is why this is the only +// constructor offered for the daemon path — a node that can reach its own +// metadata service is already proving it is the instance it claims to be. +func NewInstancePrincipalClient() (Client, error) { + provider, err := auth.InstancePrincipalConfigurationProvider() + if err != nil { + return nil, fmt.Errorf("oci instance principal: %w", err) + } + c, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider) + if err != nil { + return nil, fmt.Errorf("oci virtual network client: %w", err) + } + return &apiClient{net: c}, nil +} + +// DefaultConfigFile is where the OCI CLI and SDK both look for API-key +// credentials, and where a v1 OCI deployment is expected to provide them. +const DefaultConfigFile = "~/.oci/config" + +// DefaultConfigProfile is the profile name used when none is configured. The +// CLI's own default, so a config file written by `oci setup config` works +// unmodified. +const DefaultConfigProfile = "DEFAULT" + +// NewConfigFileClient authenticates from an API-key config file — the same +// ~/.oci/config the oci CLI uses. +// +// This is the v1 path, deliberately in preference to instance principal. It +// needs no dynamic group and no IAM policy written by a tenancy admin, and it +// never reads the instance metadata service, which matters because Spinifex's +// own per-instance IMDS endpoints claim 169.254.169.254 on the host and take +// the cloud's own metadata service with it. +func NewConfigFileClient(path, profile string) (Client, error) { + if path == "" { + path = DefaultConfigFile + } + if profile == "" { + profile = DefaultConfigProfile + } + expanded, err := expandHome(path) + if err != nil { + return nil, err + } + provider, err := common.ConfigurationProviderFromFileWithProfile(expanded, profile, "") + if err != nil { + return nil, fmt.Errorf("oci config file %s (profile %s): %w", expanded, profile, err) + } + return NewClientWithProvider(provider) +} + +// expandHome resolves a leading ~ so operators can configure the conventional +// path rather than an absolute one that differs per service user. +func expandHome(path string) (string, error) { + if !strings.HasPrefix(path, "~") { + return path, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("oci config path %q: %w", path, err) + } + return filepath.Join(home, strings.TrimPrefix(path, "~")), nil +} + +// NewClientWithProvider builds a client from an explicit configuration +// provider, for an operator running off a config file rather than on an +// instance. +func NewClientWithProvider(p common.ConfigurationProvider) (Client, error) { + c, err := core.NewVirtualNetworkClientWithConfigurationProvider(p) + if err != nil { + return nil, fmt.Errorf("oci virtual network client: %w", err) + } + return &apiClient{net: c}, nil +} + +func (c *apiClient) AssignPrivateIP(ctx context.Context, vnicID string, addr netip.Addr, displayName string) (PrivateIP, error) { + details := core.CreatePrivateIpDetails{VnicId: &vnicID} + if displayName != "" { + details.DisplayName = &displayName + } + if addr.IsValid() { + s := addr.String() + details.IpAddress = &s + } + resp, err := c.net.CreatePrivateIp(ctx, core.CreatePrivateIpRequest{CreatePrivateIpDetails: details}) + if err != nil { + return PrivateIP{}, wrap("AssignPrivateIP", err) + } + return toPrivateIP(resp.PrivateIp) +} + +func (c *apiClient) MovePrivateIP(ctx context.Context, privateIPID, vnicID string) (PrivateIP, error) { + resp, err := c.net.UpdatePrivateIp(ctx, core.UpdatePrivateIpRequest{ + PrivateIpId: &privateIPID, + UpdatePrivateIpDetails: core.UpdatePrivateIpDetails{VnicId: &vnicID}, + }) + if err != nil { + return PrivateIP{}, wrap("MovePrivateIP", err) + } + return toPrivateIP(resp.PrivateIp) +} + +func (c *apiClient) UnassignPrivateIP(ctx context.Context, privateIPID string) error { + _, err := c.net.DeletePrivateIp(ctx, core.DeletePrivateIpRequest{PrivateIpId: &privateIPID}) + return wrap("UnassignPrivateIP", err) +} + +func (c *apiClient) GetPrivateIP(ctx context.Context, privateIPID string) (PrivateIP, error) { + resp, err := c.net.GetPrivateIp(ctx, core.GetPrivateIpRequest{PrivateIpId: &privateIPID}) + if err != nil { + return PrivateIP{}, wrap("GetPrivateIP", err) + } + return toPrivateIP(resp.PrivateIp) +} + +func (c *apiClient) ListPrivateIPs(ctx context.Context, vnicID string) ([]PrivateIP, error) { + var out []PrivateIP + var page *string + for { + resp, err := c.net.ListPrivateIps(ctx, core.ListPrivateIpsRequest{VnicId: &vnicID, Page: page}) + if err != nil { + return nil, wrap("ListPrivateIPs", err) + } + for _, item := range resp.Items { + ip, err := toPrivateIP(item) + if err != nil { + return nil, err + } + out = append(out, ip) + } + if resp.OpcNextPage == nil { + return out, nil + } + page = resp.OpcNextPage + } +} + +func (c *apiClient) CreatePublicIP(ctx context.Context, compartmentID, privateIPID, displayName string) (PublicIP, error) { + details := core.CreatePublicIpDetails{ + CompartmentId: &compartmentID, + Lifetime: core.CreatePublicIpDetailsLifetimeReserved, + } + if displayName != "" { + details.DisplayName = &displayName + } + if privateIPID != "" { + details.PrivateIpId = &privateIPID + } + resp, err := c.net.CreatePublicIp(ctx, core.CreatePublicIpRequest{CreatePublicIpDetails: details}) + if err != nil { + return PublicIP{}, wrap("CreatePublicIP", err) + } + return toPublicIP(resp.PublicIp) +} + +func (c *apiClient) AttachPublicIP(ctx context.Context, publicIPID, privateIPID string) (PublicIP, error) { + resp, err := c.net.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{ + PublicIpId: &publicIPID, + UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: &privateIPID}, + }) + if err != nil { + return PublicIP{}, wrap("AttachPublicIP", err) + } + return toPublicIP(resp.PublicIp) +} + +// DetachPublicIP clears privateIpId. The SDK omits a nil pointer, so the empty +// string is sent explicitly — that is what OCI reads as "unassign", and a nil +// here would be a no-op update that reports success while changing nothing. +func (c *apiClient) DetachPublicIP(ctx context.Context, publicIPID string) (PublicIP, error) { + empty := "" + resp, err := c.net.UpdatePublicIp(ctx, core.UpdatePublicIpRequest{ + PublicIpId: &publicIPID, + UpdatePublicIpDetails: core.UpdatePublicIpDetails{PrivateIpId: &empty}, + }) + if err != nil { + return PublicIP{}, wrap("DetachPublicIP", err) + } + return toPublicIP(resp.PublicIp) +} + +func (c *apiClient) DeletePublicIP(ctx context.Context, publicIPID string) error { + _, err := c.net.DeletePublicIp(ctx, core.DeletePublicIpRequest{PublicIpId: &publicIPID}) + return wrap("DeletePublicIP", err) +} + +func (c *apiClient) GetPublicIP(ctx context.Context, publicIPID string) (PublicIP, error) { + resp, err := c.net.GetPublicIp(ctx, core.GetPublicIpRequest{PublicIpId: &publicIPID}) + if err != nil { + return PublicIP{}, wrap("GetPublicIP", err) + } + return toPublicIP(resp.PublicIp) +} + +func (c *apiClient) GetPublicIPByPrivateIPID(ctx context.Context, privateIPID string) (PublicIP, error) { + resp, err := c.net.GetPublicIpByPrivateIpId(ctx, core.GetPublicIpByPrivateIpIdRequest{ + GetPublicIpByPrivateIpIdDetails: core.GetPublicIpByPrivateIpIdDetails{PrivateIpId: &privateIPID}, + }) + if err != nil { + return PublicIP{}, wrap("GetPublicIPByPrivateIPID", err) + } + return toPublicIP(resp.PublicIp) +} + +func toPrivateIP(p core.PrivateIp) (PrivateIP, error) { + out := PrivateIP{ + ID: deref(p.Id), + VNICID: deref(p.VnicId), + SubnetID: deref(p.SubnetId), + DisplayName: deref(p.DisplayName), + IsPrimary: p.IsPrimary != nil && *p.IsPrimary, + } + if s := deref(p.IpAddress); s != "" { + addr, err := netip.ParseAddr(s) + if err != nil { + return PrivateIP{}, fmt.Errorf("oci: parse private ip %q: %w", s, err) + } + out.Address = addr + } + return out, nil +} + +func toPublicIP(p core.PublicIp) (PublicIP, error) { + out := PublicIP{ + ID: deref(p.Id), + PrivateIPID: deref(p.PrivateIpId), + DisplayName: deref(p.DisplayName), + Lifetime: string(p.Lifetime), + LifecycleState: string(p.LifecycleState), + } + if s := deref(p.IpAddress); s != "" { + addr, err := netip.ParseAddr(s) + if err != nil { + return PublicIP{}, fmt.Errorf("oci: parse public ip %q: %w", s, err) + } + out.Address = addr + } + return out, nil +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// wrap classifies an SDK error against the three sentinels. The HTTP status is +// the reliable signal; the service code is carried for the message but not +// matched on, because OCI's code strings differ per service and per endpoint +// while the statuses do not. +func wrap(op string, err error) error { + if err == nil { + return nil + } + svc, ok := common.IsServiceError(err) + if !ok { + return fmt.Errorf("oci %s: %w", op, err) + } + api := &APIError{ + Op: op, + Code: svc.GetCode(), + Message: svc.GetMessage(), + StatusCode: svc.GetHTTPStatusCode(), + RequestID: svc.GetOpcRequestID(), + } + switch { + case svc.GetHTTPStatusCode() == 404: + api.kind = ErrNotFound + case svc.GetHTTPStatusCode() == 409, svc.GetHTTPStatusCode() == 412: + api.kind = ErrConflict + case svc.GetHTTPStatusCode() == 400 && svc.GetCode() == "LimitExceeded": + api.kind = ErrLimitExceeded + case svc.GetHTTPStatusCode() == 429: + api.kind = ErrLimitExceeded + } + return api +} diff --git a/spinifex/cloud/oci/client_test.go b/spinifex/cloud/oci/client_test.go new file mode 100644 index 000000000..40e0fdb71 --- /dev/null +++ b/spinifex/cloud/oci/client_test.go @@ -0,0 +1,155 @@ +package oci_test + +import ( + "errors" + "net/netip" + "testing" + + ocisdk "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" +) + +// serviceError is an SDK service error. common.IsServiceError matches on the +// interface, so a local type is enough and no HTTP round trip is needed. +type serviceError struct { + code string + message string + status int + reqID string +} + +func (e serviceError) Error() string { return e.message } +func (e serviceError) GetHTTPStatusCode() int { return e.status } +func (e serviceError) GetMessage() string { return e.message } +func (e serviceError) GetCode() string { return e.code } +func (e serviceError) GetOpcRequestID() string { return e.reqID } + +var _ ocisdk.ServiceError = serviceError{} + +// The classification is control flow, not cosmetics: a quota refusal has to +// reach the customer as InsufficientAddressCapacity, a 404 has to let reconcile +// treat an object as already gone, and a conflict has to trigger a re-read +// rather than a blind retry. +func TestWrapClassifiesTheStatusesThatChangeBehaviour(t *testing.T) { + for _, tc := range []struct { + name string + err serviceError + want error + reason string + }{ + {"404 is not found", serviceError{status: 404, code: "NotAuthorizedOrNotFound"}, oci.ErrNotFound, + "OCI returns 404 for both missing and unauthorised, and either way the object is not ours to use"}, + {"409 is a conflict", serviceError{status: 409, code: "Conflict"}, oci.ErrConflict, ""}, + {"412 is a conflict", serviceError{status: 412, code: "PreconditionFailed"}, oci.ErrConflict, + "an etag mismatch means another node changed it first"}, + {"400 LimitExceeded is a quota", serviceError{status: 400, code: "LimitExceeded"}, oci.ErrLimitExceeded, + "64 private IPs per VNIC and 50 public IPs per region arrive as this"}, + {"429 is a quota", serviceError{status: 429, code: "TooManyRequests"}, oci.ErrLimitExceeded, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + err := oci.Wrap("TestOp", tc.err) + require.Error(t, err) + assert.ErrorIs(t, err, tc.want, tc.reason) + }) + } +} + +// A 400 that is not a quota must not read as one, or a malformed request would +// be reported to the customer as the region being full. +func TestWrapLeavesAnUnclassifiedStatusUnmatched(t *testing.T) { + err := oci.Wrap("TestOp", serviceError{status: 400, code: "InvalidParameter"}) + require.Error(t, err) + assert.NotErrorIs(t, err, oci.ErrLimitExceeded) + assert.NotErrorIs(t, err, oci.ErrNotFound) + assert.NotErrorIs(t, err, oci.ErrConflict) +} + +func TestWrapPassesANonServiceErrorThrough(t *testing.T) { + sentinel := errors.New("dial tcp: connection refused") + err := oci.Wrap("TestOp", sentinel) + require.Error(t, err) + assert.ErrorIs(t, err, sentinel, "a transport failure must stay unwrappable to its cause") + assert.Contains(t, err.Error(), "TestOp") +} + +func TestWrapOfNilIsNil(t *testing.T) { + assert.NoError(t, oci.Wrap("TestOp", nil)) +} + +// The request ID is what Oracle support asks for first, so it has to survive +// into the message rather than only into a struct field nobody prints. +func TestAPIErrorMessageCarriesTheRequestID(t *testing.T) { + err := oci.Wrap("CreatePublicIp", serviceError{ + status: 400, code: "LimitExceeded", message: "public IP limit reached", reqID: "ABC123", + }) + assert.Contains(t, err.Error(), "ABC123") + assert.Contains(t, err.Error(), "public IP limit reached") + assert.Contains(t, err.Error(), "CreatePublicIp") + assert.Contains(t, err.Error(), "400") +} + +// Every field on an SDK response is a pointer, and OCI omits the ones it has +// no value for. A converter that dereferenced blind would panic on exactly the +// responses that matter most — an ASSIGNING public IP has no address yet. +func TestConvertersToleratePointerFieldsOCIOmits(t *testing.T) { + priv, err := oci.ToPrivateIP(core.PrivateIp{}) + require.NoError(t, err) + assert.Empty(t, priv.ID) + assert.False(t, priv.Address.IsValid(), "an absent address must be the zero Addr, not a parse error") + assert.False(t, priv.IsPrimary) + + pub, err := oci.ToPublicIP(core.PublicIp{}) + require.NoError(t, err) + assert.False(t, pub.Address.IsValid()) + assert.False(t, pub.IsAssigned()) +} + +func TestConvertersCarryTheFieldsTheAllocatorReadsBack(t *testing.T) { + priv, err := oci.ToPrivateIP(core.PrivateIp{ + Id: strptr("ocid1.privateip.oc1..p1"), + IpAddress: strptr("10.200.0.183"), + VnicId: strptr("ocid1.vnic.oc1..v1"), + SubnetId: strptr("ocid1.subnet.oc1..s1"), + DisplayName: strptr("spinifex-eipalloc-1"), + IsPrimary: boolptr(true), + }) + require.NoError(t, err) + assert.Equal(t, netip.MustParseAddr("10.200.0.183"), priv.Address) + assert.Equal(t, "ocid1.vnic.oc1..v1", priv.VNICID) + assert.True(t, priv.IsPrimary) + + pub, err := oci.ToPublicIP(core.PublicIp{ + Id: strptr("ocid1.publicip.oc1..q1"), + IpAddress: strptr("161.33.227.97"), + PrivateIpId: strptr("ocid1.privateip.oc1..p1"), + Lifetime: core.PublicIpLifetimeReserved, + LifecycleState: core.PublicIpLifecycleStateAssigned, + }) + require.NoError(t, err) + assert.Equal(t, netip.MustParseAddr("161.33.227.97"), pub.Address) + assert.Equal(t, oci.LifetimeReserved, pub.Lifetime) + assert.True(t, pub.IsAssigned(), "an ASSIGNED public IP is the only one safe to hand out") +} + +// A malformed address has to be an error, not a silently zero Addr that the +// allocator would then record as a customer's public IP. +func TestConvertersRefuseAnUnparseableAddress(t *testing.T) { + _, err := oci.ToPrivateIP(core.PrivateIp{IpAddress: strptr("not-an-ip")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not-an-ip") + + _, err = oci.ToPublicIP(core.PublicIp{IpAddress: strptr("999.1.1.1")}) + require.Error(t, err) +} + +func TestDerefOfNilIsEmpty(t *testing.T) { + assert.Empty(t, oci.Deref(nil)) + assert.Equal(t, "x", oci.Deref(strptr("x"))) +} + +func strptr(s string) *string { return &s } +func boolptr(b bool) *bool { return &b } diff --git a/spinifex/cloud/oci/export_test.go b/spinifex/cloud/oci/export_test.go new file mode 100644 index 000000000..2245b2b5b --- /dev/null +++ b/spinifex/cloud/oci/export_test.go @@ -0,0 +1,40 @@ +package oci + +import ( + "net/http" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/core" +) + +// The MAC-matching half of VNIC resolution is deliberately unexported — it is +// an implementation detail of ResolveVNICByInterface — but it is also the part +// worth testing directly, because the alternative is a test that depends on the +// host having an OCI NIC. Exported here for the external test package. +var ( + NormalizeMAC = normalizeMAC + MatchVNICByMAC = matchVNICByMAC +) + +// The error classifier and the SDK-to-our-types converters carry the logic in +// client.go that is worth testing; the rest of that file is SDK plumbing that +// only a live endpoint exercises. +var ( + Wrap = wrap + ToPrivateIP = toPrivateIP + ToPublicIP = toPublicIP + Deref = deref +) + +// NewTestClient points the real SDK-backed client at a local endpoint, so the +// request shapes this package builds — pagination, and the explicit empty +// string that DetachPublicIP depends on — are testable without a tenancy. +func NewTestClient(provider common.ConfigurationProvider, endpoint string, hc *http.Client) (Client, error) { + c, err := core.NewVirtualNetworkClientWithConfigurationProvider(provider) + if err != nil { + return nil, err + } + c.Host = endpoint + c.HTTPClient = hc + return &apiClient{net: c}, nil +} diff --git a/spinifex/cloud/oci/fake.go b/spinifex/cloud/oci/fake.go new file mode 100644 index 000000000..ddd790120 --- /dev/null +++ b/spinifex/cloud/oci/fake.go @@ -0,0 +1,334 @@ +package oci + +import ( + "context" + "fmt" + "net/netip" + "sync" +) + +// Fake is an in-memory Client for tests. It is in the production package +// rather than a _test file because the allocator package needs it too, and it +// models the two behaviours that actually bite: OCI picks the address, and +// assignment is asynchronous so a create is not immediately ASSIGNED. +type Fake struct { + mu sync.Mutex + + // NextAddr supplies successive private addresses when the caller does not + // name one, standing in for OCI's own choice from the subnet. + NextAddr netip.Addr + // NextPublic supplies successive public addresses. + NextPublic netip.Addr + + // AssignAfter is how many Get calls a public IP spends in ASSIGNING before + // reporting ASSIGNED. Zero means assigned immediately. + AssignAfter int + + // FailWith, when non-nil, is returned by the next call to the named + // operation and then cleared. Keyed by the Op strings used in APIError. + FailWith map[string]error + + // PrivateLimit caps private IPs per VNIC; 0 means unlimited. OCI's real + // cap is 64. + PrivateLimit int + + privateIPs map[string]PrivateIP // by OCID + publicIPs map[string]PublicIP // by OCID + pending map[string]int // public OCID -> Gets remaining before ASSIGNED + seq int +} + +var _ Client = (*Fake)(nil) + +// NewFake returns a Fake seeded with sensible address counters. +func NewFake() *Fake { + return &Fake{ + NextAddr: netip.MustParseAddr("10.200.0.100"), + NextPublic: netip.MustParseAddr("203.0.113.10"), + FailWith: map[string]error{}, + privateIPs: map[string]PrivateIP{}, + publicIPs: map[string]PublicIP{}, + pending: map[string]int{}, + } +} + +// SeedPrivateIP inserts a private IP directly, for reconcile tests that need +// OCI to already hold state our KV does not know about. +func (f *Fake) SeedPrivateIP(p PrivateIP) { + f.mu.Lock() + defer f.mu.Unlock() + f.privateIPs[p.ID] = p +} + +// SeedPublicIP inserts a public IP directly. +func (f *Fake) SeedPublicIP(p PublicIP) { + f.mu.Lock() + defer f.mu.Unlock() + f.publicIPs[p.ID] = p +} + +// PrivateIPs returns a snapshot, so a test can assert nothing leaked. +func (f *Fake) PrivateIPs() []PrivateIP { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]PrivateIP, 0, len(f.privateIPs)) + for _, p := range f.privateIPs { + out = append(out, p) + } + return out +} + +// PublicIPs returns a snapshot. +func (f *Fake) PublicIPs() []PublicIP { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]PublicIP, 0, len(f.publicIPs)) + for _, p := range f.publicIPs { + out = append(out, p) + } + return out +} + +// FailOp stages err for the next call to op. Locked, so a test may stage one +// against a Fake that a loop under test is already calling. +func (f *Fake) FailOp(op string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.FailWith[op] = err +} + +// take returns and clears a staged failure for op. +func (f *Fake) take(op string) error { + if err, ok := f.FailWith[op]; ok { + delete(f.FailWith, op) + return err + } + return nil +} + +func (f *Fake) next() int { f.seq++; return f.seq } + +func (f *Fake) AssignPrivateIP(_ context.Context, vnicID string, addr netip.Addr, displayName string) (PrivateIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("AssignPrivateIP"); err != nil { + return PrivateIP{}, err + } + if f.PrivateLimit > 0 { + n := 0 + for _, p := range f.privateIPs { + if p.VNICID == vnicID { + n++ + } + } + if n >= f.PrivateLimit { + return PrivateIP{}, &APIError{ + Op: "AssignPrivateIP", Code: "LimitExceeded", StatusCode: 400, + Message: fmt.Sprintf("vnic %s already has %d private IPs", vnicID, n), + kind: ErrLimitExceeded, + } + } + } + if !addr.IsValid() { + addr = f.NextAddr + f.NextAddr = f.NextAddr.Next() + } + for _, p := range f.privateIPs { + if p.Address == addr { + return PrivateIP{}, &APIError{ + Op: "AssignPrivateIP", Code: "Conflict", StatusCode: 409, + Message: fmt.Sprintf("address %s already assigned", addr), kind: ErrConflict, + } + } + } + p := PrivateIP{ + ID: fmt.Sprintf("ocid1.privateip.oc1..fake%03d", f.next()), + Address: addr, + VNICID: vnicID, + DisplayName: displayName, + } + f.privateIPs[p.ID] = p + return p, nil +} + +func (f *Fake) MovePrivateIP(_ context.Context, privateIPID, vnicID string) (PrivateIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("MovePrivateIP"); err != nil { + return PrivateIP{}, err + } + p, ok := f.privateIPs[privateIPID] + if !ok { + return PrivateIP{}, &APIError{Op: "MovePrivateIP", StatusCode: 404, Message: "no such private ip", kind: ErrNotFound} + } + if p.IsPrimary { + return PrivateIP{}, &APIError{ + Op: "MovePrivateIP", StatusCode: 409, kind: ErrConflict, + Message: "cannot reassign the primary private ip", + } + } + // The OCID and the address survive the move, which is the whole point: an + // attached public IP references this object, so it follows without being + // touched. + p.VNICID = vnicID + f.privateIPs[privateIPID] = p + return p, nil +} + +func (f *Fake) UnassignPrivateIP(_ context.Context, privateIPID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("UnassignPrivateIP"); err != nil { + return err + } + p, ok := f.privateIPs[privateIPID] + if !ok { + return &APIError{Op: "UnassignPrivateIP", StatusCode: 404, Message: "no such private ip", kind: ErrNotFound} + } + if p.IsPrimary { + return &APIError{Op: "UnassignPrivateIP", StatusCode: 409, Message: "cannot delete the primary private ip", kind: ErrConflict} + } + delete(f.privateIPs, privateIPID) + return nil +} + +func (f *Fake) GetPrivateIP(_ context.Context, privateIPID string) (PrivateIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("GetPrivateIP"); err != nil { + return PrivateIP{}, err + } + p, ok := f.privateIPs[privateIPID] + if !ok { + return PrivateIP{}, &APIError{Op: "GetPrivateIP", StatusCode: 404, Message: "no such private ip", kind: ErrNotFound} + } + return p, nil +} + +func (f *Fake) ListPrivateIPs(_ context.Context, vnicID string) ([]PrivateIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("ListPrivateIPs"); err != nil { + return nil, err + } + var out []PrivateIP + for _, p := range f.privateIPs { + if p.VNICID == vnicID { + out = append(out, p) + } + } + return out, nil +} + +func (f *Fake) CreatePublicIP(_ context.Context, compartmentID, privateIPID, displayName string) (PublicIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("CreatePublicIP"); err != nil { + return PublicIP{}, err + } + _ = compartmentID + addr := f.NextPublic + f.NextPublic = f.NextPublic.Next() + p := PublicIP{ + ID: fmt.Sprintf("ocid1.publicip.oc1..fake%03d", f.next()), + Address: addr, + PrivateIPID: privateIPID, + DisplayName: displayName, + Lifetime: LifetimeReserved, + LifecycleState: LifecycleStateAssigned, + } + if privateIPID == "" { + p.LifecycleState = LifecycleStateAvailable + } else if f.AssignAfter > 0 { + p.LifecycleState = LifecycleStateAssigning + f.pending[p.ID] = f.AssignAfter + } + f.publicIPs[p.ID] = p + return p, nil +} + +func (f *Fake) AttachPublicIP(_ context.Context, publicIPID, privateIPID string) (PublicIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("AttachPublicIP"); err != nil { + return PublicIP{}, err + } + p, ok := f.publicIPs[publicIPID] + if !ok { + return PublicIP{}, &APIError{Op: "AttachPublicIP", StatusCode: 404, Message: "no such public ip", kind: ErrNotFound} + } + p.PrivateIPID = privateIPID + p.LifecycleState = LifecycleStateAssigned + if f.AssignAfter > 0 { + p.LifecycleState = LifecycleStateAssigning + f.pending[p.ID] = f.AssignAfter + } + f.publicIPs[publicIPID] = p + return p, nil +} + +func (f *Fake) DetachPublicIP(_ context.Context, publicIPID string) (PublicIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("DetachPublicIP"); err != nil { + return PublicIP{}, err + } + p, ok := f.publicIPs[publicIPID] + if !ok { + return PublicIP{}, &APIError{Op: "DetachPublicIP", StatusCode: 404, Message: "no such public ip", kind: ErrNotFound} + } + p.PrivateIPID = "" + p.LifecycleState = LifecycleStateAvailable + f.publicIPs[publicIPID] = p + return p, nil +} + +func (f *Fake) DeletePublicIP(_ context.Context, publicIPID string) error { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("DeletePublicIP"); err != nil { + return err + } + if _, ok := f.publicIPs[publicIPID]; !ok { + return &APIError{Op: "DeletePublicIP", StatusCode: 404, Message: "no such public ip", kind: ErrNotFound} + } + delete(f.publicIPs, publicIPID) + delete(f.pending, publicIPID) + return nil +} + +func (f *Fake) GetPublicIP(_ context.Context, publicIPID string) (PublicIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("GetPublicIP"); err != nil { + return PublicIP{}, err + } + p, ok := f.publicIPs[publicIPID] + if !ok { + return PublicIP{}, &APIError{Op: "GetPublicIP", StatusCode: 404, Message: "no such public ip", kind: ErrNotFound} + } + if n, pendingNow := f.pending[publicIPID]; pendingNow { + if n <= 1 { + delete(f.pending, publicIPID) + p.LifecycleState = LifecycleStateAssigned + f.publicIPs[publicIPID] = p + } else { + f.pending[publicIPID] = n - 1 + } + } + return p, nil +} + +func (f *Fake) GetPublicIPByPrivateIPID(_ context.Context, privateIPID string) (PublicIP, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.take("GetPublicIPByPrivateIPID"); err != nil { + return PublicIP{}, err + } + for _, p := range f.publicIPs { + if p.PrivateIPID == privateIPID { + return p, nil + } + } + return PublicIP{}, &APIError{Op: "GetPublicIPByPrivateIPID", StatusCode: 404, Message: "no public ip for private ip", kind: ErrNotFound} +} diff --git a/spinifex/cloud/oci/fake_test.go b/spinifex/cloud/oci/fake_test.go new file mode 100644 index 000000000..4b06040ea --- /dev/null +++ b/spinifex/cloud/oci/fake_test.go @@ -0,0 +1,207 @@ +package oci_test + +import ( + "context" + "errors" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" +) + +// The Fake is what every allocator test is measured against, so a Fake that +// models OCI wrongly makes those tests agree on a fiction. These check the +// behaviours the allocator actually depends on. + +func TestFakeAssignsSuccessiveAddressesLikeOCIChoosingThem(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + // The caller passes the zero Addr: OCI picks, not us. That asymmetry with + // a static pool is the whole reason this package exists. + a, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + b, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-2") + require.NoError(t, err) + + assert.Equal(t, "10.200.0.100", a.Address.String()) + assert.Equal(t, "10.200.0.101", b.Address.String()) + assert.NotEqual(t, a.ID, b.ID, "each assignment is a distinct OCI object") + assert.Equal(t, "vnic1", a.VNICID) +} + +func TestFakeHonoursACallerChosenAddress(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + got, err := f.AssignPrivateIP(ctx, "vnic1", netip.MustParseAddr("10.200.0.183"), "spinifex-1") + require.NoError(t, err) + assert.Equal(t, "10.200.0.183", got.Address.String()) +} + +// OCI caps private IPs at 64 per VNIC and the allocator has to surface that as +// InsufficientAddressCapacity, so the Fake must refuse with the sentinel rather +// than with a generic error. +func TestFakeRefusesPastThePrivateIPLimit(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + f.PrivateLimit = 1 + + _, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + + _, err = f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-2") + require.Error(t, err) + assert.ErrorIs(t, err, oci.ErrLimitExceeded) +} + +// A create returns ASSIGNING and the address is not reachable until it settles. +// An allocator that returned on the first response would hand out a dead +// address, which is why the poll ladder exists. +func TestFakeModelsAsynchronousAssignment(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + f.AssignAfter = 2 + + priv, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + pub, err := f.CreatePublicIP(ctx, "comp1", priv.ID, "spinifex-1") + require.NoError(t, err) + assert.False(t, pub.IsAssigned(), "a fresh public IP must not claim to be assigned") + + first, err := f.GetPublicIP(ctx, pub.ID) + require.NoError(t, err) + assert.False(t, first.IsAssigned()) + + second, err := f.GetPublicIP(ctx, pub.ID) + require.NoError(t, err) + assert.True(t, second.IsAssigned(), "it must settle after the configured number of polls") +} + +func TestFakeCreatesReservedPublicIPsAttachedToTheirPrivateIP(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + priv, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + pub, err := f.CreatePublicIP(ctx, "comp1", priv.ID, "spinifex-1") + require.NoError(t, err) + + assert.Equal(t, oci.LifetimeReserved, pub.Lifetime, + "an EIP must outlive its association, which is what RESERVED means") + assert.Equal(t, priv.ID, pub.PrivateIPID) + assert.Equal(t, "203.0.113.10", pub.Address.String()) + + found, err := f.GetPublicIPByPrivateIPID(ctx, priv.ID) + require.NoError(t, err) + assert.Equal(t, pub.ID, found.ID) +} + +func TestFakeReportsNotFoundForObjectsThatAreGone(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + _, err := f.GetPrivateIP(ctx, "ocid1.privateip.oc1..missing") + assert.ErrorIs(t, err, oci.ErrNotFound) + + _, err = f.GetPublicIP(ctx, "ocid1.publicip.oc1..missing") + assert.ErrorIs(t, err, oci.ErrNotFound) + + _, err = f.GetPublicIPByPrivateIPID(ctx, "ocid1.privateip.oc1..missing") + assert.ErrorIs(t, err, oci.ErrNotFound, + "reconcile distinguishes a leaked private IP with no public IP from an API failure by this") +} + +func TestFakeDetachLeavesThePublicIPButUnassigned(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + priv, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + pub, err := f.CreatePublicIP(ctx, "comp1", priv.ID, "spinifex-1") + require.NoError(t, err) + + got, err := f.DetachPublicIP(ctx, pub.ID) + require.NoError(t, err) + assert.Empty(t, got.PrivateIPID) + assert.False(t, got.IsAssigned()) + assert.Len(t, f.PublicIPs(), 1, "detach unassigns; only delete removes the object and stops the bill") +} + +func TestFakeAttachRepointsAPublicIP(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + first, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + second, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-2") + require.NoError(t, err) + pub, err := f.CreatePublicIP(ctx, "comp1", first.ID, "spinifex-1") + require.NoError(t, err) + + got, err := f.AttachPublicIP(ctx, pub.ID, second.ID) + require.NoError(t, err) + assert.Equal(t, second.ID, got.PrivateIPID) +} + +func TestFakeListsOnlyTheNamedVNICsAddresses(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + _, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + _, err = f.AssignPrivateIP(ctx, "vnic2", netip.Addr{}, "spinifex-2") + require.NoError(t, err) + + got, err := f.ListPrivateIPs(ctx, "vnic1") + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "vnic1", got[0].VNICID) +} + +func TestFakeUnassignAndDeleteRemoveTheObjects(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + + priv, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + require.NoError(t, err) + pub, err := f.CreatePublicIP(ctx, "comp1", priv.ID, "spinifex-1") + require.NoError(t, err) + + require.NoError(t, f.DeletePublicIP(ctx, pub.ID)) + require.NoError(t, f.UnassignPrivateIP(ctx, priv.ID)) + assert.Empty(t, f.PublicIPs()) + assert.Empty(t, f.PrivateIPs()) + + assert.ErrorIs(t, f.UnassignPrivateIP(ctx, priv.ID), oci.ErrNotFound) + assert.ErrorIs(t, f.DeletePublicIP(ctx, pub.ID), oci.ErrNotFound) +} + +// FailWith is how the rollback tests force a failure at an exact step, so it +// has to fire once and then clear — otherwise the cleanup call it triggers +// fails too and the test measures the wrong thing. +func TestFakeFailWithFiresOnceThenClears(t *testing.T) { + ctx := context.Background() + f := oci.NewFake() + boom := errors.New("boom") + f.FailWith["AssignPrivateIP"] = boom + + _, err := f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + assert.ErrorIs(t, err, boom) + + _, err = f.AssignPrivateIP(ctx, "vnic1", netip.Addr{}, "spinifex-1") + assert.NoError(t, err, "a second call must succeed or rollback paths cannot be tested") +} + +func TestFakeSeedHelpersExposeStateReconcileTestsNeed(t *testing.T) { + f := oci.NewFake() + f.SeedPrivateIP(oci.PrivateIP{ID: "p1", VNICID: "vnic1", DisplayName: "vnic-1", IsPrimary: true}) + f.SeedPublicIP(oci.PublicIP{ID: "q1", PrivateIPID: "p1"}) + + assert.Len(t, f.PrivateIPs(), 1) + assert.Len(t, f.PublicIPs(), 1) + assert.True(t, f.PrivateIPs()[0].IsPrimary) +} diff --git a/spinifex/cloud/oci/metadata.go b/spinifex/cloud/oci/metadata.go new file mode 100644 index 000000000..9ba3932a9 --- /dev/null +++ b/spinifex/cloud/oci/metadata.go @@ -0,0 +1,141 @@ +package oci + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "time" +) + +// IMDSBaseURL is the OCI instance metadata service. v2 requires the bearer +// header below; v1 is unauthenticated and disabled on current images. +const IMDSBaseURL = "http://169.254.169.254/opc/v2" + +// imdsTimeout bounds a metadata read. The service is link-local and answers in +// single-digit milliseconds, so a slow read means it is not there — which is +// the answer a non-OCI host needs, and it should not take the startup path +// down with it. +const imdsTimeout = 5 * time.Second + +// VNICMetadata is one entry of /opc/v2/vnics/. Only the fields that identify +// the VNIC are decoded; the rest of the document is not our business. +type VNICMetadata struct { + VNICID string `json:"vnicId"` + MACAddr string `json:"macAddr"` + PrivateIP string `json:"privateIp"` + SubnetCIDRBlock string `json:"subnetCidrBlock"` + VirtualRouterIP string `json:"virtualRouterIp"` + VLANTag int `json:"vlanTag"` +} + +// InstanceMetadata is the subset of /opc/v2/instance/ the allocator needs. +type InstanceMetadata struct { + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + Region string `json:"region"` + CanonicalRegionName string `json:"canonicalRegionName"` + AvailabilityDomain string `json:"availabilityDomain"` + Shape string `json:"shape"` +} + +// MetadataClient reads the instance metadata service. +type MetadataClient struct { + BaseURL string + HTTP *http.Client +} + +// NewMetadataClient returns a client for the link-local metadata service. +func NewMetadataClient() *MetadataClient { + return &MetadataClient{ + BaseURL: IMDSBaseURL, + HTTP: &http.Client{Timeout: imdsTimeout}, + } +} + +// VNICs returns the VNICs attached to this instance, in Oracle's order. +func (c *MetadataClient) VNICs(ctx context.Context) ([]VNICMetadata, error) { + var out []VNICMetadata + if err := c.get(ctx, "/vnics/", &out); err != nil { + return nil, err + } + return out, nil +} + +// Instance returns this instance's own metadata document. +func (c *MetadataClient) Instance(ctx context.Context) (InstanceMetadata, error) { + var out InstanceMetadata + if err := c.get(ctx, "/instance/", &out); err != nil { + return InstanceMetadata{}, err + } + return out, nil +} + +func (c *MetadataClient) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return fmt.Errorf("oci imds %s: %w", path, err) + } + // v2 refuses without this exact header; it is a confused-deputy guard, not + // a credential, and Oracle documents the literal string. + req.Header.Set("Authorization", "Bearer Oracle") + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("oci imds %s: %w", path, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("oci imds %s: http %d", path, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("oci imds %s: decode: %w", path, err) + } + return nil +} + +// ResolveVNICByInterface maps a host interface name to the VNIC OCID carrying +// its MAC. Matching on MAC rather than on Oracle's VNIC order is what makes +// this safe: the order is not a documented contract, and on the reference host +// the external VNIC is the second entry only by chance. +// +// A MAC-cloned Linux bridge resolves to the same VNIC as the NIC inside it, +// which is the intended answer — on OCI the external datapath is a bridge that +// clones the VNIC's MAC, so an operator naming either gets the same VNIC. +func ResolveVNICByInterface(ctx context.Context, md *MetadataClient, iface string) (VNICMetadata, error) { + link, err := net.InterfaceByName(iface) + if err != nil { + return VNICMetadata{}, fmt.Errorf("oci resolve vnic: interface %q: %w", iface, err) + } + mac := normalizeMAC(link.HardwareAddr.String()) + if mac == "" { + return VNICMetadata{}, fmt.Errorf("oci resolve vnic: interface %q has no MAC", iface) + } + + vnics, err := md.VNICs(ctx) + if err != nil { + return VNICMetadata{}, err + } + return matchVNICByMAC(vnics, mac, iface) +} + +// matchVNICByMAC is the matching half of ResolveVNICByInterface, split out so +// it can be tested without depending on the host having an OCI NIC. +func matchVNICByMAC(vnics []VNICMetadata, mac, iface string) (VNICMetadata, error) { + for _, v := range vnics { + // IMDS reports the MAC uppercase and the kernel reports it lowercase, + // so a byte comparison of the two strings never matches. + if normalizeMAC(v.MACAddr) == normalizeMAC(mac) { + return v, nil + } + } + attached := make([]string, 0, len(vnics)) + for _, v := range vnics { + attached = append(attached, v.MACAddr) + } + return VNICMetadata{}, fmt.Errorf("oci resolve vnic: no VNIC has interface %q's MAC %s (attached: %s)", + iface, mac, strings.Join(attached, ", ")) +} + +func normalizeMAC(s string) string { return strings.ToLower(strings.TrimSpace(s)) } diff --git a/spinifex/cloud/oci/metadata_test.go b/spinifex/cloud/oci/metadata_test.go new file mode 100644 index 000000000..a673eaad6 --- /dev/null +++ b/spinifex/cloud/oci/metadata_test.go @@ -0,0 +1,127 @@ +package oci_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The document shape is taken verbatim from mulga-poc, uppercase MACs and all. +const vnicsDoc = `[ + {"macAddr":"02:00:17:00:EF:07","privateIp":"10.200.0.144","subnetCidrBlock":"10.200.0.0/24", + "virtualRouterIp":"10.200.0.1","vlanTag":119, + "vnicId":"ocid1.vnic.oc1.ap-sydney-1.abzxsljrs4pqgaicbsgasbozqrjh3bocqvqpsu5gda2a2aseijmow67t7nsq"}, + {"macAddr":"02:00:17:01:7E:71","privateIp":"10.200.0.29","subnetCidrBlock":"10.200.0.0/24", + "virtualRouterIp":"10.200.0.1","vlanTag":1603, + "vnicId":"ocid1.vnic.oc1.ap-sydney-1.abzxsljrygicziuh2jktbre2ftvqxe2yh7lby2rrsqcbu6irus2sg73ajj5a"} +]` + +const instanceDoc = `{ + "id":"ocid1.instance.oc1.ap-sydney-1.anzxsljr6eq57gicdlugsqfidppe356dhslyar7pq762qpgkdv2loyrv7uyq", + "compartmentId":"ocid1.compartment.oc1..aaaaaaaa6nkkqia2q3z2gjmqhqpq66t4k42keqob5wphgvtrfwlkfbr3ztta", + "region":"ap-sydney-1","canonicalRegionName":"ap-sydney-1", + "availabilityDomain":"RgrR:AP-SYDNEY-1-AD-1","shape":"VM.Standard.E6.Flex" +}` + +func testMetadata(t *testing.T) (*oci.MetadataClient, *[]string) { + t.Helper() + var auth []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth = append(auth, r.Header.Get("Authorization")) + switch r.URL.Path { + case "/vnics/": + _, _ = w.Write([]byte(vnicsDoc)) + case "/instance/": + _, _ = w.Write([]byte(instanceDoc)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return &oci.MetadataClient{BaseURL: srv.URL, HTTP: srv.Client()}, &auth +} + +func TestVNICsDecodesTheMetadataDocument(t *testing.T) { + md, auth := testMetadata(t) + + vnics, err := md.VNICs(context.Background()) + require.NoError(t, err) + require.Len(t, vnics, 2) + + assert.Equal(t, "10.200.0.29", vnics[1].PrivateIP) + assert.Equal(t, 1603, vnics[1].VLANTag) + assert.Contains(t, vnics[1].VNICID, "ocid1.vnic.oc1.ap-sydney-1.") + // IMDS v2 refuses the request without the literal bearer header. + assert.Equal(t, []string{"Bearer Oracle"}, *auth) +} + +func TestInstanceCarriesTheCompartment(t *testing.T) { + md, _ := testMetadata(t) + + inst, err := md.Instance(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ocid1.compartment.oc1..aaaaaaaa6nkkqia2q3z2gjmqhqpq66t4k42keqob5wphgvtrfwlkfbr3ztta", inst.CompartmentID) + assert.Equal(t, "ap-sydney-1", inst.CanonicalRegionName) +} + +// IMDS reports MACs uppercase and the kernel reports them lowercase, so a byte +// comparison of the two never matches and every resolve would fail. +func TestMACMatchingIsCaseInsensitive(t *testing.T) { + assert.Equal(t, oci.NormalizeMAC("02:00:17:01:7E:71"), oci.NormalizeMAC("02:00:17:01:7e:71")) + assert.NotEqual(t, oci.NormalizeMAC("02:00:17:01:7E:71"), oci.NormalizeMAC("02:00:17:00:ef:07")) +} + +// A metadata read that fails has to say which document and why: on a non-OCI +// host this is the error an operator sees, and "404" alone explains nothing. +func TestMetadataErrorsCarryThePathAndStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + md := &oci.MetadataClient{BaseURL: srv.URL, HTTP: srv.Client()} + + _, err := md.VNICs(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "/vnics/") + assert.Contains(t, err.Error(), "404") +} + +// A MAC-cloned bridge (br-wan carries enp1s0's MAC on the reference host) must +// resolve to the same VNIC as the NIC inside it — that is the whole reason +// matching is on MAC rather than on Oracle's VNIC ordering, which is not a +// documented contract and puts the external VNIC second only by chance. +func TestMatchVNICResolvesTheClonedMACToTheExternalVNIC(t *testing.T) { + md, _ := testMetadata(t) + vnics, err := md.VNICs(context.Background()) + require.NoError(t, err) + + got, err := oci.MatchVNICByMAC(vnics, "02:00:17:01:7e:71", "br-wan") + require.NoError(t, err) + assert.Equal(t, "10.200.0.29", got.PrivateIP) + assert.Equal(t, vnics[1].VNICID, got.VNICID) +} + +func TestMatchVNICNamesTheAttachedMACsWhenItCannotMatch(t *testing.T) { + md, _ := testMetadata(t) + vnics, err := md.VNICs(context.Background()) + require.NoError(t, err) + + _, err = oci.MatchVNICByMAC(vnics, "aa:bb:cc:dd:ee:ff", "eth9") + require.Error(t, err) + // The operator's next move is comparing MACs, so the error has to show them. + assert.Contains(t, err.Error(), "eth9") + assert.Contains(t, err.Error(), "02:00:17:01:7E:71") +} + +func TestResolveVNICRejectsAnUnknownInterface(t *testing.T) { + md, _ := testMetadata(t) + + _, err := oci.ResolveVNICByInterface(context.Background(), md, "definitely-not-a-nic") + require.Error(t, err) + assert.Contains(t, err.Error(), "definitely-not-a-nic") +} diff --git a/spinifex/cloud/oci/types.go b/spinifex/cloud/oci/types.go new file mode 100644 index 000000000..f54f27394 --- /dev/null +++ b/spinifex/cloud/oci/types.go @@ -0,0 +1,109 @@ +// Package oci wraps the subset of the Oracle Cloud API that Spinifex needs to +// hand out publicly reachable addresses on an OCI instance. +// +// OCI enforces the source IP per VNIC: a packet leaving the instance with a +// source address that is not a registered private IP object on that VNIC is +// dropped by the hypervisor, whatever the MAC. Measured on mulga-poc — from one +// NIC with one MAC, an unregistered address gets 100% loss past the subnet +// while registered ones get none. So an external address is not ours to invent +// from range math; it has to be created through this API first. +// +// The unit of a publicly reachable address here is a pair: a secondary private +// IP on the VNIC, which is what travels on the wire and what the host +// configures on the interface, and a RESERVED public IP attached to it, which +// is what the internet sees and what AWS semantics call the Elastic IP. OCI +// 1:1 NATs between them. RESERVED rather than EPHEMERAL because an AWS EIP +// outlives its association — an ephemeral public IP dies with the private IP +// it was created against, which would change the customer's address on every +// re-association. +package oci + +import ( + "errors" + "fmt" + "net/netip" +) + +// PrivateIP is a VCN private IP object on a VNIC. The OCID, not the address, +// is the handle every other call takes. +type PrivateIP struct { + ID string + Address netip.Addr + VNICID string + SubnetID string + DisplayName string + IsPrimary bool +} + +// PublicIP is an OCI public IP object. PrivateIPID is empty when the address +// is reserved but not currently attached to anything — the state an allocated +// but unassociated AWS EIP maps onto. +type PublicIP struct { + ID string + Address netip.Addr + PrivateIPID string + DisplayName string + Lifetime string + LifecycleState string +} + +// IsAssigned reports whether the public IP has finished provisioning and is +// attached. OCI assignment is asynchronous, so a create returns before this is +// true and callers poll. +func (p PublicIP) IsAssigned() bool { return p.LifecycleState == LifecycleStateAssigned } + +// Public IP lifecycle states, as returned by the API. +const ( + LifecycleStateProvisioning = "PROVISIONING" + LifecycleStateAvailable = "AVAILABLE" + LifecycleStateAssigning = "ASSIGNING" + LifecycleStateAssigned = "ASSIGNED" + LifecycleStateUnassigning = "UNASSIGNING" + LifecycleStateUnassigned = "UNASSIGNED" + LifecycleStateTerminating = "TERMINATING" + LifecycleStateTerminated = "TERMINATED" +) + +// Public IP lifetimes. +const ( + LifetimeReserved = "RESERVED" + LifetimeEphemeral = "EPHEMERAL" +) + +// The three failures that need distinct handling upstream. Everything else is +// returned wrapped and untyped, because a caller that cannot act differently on +// an error does not benefit from being able to name it. +var ( + // ErrLimitExceeded is a quota refusal: 64 private IPs per VNIC, 50 public + // IPs per region. The allocator maps this to InsufficientAddressCapacity so + // it reaches the customer as the AWS error that means the same thing. + ErrLimitExceeded = errors.New("oci: service limit exceeded") + + // ErrNotFound is a 404. Release paths treat it as success — an address that + // is already gone is the state the caller wanted. + ErrNotFound = errors.New("oci: resource not found") + + // ErrConflict is a 409 or a 412 etag mismatch: another node changed the + // resource first. The caller re-reads rather than retrying blind. + ErrConflict = errors.New("oci: conflicting state") +) + +// APIError carries the OCI service error alongside one of the sentinels above, +// so errors.Is works for control flow while the message keeps the request ID +// that Oracle support asks for. +type APIError struct { + Op string + Code string + Message string + StatusCode int + RequestID string + kind error +} + +func (e *APIError) Error() string { + return fmt.Sprintf("oci %s: %s (%s, http %d, opc-request-id %s)", + e.Op, e.Message, e.Code, e.StatusCode, e.RequestID) +} + +// Unwrap returns the sentinel so errors.Is(err, ErrLimitExceeded) matches. +func (e *APIError) Unwrap() error { return e.kind } diff --git a/spinifex/config/config.go b/spinifex/config/config.go index 4109996fa..23c3a8180 100644 --- a/spinifex/config/config.go +++ b/spinifex/config/config.go @@ -65,7 +65,7 @@ type AWSConfig struct { // ExternalPool defines a range of routable IPs that Spinifex manages for public subnets. type ExternalPool struct { Name string `mapstructure:"name"` // Pool identifier (e.g., "wan", "dc1-primary") - Source string `mapstructure:"source"` // IP source: "static" (default) or "dhcp" + Source string `mapstructure:"source"` // IP source: "static" (default), "dhcp" or "oci" BindBridge string `mapstructure:"bind_bridge"` // Linux bridge for DHCP DORA (source=dhcp only) DHCPMAC string `mapstructure:"dhcp_mac"` // DHCP client MAC strategy: "derived" (default) or "interface" (source=dhcp only) RangeStart string `mapstructure:"range_start"` // First IP in range (static source only) @@ -80,6 +80,18 @@ type ExternalPool struct { // Must NOT overlap [RangeStart, RangeEnd] — link-local 169.254/16 is rejected by upstream routers. GwLrpRangeStart string `mapstructure:"gw_lrp_range_start"` GwLrpRangeEnd string `mapstructure:"gw_lrp_range_end"` + + // OCI identifies the VNIC and compartment the provider allocates against + // (source=oci only). An OCI VNIC drops any source address that is not a + // registered private IP object on it, so the addresses are created through + // the provider rather than computed from a range. + OCICompartmentID string `mapstructure:"oci_compartment_id"` // Compartment OCID the public IPs are created in + OCIVNICID string `mapstructure:"oci_vnic_id"` // VNIC OCID carrying the addresses + OCIVNICIface string `mapstructure:"oci_vnic_iface"` // Host interface to resolve the VNIC OCID from, instead of oci_vnic_id + OCISubnetID string `mapstructure:"oci_subnet_id"` // Subnet OCID the private IPs come from (optional; defaults to the VNIC's) + OCIPublicIPPool string `mapstructure:"oci_public_ip_pool"` // Public IP pool OCID for BYOIP (optional) + OCIConfigFile string `mapstructure:"oci_config_file"` // API-key config file (optional; defaults to ~/.oci/config) + OCIConfigProfile string `mapstructure:"oci_config_profile"` // Profile within that file (optional; defaults to DEFAULT) } // DefaultUnderlayMTU is the standard Ethernet payload, and the assumption a @@ -88,7 +100,7 @@ const DefaultUnderlayMTU = 1500 // NetworkConfig holds cluster-wide external network settings. type NetworkConfig struct { - ExternalMode string `mapstructure:"external_mode"` // "pool" or "" (disabled) + ExternalMode string `mapstructure:"external_mode"` // "pool", "nat" or "" (disabled) ExternalPools []ExternalPool `mapstructure:"external_pools"` // One or more IP pools // IPSecEnabled toggles OVN native IPsec (AES-256-GCM) on every node. Default true; disable only for trusted lab topologies. IPSecEnabled bool `mapstructure:"ipsec_enabled"` @@ -114,6 +126,16 @@ type NetworkConfig struct { // operator workaround for a tenant with a legitimate need until per-account // exceptions exist. EgressBlockExemptVPCs []string `mapstructure:"egress_block_exempt_vpcs"` + // IMDSHostMetaIP and IMDSHostDNSIP move the IMDS endpoint's own addresses + // off 169.254.169.254 / .253, for a node that needs those for itself. + // A cloud guest does — they are its metadata service and its resolver — and + // an endpoint /32 shadows the route to both, so the node loses DNS and its + // cloud API for as long as any guest runs. Guests are unaffected: they keep + // addressing the standard pair and a DNAT on the endpoint rewrites it. + // + // Set both or neither. Pick addresses the host has no route to. + IMDSHostMetaIP string `mapstructure:"imds_host_meta_ip"` + IMDSHostDNSIP string `mapstructure:"imds_host_dns_ip"` } // DefaultBlockedWANPorts mirrors AWS's out-of-the-box outbound mail block: @@ -632,6 +654,9 @@ func validateClusterConfig(cc *ClusterConfig) error { } var ranges []poolRange for _, p := range cc.Network.ExternalPools { + if p.Source != "oci" && (p.OCICompartmentID != "" || p.OCIVNICID != "" || p.OCIVNICIface != "" || p.OCISubnetID != "" || p.OCIPublicIPPool != "" || p.OCIConfigFile != "" || p.OCIConfigProfile != "") { + return fmt.Errorf("config: [[network.external_pools]] %q: oci_* keys are only valid with source=\"oci\"", p.Name) + } switch p.DHCPMAC { case "", "derived", "interface": default: @@ -656,8 +681,39 @@ func validateClusterConfig(cc *ClusterConfig) error { return fmt.Errorf("config: [[network.external_pools]] %q: gw_lrp_range_start/gw_lrp_range_end not allowed with source=\"dhcp\" (gateway LRP IP is DORA'd per VPC)", p.Name) } continue + case "oci": + if p.OCICompartmentID == "" { + return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires oci_compartment_id", p.Name) + } + // One or the other, never both: an OCID and an interface name that + // disagree would send allocations to a VNIC the datapath is not on, + // and the addresses would be silently unreachable. + if (p.OCIVNICID == "") == (p.OCIVNICIface == "") { + return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires exactly one of oci_vnic_id or oci_vnic_iface", p.Name) + } + if p.BindBridge != "" || p.DHCPMAC != "" { + return fmt.Errorf("config: [[network.external_pools]] %q: bind_bridge/dhcp_mac are only valid with source=\"dhcp\"", p.Name) + } + if p.RangeStart != "" || p.RangeEnd != "" { + return fmt.Errorf("config: [[network.external_pools]] %q: range_start/range_end not allowed with source=\"oci\" (OCI picks the address)", p.Name) + } + if p.GwLrpRangeStart != "" || p.GwLrpRangeEnd != "" { + return fmt.Errorf("config: [[network.external_pools]] %q: gw_lrp_range_start/gw_lrp_range_end not allowed with source=\"oci\" (an OCI address per VPC gateway would exhaust the 50-per-region public IP quota)", p.Name) + } + // Pool mode puts a per-VPC gateway MAC and a per-NAT-rule external + // MAC on the uplink and ARPs for the address. An OCI VNIC accepts + // exactly one MAC, its own, and delivers every inbound frame to it + // regardless of which of its addresses the packet is for — so a + // pool-mode guest's egress is dropped and its ingress never reaches + // OVN. Routed mode keeps the address on the host, where it already + // wears the VNIC's MAC. Refusing here costs a config error; not + // refusing costs a day of tcpdump. + if cc.Network.ExternalMode != "nat" { + return fmt.Errorf("config: [[network.external_pools]] %q: source=\"oci\" requires [network] external_mode = \"nat\" (routed); OCI VNICs accept only their own MAC, so pool mode's per-VPC gateway MACs are dropped by the provider", p.Name) + } + continue default: - return fmt.Errorf("config: [[network.external_pools]] %q: source=%q unsupported; use \"static\" or \"dhcp\"", p.Name, p.Source) + return fmt.Errorf("config: [[network.external_pools]] %q: source=%q unsupported; use \"static\", \"dhcp\" or \"oci\"", p.Name, p.Source) } if p.RangeStart == "" || p.RangeEnd == "" { continue diff --git a/spinifex/config/config_test.go b/spinifex/config/config_test.go index 7c0689a9e..7a02291b6 100644 --- a/spinifex/config/config_test.go +++ b/spinifex/config/config_test.go @@ -1178,3 +1178,157 @@ func TestResolvedBlockedWANPorts(t *testing.T) { custom := []int{25, 2525} assert.Equal(t, custom, NetworkConfig{BlockedPortsWAN: &custom}.ResolvedBlockedWANPorts()) } + +// ociPoolTOML builds a config whose OCI pool body is the caller's, so each +// rejection test differs only by the thing it is testing. +func ociPoolTOML(externalMode, poolBody string) string { + return ` +node = "n1" + +[network] +external_mode = "` + externalMode + `" + +[[network.external_pools]] +name = "wan" +` + poolBody + ` + +[nodes.n1] +region = "ap-southeast-2" +` +} + +func loadOCIPool(t *testing.T, externalMode, poolBody string) (*ClusterConfig, error) { + t.Helper() + resetViper(t) + path := filepath.Join(t.TempDir(), "spinifex.toml") + require.NoError(t, os.WriteFile(path, []byte(ociPoolTOML(externalMode, poolBody)), 0600)) + return LoadConfig(path) +} + +func TestLoadConfig_NetworkPoolOCISourceAccepted(t *testing.T) { + cfg, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_iface = "br-wan" +gateway = "10.200.0.1" +prefix_len = 24 +`) + require.NoError(t, err) + require.Len(t, cfg.Network.ExternalPools, 1) + p := cfg.Network.ExternalPools[0] + assert.Equal(t, "oci", p.Source) + assert.Equal(t, "ocid1.compartment.oc1..c1", p.OCICompartmentID) + assert.Equal(t, "br-wan", p.OCIVNICIface) +} + +func TestLoadConfig_NetworkPoolOCIAcceptsAVNICOCID(t *testing.T) { + cfg, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_id = "ocid1.vnic.oc1.ap-sydney-1.v1" +`) + require.NoError(t, err) + assert.Equal(t, "ocid1.vnic.oc1.ap-sydney-1.v1", cfg.Network.ExternalPools[0].OCIVNICID) +} + +func TestLoadConfig_NetworkPoolOCIRequiresCompartment(t *testing.T) { + _, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_vnic_iface = "br-wan" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "oci_compartment_id") +} + +// An OCID and an interface name that disagree would send allocations to a VNIC +// the datapath is not on, and OCI would drop the traffic silently. +func TestLoadConfig_NetworkPoolOCIRejectsBothVNICKeys(t *testing.T) { + _, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_id = "ocid1.vnic.oc1.ap-sydney-1.v1" +oci_vnic_iface = "br-wan" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of oci_vnic_id or oci_vnic_iface") +} + +func TestLoadConfig_NetworkPoolOCIRejectsNeitherVNICKey(t *testing.T) { + _, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "exactly one of oci_vnic_id or oci_vnic_iface") +} + +func TestLoadConfig_NetworkPoolOCIRejectsRange(t *testing.T) { + _, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_iface = "br-wan" +range_start = "10.200.0.100" +range_end = "10.200.0.110" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "range_start") +} + +// One OCI address per VPC gateway would exhaust the 50-per-region public IP +// quota after fifty VPCs, for addresses no customer ever sees. +func TestLoadConfig_NetworkPoolOCIRejectsGwLrpRange(t *testing.T) { + _, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_iface = "br-wan" +gw_lrp_range_start = "10.200.0.200" +gw_lrp_range_end = "10.200.0.210" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "gw_lrp_range_start") +} + +func TestLoadConfig_NetworkPoolOCIRejectsDHCPKeys(t *testing.T) { + _, err := loadOCIPool(t, "nat", ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_iface = "br-wan" +bind_bridge = "br-wan" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "bind_bridge") +} + +// Pool mode puts a per-VPC gateway MAC on the uplink and ARPs for the address. +// An OCI VNIC accepts only its own MAC, so a pool-mode guest's egress is +// dropped and its ingress never reaches OVN — silently, and only once a guest +// boots. Refusing at config time is the whole point. +func TestLoadConfig_NetworkPoolOCIRequiresRoutedMode(t *testing.T) { + for _, mode := range []string{"", "pool"} { + _, err := loadOCIPool(t, mode, ` +source = "oci" +oci_compartment_id = "ocid1.compartment.oc1..c1" +oci_vnic_iface = "br-wan" +`) + require.Error(t, err, "external_mode=%q must be refused for source=oci", mode) + assert.Contains(t, err.Error(), "external_mode") + assert.Contains(t, err.Error(), "MAC", "the refusal must say why, or it reads as arbitrary") + } +} + +func TestLoadConfig_OCIKeysRejectedOnANonOCIPool(t *testing.T) { + _, err := loadOCIPool(t, "pool", ` +source = "static" +oci_compartment_id = "ocid1.compartment.oc1..c1" +range_start = "10.200.0.100" +range_end = "10.200.0.110" +`) + require.Error(t, err) + assert.Contains(t, err.Error(), "oci_* keys are only valid") +} + +func TestLoadConfig_UnknownSourceErrorNamesOCI(t *testing.T) { + _, err := loadOCIPool(t, "pool", `source = "magic"`) + require.Error(t, err) + assert.Contains(t, err.Error(), `"oci"`) +} diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 5258ccfd0..f53c75352 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -65,6 +65,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/kvutil" "github.com/mulgadc/spinifex/spinifex/network/external" "github.com/mulgadc/spinifex/spinifex/network/external/dhcp" + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" "github.com/mulgadc/spinifex/spinifex/network/host" "github.com/mulgadc/spinifex/spinifex/objectstore" "github.com/mulgadc/spinifex/spinifex/otelsetup" @@ -1268,6 +1269,9 @@ func (d *Daemon) subscribeAll() error { natsSub{"ec2.DisassociateAddress", handleNATSRequest(d.node, d.eipService.DisassociateAddress), "spinifex-workers"}, natsSub{"ec2.DescribeAddresses", handleNATSRequest(d.node, d.eipService.DescribeAddresses), "spinifex-workers"}, natsSub{"ec2.DescribeAddressesAttribute", handleNATSRequest(d.node, d.eipService.DescribeAddressesAttribute), "spinifex-workers"}, + // Fan-out, no queue group: the association has to reach the node running + // the instance, which is rarely the one that served the request. + natsSub{handlers_ec2_eip.SubjectENIPublicIPChanged, d.handleENIPublicIPChanged, ""}, // vpcd holds the leases, but the records naming those addresses live // here, so the reconcile request flows daemon-ward. natsSub{dhcp.TopicLeaseChanged, d.handleDHCPLeaseChanged, "spinifex-workers"}, @@ -1358,6 +1362,12 @@ func (d *Daemon) startLocal() error { } } + // Before any tap is attached: the endpoint addresses and vpcd's binds have + // to agree, and both read the same config keys. + if d.clusterConfig != nil { + host.SetIMDSHostAddrs(d.clusterConfig.Network.IMDSHostMetaIP, d.clusterConfig.Network.IMDSHostDNSIP) + } + // Initialise OVS network plumber (no NATS dep). if d.networkPlumber == nil { d.networkPlumber = host.NewOVSPlumber() @@ -1436,6 +1446,14 @@ func (d *Daemon) externalPoolConfigs() (pools []external.ExternalPoolConfig, any AZ: p.AZ, GwLrpRangeStart: p.GwLrpRangeStart, GwLrpRangeEnd: p.GwLrpRangeEnd, + + OCICompartmentID: p.OCICompartmentID, + OCIVNICID: p.OCIVNICID, + OCIVNICIface: p.OCIVNICIface, + OCISubnetID: p.OCISubnetID, + OCIPublicIPPool: p.OCIPublicIPPool, + OCIConfigFile: p.OCIConfigFile, + OCIConfigProfile: p.OCIConfigProfile, }) if p.Source == "dhcp" { anyDHCP = true @@ -1444,6 +1462,79 @@ func (d *Daemon) externalPoolConfigs() (pools []external.ExternalPoolConfig, any return pools, anyDHCP } +// ovnSBAddr is this node's OVN Southbound address, empty when unconfigured, +// which leaves the local socket. +func (d *Daemon) ovnSBAddr() string { + if d.clusterConfig == nil { + return "" + } + return d.clusterConfig.Nodes[d.clusterConfig.Node].VPCD.OVNSBAddr +} + +// installOCIAllocators builds one OCI allocator per source="oci" pool and +// reconciles it against OCI before it serves anything. The reconcile is on the +// startup path on purpose: Allocate creates OCI objects before writing its +// record, so a crash in between leaves a reserved public IP that bills and +// holds one of the 50 per-region slots with nothing referencing it, and this +// pass is the only thing that ever finds it. +func (d *Daemon) installOCIAllocators(ipam *handlers_ec2_vpc.ExternalIPAM, js jetstream.JetStream) error { + for _, p := range ipam.PoolsWithSource(external.SourceOCI) { + alloc, err := ocinet.FromPoolConfig(d.ctx, js, p, d.ovnSBAddr()) + if err != nil { + return fmt.Errorf("build OCI allocator for pool %q: %w", p.Name, err) + } + if err := ipam.InstallAllocator(p.Name, alloc); err != nil { + return err + } + // A reconcile failure is not fatal: it leaks money, not correctness, + // and refusing to start would take EIPs down over a transient API + // error. It is logged at Error so it is never silently skipped. + res, err := alloc.Reconcile(d.ctx) + if err != nil { + slog.Error("OCI allocator reconcile failed; leaked addresses may be billing", + "pool", p.Name, "err", err) + continue + } + slog.Info("OCI allocator ready", "pool", p.Name, + "collected", len(res.Collected), "stale_bindings", len(res.Stale), "skipped", res.Skipped) + go d.runOCIAffinityLoop(alloc, p.Name) + } + return nil +} + +// ociAffinityInterval is how often a node checks that OCI delivers its guests' +// addresses to it. A guest that has just started elsewhere is dark until the +// pass runs, so it is short relative to a boot — the address is back before the +// guest has finished coming up. +const ociAffinityInterval = 15 * time.Second + +// runOCIAffinityLoop keeps OCI's idea of where an address lives in step with +// where its guest actually runs. Every node runs its own, like the host EIP +// loop and for the same reason: the question is about this host's guests, and a +// node that never wins the reconcile lease would otherwise never claim the +// addresses of the instances it is running. +func (d *Daemon) runOCIAffinityLoop(alloc *ocinet.PoolAllocator, poolName string) { + ticker := time.NewTicker(ociAffinityInterval) + defer ticker.Stop() + for { + select { + case <-d.ctx.Done(): + return + case <-ticker.C: + } + res, err := alloc.ClaimLocalAddresses(d.ctx) + if err != nil { + slog.Error("OCI address affinity pass failed; a guest here may be unreachable on its public address", + "pool", poolName, "err", err) + continue + } + if len(res.Claimed) > 0 { + slog.Info("OCI addresses moved to this node", "pool", poolName, + "claimed", res.Claimed, "local_bindings", res.Local) + } + } +} + // hasPublicIPPools reports whether the cluster can allocate routable public // IPs: pool mode always, nat mode only with a public pool beside the transit. func (d *Daemon) hasPublicIPPools() bool { @@ -1691,6 +1782,9 @@ func (d *Daemon) startCluster() error { return nil, fmt.Errorf("enable DHCP allocator: %w", dhcpErr) } } + if ociErr := d.installOCIAllocators(ipam, js); ociErr != nil { + return nil, ociErr + } return ipam, nil }) if err != nil { diff --git a/spinifex/daemon/daemon_handlers_eni_public_ip.go b/spinifex/daemon/daemon_handlers_eni_public_ip.go new file mode 100644 index 000000000..f808c136c --- /dev/null +++ b/spinifex/daemon/daemon_handlers_eni_public_ip.go @@ -0,0 +1,49 @@ +package daemon + +import ( + "encoding/json" + "log/slog" + + "github.com/nats-io/nats.go" + + handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip" + "github.com/mulgadc/spinifex/spinifex/vm" +) + +// handleENIPublicIPChanged applies an EIP association to the instance record on +// whichever node is running it. AssociateAddress is served by a queue-group +// worker that is rarely that node, so without this the address was written to +// the EIP and ENI records while the instance kept reporting whatever launch +// assigned it — the stale address survived the association and every describe. +// +// Every node receives this and all but one ignore it: UpdateState reports the +// instance absent, which is the normal case, not a failure. +func (d *Daemon) handleENIPublicIPChanged(msg *nats.Msg) string { + var evt handlers_ec2_eip.ENIPublicIPChanged + if err := json.Unmarshal(msg.Data, &evt); err != nil { + slog.Error("public-IP change: bad event", "err", err) + return outcomeError + } + if evt.InstanceID == "" { + return outcomeSuccess + } + + found, err := d.vmMgr.UpdateAndPersist(evt.InstanceID, func(v *vm.VM) bool { + if v.PublicIP == evt.PublicIP { + return false + } + v.PublicIP = evt.PublicIP + v.PublicIPPool = evt.PoolName + return true + }) + if err != nil { + slog.Error("public-IP change: failed to persist the new address", + "instanceId", evt.InstanceID, "publicIp", evt.PublicIP, "err", err) + return outcomeError + } + if found { + slog.Info("public-IP change applied to a local instance", + "instanceId", evt.InstanceID, "eniId", evt.ENIID, "publicIp", evt.PublicIP) + } + return outcomeSuccess +} diff --git a/spinifex/daemon/vm_adapters.go b/spinifex/daemon/vm_adapters.go index 06a83fd73..2f7c4aba2 100644 --- a/spinifex/daemon/vm_adapters.go +++ b/spinifex/daemon/vm_adapters.go @@ -768,7 +768,9 @@ func (a *instanceCleanerAdapter) CleanupMgmtNetwork(instance *vm.VM) { // ReleasePublicIP publishes vpc.delete-nat for the OVN dnat_and_snat rule // and releases the public IP back to the external IPAM pool. No-op when -// the instance has no public IP. +// the instance has no public IP. Terminate calls it for whatever the instance +// holds; the stop path calls it only once it has established the address is +// not an Elastic IP. func (a *instanceCleanerAdapter) ReleasePublicIP(instance *vm.VM) error { if instance.PublicIP == "" || instance.PublicIPPool == "" || a.d.externalIPAM == nil { return nil @@ -800,15 +802,59 @@ func (a *instanceCleanerAdapter) ReleasePublicIP(instance *vm.VM) error { "instanceId", instance.ID, "err", err) return nil } - slog.Warn("Failed to release public IP on termination", + slog.Warn("Failed to release public IP", "ip", instance.PublicIP, "pool", instance.PublicIPPool, "err", err) return err } - slog.Info("Released public IP on termination", + slog.Info("Released public IP", "ip", instance.PublicIP, "instanceId", instance.ID) return nil } +// ReleaseAutoAssignedPublicIP returns an auto-assigned address to its pool when +// the instance stops, as AWS does. Three things can be holding the address and +// only one of them may be reclaimed here: an EIP allocated through the EIP +// service (PublicIPAllocID), an EIP associated to this ENI after the fact, or +// the address the launch borrowed. The first two are the customer's and stay. +func (a *instanceCleanerAdapter) ReleaseAutoAssignedPublicIP(instance *vm.VM) (bool, error) { + if instance.PublicIP == "" || instance.PublicIPPool == "" || a.d.externalIPAM == nil { + return false, nil + } + if instance.PublicIPAllocID != "" { + return false, nil + } + ctx := context.Background() + if instance.ENIId != "" && a.d.vpcService != nil { + owned, err := a.d.vpcService.ENIHasEIP(ctx, instance.AccountID, instance.ENIId) + if err != nil { + // Keeping the address costs a pool slot until terminate; releasing one + // we could not prove is ours destroys a customer's Elastic IP. + return false, fmt.Errorf("check EIP association for %s: %w", instance.ENIId, err) + } + if owned { + slog.Info("Stop kept the public address — it belongs to an Elastic IP", + "instanceId", instance.ID, "ip", instance.PublicIP, "eni", instance.ENIId) + return false, nil + } + } + + if err := a.ReleasePublicIP(instance); err != nil { + return false, err + } + + // The reconciler rebuilds the NAT rule from the ENI record, so leaving the + // address on it puts back the rule the release just took down. + if instance.ENIId != "" && a.d.vpcService != nil { + if err := a.d.vpcService.UpdateENIPublicIP(instance.AccountID, instance.ENIId, "", ""); err != nil { + slog.Warn("Failed to clear ENI public IP on stop", + "instanceId", instance.ID, "eni", instance.ENIId, "err", err) + } + } + slog.Info("Released auto-assigned public IP on stop", + "instanceId", instance.ID, "ip", instance.PublicIP, "pool", instance.PublicIPPool) + return true, nil +} + // DetachAndDeleteENI detaches the auto-created ENI from the instance and // deletes it via the VPC service. NotFound is tolerated. It then sweeps every // other ENI the KV records against this instance — post-launch attaches diff --git a/spinifex/daemon/vpc_adapter.go b/spinifex/daemon/vpc_adapter.go index 54a16a934..97c8524a0 100644 --- a/spinifex/daemon/vpc_adapter.go +++ b/spinifex/daemon/vpc_adapter.go @@ -54,6 +54,8 @@ func (a *daemonENICreator) GetENI(_ context.Context, accountID, eniID string) (* MacAddress: rec.MacAddress, Status: rec.Status, SecurityGroupIDs: rec.SecurityGroupIds, + PublicIpAddress: rec.PublicIpAddress, + PublicIpPool: rec.PublicIpPool, DeleteOnTermination: rec.DeleteOnTermination == nil || *rec.DeleteOnTermination, }, nil @@ -75,6 +77,10 @@ func (a *daemonENICreator) UpdateENIPublicIP(_ context.Context, accountID, eniID return a.d.vpcService.UpdateENIPublicIP(accountID, eniID, publicIP, poolName) } +func (a *daemonENICreator) ENIHasEIP(ctx context.Context, accountID, eniID string) (bool, error) { + return a.d.vpcService.ENIHasEIP(ctx, accountID, eniID) +} + func (a *daemonENICreator) ListInstanceENIs(_ context.Context, accountID, instanceID string) ([]handlers_ec2_instance.ENIInfo, error) { records, err := a.d.vpcService.ListInstanceENIs(accountID, instanceID) if err != nil { diff --git a/spinifex/formation/formation.go b/spinifex/formation/formation.go index 3c74498a0..fc822a7b7 100644 --- a/spinifex/formation/formation.go +++ b/spinifex/formation/formation.go @@ -16,6 +16,7 @@ import ( "time" "github.com/mulgadc/bluebottle/pkg/tlsconfig" + "github.com/mulgadc/spinifex/spinifex/admin" ) // NodeInfo describes a node participating in cluster formation. @@ -63,16 +64,14 @@ type StatusResponse struct { // NetworkConfig holds the cluster-wide external networking configuration // propagated from the init node to joining nodes during formation. type NetworkConfig struct { - ExternalMode string `json:"external_mode"` - PoolName string `json:"pool_name"` - PoolSource string `json:"pool_source,omitempty"` - PoolBindBridge string `json:"pool_bind_bridge,omitempty"` - PoolStart string `json:"pool_start,omitempty"` - PoolEnd string `json:"pool_end,omitempty"` - PoolGateway string `json:"pool_gateway"` - PoolGatewayIP string `json:"pool_gateway_ip,omitempty"` - PoolPrefixLen int `json:"pool_prefix_len"` - PoolDNSServers []string `json:"pool_dns_servers,omitempty"` + ExternalMode string `json:"external_mode"` + + // Pools is every [[network.external_pools]] entry the init node rendered, in + // order. A list rather than one flattened pool because routed NAT has two — + // the transit segment and any public pool — and the transit pool carries a + // gateway-LRP range that has no equivalent in pool mode. + Pools []admin.PoolData `json:"pools,omitempty"` + PoolDNSServers []string `json:"pool_dns_servers,omitempty"` // IPSecEnabled propagates the cluster-wide intra-AZ IPsec toggle so the // joining node provisions strongSwan and flips OVS ipsec_encapsulation to diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index dab658f3d..7dc992a17 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -482,6 +482,14 @@ var ec2Actions = map[string]ec2Action{ "DescribeVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_vpc.DescribeVpcAttribute(ctx, input, gw.NATSConn, accountID) }), + // No VPC endpoint can exist here, because CreateVpcEndpoint is not served, so + // the empty list is the true answer rather than a stand-in for one. It has to + // be answered all the same: the Terraform AWS provider calls this on every VPC + // delete to sweep GuardDuty-managed endpoints, and an InvalidAction there fails + // the destroy and strands the VPC. + "DescribeVpcEndpoints": ec2Handler(func(_ context.Context, _ *ec2.DescribeVpcEndpointsInput, _ *GatewayConfig, _ string) (any, error) { + return ec2.DescribeVpcEndpointsOutput{VpcEndpoints: []*ec2.VpcEndpoint{}}, nil + }), "CreateSubnet": ec2Handler(func(ctx context.Context, input *ec2.CreateSubnetInput, gw *GatewayConfig, accountID string) (any, error) { if err := gw.Quota.EnforceSubnets(ctx, gw.NATSConn, accountID, 1); err != nil { return nil, err diff --git a/spinifex/gateway/ec2/authz.go b/spinifex/gateway/ec2/authz.go index 060c27aa0..6ca5b30a2 100644 --- a/spinifex/gateway/ec2/authz.go +++ b/spinifex/gateway/ec2/authz.go @@ -295,6 +295,7 @@ var ec2Scopes = map[string][]*resourceScope{ "DescribeVolumes": unscoped, "DescribeVolumesModifications": unscoped, "DescribeVpcAttribute": unscoped, + "DescribeVpcEndpoints": unscoped, "DescribeVpcs": unscoped, } diff --git a/spinifex/handlers/ec2/eip/service_impl.go b/spinifex/handlers/ec2/eip/service_impl.go index 868d798f1..57a4823e4 100644 --- a/spinifex/handlers/ec2/eip/service_impl.go +++ b/spinifex/handlers/ec2/eip/service_impl.go @@ -40,6 +40,21 @@ type EIPServiceImpl struct { allocSF singleflight.Group } +// SubjectENIPublicIPChanged carries an EIP association to every daemon, because +// the node that serves AssociateAddress is load-balanced and is rarely the one +// running the instance. Fan-out with no queue group: the node holding the +// instance is the only one that acts, and every other ignores it. +const SubjectENIPublicIPChanged = "ec2.eni.public-ip-changed" + +// ENIPublicIPChanged is the payload on SubjectENIPublicIPChanged. An empty +// PublicIP is a disassociate. +type ENIPublicIPChanged struct { + InstanceID string `json:"instance_id"` + ENIID string `json:"eni_id"` + PublicIP string `json:"public_ip"` + PoolName string `json:"pool_name,omitempty"` +} + // natEvent is the payload published to vpc.add-nat / vpc.delete-nat topics. type natEvent struct { VpcId string `json:"vpc_id"` @@ -258,6 +273,10 @@ func (s *EIPServiceImpl) AssociateAddress(ctx context.Context, input *ec2.Associ return nil, errors.New(awserrors.ErrorServerInternal) } + s.bindPoolOwner(ctx, record.PoolName, record.PublicIp, eniID) + s.stampENIPublicIP(ctx, accountID, eniID, record.PublicIp, record.PoolName) + s.announcePublicIP(ctx, instanceID, eniID, record.PublicIp, record.PoolName) + // Publish vpc.add-nat event (fire-and-forget). s.publishNATEvent("vpc.add-nat", vpcID, record.PublicIp, privateIP, eniID, macAddr) @@ -299,6 +318,8 @@ func (s *EIPServiceImpl) DisassociateAddress(ctx context.Context, input *ec2.Dis s.publishNATEvent("vpc.delete-nat", record.VpcId, record.PublicIp, record.PrivateIp, record.ENIId, macAddr) } + poolName, publicIP := record.PoolName, record.PublicIp + eniID, instanceID := record.ENIId, record.InstanceId clearAssociation(record) data, err := json.Marshal(record) @@ -308,12 +329,84 @@ func (s *EIPServiceImpl) DisassociateAddress(ctx context.Context, input *ec2.Dis if _, err := s.eipKV.Update(ctx, key, data, revision); err != nil { return nil, errors.New(awserrors.ErrorServerInternal) } + s.bindPoolOwner(ctx, poolName, publicIP, "") + s.stampENIPublicIP(ctx, accountID, eniID, "", "") + s.announcePublicIP(ctx, instanceID, eniID, "", "") slog.InfoContext(ctx, "DisassociateAddress completed", "associationId", associationID, "accountID", accountID) return &ec2.DisassociateAddressOutput{}, nil } +// bindPoolOwner tells the pool which ENI now holds publicIP, empty for none. +// Most pools have nothing to do with it; a pool whose addresses are delivered +// to one node needs it, because an EIP is allocated with no ENI and this is the +// only point the owner is ever known. +// +// Logged rather than returned: the association itself has already been written +// and is what AWS answers on, so failing the call here would report an error for +// an association that did happen. The pool's own reconcile converges it. +func (s *EIPServiceImpl) bindPoolOwner(ctx context.Context, poolName, publicIP, eniID string) { + if poolName == "" || publicIP == "" { + return + } + if err := s.externalIPAM.BindOwner(ctx, poolName, publicIP, eniID); err != nil { + slog.WarnContext(ctx, "failed to record the EIP owner with its pool", + "pool", poolName, "publicIp", publicIP, "eniId", eniID, "err", err) + } +} + +// announcePublicIP tells every daemon that eniID's public address changed, so +// the one running the instance can report it. Best-effort: the EIP and ENI +// records are already written and are what the next reconcile reads, so a lost +// announcement costs a stale DescribeInstances until then, not correctness. +func (s *EIPServiceImpl) announcePublicIP(ctx context.Context, instanceID, eniID, publicIP, poolName string) { + if s.natsConn == nil || instanceID == "" { + return + } + payload, err := json.Marshal(ENIPublicIPChanged{ + InstanceID: instanceID, ENIID: eniID, PublicIP: publicIP, PoolName: poolName, + }) + if err != nil { + slog.WarnContext(ctx, "failed to encode the public-IP change announcement", + "instanceId", instanceID, "err", err) + return + } + if err := s.natsConn.Publish(SubjectENIPublicIPChanged, payload); err != nil { + slog.WarnContext(ctx, "failed to announce the public-IP change", + "instanceId", instanceID, "eniId", eniID, "publicIp", publicIP, "err", err) + } +} + +// eniPublicIPStamper is the ENI-record write the EIP service needs. Narrowed to +// this one method rather than widening VPCService, which every other consumer +// would then have to implement. +type eniPublicIPStamper interface { + UpdateENIPublicIP(accountID, eniID, publicIP, poolName string) error +} + +// stampENIPublicIP records the address on the ENI, empty to clear it. The ENI +// record is what IMDS serves and what the network reconcile reads, so without +// this an associated EIP was visible only on the EIP record and the interface +// still advertised whatever launch assigned it. +// +// Logged rather than returned, like bindPoolOwner: the association is already +// written and answered on, so failing here would report an error for something +// that did happen. +func (s *EIPServiceImpl) stampENIPublicIP(ctx context.Context, accountID, eniID, publicIP, poolName string) { + if eniID == "" { + return + } + stamper, ok := s.vpcService.(eniPublicIPStamper) + if !ok { + return + } + if err := stamper.UpdateENIPublicIP(accountID, eniID, publicIP, poolName); err != nil { + slog.WarnContext(ctx, "failed to stamp the public IP on the ENI record", + "eniId", eniID, "publicIp", publicIP, "err", err) + } +} + // clearAssociation reverts a record to the unassociated "allocated" state. The // allocation itself survives: disassociating never hands the address back. func clearAssociation(record *EIPRecord) { @@ -352,7 +445,7 @@ func (s *EIPServiceImpl) DisassociateByENI(ctx context.Context, accountID, eniID // as part of tearing that ENI down, so the interface may already be gone. s.publishNATEvent("vpc.delete-nat", record.VpcId, record.PublicIp, record.PrivateIp, eniID, record.MacAddress) - publicIP, allocationID := record.PublicIp, record.AllocationId + publicIP, allocationID, poolName := record.PublicIp, record.AllocationId, record.PoolName clearAssociation(record) data, err := json.Marshal(record) @@ -362,6 +455,8 @@ func (s *EIPServiceImpl) DisassociateByENI(ctx context.Context, accountID, eniID if _, err := s.eipKV.Update(ctx, m.key, data, m.revision); err != nil { return false, errors.New(awserrors.ErrorServerInternal) } + s.bindPoolOwner(ctx, poolName, publicIP, "") + // No ENI stamp to clear: this runs while that ENI is being deleted. slog.InfoContext(ctx, "DisassociateByENI completed", "eniId", eniID, "publicIp", publicIP, "allocationId", allocationID, "accountID", accountID) diff --git a/spinifex/handlers/ec2/instance/projection.go b/spinifex/handlers/ec2/instance/projection.go index a12fbe611..3782b6cae 100644 --- a/spinifex/handlers/ec2/instance/projection.go +++ b/spinifex/handlers/ec2/instance/projection.go @@ -52,8 +52,18 @@ func ProjectInstance(v *vm.VM, cfg InstanceProjection) (inst *ec2.Instance, stat // Public IP and its derived DNS names exist only while the instance runs; a // stopped instance has released the public IP, so the KV path leaves them // unset. Mirrors the records the control-plane writer publishes to northstar. + if !cfg.IncludeRuntimeNetwork { + // Cleared, not merely left alone: launch stamps the auto-assigned address + // onto the stored instance, so a stopped instance kept reporting an + // address that had been released and may already belong to someone else. + instanceCopy.PublicIpAddress = nil + instanceCopy.PublicDnsName = nil + } if cfg.IncludeRuntimeNetwork { - if v.PublicIP != "" && instanceCopy.PublicIpAddress == nil { + // The runtime address wins over the launch stamp. Associating an EIP + // replaces what an instance is reachable on, and filling only a nil field + // could never express that — the stamp is always present. + if v.PublicIP != "" { instanceCopy.PublicIpAddress = aws.String(v.PublicIP) } @@ -172,7 +182,12 @@ func ProjectInstance(v *vm.VM, cfg InstanceProjection) (inst *ec2.Instance, stat // force-new, so leaving Association unset reports false for an instance // that has a public IP and every plan proposes a destroy/recreate. isPrimaryNIC := nicCopy.Attachment != nil && nicCopy.Attachment.DeviceIndex != nil && *nicCopy.Attachment.DeviceIndex == 0 - if cfg.IncludeRuntimeNetwork && isPrimaryNIC && v.PublicIP != "" { + switch { + case !cfg.IncludeRuntimeNetwork: + // Same reason the instance-level address is cleared: the stored + // association is the launch-time one and outlives the address. + nicCopy.Association = nil + case isPrimaryNIC && v.PublicIP != "": nicCopy.Association = &ec2.InstanceNetworkInterfaceAssociation{ PublicIp: aws.String(v.PublicIP), PublicDnsName: aws.String(publicDNS), diff --git a/spinifex/handlers/ec2/instance/projection_public_ip_test.go b/spinifex/handlers/ec2/instance/projection_public_ip_test.go new file mode 100644 index 000000000..f6db2f04f --- /dev/null +++ b/spinifex/handlers/ec2/instance/projection_public_ip_test.go @@ -0,0 +1,94 @@ +//test:in-package — extends projection_test.go's in-package fullVM fixture and +// projects through the unexported field set it builds. + +package handlers_ec2_instance + +import ( + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/vm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stampedVM is a launched instance: the auto-assigned address is written onto +// the stored ec2.Instance at launch and stays there for the instance's life. +func stampedVM(status vm.InstanceState, runtimePublicIP string) *vm.VM { + v := fullVM(status) + v.PublicIP = runtimePublicIP + v.Instance.PublicIpAddress = aws.String("203.0.113.7") + v.Instance.PublicDnsName = aws.String("ec2-203-0-113-7.example.com") + v.Instance.NetworkInterfaces = []*ec2.InstanceNetworkInterface{{ + NetworkInterfaceId: aws.String("eni-1"), + Attachment: &ec2.InstanceNetworkInterfaceAttachment{DeviceIndex: aws.Int64(0)}, + Association: &ec2.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("203.0.113.7"), + }, + }} + return v +} + +// AWS releases an auto-assigned address when an instance stops. Ours kept +// reporting it, because the launch stamp lives on the stored instance and the +// stopped path only declined to add an address — it never removed one. +func TestStoppedInstanceReportsNoPublicAddress(t *testing.T) { + v := stampedVM(vm.StateStopped, "") + + got, _ := ProjectInstance(v, InstanceProjection{ + Region: "us-west-1", IncludeRuntimeNetwork: false, + FallbackStateCode: 80, FallbackStateName: "stopped", + }) + + assert.Nil(t, got.PublicIpAddress, "a stopped instance still advertises a released address") + assert.Nil(t, got.PublicDnsName) + require.Len(t, got.NetworkInterfaces, 1) + assert.Nil(t, got.NetworkInterfaces[0].Association, + "the primary NIC still advertises the released association") +} + +// Clearing the projection must not reach into the stored instance, or the +// address is gone for good and a restart cannot report it again. +func TestClearingTheStoppedAddressLeavesTheStoredInstanceIntact(t *testing.T) { + v := stampedVM(vm.StateStopped, "") + + _, _ = ProjectInstance(v, InstanceProjection{ + Region: "us-west-1", IncludeRuntimeNetwork: false, + FallbackStateCode: 80, FallbackStateName: "stopped", + }) + + assert.Equal(t, "203.0.113.7", aws.StringValue(v.Instance.PublicIpAddress)) + require.Len(t, v.Instance.NetworkInterfaces, 1) + require.NotNil(t, v.Instance.NetworkInterfaces[0].Association) +} + +// Associating an EIP replaces what the instance is reachable on. The old +// projection only filled a nil field, and the launch stamp is never nil, so the +// EIP could not be reported however correctly it was recorded. +func TestTheRuntimeAddressWinsOverTheLaunchStamp(t *testing.T) { + v := stampedVM(vm.StateRunning, "198.51.100.42") + + got, _ := ProjectInstance(v, InstanceProjection{ + Region: "us-west-1", IncludeRuntimeNetwork: true, + FallbackStateCode: 16, FallbackStateName: "running", + }) + + assert.Equal(t, "198.51.100.42", aws.StringValue(got.PublicIpAddress)) + require.Len(t, got.NetworkInterfaces, 1) + require.NotNil(t, got.NetworkInterfaces[0].Association) + assert.Equal(t, "198.51.100.42", aws.StringValue(got.NetworkInterfaces[0].Association.PublicIp)) +} + +// With no EIP the auto-assigned address is still the answer, so the stamp must +// survive a running projection. +func TestTheLaunchStampStandsWhenThereIsNoRuntimeAddress(t *testing.T) { + v := stampedVM(vm.StateRunning, "") + + got, _ := ProjectInstance(v, InstanceProjection{ + Region: "us-west-1", IncludeRuntimeNetwork: true, + FallbackStateCode: 16, FallbackStateName: "running", + }) + + assert.Equal(t, "203.0.113.7", aws.StringValue(got.PublicIpAddress)) +} diff --git a/spinifex/handlers/ec2/instance/service.go b/spinifex/handlers/ec2/instance/service.go index f2f6bc676..87be23040 100644 --- a/spinifex/handlers/ec2/instance/service.go +++ b/spinifex/handlers/ec2/instance/service.go @@ -188,6 +188,11 @@ type ENIInfo struct { MacAddress string Status string SecurityGroupIDs []string + // PublicIpAddress / PublicIpPool are the address the interface is currently + // reachable on. The record outlives a stop, so a start reads them to find an + // Elastic IP associated while the instance was down. + PublicIpAddress string + PublicIpPool string // DeleteOnTermination mirrors the stored ENIRecord field, defaulted true // when unset. Read by the terminate sweep to decide detach-only vs delete. DeleteOnTermination bool @@ -205,6 +210,11 @@ type ENICreator interface { AttachENI(ctx context.Context, accountID, eniID, instanceID string, deviceIndex int64) (string, error) DetachENI(ctx context.Context, accountID, eniID string) error UpdateENIPublicIP(ctx context.Context, accountID, eniID, publicIP, poolName string) error + // ENIHasEIP reports whether an Elastic IP is associated with the ENI. A + // start must not auto-assign an address beside one the customer already + // owns: the EIP is what the instance is reachable on, and a second address + // is one they never asked for and would be billed for on some providers. + ENIHasEIP(ctx context.Context, accountID, eniID string) (bool, error) // ListInstanceENIs returns every ENI currently attached to instanceID. // Used by the terminate sweep for post-launch attachments the launch-time // ENIId scalar on vm.VM never carries. diff --git a/spinifex/handlers/ec2/instance/service_impl.go b/spinifex/handlers/ec2/instance/service_impl.go index 76f86afaa..a2f18d89f 100644 --- a/spinifex/handlers/ec2/instance/service_impl.go +++ b/spinifex/handlers/ec2/instance/service_impl.go @@ -2658,8 +2658,29 @@ func (s *InstanceServiceImpl) StartStoppedInstance(ctx context.Context, input *S "count", len(attachments), "attachments", attachments) } + // Before Run, so a pool with nothing left refuses the start instead of + // booting a guest the customer cannot reach. + addressAllocated, err := s.reassignAutoAssignedPublicIP(ctx, instance) + if err != nil { + slog.ErrorContext(ctx, "StartStoppedInstance: public IP allocation failed — refusing the start", + "instanceId", input.InstanceID, "err", err) + if gpuClaimed { + if relErr := s.gpuClaimer.Release(instance.ID); relErr != nil { + slog.ErrorContext(ctx, "StartStoppedInstance: GPU release failed after address allocation failure", + "instanceId", input.InstanceID, "err", relErr) + } + } + s.resourceMgr.Deallocate(instanceType) + s.vmMgr.Delete(instance.ID) + s.restoreClaimedStoppedInstance(ctx, instance) + return nil, errors.New(awserrors.ValidErrorCodeFromError(err)) + } + if err := s.vmMgr.Run(ctx, instance); err != nil { slog.ErrorContext(ctx, "StartStoppedInstance: vmMgr.Run failed", "instanceId", input.InstanceID, "err", err) + if addressAllocated { + s.rollbackStartPublicIP(ctx, instance) + } if gpuClaimed { if relErr := s.gpuClaimer.Release(instance.ID); relErr != nil { slog.ErrorContext(ctx, "StartStoppedInstance: GPU release failed after launch failure", @@ -2881,6 +2902,110 @@ func (s *InstanceServiceImpl) allocatePublicIP(ctx context.Context, eniID, insta return s.ipAllocator.AllocateIP(ctx, region, az, handlers_ec2_vpc.PurposeENIPublic, "", eniID, instanceID) } +// reassignAutoAssignedPublicIP gives a starting instance the fresh address AWS +// would. The one it had went back to its pool when it stopped, so an address is +// taken here and stamped everywhere the datapath reads it. +// +// It does nothing unless the stop actually released one, which is what keeps an +// instance that never had a public address, one holding an Elastic IP, and one +// stopped by a build that predates this from being handed an address on start. +// An exhausted pool is returned as an error so the caller refuses the start +// rather than booting a guest the customer cannot reach. +// +// allocated reports whether an address came out of the pool here, which is the +// only case a later failure may hand back: an Elastic IP adopted below is the +// customer's and a rollback must not touch it. +func (s *InstanceServiceImpl) reassignAutoAssignedPublicIP(ctx context.Context, instance *vm.VM) (allocated bool, err error) { + if !instance.AutoAssignPublicIP || instance.PublicIP != "" || instance.ENIId == "" { + return false, nil + } + // The flag is cleared only once the instance is holding an address, so a + // refused start stays owed one and the retry does not bring it up with none. + eni, err := s.eniInfoForStart(ctx, instance) + if err != nil { + return false, err + } + + // An Elastic IP associated while the instance was stopped is what it comes + // back on. Auto-assigning here would hand the customer a second address they + // never asked for, so adopt the EIP instead and take nothing from the pool. + if s.eniCreator != nil { + owned, eipErr := s.eniCreator.ENIHasEIP(ctx, instance.AccountID, instance.ENIId) + if eipErr != nil { + return false, fmt.Errorf("check EIP association for %s: %w", instance.ENIId, eipErr) + } + if owned { + instance.PublicIP = eni.PublicIpAddress + instance.PublicIPPool = eni.PublicIpPool + instance.AutoAssignPublicIP = false + slog.InfoContext(ctx, "StartStoppedInstance: instance starts on the Elastic IP associated to its interface", + "instanceId", instance.ID, "eniId", instance.ENIId, "publicIp", instance.PublicIP) + return false, nil + } + } + + publicIP, poolName, err := s.allocatePublicIP(ctx, instance.ENIId, instance.ID) + if err != nil { + return false, fmt.Errorf("allocate public IP for %s: %w", instance.ID, err) + } + if updateErr := s.eniCreator.UpdateENIPublicIP(ctx, instance.AccountID, instance.ENIId, publicIP, poolName); updateErr != nil { + // The reconciler builds the NAT rule from this record, so an address the + // instance holds but the record does not is an address nothing routes. + slog.WarnContext(ctx, "StartStoppedInstance: failed to update ENI with public IP", + "instanceId", instance.ID, "eniId", instance.ENIId, "err", updateErr) + } + if natErr := utils.AddNAT(s.natsConn, eni.VpcID, publicIP, eni.PrivateIpAddress, + topology.Port(instance.ENIId), eni.MacAddress); natErr != nil { + // Neutralise before releasing in case the timeout committed the rule. + utils.PublishNATEvent(s.natsConn, "vpc.delete-nat", eni.VpcID, publicIP, eni.PrivateIpAddress, + topology.Port(instance.ENIId), eni.MacAddress) + s.releaseAutoAssignedPublicIP(ctx, instance.AccountID, instance.ENIId, publicIP, poolName) + return false, natErr + } + + instance.PublicIP = publicIP + instance.PublicIPPool = poolName + instance.AutoAssignPublicIP = false + slog.InfoContext(ctx, "StartStoppedInstance: auto-assigned a new public IP", + "instanceId", instance.ID, "publicIp", publicIP, "pool", poolName) + return true, nil +} + +// eniInfoForStart reads the interface a start is bringing back up. The stored +// instance carries the launch-time copy, which a cross-node start and an EIP +// associated while stopped both outdate, so the record is read fresh. +func (s *InstanceServiceImpl) eniInfoForStart(ctx context.Context, instance *vm.VM) (*ENIInfo, error) { + if s.eniCreator == nil { + return nil, fmt.Errorf("no ENI service on this node: %w", errors.New(awserrors.ErrorServerInternal)) + } + eni, err := s.eniCreator.GetENI(ctx, instance.AccountID, instance.ENIId) + if err != nil { + return nil, fmt.Errorf("read ENI %s: %w", instance.ENIId, err) + } + return eni, nil +} + +// rollbackStartPublicIP returns the address a start took when the launch that +// followed failed, so a refused start does not cost the pool a slot per attempt. +// The instance goes back to the stopped store still marked for re-assignment. +func (s *InstanceServiceImpl) rollbackStartPublicIP(ctx context.Context, instance *vm.VM) { + if instance.PublicIP == "" || instance.PublicIPPool == "" { + return + } + vpcID := "" + privateIP := "" + if instance.Instance != nil { + vpcID = aws.StringValue(instance.Instance.VpcId) + privateIP = aws.StringValue(instance.Instance.PrivateIpAddress) + } + utils.PublishNATEvent(s.natsConn, "vpc.delete-nat", vpcID, instance.PublicIP, privateIP, + topology.Port(instance.ENIId), instance.ENIMac) + s.releaseAutoAssignedPublicIP(ctx, instance.AccountID, instance.ENIId, instance.PublicIP, instance.PublicIPPool) + instance.PublicIP = "" + instance.PublicIPPool = "" + instance.AutoAssignPublicIP = true +} + // rollbackAutoAssignedPublicIP unwinds a failed auto-assign: clears the ENI // public IP record, releases the IPAM lease, then detaches and deletes the ENI. func (s *InstanceServiceImpl) rollbackAutoAssignedPublicIP(ctx context.Context, accountID, instanceID, eniID, publicIP, poolName string) { diff --git a/spinifex/handlers/ec2/instance/service_impl_test.go b/spinifex/handlers/ec2/instance/service_impl_test.go index 1f2f3d242..1ec4309e6 100644 --- a/spinifex/handlers/ec2/instance/service_impl_test.go +++ b/spinifex/handlers/ec2/instance/service_impl_test.go @@ -3497,6 +3497,12 @@ type fakeENICreator struct { detachErr error // returned by every DetachENI call when set instanceENIs map[string][]ENIInfo // keyed by instanceID, for ListInstanceENIs listENIsErr error + eniHasEIP bool // what ENIHasEIP reports for every ENI + eniHasEIPErr error // when set, ENIHasEIP fails instead +} + +func (f *fakeENICreator) ENIHasEIP(_ context.Context, _, _ string) (bool, error) { + return f.eniHasEIP, f.eniHasEIPErr } func (f *fakeENICreator) GetDefaultSubnet(_ context.Context, _ string) (*SubnetInfo, error) { diff --git a/spinifex/handlers/ec2/instance/start_public_ip_test.go b/spinifex/handlers/ec2/instance/start_public_ip_test.go new file mode 100644 index 000000000..0cd46c965 --- /dev/null +++ b/spinifex/handlers/ec2/instance/start_public_ip_test.go @@ -0,0 +1,202 @@ +// Builds an InstanceServiceImpl field by field so the allocator, the releaser +// and the ENI creator are all fakes, which is the only way to see what a start +// did to the address before the launch it then fails. +// +//test:in-package — every dependency it substitutes is an unexported field. +package handlers_ec2_instance + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/types" + "github.com/mulgadc/spinifex/spinifex/vm" + vmmock "github.com/mulgadc/spinifex/spinifex/vm/mock" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// refusingMounter fails the launch at its first real step, which is the +// earliest point past the address work these tests are about. Standing up a +// launchable guest would prove nothing extra and would need QEMU. +type refusingMounter struct{} + +func (refusingMounter) Mount(context.Context, *vm.VM) error { return errors.New("no volumes here") } +func (refusingMounter) Unmount(context.Context, *vm.VM) error { return nil } +func (refusingMounter) MountOne(context.Context, string, *types.EBSRequest) error { + return nil +} +func (refusingMounter) UnmountOne(context.Context, string, types.EBSRequest) error { return nil } + +// startFixture builds a stopped instance whose address went back to the pool, +// wired to fakes for everything the re-assignment touches. vmMgr.Run always +// fails in this harness, so every case asserts what the start did to the +// address before the launch, plus whatever the failure hands back. +type startFixture struct { + svc *InstanceServiceImpl + store *vmmock.StateStore + ipam *fakeIPAllocator + rel *fakePublicIPReleaser + eni *fakeENICreator +} + +func newStartFixture(t *testing.T, stopped *vm.VM) *startFixture { + t.Helper() + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) + + store := &vmmock.StateStore{Stopped: map[string]*vm.VM{stopped.ID: stopped}} + prov := &fakeResourceCapacityProvider{ + instanceTypes: map[string]*ec2.InstanceTypeInfo{ + "t3.micro": {InstanceType: aws.String("t3.micro")}, + }, + } + eniCreator := &fakeENICreator{getENIByID: map[string]*ENIInfo{ + "eni-1": { + NetworkInterfaceID: "eni-1", + VpcID: "vpc-1", + PrivateIpAddress: "10.0.0.5", + MacAddress: "02:00:00:00:00:01", + }, + }} + f := &startFixture{ + store: store, + ipam: &fakeIPAllocator{publicIP: "203.0.113.20", poolName: "wan"}, + rel: &fakePublicIPReleaser{}, + eni: eniCreator, + } + vmMgr := vm.NewManager() + vmMgr.SetDeps(vm.Deps{VolumeMounter: refusingMounter{}}) + f.svc = &InstanceServiceImpl{ + stoppedStore: store, + resourceMgr: prov, + vmMgr: vmMgr, + ipAllocator: f.ipam, + ipReleaser: f.rel, + eniCreator: eniCreator, + natsConn: embeddedNATS(t), + } + + for _, topic := range []string{"vpc.add-nat", "vpc.delete-nat"} { + sub, err := f.svc.natsConn.Subscribe(topic, func(msg *nats.Msg) { + _ = msg.Respond([]byte(`{"success":true}`)) + }) + require.NoError(t, err) + t.Cleanup(func() { _ = sub.Unsubscribe() }) + } + return f +} + +func (f *startFixture) start(t *testing.T, id string) error { + t.Helper() + _, err := f.svc.StartStoppedInstance(context.Background(), &StartStoppedInstanceInput{InstanceID: id}, "acc") + return err +} + +// releasedOnStop is the shape the stop path leaves behind: no address, and the +// marker saying one is owed. +func releasedOnStop(id string) *vm.VM { + return &vm.VM{ + ID: id, + Status: vm.StateStopped, + AccountID: "acc", + InstanceType: "t3.micro", + ENIId: "eni-1", + AutoAssignPublicIP: true, + Instance: &ec2.Instance{VpcId: aws.String("vpc-1"), PrivateIpAddress: aws.String("10.0.0.5")}, + } +} + +// AWS gives a restarted instance a different address than the one it stopped +// with, so a start has to take a new one rather than assume the old one is +// still there. +func TestAStartTakesANewAddressForAnInstanceThatReleasedOneOnStop(t *testing.T) { + f := newStartFixture(t, releasedOnStop("i-restart")) + + require.Error(t, f.start(t, "i-restart"), "the harness has no runnable VM") + + assert.Equal(t, 1, f.ipam.calls, "start did not allocate a replacement address") + assert.Equal(t, 1, f.eni.updateCalls-f.eni.clearCalls, + "the ENI record never learned the new address, so the reconciler would build no NAT rule") +} + +// An instance that never had a public address must not be handed one by a +// start: a private-subnet guest is private on purpose. +func TestAStartLeavesAnInstanceThatNeverHadAnAddressAlone(t *testing.T) { + stopped := releasedOnStop("i-private") + stopped.AutoAssignPublicIP = false + f := newStartFixture(t, stopped) + + require.Error(t, f.start(t, "i-private")) + + assert.Zero(t, f.ipam.calls, "start assigned a public address to an instance that had none") +} + +// An Elastic IP associated while the instance was stopped is what it comes back +// on. Auto-assigning here would hand the customer a second address they never +// asked for and, on OCI, would bill them for it. +func TestAStartAdoptsAnElasticIPRatherThanAssigningBesideIt(t *testing.T) { + f := newStartFixture(t, releasedOnStop("i-eip")) + f.eni.eniHasEIP = true + f.eni.getENIByID["eni-1"].PublicIpAddress = "198.51.100.7" + f.eni.getENIByID["eni-1"].PublicIpPool = "wan" + + require.Error(t, f.start(t, "i-eip")) + + assert.Zero(t, f.ipam.calls, "start assigned a second address beside an Elastic IP") + restored := f.store.WroteStopped["i-eip"] + require.NotNil(t, restored) + assert.Equal(t, "198.51.100.7", restored.PublicIP, + "the instance does not report the Elastic IP it is reachable on") +} + +// An exhausted pool is the AWS answer to a start that cannot be given an +// address, and the instance has to stay stopped and startable rather than be +// lost by the claim that already removed it. +func TestAStartAgainstAnExhaustedPoolIsRefusedAndTheInstanceSurvives(t *testing.T) { + f := newStartFixture(t, releasedOnStop("i-exhausted")) + f.ipam.err = errors.New(awserrors.ErrorInsufficientAddressCapacity) + + err := f.start(t, "i-exhausted") + + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInsufficientAddressCapacity, err.Error()) + restored := f.store.WroteStopped["i-exhausted"] + require.NotNil(t, restored, "the claim removed the instance and nothing put it back") + assert.Equal(t, vm.StateStopped, restored.Status) + assert.True(t, restored.AutoAssignPublicIP, + "a retry would start the instance with no address at all") +} + +// A launch that fails after the address was taken must hand it back, or every +// refused start costs the pool a slot. +func TestAFailedLaunchReturnsTheAddressTheStartTook(t *testing.T) { + f := newStartFixture(t, releasedOnStop("i-launch-fail")) + + require.Error(t, f.start(t, "i-launch-fail")) + + assert.Equal(t, []string{"203.0.113.20"}, f.rel.released, + "the address the start took was not returned when the launch failed") + restored := f.store.WroteStopped["i-launch-fail"] + require.NotNil(t, restored) + assert.Empty(t, restored.PublicIP, "the restored record holds an address the pool has back") + assert.True(t, restored.AutoAssignPublicIP, "the next start would bring the instance up with no address") +} + +// An adopted Elastic IP is not the start's to give back, so a failed launch +// must leave it exactly where it is. +func TestAFailedLaunchDoesNotReturnAnAdoptedElasticIP(t *testing.T) { + f := newStartFixture(t, releasedOnStop("i-eip-fail")) + f.eni.eniHasEIP = true + f.eni.getENIByID["eni-1"].PublicIpAddress = "198.51.100.7" + f.eni.getENIByID["eni-1"].PublicIpPool = "wan" + + require.Error(t, f.start(t, "i-eip-fail")) + + assert.Empty(t, f.rel.released, + "a failed launch gave a customer's Elastic IP back to the pool") +} diff --git a/spinifex/handlers/ec2/vpc/eni.go b/spinifex/handlers/ec2/vpc/eni.go index cbe604360..f3cc99f67 100644 --- a/spinifex/handlers/ec2/vpc/eni.go +++ b/spinifex/handlers/ec2/vpc/eni.go @@ -1122,6 +1122,14 @@ func (s *VPCServiceImpl) validateSGAttachment(ctx context.Context, accountID str return nil } +// ENIHasEIP reports whether an Elastic IP is associated with eniID. It is the +// one question three different moments in an instance's life have to ask: an +// ENI delete may not return an EIP-backed address to the pool, a stop may not +// reclaim one, and a start must not auto-assign a second address beside one. +func (s *VPCServiceImpl) ENIHasEIP(ctx context.Context, accountID, eniID string) (bool, error) { + return s.isEIPOwned(ctx, eniID, accountID) +} + // isEIPOwned checks whether the given ENI's public IP is owned by an Elastic IP. // Returns (true, nil) if an EIP record references this ENI, (false, nil) if none // match, or (false, err) if the KV store could not be read. diff --git a/spinifex/handlers/ec2/vpc/external_ipam.go b/spinifex/handlers/ec2/vpc/external_ipam.go index 330b0d6f6..b26a93496 100644 --- a/spinifex/handlers/ec2/vpc/external_ipam.go +++ b/spinifex/handlers/ec2/vpc/external_ipam.go @@ -27,17 +27,19 @@ const ( ) // ExternalIPAM is the AWS-facing entry point for external IP allocation, -// dispatching to StaticPoolAllocator or dhcp.DHCPPoolAllocator per pool name. +// dispatching per pool name to the static allocator or to whatever the wiring +// installed for that pool. type ExternalIPAM struct { kv jetstream.KeyValue pools []external.ExternalPoolConfig static *external.StaticPoolAllocator - perPool map[string]external.Allocator // dhcp overrides; static pools fall through to `static` + perPool map[string]external.Allocator // installed overrides; static pools fall through to `static` } // NewExternalIPAM creates a new ExternalIPAM. Static pools wire through -// external.StaticPoolAllocator; DHCP-sourced pools wait for EnableDHCP -// to install the per-pool dhcp.DHCPPoolAllocator. +// external.StaticPoolAllocator; every other source waits for the wiring to +// call InstallAllocator, because their allocators need dependencies this +// package must not construct. func NewExternalIPAM(ctx context.Context, js jetstream.JetStream, pools []external.ExternalPoolConfig) (*ExternalIPAM, error) { staticPools := filterStatic(pools) var ( @@ -63,6 +65,40 @@ func NewExternalIPAMWithKV(kv jetstream.KeyValue, pools []external.ExternalPoolC return &ExternalIPAM{kv: kv, pools: pools, static: alloc, perPool: map[string]external.Allocator{}} } +// InstallAllocator routes one pool to a non-static allocator. Every source +// other than static needs a dependency this package must not construct — a +// NATS client for DHCP, an authenticated API client for OCI — so the wiring +// hands the built allocator in rather than naming the source here. +// Idempotent: a repeated call replaces the entry. +func (m *ExternalIPAM) InstallAllocator(poolName string, a external.Allocator) error { + if m == nil { + return errors.New("ExternalIPAM InstallAllocator: nil IPAM") + } + if poolName == "" { + return errors.New("ExternalIPAM InstallAllocator: empty pool name") + } + if a == nil { + return fmt.Errorf("ExternalIPAM InstallAllocator: nil allocator for pool %q", poolName) + } + m.perPool[poolName] = a + return nil +} + +// PoolsWithSource returns the configured pools using the given source, so the +// wiring can build one allocator per pool without reaching into m.pools. +func (m *ExternalIPAM) PoolsWithSource(source string) []external.ExternalPoolConfig { + if m == nil { + return nil + } + var out []external.ExternalPoolConfig + for _, p := range m.pools { + if p.Source == source { + out = append(out, p) + } + } + return out +} + // EnableDHCP installs a DHCPPoolAllocator for every pool with // Source="dhcp". client is the daemon-side NATS wrapper that fans out // to vpcd. Idempotent — repeated calls overwrite existing dhcp entries. @@ -70,11 +106,10 @@ func (m *ExternalIPAM) EnableDHCP(client *dhcp.NATSClient) error { if client == nil { return errors.New("ExternalIPAM EnableDHCP: nil NATSClient") } - for _, p := range m.pools { - if p.Source != external.SourceDHCP { - continue + for _, p := range m.PoolsWithSource(external.SourceDHCP) { + if err := m.InstallAllocator(p.Name, dhcp.NewDHCPPoolAllocator(client, p)); err != nil { + return err } - m.perPool[p.Name] = dhcp.NewDHCPPoolAllocator(client, p) } return nil } @@ -136,6 +171,33 @@ func (m *ExternalIPAM) ReleaseIP(ctx context.Context, poolName, ip, ownerENIID s return alloc.Release(ctx, poolName, addr, ownerENIID) } +// BindOwner tells a pool which ENI an already-allocated address is attached to, +// for the allocators that need it. An EIP is allocated bare and associated +// later, so this is the only point at which an OCI pool can learn where the +// address has to be delivered. An empty eniID records the disassociate. +// +// A pool whose allocator does not track owners is a success with nothing to do: +// for a static or DHCP address the association lives in the EIP record alone, +// and failing here would break EIPs on every non-OCI pool. +func (m *ExternalIPAM) BindOwner(ctx context.Context, poolName, ip, eniID string) error { + if m == nil { + return errors.New("external IPAM unavailable on this node") + } + addr, err := netip.ParseAddr(ip) + if err != nil { + return fmt.Errorf("parse owner bind IP %q: %w", ip, err) + } + alloc, err := m.allocatorFor(poolName) + if err != nil { + return err + } + tracker, ok := alloc.(external.OwnerTracker) + if !ok { + return nil + } + return tracker.BindOwner(ctx, poolName, addr, eniID) +} + // GetPoolRecord returns the current IPAM record for a pool. DHCP-sourced // pools have no static record — the per-AZ lease bucket is authoritative. func (m *ExternalIPAM) GetPoolRecord(poolName string) (*ExternalIPAMRecord, error) { @@ -154,7 +216,7 @@ func (m *ExternalIPAM) allocatorFor(poolName string) (external.Allocator, error) return a, nil } if m.static == nil { - return nil, fmt.Errorf("no allocator for pool %q (static disabled, dhcp not enabled)", poolName) + return nil, fmt.Errorf("no allocator for pool %q (static disabled and no allocator installed for it)", poolName) } return m.static, nil } diff --git a/spinifex/handlers/imds/constants.go b/spinifex/handlers/imds/constants.go index 610aecbcd..2ca10aaf8 100644 --- a/spinifex/handlers/imds/constants.go +++ b/spinifex/handlers/imds/constants.go @@ -8,6 +8,36 @@ const MetaDataServerIP = "169.254.169.254" // the same demux flows). Guests receive it as their DHCP nameserver. const VPCDNSServerIP = "169.254.169.253" +// HostBindAddrs are the host-side addresses a per-tap responder binds. They +// are the guest-facing addresses everywhere the host has no use for them +// itself, which is every bare-metal deployment. +// +// A cloud host is the exception: 169.254.169.254 is its own metadata service +// and 169.254.169.253 its own resolver, and a /32 on an ime- endpoint shadows +// both — so the node loses DNS and its cloud API the moment a guest starts. +// Binding elsewhere and DNAT'ing the guest's packets keeps the guest's view +// at 169.254.169.254 while leaving the host's routes alone. +type HostBindAddrs struct { + Meta string + DNS string +} + +// NewHostBindAddrs takes the configured pair, falling back to the guest-facing +// addresses when either is unset — half a remap would bind one address the +// guest can reach and one the host has already taken. +func NewHostBindAddrs(meta, dns string) HostBindAddrs { + if meta == "" || dns == "" { + return HostBindAddrs{Meta: MetaDataServerIP, DNS: VPCDNSServerIP} + } + return HostBindAddrs{Meta: meta, DNS: dns} +} + +// Remapped reports whether the host serves these on addresses other than the +// ones the guest addresses, and so needs the DNAT that restores the fiction. +func (a HostBindAddrs) Remapped() bool { + return a.Meta != MetaDataServerIP || a.DNS != VPCDNSServerIP +} + // pinnedVersion is the dated IMDS API version advertised by GET /. const pinnedVersion = "2021-07-15" diff --git a/spinifex/handlers/imds/dns_forwarder.go b/spinifex/handlers/imds/dns_forwarder.go index 1cc9dde92..722deace6 100644 --- a/spinifex/handlers/imds/dns_forwarder.go +++ b/spinifex/handlers/imds/dns_forwarder.go @@ -305,23 +305,25 @@ func (tb *tokenBucket) allow() bool { return tb.limiter.AllowN(tb.now(), 1) } -// bindTapDNS opens the DNS shim sockets — UDP and TCP 169.254.169.253:53 on the -// tap's endpoint via SO_BINDTODEVICE. Like the .254 HTTP bind, no netns is -// involved: the endpoint owns .253, and the per-tap demux flow already steers -// guest queries here, so binding it lights up the reserved co-tenant slot. -func bindTapDNS(ctx context.Context, endpoint string) (net.PacketConn, net.Listener, error) { - lc := net.ListenConfig{Control: bindToDeviceControl(endpoint)} - addr := net.JoinHostPort(VPCDNSServerIP, "53") - pc, err := lc.ListenPacket(ctx, "udp4", addr) - if err != nil { - return nil, nil, err +// tapDNSBinder opens the DNS shim sockets — UDP and TCP bindIP:53 on the tap's +// endpoint via SO_BINDTODEVICE. Like the HTTP bind, no netns is involved: the +// endpoint owns the address, and the per-tap demux flow already steers guest +// queries here, so binding it lights up the reserved co-tenant slot. +func tapDNSBinder(bindIP string) dnsListenFunc { + return func(ctx context.Context, endpoint string) (net.PacketConn, net.Listener, error) { + lc := net.ListenConfig{Control: bindToDeviceControl(endpoint)} + addr := net.JoinHostPort(bindIP, "53") + pc, err := lc.ListenPacket(ctx, "udp4", addr) + if err != nil { + return nil, nil, err + } + ln, err := lc.Listen(ctx, "tcp4", addr) + if err != nil { + _ = pc.Close() + return nil, nil, err + } + return pc, ln, nil } - ln, err := lc.Listen(ctx, "tcp4", addr) - if err != nil { - _ = pc.Close() - return nil, nil, err - } - return pc, ln, nil } // servfail rewrites a query header in place into a minimal SERVFAIL response, so diff --git a/spinifex/handlers/imds/service.go b/spinifex/handlers/imds/service.go index 2e0763acb..d490bc414 100644 --- a/spinifex/handlers/imds/service.go +++ b/spinifex/handlers/imds/service.go @@ -95,7 +95,10 @@ type IMDSServiceImpl struct { // nil, degrading that source to a miss rather than failing IMDS to start — // the caller (vpcd) owns their construction and availability. // ctx bounds the bucket opens only; each served request carries its own. -func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, pubKeys publicKeyLookup, localState localStateReader, records recordLoader, listTaps listTapsFunc, baseDomain, internalDomain, servicesDomain, caCertPath string, resolverIPs []string) (*IMDSServiceImpl, error) { +func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer, iamSvc profileLookup, pubKeys publicKeyLookup, localState localStateReader, records recordLoader, listTaps listTapsFunc, baseDomain, internalDomain, servicesDomain, caCertPath string, resolverIPs []string, bind HostBindAddrs) (*IMDSServiceImpl, error) { + if bind.Meta == "" || bind.DNS == "" { + return nil, errors.New("IMDS host bind addresses required (use NewHostBindAddrs)") + } if natsConn == nil { return nil, errors.New("nil NATS connection") } @@ -173,14 +176,19 @@ func NewIMDSServiceImpl(ctx context.Context, natsConn *nats.Conn, sts stsAssumer } // Each per-tap responder serves the shared mux, threading its tap's ENI // identity into every request via BaseContext. - svc.tapResp = newTapResponderManager(svc.httpHandler(), svc.resolver.resolveENIByID, bindTapListener) + svc.tapResp = newTapResponderManager(svc.httpHandler(), svc.resolver.resolveENIByID, tapListenerBinder(bind.Meta)) if len(resolverIPs) > 0 { targets := make([]string, 0, len(resolverIPs)) for _, ip := range resolverIPs { targets = append(targets, net.JoinHostPort(ip, northstarResolverPort)) } - svc.tapResp.enableDNS(bindTapDNS, newDNSForwarder(targets)) - slog.Info("IMDS: VPC DNS shim enabled", "addr", VPCDNSServerIP, "backends", targets) + svc.tapResp.enableDNS(tapDNSBinder(bind.DNS), newDNSForwarder(targets)) + slog.Info("IMDS: VPC DNS shim enabled", "addr", bind.DNS, "backends", targets) + } + if bind.Remapped() { + slog.Info("IMDS: serving on remapped host addresses; guests still address the standard ones", + "meta_bind", bind.Meta, "dns_bind", bind.DNS, + "guest_meta", MetaDataServerIP, "guest_dns", VPCDNSServerIP) } slog.Info("IMDS service initialized", "eni_bucket", kvBucketENIs) diff --git a/spinifex/handlers/imds/tap_responder.go b/spinifex/handlers/imds/tap_responder.go index 6662dcdbf..388bc0177 100644 --- a/spinifex/handlers/imds/tap_responder.go +++ b/spinifex/handlers/imds/tap_responder.go @@ -328,12 +328,15 @@ func deviceIfindex(dev string) (int, error) { return iface.Index, nil } -// bindTapListener opens 169.254.169.254:80 on the tap's endpoint via SO_BINDTODEVICE -// — the per-tap serving socket. No netns/CAP_SYS_ADMIN: the endpoint is in the root -// netns on br-imds and owns .254, so the bind targets the address the guest does. -func bindTapListener(ctx context.Context, endpoint string) (net.Listener, error) { - lc := net.ListenConfig{Control: bindToDeviceControl(endpoint)} - return lc.Listen(ctx, "tcp4", net.JoinHostPort(MetaDataServerIP, "80")) +// tapListenerBinder opens bindIP:80 on the tap's endpoint via SO_BINDTODEVICE +// — the per-tap serving socket. No netns/CAP_SYS_ADMIN: the endpoint is in the +// root netns on br-imds and owns the address, so the bind targets what the +// guest addresses, or what the host's DNAT rewrites it to. +func tapListenerBinder(bindIP string) tapListenFunc { + return func(ctx context.Context, endpoint string) (net.Listener, error) { + lc := net.ListenConfig{Control: bindToDeviceControl(endpoint)} + return lc.Listen(ctx, "tcp4", net.JoinHostPort(bindIP, "80")) + } } // bindToDeviceControl returns a ListenConfig.Control that scopes the socket to diff --git a/spinifex/network/external/allocator.go b/spinifex/network/external/allocator.go index bde393284..b019fce36 100644 --- a/spinifex/network/external/allocator.go +++ b/spinifex/network/external/allocator.go @@ -16,6 +16,20 @@ type AllocateRequest struct { InstanceID string } +// OwnerTracker is an Allocator that also needs to hear about associations made +// after the address was allocated. An EIP is allocated with no ENI and attached +// later, so an allocator whose behaviour depends on the owner cannot learn it +// from Allocate alone. +// +// Only OCI needs this: the address is delivered to one VNIC, so the owning ENI +// is what decides which node that must be. A static or DHCP address is on the +// wire everywhere its node can reach, and those allocators do not implement it. +type OwnerTracker interface { + // BindOwner records eniID as the current owner of ip in poolName. An empty + // eniID clears the owner, which is what a disassociate leaves behind. + BindOwner(ctx context.Context, poolName string, ip netip.Addr, eniID string) error +} + // Allocator hands out a single external IP per AWS identity from a named // pool. Implementations: StaticPoolAllocator (range math + KV CAS) and // DHCPPoolAllocator (RFC 2131 DORA via vpcd, Q4). diff --git a/spinifex/network/external/dhcp/manager.go b/spinifex/network/external/dhcp/manager.go index cf8022027..37cacb7a4 100644 --- a/spinifex/network/external/dhcp/manager.go +++ b/spinifex/network/external/dhcp/manager.go @@ -598,6 +598,16 @@ func (m *Manager) handleReleaseMsg(msg *nats.Msg) { entry, err := m.store.LookupByIP(ctx, req.PoolName, req.IP) switch { case err == nil: + // An EIP's lease is the only thing holding the address — a DHCP + // server promises nothing about handing it back — so a release made + // for a departing interface must leave it renewing. Only the + // address's own owner, which names no interface, may free it. + if req.OwnerScoped && entry.Purpose == PurposeEIP { + slog.Info("dhcp manager: release skip — address belongs to an Elastic IP allocation", + "pool", req.PoolName, "ip", req.IP, "client_id", entry.Lease.ClientID) + _ = msg.Respond(emptyReleaseReply) + return + } clientID = entry.Lease.ClientID case errors.Is(err, jetstream.ErrKeyNotFound): // Answering SUCCESS here told the caller the address was freed when diff --git a/spinifex/network/external/dhcp/nats_client.go b/spinifex/network/external/dhcp/nats_client.go index ceecd8220..b65ac1436 100644 --- a/spinifex/network/external/dhcp/nats_client.go +++ b/spinifex/network/external/dhcp/nats_client.go @@ -101,11 +101,13 @@ func (c *NATSClient) RequestRelease(ctx context.Context, clientID string) error // RequestReleaseByIP releases the lease that holds (poolName, ip). // Used by DHCPPoolAllocator.Release, whose external.Allocator signature // only carries the IP — vpcd looks up the client-id from the lease store. -func (c *NATSClient) RequestReleaseByIP(ctx context.Context, poolName, ip string) error { +// ownerScoped marks a release made for a departing interface rather than by the +// address's owner, which vpcd refuses for an EIP. +func (c *NATSClient) RequestReleaseByIP(ctx context.Context, poolName, ip string, ownerScoped bool) error { if ip == "" { return errors.New("dhcp NATSClient: ip required") } - return c.requestRelease(ctx, releaseWireRequest{PoolName: poolName, IP: ip}) + return c.requestRelease(ctx, releaseWireRequest{PoolName: poolName, IP: ip, OwnerScoped: ownerScoped}) } func (c *NATSClient) requestRelease(ctx context.Context, req releaseWireRequest) error { diff --git a/spinifex/network/external/dhcp/pool_allocator.go b/spinifex/network/external/dhcp/pool_allocator.go index 604f8e160..91068c7b6 100644 --- a/spinifex/network/external/dhcp/pool_allocator.go +++ b/spinifex/network/external/dhcp/pool_allocator.go @@ -83,10 +83,11 @@ func (a *DHCPPoolAllocator) Allocate(ctx context.Context, req external.AllocateR } // Release issues vpc.dhcp.release for ip. Errors if poolName doesn't match -// this allocator's pool to prevent cross-pool release. ownerENIID is unused: -// DHCP leases are keyed by client-ID, not ENI, so ownership scoping does not -// apply. -func (a *DHCPPoolAllocator) Release(ctx context.Context, poolName string, ip netip.Addr, _ string) error { +// this allocator's pool to prevent cross-pool release. ownerENIID is not a +// lease key — leases are keyed by client-ID — but its presence still carries +// meaning: it marks a release made for a departing interface, which vpcd +// refuses for an EIP. +func (a *DHCPPoolAllocator) Release(ctx context.Context, poolName string, ip netip.Addr, ownerENIID string) error { if a == nil || a.client == nil { return errors.New("dhcp pool allocator: nil client") } @@ -96,7 +97,7 @@ func (a *DHCPPoolAllocator) Release(ctx context.Context, poolName string, ip net if !ip.IsValid() { return errors.New("dhcp pool allocator: invalid ip") } - return a.client.RequestReleaseByIP(ctx, a.pool.Name, ip.String()) + return a.client.RequestReleaseByIP(ctx, a.pool.Name, ip.String(), ownerENIID != "") } func (a *DHCPPoolAllocator) hwAddrFor(clientID string) net.HardwareAddr { diff --git a/spinifex/network/external/dhcp/pool_allocator_test.go b/spinifex/network/external/dhcp/pool_allocator_test.go index be85c7dc9..e646c89f0 100644 --- a/spinifex/network/external/dhcp/pool_allocator_test.go +++ b/spinifex/network/external/dhcp/pool_allocator_test.go @@ -141,7 +141,7 @@ func TestDHCPPoolAllocatorReleaseUncodedErrorIsNotTerminal(t *testing.T) { t.Cleanup(func() { _ = sub.Unsubscribe() }) client := dhcp.NewNATSClient(nc, 3*time.Second) - err = client.RequestReleaseByIP(context.Background(), "wan", "198.51.100.99") + err = client.RequestReleaseByIP(context.Background(), "wan", "198.51.100.99", false) require.Error(t, err) assert.NotErrorIs(t, err, dhcp.ErrLeaseNotTracked) assert.Contains(t, err.Error(), "upstream unreachable") diff --git a/spinifex/network/external/dhcp/release_scope_test.go b/spinifex/network/external/dhcp/release_scope_test.go new file mode 100644 index 000000000..e05e86357 --- /dev/null +++ b/spinifex/network/external/dhcp/release_scope_test.go @@ -0,0 +1,84 @@ +package dhcp_test + +import ( + "context" + "net" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/network/external/dhcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedLeaseFor puts a live lease in the store under purpose and returns the +// address it holds, so a release can be aimed at it by IP the way vpcd is. +func seedLeaseFor(t *testing.T, fake *dhcp.Fake, store *dhcp.Store, purpose, clientID, ip string) string { + t.Helper() + hw, err := net.ParseMAC("02:00:00:aa:bb:cc") + require.NoError(t, err) + lease, err := fake.Acquire(context.Background(), dhcp.AcquireRequest{ + Bridge: "br-wan", ClientID: clientID, HWAddr: hw, + }) + require.NoError(t, err) + lease.IP = net.ParseIP(ip).To4() + lease.LeaseDuration = time.Hour + require.NoError(t, store.Put(t.Context(), dhcp.Entry{ + Purpose: purpose, PoolName: "wan", Lease: lease, + })) + return ip +} + +// Instance teardown releases by address, and after an associate that address is +// the EIP. On a DHCP pool the lease is the only thing holding it — the server +// promises nothing about handing it back — so this release has to be refused. +func TestAnOwnerScopedReleaseLeavesAnEIPLeaseAlone(t *testing.T) { + fake := dhcp.NewFake() + mgr, store, nc := newTestManager(t, "az1", fake, time.Now) + ip := seedLeaseFor(t, fake, store, dhcp.PurposeEIP, "eipalloc-1", "192.0.2.50") + + _, err := mgr.Subscribe(nc) + require.NoError(t, err) + client := dhcp.NewNATSClient(nc, 3*time.Second) + + require.NoError(t, client.RequestReleaseByIP(t.Context(), "wan", ip, true)) + + assert.Zero(t, fake.ReleaseCount(), + "terminating an instance sent DHCPRELEASE for a customer's Elastic IP") + _, err = store.Get(t.Context(), "eipalloc-1") + assert.NoError(t, err, "the EIP's lease was deleted, so nothing is renewing it") +} + +// ReleaseAddress is the address's own owner and names no interface, so it must +// still be able to give the EIP up. +func TestAnUnscopedReleaseStillFreesAnEIPLease(t *testing.T) { + fake := dhcp.NewFake() + mgr, store, nc := newTestManager(t, "az1", fake, time.Now) + ip := seedLeaseFor(t, fake, store, dhcp.PurposeEIP, "eipalloc-1", "192.0.2.50") + + _, err := mgr.Subscribe(nc) + require.NoError(t, err) + client := dhcp.NewNATSClient(nc, 3*time.Second) + + require.NoError(t, client.RequestReleaseByIP(t.Context(), "wan", ip, false)) + + assert.Equal(t, 1, fake.ReleaseCount(), "ReleaseAddress could not free its own address") +} + +// The refusal must not reach the auto-assigned address, which is exactly what +// instance teardown is there to reclaim. +func TestAnOwnerScopedReleaseStillFreesAnAutoAssignedLease(t *testing.T) { + fake := dhcp.NewFake() + mgr, store, nc := newTestManager(t, "az1", fake, time.Now) + ip := seedLeaseFor(t, fake, store, dhcp.PurposeENIPublic, "eni-1", "192.0.2.51") + + _, err := mgr.Subscribe(nc) + require.NoError(t, err) + client := dhcp.NewNATSClient(nc, 3*time.Second) + + require.NoError(t, client.RequestReleaseByIP(t.Context(), "wan", ip, true)) + + assert.Equal(t, 1, fake.ReleaseCount(), "an auto-assigned lease leaked on teardown") + _, err = store.Get(t.Context(), "eni-1") + assert.Error(t, err, "the lease record outlived the address") +} diff --git a/spinifex/network/external/dhcp/wire.go b/spinifex/network/external/dhcp/wire.go index 0f536656a..541b8335f 100644 --- a/spinifex/network/external/dhcp/wire.go +++ b/spinifex/network/external/dhcp/wire.go @@ -111,6 +111,12 @@ type releaseWireRequest struct { ClientID string `json:"client_id,omitempty"` PoolName string `json:"pool_name,omitempty"` IP string `json:"ip,omitempty"` + // OwnerScoped marks a release made on behalf of an interface that is going + // away, rather than one made by the address's own owner. Such a release must + // never free an EIP: the lease is the only thing holding the address, and a + // DHCP server gives no guarantee of handing it back. Absent on an older + // caller, which reads as false and keeps the previous behaviour. + OwnerScoped bool `json:"owner_scoped,omitempty"` } // Code is machine-readable and lets callers branch on a condition without diff --git a/spinifex/network/external/eip_release_guard_test.go b/spinifex/network/external/eip_release_guard_test.go new file mode 100644 index 000000000..2c0527714 --- /dev/null +++ b/spinifex/network/external/eip_release_guard_test.go @@ -0,0 +1,75 @@ +//test:in-package — reads PoolRecord.Allocated and the unexported wanPool / +// newStaticAllocator fixtures from static_pool_test.go to assert on the stored +// allocation rather than on a return value. + +package external + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// An EIP is allocated with no ENI and associated later, so the ownership guard +// — which only fires when the stored allocation already names an ENI — cannot +// protect it. The purpose has to. +func TestAnOwnerScopedReleaseWillNotFreeAnEIP(t *testing.T) { + a := newStaticAllocator(t, []ExternalPoolConfig{wanPool()}) + ctx := t.Context() + + eip, err := a.Allocate(ctx, AllocateRequest{ + PoolName: "wan", Purpose: "eip", AllocationID: "eipalloc-1", + }) + require.NoError(t, err) + + // What instance teardown does: release the address the instance is holding, + // scoped to the ENI it is holding it on. After an associate, that address is + // the EIP. + require.NoError(t, a.Release(ctx, "wan", eip, "eni-1")) + + rec, err := a.GetPoolRecord(ctx, "wan") + require.NoError(t, err) + alloc, stillHeld := rec.Allocated[eip.String()] + require.True(t, stillHeld, + "terminating an instance gave a customer's Elastic IP back to the pool") + assert.Equal(t, "eipalloc-1", alloc.AllocationID) +} + +// ReleaseAddress is the only caller allowed to free an EIP, and it names no +// owner precisely because there is no interface to scope to. +func TestAnUnscopedReleaseStillFreesAnEIP(t *testing.T) { + a := newStaticAllocator(t, []ExternalPoolConfig{wanPool()}) + ctx := t.Context() + + eip, err := a.Allocate(ctx, AllocateRequest{ + PoolName: "wan", Purpose: "eip", AllocationID: "eipalloc-1", + }) + require.NoError(t, err) + + require.NoError(t, a.Release(ctx, "wan", eip, "")) + + rec, err := a.GetPoolRecord(ctx, "wan") + require.NoError(t, err) + _, stillHeld := rec.Allocated[eip.String()] + assert.False(t, stillHeld, "ReleaseAddress could not free the address it owns") +} + +// The guard must not reach the auto-assigned address, which is exactly what +// instance teardown is supposed to reclaim. +func TestAnOwnerScopedReleaseStillFreesAnAutoAssignedAddress(t *testing.T) { + a := newStaticAllocator(t, []ExternalPoolConfig{wanPool()}) + ctx := t.Context() + + ip, err := a.Allocate(ctx, AllocateRequest{ + PoolName: "wan", Purpose: "eni-public", ENIID: "eni-1", InstanceID: "i-1", + }) + require.NoError(t, err) + + require.NoError(t, a.Release(ctx, "wan", ip, "eni-1")) + + rec, err := a.GetPoolRecord(ctx, "wan") + require.NoError(t, err) + _, stillHeld := rec.Allocated[ip.String()] + assert.False(t, stillHeld, "an auto-assigned address leaked on teardown") +} diff --git a/spinifex/network/external/ocinet/affinity.go b/spinifex/network/external/ocinet/affinity.go new file mode 100644 index 000000000..a235d3be0 --- /dev/null +++ b/spinifex/network/external/ocinet/affinity.go @@ -0,0 +1,183 @@ +package ocinet + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/mulgadc/spinifex/spinifex/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/topology" +) + +// ClaimResult reports what an affinity pass moved, so a caller can log it and a +// test can assert on it without reading the fake's internals. +type ClaimResult struct { + // Claimed is the public addresses whose private half this node took over. + Claimed []string + // Local is how many bindings this node already owned, moved or not. + Local int +} + +// ClaimLocalAddresses makes OCI agree with where the guests actually are. +// +// An OCI address is delivered to whichever VNIC carries its private half, and +// nothing about allocating one consults the node the instance will run on: the +// AWS request is served by an arbitrary queue-group worker, so the private IP +// lands on that worker's VNIC. It is then never moved. A guest that starts on a +// different node than it stopped on — which stop/start does routinely, since the +// start is only *preferentially* routed back to LastNode — keeps a correct OVN +// rule and a correct host route on its new node while OCI keeps delivering to +// the old one. The address goes dark and stays dark. +// +// The fix is one call: OCI reassigns a secondary private IP between VNICs in +// the same subnet server-side, keeping the OCID, so the reserved public IP +// attached to it follows without being touched and the customer's address never +// changes. There is no window in which the address belongs to neither node, and +// so nothing for the losing node to be told — it drops its now-unwanted host +// route on its own next pass, exactly as it would for a released address. +// +// Ownership is decided by local OVS, not by the record: a tap plugged in here +// is a guest running here, and it is the same authority the host EIP binder +// already uses to decide which addresses this node plumbs. Asking OCI which +// private IPs are on our own VNIC costs one call per pass and is authoritative, +// so a record that has drifted — an address moved in the console — is repaired +// rather than believed. +// +// A NAT gateway's address has no tap to look for, and the same reasoning gives +// the same answer from a different local fact: it is a logical router's SNAT +// source, so it belongs on the VPC's gateway chassis, and whether that is this +// node is something the chassisredirect port binding says locally. +func (a *PoolAllocator) ClaimLocalAddresses(ctx context.Context) (ClaimResult, error) { + var res ClaimResult + if a.localPorts == nil && a.localGateway == nil { + return res, nil + } + + var plugged map[string]struct{} + if a.localPorts != nil { + var err error + plugged, err = a.localPorts(ctx) + if err != nil { + return res, fmt.Errorf("ocinet affinity: list local OVS ports: %w", err) + } + } + // No taps and no gateway is a node carrying nothing, which owns no + // addresses. It is also what a broken OVS looks like, so there is nothing to + // claim either way and the pass costs no API call. + if len(plugged) == 0 && a.localGateway == nil { + return res, nil + } + + rec, err := a.store.Get(ctx, a.cfg.Pool.Name) + if err != nil { + if errors.Is(err, kvstore.ErrNotFound) { + return res, nil + } + return res, fmt.Errorf("ocinet affinity: read bindings: %w", err) + } + + mine := make(map[string]struct{}) + for key, b := range rec.Bindings { + if b.PrivateIPID == "" { + continue + } + local, err := a.ownsBinding(ctx, b, plugged) + if err != nil { + return res, err + } + if !local { + continue + } + res.Local++ + mine[key] = struct{}{} + } + if len(mine) == 0 { + return res, nil + } + + // One call, not one per binding: this is the authoritative answer to "what + // does OCI already deliver to me", and every binding is judged against it. + held, err := a.heldPrivateIPIDs(ctx) + if err != nil { + return res, err + } + + for key := range mine { + b := rec.Bindings[key] + if _, ok := held[b.PrivateIPID]; ok { + continue + } + moved, err := a.client.MovePrivateIP(ctx, b.PrivateIPID, a.cfg.VNICID) + if err != nil { + return res, fmt.Errorf("ocinet affinity: move %s (%s) to %s: %w", + b.PrivateAddr, b.PrivateIPID, a.cfg.VNICID, err) + } + slog.WarnContext(ctx, "ocinet claimed an address whose datapath runs here", + "pool", a.cfg.Pool.Name, "public_ip", key, "private_ip", b.PrivateAddr, + "eni_id", b.ENIID, "gateway_vpc_id", b.GatewayVPCID, + "from_vnic", b.VNICID, "to_vnic", moved.VNICID) + + if err := a.recordVNIC(ctx, key, b.PrivateIPID, moved.VNICID); err != nil { + return res, err + } + res.Claimed = append(res.Claimed, key) + } + return res, nil +} + +// ownsBinding answers whether this node is where b's traffic is handled, and so +// whose VNIC OCI has to deliver it to. +// +// A guest address belongs where its tap is plugged. A NAT gateway's belongs on +// the VPC's gateway chassis: it is the SNAT source for a logical router, so the +// node that forwards is the node OCI must see it leave from. An address that is +// neither — a bare EIP nobody has associated yet — has no node, and leaving it +// where it was allocated is correct until something claims it. +func (a *PoolAllocator) ownsBinding(ctx context.Context, b Binding, plugged map[string]struct{}) (bool, error) { + if b.ENIID != "" { + _, local := plugged[topology.Port(b.ENIID)] + return local, nil + } + if b.GatewayVPCID == "" || a.localGateway == nil { + return false, nil + } + local, err := a.localGateway(ctx, b.GatewayVPCID) + if err != nil { + return false, fmt.Errorf("ocinet affinity: gateway chassis for %s: %w", b.GatewayVPCID, err) + } + return local, nil +} + +// heldPrivateIPIDs is the set of private IP OCIDs OCI currently carries on this +// node's external VNIC. +func (a *PoolAllocator) heldPrivateIPIDs(ctx context.Context) (map[string]struct{}, error) { + live, err := a.client.ListPrivateIPs(ctx, a.cfg.VNICID) + if err != nil { + return nil, fmt.Errorf("ocinet affinity: list private ips on %s: %w", a.cfg.VNICID, err) + } + held := make(map[string]struct{}, len(live)) + for _, p := range live { + held[p.ID] = struct{}{} + } + return held, nil +} + +// recordVNIC writes the new owner back under CAS. The move already happened, so +// a binding that changed underneath us is re-read rather than overwritten: only +// the VNIC is ours to update, and only while it still names the object we moved. +func (a *PoolAllocator) recordVNIC(ctx context.Context, key, privateIPID, vnicID string) error { + err := a.store.Mutate(ctx, a.cfg.Pool.Name, func(r *Record) (bool, error) { + b, ok := r.Bindings[key] + if !ok || b.PrivateIPID != privateIPID || b.VNICID == vnicID { + return false, nil + } + b.VNICID = vnicID + r.Bindings[key] = b + return true, nil + }) + if err != nil { + return fmt.Errorf("ocinet affinity: record new VNIC for %s: %w", key, err) + } + return nil +} diff --git a/spinifex/network/external/ocinet/affinity_test.go b/spinifex/network/external/ocinet/affinity_test.go new file mode 100644 index 000000000..57e875bff --- /dev/null +++ b/spinifex/network/external/ocinet/affinity_test.go @@ -0,0 +1,315 @@ +package ocinet_test + +import ( + "context" + "errors" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/network/external" + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" +) + +const ( + thisVNIC = "ocid1.vnic.oc1..vnic1" + otherVNIC = "ocid1.vnic.oc1..vnic2" +) + +// newAffinityAllocator builds an allocator whose VNIC is thisVNIC and whose +// idea of "running here" is the caller's port set. +func newAffinityAllocator(t *testing.T, fake *oci.Fake, local ...string) (*ocinet.PoolAllocator, *ocinet.MemStore) { + t.Helper() + ports := make(map[string]struct{}, len(local)) + for _, p := range local { + ports[p] = struct{}{} + } + store := ocinet.NewMemStore() + a, err := ocinet.New(fake, store, ocinet.Config{ + Pool: external.ExternalPoolConfig{Name: "oci-wan", Source: external.SourceOCI}, + VNICID: thisVNIC, + CompartmentID: "ocid1.compartment.oc1..comp1", + Schedule: []time.Duration{time.Millisecond}, + Budget: time.Second, + Sleep: func(context.Context, time.Duration) error { return nil }, + LocalPorts: func(context.Context) (map[string]struct{}, error) { return ports, nil }, + }) + require.NoError(t, err) + return a, store +} + +// seedForeignBinding puts an allocated pair on otherVNIC and records it, which +// is the state after a guest has been started on a node other than the one that +// served its AllocateAddress. +func seedForeignBinding(t *testing.T, fake *oci.Fake, store *ocinet.MemStore, eniID string) (pubAddr string, privID string) { + t.Helper() + ctx := context.Background() + priv, err := fake.AssignPrivateIP(ctx, otherVNIC, netip.Addr{}, ocinet.DisplayNamePrefix+eniID) + require.NoError(t, err) + pub, err := fake.CreatePublicIP(ctx, "ocid1.compartment.oc1..comp1", priv.ID, ocinet.DisplayNamePrefix+eniID) + require.NoError(t, err) + require.NoError(t, store.Mutate(ctx, "oci-wan", func(r *ocinet.Record) (bool, error) { + r.Bindings[pub.Address.String()] = ocinet.Binding{ + PublicIPID: pub.ID, PrivateIPID: priv.ID, PrivateAddr: priv.Address.String(), + ENIID: eniID, VNICID: otherVNIC, + } + return true, nil + })) + return pub.Address.String(), priv.ID +} + +// The bug this pass exists for: the guest runs here, OCI delivers elsewhere, so +// the address is dark until the private half is moved onto this node's VNIC. +func TestClaimMovesAnAddressWhoseGuestRunsHere(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newAffinityAllocator(t, fake, "port-eni-1") + pubAddr, privID := seedForeignBinding(t, fake, store, "eni-1") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Equal(t, []string{pubAddr}, res.Claimed) + + got, err := fake.GetPrivateIP(ctx, privID) + require.NoError(t, err) + assert.Equal(t, thisVNIC, got.VNICID, "OCI still delivers this address to the old node") + + // The record must agree, or the startup reconcile reads the address as a + // peer's and leaves a real leak of ours alone forever. + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Equal(t, thisVNIC, rec.Bindings[pubAddr].VNICID) +} + +// The reserved public IP is the customer's address. A move that re-created the +// private IP would drop it; reassigning keeps the OCID, so the pairing holds. +func TestClaimKeepsThePublicAddressAndItsAttachment(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newAffinityAllocator(t, fake, "port-eni-1") + pubAddr, privID := seedForeignBinding(t, fake, store, "eni-1") + + _, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + + pubs := fake.PublicIPs() + require.Len(t, pubs, 1, "the reserved public IP must survive the move") + assert.Equal(t, pubAddr, pubs[0].Address.String(), "the customer's address changed") + assert.Equal(t, privID, pubs[0].PrivateIPID, "the public IP came detached from its private half") +} + +// A node must never take an address whose guest is somewhere else. Getting this +// wrong is worse than the bug: it steals a working address from a live guest. +func TestClaimLeavesAnAddressWhoseGuestRunsElsewhere(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + // No local ports at all beyond an unrelated one. + a, store := newAffinityAllocator(t, fake, "port-eni-somebody-else") + _, privID := seedForeignBinding(t, fake, store, "eni-1") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Empty(t, res.Claimed) + assert.Zero(t, res.Local) + + got, err := fake.GetPrivateIP(ctx, privID) + require.NoError(t, err) + assert.Equal(t, otherVNIC, got.VNICID, "took an address belonging to another node's guest") +} + +// An allocated-but-unassociated EIP has no guest, so no node owns it. Claiming +// it would drag every free address onto whichever node swept last. +func TestClaimIgnoresAnUnassociatedAddress(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newAffinityAllocator(t, fake, "port-eni-1") + _, privID := seedForeignBinding(t, fake, store, "") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Empty(t, res.Claimed) + + got, err := fake.GetPrivateIP(ctx, privID) + require.NoError(t, err) + assert.Equal(t, otherVNIC, got.VNICID) +} + +// Steady state is every pass on every node, so it must cost nothing once the +// addresses are where they belong. +func TestClaimMakesNoMoveCallWhenTheAddressIsAlreadyOurs(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, _ := newAffinityAllocator(t, fake, "port-eni-1") + + _, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", ENIID: "eni-1"}) + require.NoError(t, err) + + // Any MovePrivateIP at all now is a needless write against a live address. + fake.FailOp("MovePrivateIP", errors.New("must not be called")) + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Empty(t, res.Claimed) + assert.Equal(t, 1, res.Local, "the binding is this node's and should be counted") +} + +// OVS is the only signal for "is this mine". A pass that guessed when it could +// not read it would either strand addresses or steal them. +func TestClaimFailsThePassWhenLocalOVSCannotBeRead(t *testing.T) { + store := ocinet.NewMemStore() + fake := oci.NewFake() + a, err := ocinet.New(fake, store, ocinet.Config{ + Pool: external.ExternalPoolConfig{Name: "oci-wan", Source: external.SourceOCI}, + VNICID: thisVNIC, + CompartmentID: "ocid1.compartment.oc1..comp1", + LocalPorts: func(context.Context) (map[string]struct{}, error) { + return nil, errors.New("ovs-vsctl: connection refused") + }, + }) + require.NoError(t, err) + + _, err = a.ClaimLocalAddresses(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "list local OVS ports") +} + +// A single-node cluster and the allocator's own unit tests wire no port source; +// that must be a no-op rather than a nil dereference or a claim of everything. +func TestClaimIsANoOpWithoutALocalPortSource(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, _ := newTestAllocator(t, fake) + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Empty(t, res.Claimed) + assert.Zero(t, res.Local) +} + +// The move succeeded against OCI before the record was written, so a failure to +// write it back must surface — silently keeping the old VNIC would have the +// startup reconcile treat this node's own address as a peer's. +func TestClaimReportsAFailureToRecordTheNewOwner(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newAffinityAllocator(t, fake, "port-eni-1") + seedForeignBinding(t, fake, store, "eni-1") + + store.FailMutate(errors.New("kv unavailable")) + _, err := a.ClaimLocalAddresses(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "record new VNIC") +} + +// newGatewayAffinityAllocator builds an allocator with no guests at all and a +// fixed answer to "am I the gateway chassis for this VPC". +func newGatewayAffinityAllocator(t *testing.T, fake *oci.Fake, local bool, err error) (*ocinet.PoolAllocator, *ocinet.MemStore) { + t.Helper() + store := ocinet.NewMemStore() + a, aerr := ocinet.New(fake, store, ocinet.Config{ + Pool: external.ExternalPoolConfig{Name: "oci-wan", Source: external.SourceOCI}, + VNICID: thisVNIC, + CompartmentID: "ocid1.compartment.oc1..comp1", + Schedule: []time.Duration{time.Millisecond}, + Budget: time.Second, + Sleep: func(context.Context, time.Duration) error { return nil }, + LocalPorts: func(context.Context) (map[string]struct{}, error) { return nil, nil }, + LocalGateway: func(context.Context, string) (bool, error) { return local, err }, + }) + require.NoError(t, aerr) + return a, store +} + +// seedGatewayBinding puts an allocated pair on otherVNIC with no ENI and a VPC, +// which is a NAT gateway's address created by a node that does not forward it. +func seedGatewayBinding(t *testing.T, fake *oci.Fake, store *ocinet.MemStore, vpcID string) (pubAddr string, privID string) { + t.Helper() + ctx := context.Background() + priv, err := fake.AssignPrivateIP(ctx, otherVNIC, netip.Addr{}, ocinet.DisplayNamePrefix+vpcID) + require.NoError(t, err) + pub, err := fake.CreatePublicIP(ctx, "ocid1.compartment.oc1..comp1", priv.ID, ocinet.DisplayNamePrefix+vpcID) + require.NoError(t, err) + require.NoError(t, store.Mutate(ctx, "oci-wan", func(r *ocinet.Record) (bool, error) { + r.Bindings[pub.Address.String()] = ocinet.Binding{ + PublicIPID: pub.ID, PrivateIPID: priv.ID, PrivateAddr: priv.Address.String(), + GatewayVPCID: vpcID, VNICID: otherVNIC, + } + return true, nil + })) + return pub.Address.String(), priv.ID +} + +// The NAT gateway case: no guest, no ENI, and the address still has to be on +// the VNIC of the node that forwards for the VPC or OCI drops every packet. +func TestClaimMovesANATGatewayAddressToItsGatewayChassis(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newGatewayAffinityAllocator(t, fake, true, nil) + pubAddr, privID := seedGatewayBinding(t, fake, store, "vpc-1") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Equal(t, []string{pubAddr}, res.Claimed) + + got, err := fake.GetPrivateIP(ctx, privID) + require.NoError(t, err) + assert.Equal(t, thisVNIC, got.VNICID, "the SNAT source is not on the forwarding node's VNIC") + + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Equal(t, thisVNIC, rec.Bindings[pubAddr].VNICID) +} + +// Taking a gateway address from the node that forwards for it is the same theft +// as taking a guest's, and breaks every private subnet in that VPC. +func TestClaimLeavesANATGatewayAddressWhoseChassisIsElsewhere(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newGatewayAffinityAllocator(t, fake, false, nil) + _, privID := seedGatewayBinding(t, fake, store, "vpc-1") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Empty(t, res.Claimed) + + got, err := fake.GetPrivateIP(ctx, privID) + require.NoError(t, err) + assert.Equal(t, otherVNIC, got.VNICID) +} + +// An unreadable chassis binding is not "not mine": answering no would leave a +// live gateway dark, so the pass fails and the next one asks again. +func TestClaimFailsWhenTheGatewayChassisCannotBeRead(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newGatewayAffinityAllocator(t, fake, false, errors.New("ovn-sbctl: connection refused")) + _, privID := seedGatewayBinding(t, fake, store, "vpc-1") + + _, err := a.ClaimLocalAddresses(ctx) + require.Error(t, err) + + got, gerr := fake.GetPrivateIP(ctx, privID) + require.NoError(t, gerr) + assert.Equal(t, otherVNIC, got.VNICID, "a failed pass must not have moved anything") +} + +// A bare EIP nobody has associated still has no node, and the gateway rule must +// not change that — claiming one would strand it on whichever node ran first. +func TestClaimStillLeavesAnUnassociatedEIPAlone(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newGatewayAffinityAllocator(t, fake, true, nil) + _, privID := seedGatewayBinding(t, fake, store, "") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Empty(t, res.Claimed) + + got, gerr := fake.GetPrivateIP(ctx, privID) + require.NoError(t, gerr) + assert.Equal(t, otherVNIC, got.VNICID) +} diff --git a/spinifex/network/external/ocinet/allocator.go b/spinifex/network/external/ocinet/allocator.go new file mode 100644 index 000000000..7dd5e21a6 --- /dev/null +++ b/spinifex/network/external/ocinet/allocator.go @@ -0,0 +1,497 @@ +// Package ocinet implements external.Allocator against the Oracle Cloud API. +// +// On bare metal an external pool is range math: the pool owns 72.52.77.233-254 +// and handing one out is arithmetic plus a CAS. On OCI it is not. The +// hypervisor drops any packet whose source is not a registered private IP +// object on the sending VNIC, so an address only exists once OCI has been asked +// to create it. That makes allocation a round trip to a cloud API rather than a +// local computation, which is the whole reason this package exists alongside +// StaticPoolAllocator rather than replacing it. +// +// One allocation is two OCI objects. A secondary private IP on the VNIC is what +// rides the wire; a RESERVED public IP attached to it is what the internet +// reaches and what AWS calls the Elastic IP. Allocate returns the public +// address, because that is the address AWS semantics are about — the private +// half is carried in the binding record, and Lookup is how vpcd finds it when +// it programs the OVN NAT rule and the host ingress route. +// +// The host does not configure the private address on an interface. Owning it +// would make the host terminate the guest's traffic — an SSH to the public +// address would reach the node, not the instance. It is routed instead: a /32 +// via the VPC gateway LRP over the transit veth, which is what EnsureEIPIngress +// already installs for a routed-mode EIP. +package ocinet + +import ( + "context" + "errors" + "fmt" + "log/slog" + "math/rand/v2" + "net/netip" + "time" + + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/external" +) + +// DisplayNamePrefix marks every OCI object this package creates. Reconcile only +// ever deletes objects carrying it, so an operator's own secondary addresses on +// the same VNIC — mulga-poc has two — are never candidates for cleanup. A +// prefix rather than a freeform tag because it is visible in the OCI console, +// where whoever is wondering what created an address will actually look. +const DisplayNamePrefix = "spinifex-" + +// purposeEIP duplicates handlers/ec2/vpc.PurposeEIP so the allocator can tell +// an Elastic IP from an auto-assigned address without importing handlers. +const purposeEIP = "eip" + +// assignSchedule is the poll ladder for an address to reach ASSIGNED. OCI +// assignment is asynchronous: CreatePublicIp returns ASSIGNING and the address +// is not reachable until it settles. Shaped like the DHCP manager's DORA ladder +// and bounded by the same reasoning — allocation sits inside the AWS client's +// read timeout, so the budget has to leave room for the reply. +var assignSchedule = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second} + +// assignBudget caps the wallclock for one Allocate. Sized against the same 60s +// botocore read timeout that gives dhcp.defaultAcquireBudget its 45s. +const assignBudget = 45 * time.Second + +// assignJitter is the ± window on each poll so concurrent allocations across +// nodes do not synchronise onto the same API second. +const assignJitter = 200 * time.Millisecond + +// Binding is one allocated external address: the pair of OCI objects behind it +// plus the AWS identity that claimed it. Keyed in the record by the public +// address, mirroring StaticPoolAllocator.Allocated, so Release is a direct +// lookup and the ownership guard reads the same way. +type Binding struct { + PublicIPID string `json:"public_ip_id"` + PrivateIPID string `json:"private_ip_id"` + // PrivateAddr is what the host configures on the VNIC and what inbound + // packets actually carry — OCI NATs the public address to it before the + // instance ever sees the packet, so the DNAT rule matches this, not the + // public address. + PrivateAddr string `json:"private_addr"` + Purpose string `json:"purpose,omitempty"` + AllocationID string `json:"allocation_id,omitempty"` + ENIID string `json:"eni_id,omitempty"` + InstanceID string `json:"instance_id,omitempty"` + // GatewayVPCID names the VPC whose gateway chassis must carry this address, + // set when a NAT gateway consumes it. Such an address is never attached to + // an ENI, so it is the only thing that says which node it belongs on. + GatewayVPCID string `json:"gateway_vpc_id,omitempty"` + // VNICID is the VNIC currently carrying the private IP, and so the node + // the address lives on. A private IP can be reassigned to another VNIC in + // the same subnet without losing its public IP, which is how an address + // follows an instance to a surviving node — and this field is how a node + // that comes back from the dead tells "mine" from "moved on without me" + // before it reasserts host plumbing for an address it no longer holds. + VNICID string `json:"vnic_id,omitempty"` +} + +// Config wires one pool to one VNIC in one compartment. +type Config struct { + // Pool is the external pool this allocator serves. Source must be "oci". + Pool external.ExternalPoolConfig + // VNICID is the OCID of the VNIC that carries external traffic. On the + // reference topology that is the second VNIC, the one in br-wan. + VNICID string + // CompartmentID is where reserved public IPs are created. + CompartmentID string + + // LocalPorts returns the OVN logical ports with a live tap on this host. + // It is how ClaimLocalAddresses knows which addresses belong here, and the + // same authority the host EIP binder uses for the same question. Nil + // disables the affinity pass, which is correct for a single-node cluster + // and for tests that only exercise allocate and release. + LocalPorts func(ctx context.Context) (map[string]struct{}, error) + + // LocalGateway reports whether this host is the gateway chassis for a VPC. + // It is the same question LocalPorts answers for a guest, asked of a NAT + // gateway, which has no tap to look for. Nil leaves gateway addresses where + // they were allocated, which is correct on a single node. + LocalGateway func(ctx context.Context, vpcID string) (bool, error) + + // Schedule and Budget override the poll ladder; zero values take the + // package defaults. Tests set them to keep runtime down. + Schedule []time.Duration + Budget time.Duration + // Sleep is the delay function, so tests do not wait. + Sleep func(context.Context, time.Duration) error +} + +// Store persists bindings. Implemented over JetStream KV in store.go; the +// interface exists so the allocator's tests need no NATS. +type Store interface { + // Mutate applies fn to the pool's binding set under CAS, retrying on + // contention. fn reports whether it changed anything. + Mutate(ctx context.Context, poolName string, fn func(*Record) (bool, error)) error + // Get reads the current record. + Get(ctx context.Context, poolName string) (Record, error) +} + +// Record is the per-pool binding set. +type Record struct { + Bindings map[string]Binding `json:"bindings"` +} + +// PoolAllocator implements external.Allocator against OCI. +type PoolAllocator struct { + client oci.Client + store Store + cfg Config + + schedule []time.Duration + budget time.Duration + sleep func(context.Context, time.Duration) error + localPorts func(context.Context) (map[string]struct{}, error) + localGateway func(context.Context, string) (bool, error) +} + +var ( + _ external.Allocator = (*PoolAllocator)(nil) + _ external.OwnerTracker = (*PoolAllocator)(nil) +) + +// New constructs an allocator bound to a single pool. +func New(client oci.Client, store Store, cfg Config) (*PoolAllocator, error) { + if client == nil { + return nil, errors.New("ocinet: nil OCI client") + } + if store == nil { + return nil, errors.New("ocinet: nil store") + } + if cfg.VNICID == "" { + return nil, fmt.Errorf("ocinet: pool %q missing vnic_id", cfg.Pool.Name) + } + if cfg.CompartmentID == "" { + return nil, fmt.Errorf("ocinet: pool %q missing compartment_id", cfg.Pool.Name) + } + a := &PoolAllocator{ + client: client, + store: store, + cfg: cfg, + schedule: cfg.Schedule, + budget: cfg.Budget, + sleep: cfg.Sleep, + localPorts: cfg.LocalPorts, + localGateway: cfg.LocalGateway, + } + if len(a.schedule) == 0 { + a.schedule = assignSchedule + } + if a.budget == 0 { + a.budget = assignBudget + } + if a.sleep == nil { + a.sleep = sleepCtx + } + return a, nil +} + +// Pool returns the pool this allocator manages. +func (a *PoolAllocator) Pool() external.ExternalPoolConfig { return a.cfg.Pool } + +// Allocate creates a secondary private IP on the VNIC, attaches a reserved +// public IP to it, waits for the assignment to settle, and records the pair. +// +// The OCI objects are created before the record is written, deliberately. The +// inverse order would let a crash leave a record naming addresses that do not +// exist, which nothing can detect — whereas a crash here leaves real objects +// carrying our display-name prefix and no record, which Reconcile finds by +// listing the VNIC. A leak that is discoverable beats a record that lies. +func (a *PoolAllocator) Allocate(ctx context.Context, req external.AllocateRequest) (netip.Addr, error) { + if req.PoolName != "" && req.PoolName != a.cfg.Pool.Name { + return netip.Addr{}, fmt.Errorf("ocinet: pool mismatch (got %q, bound to %q)", req.PoolName, a.cfg.Pool.Name) + } + + name := displayName(req) + priv, err := a.client.AssignPrivateIP(ctx, a.cfg.VNICID, netip.Addr{}, name) + if err != nil { + return netip.Addr{}, capacityErr(fmt.Errorf("ocinet: assign private ip: %w", err), err) + } + + pub, err := a.client.CreatePublicIP(ctx, a.cfg.CompartmentID, priv.ID, name) + if err != nil { + // Give the private IP back rather than leaving it for Reconcile: we are + // still here and we know exactly what we just made. + a.undoPrivate(ctx, priv.ID) + return netip.Addr{}, capacityErr(fmt.Errorf("ocinet: create public ip: %w", err), err) + } + + pub, err = a.waitAssigned(ctx, pub) + if err != nil { + a.undoPublic(ctx, pub.ID) + a.undoPrivate(ctx, priv.ID) + return netip.Addr{}, err + } + if !pub.Address.IsValid() { + a.undoPublic(ctx, pub.ID) + a.undoPrivate(ctx, priv.ID) + return netip.Addr{}, fmt.Errorf("ocinet: public ip %s has no address", pub.ID) + } + + key := pub.Address.String() + binding := Binding{ + PublicIPID: pub.ID, + PrivateIPID: priv.ID, + PrivateAddr: priv.Address.String(), + Purpose: req.Purpose, + AllocationID: req.AllocationID, + ENIID: req.ENIID, + InstanceID: req.InstanceID, + VNICID: priv.VNICID, + } + err = a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) { + if rec.Bindings == nil { + rec.Bindings = map[string]Binding{} + } + rec.Bindings[key] = binding + return true, nil + }) + if err != nil { + a.undoPublic(ctx, pub.ID) + a.undoPrivate(ctx, priv.ID) + return netip.Addr{}, fmt.Errorf("ocinet: record binding for %s: %w", key, err) + } + + slog.InfoContext(ctx, "ocinet allocated external IP", + "pool", a.cfg.Pool.Name, "public_ip", key, "private_ip", binding.PrivateAddr, + "purpose", req.Purpose, "public_ip_id", pub.ID, "private_ip_id", priv.ID) + return pub.Address, nil +} + +// Release deletes both OCI objects and drops the binding. ownerENIID scopes the +// release exactly as StaticPoolAllocator.Release does: external addresses are +// recycled and teardown sweeps re-emit releases, so a release naming an owner +// that no longer holds the binding is a no-op rather than a double-free of an +// address already handed to another instance. +// +// The record is dropped first here, the opposite of Allocate, and for the same +// reason: whichever half survives a crash should be the discoverable one. A +// binding removed with the objects still present is a leak Reconcile collects; +// objects removed with the binding still present would be a record that lies. +func (a *PoolAllocator) Release(ctx context.Context, poolName string, ip netip.Addr, ownerENIID string) error { + if poolName != "" && poolName != a.cfg.Pool.Name { + return fmt.Errorf("ocinet: pool mismatch (got %q, bound to %q)", poolName, a.cfg.Pool.Name) + } + if !ip.IsValid() { + return errors.New("ocinet: invalid ip") + } + key := ip.String() + + var released Binding + var found bool + err := a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) { + // Reset per attempt: a CAS retry that finds the binding already gone + // must not inherit the previous attempt's verdict. + released, found = Binding{}, false + + b, ok := rec.Bindings[key] + if !ok { + if ownerENIID != "" { + return false, nil + } + return false, fmt.Errorf("ocinet: %s not allocated in pool %s", key, a.cfg.Pool.Name) + } + if ownerENIID != "" && b.ENIID != "" && b.ENIID != ownerENIID { + slog.InfoContext(ctx, "ocinet release skip — address reassigned to a different ENI (stale release)", + "pool", a.cfg.Pool.Name, "public_ip", key, + "stale_owner_eni", ownerENIID, "current_owner_eni", b.ENIID) + return false, nil + } + // Only an unscoped release — ReleaseAddress, which names no interface — + // may free an EIP. Here the address is a reserved public IP object, so + // freeing one on instance teardown destroys it at the provider and the + // customer cannot get it back. + // AllocationID is set only for an EIP, so it stands in when a caller + // allocated without naming a purpose. + if ownerENIID != "" && (b.Purpose == purposeEIP || b.AllocationID != "") { + slog.InfoContext(ctx, "ocinet release skip — address belongs to an Elastic IP allocation", + "pool", a.cfg.Pool.Name, "public_ip", key, + "allocation_id", b.AllocationID, "owner_eni", ownerENIID) + return false, nil + } + delete(rec.Bindings, key) + released, found = b, true + return true, nil + }) + if err != nil || !found { + return err + } + + if err := a.deletePair(ctx, released.PublicIPID, released.PrivateIPID); err != nil { + return err + } + slog.InfoContext(ctx, "ocinet released external IP", + "pool", a.cfg.Pool.Name, "public_ip", key, "private_ip", released.PrivateAddr) + return nil +} + +// BindOwner records which ENI an already-allocated address is attached to. +// +// Allocate learns the owner only when one exists at the time — true for an +// auto-assigned address, never for an EIP, which is allocated bare and attached +// by a later AssociateAddress. Without this the binding of every EIP names no +// ENI, so ClaimLocalAddresses cannot tell which node should hold it and the +// address stays wherever the allocating node happened to put it. +// +// An empty eniID clears the owner, which is the state a disassociate leaves: the +// address is still allocated and still billing, but no guest answers on it and +// no node should claim it. +func (a *PoolAllocator) BindOwner(ctx context.Context, poolName string, ip netip.Addr, eniID string) error { + if poolName != "" && poolName != a.cfg.Pool.Name { + return fmt.Errorf("ocinet: pool mismatch (got %q, bound to %q)", poolName, a.cfg.Pool.Name) + } + if !ip.IsValid() { + return errors.New("ocinet: invalid ip") + } + key := ip.String() + return a.store.Mutate(ctx, a.cfg.Pool.Name, func(rec *Record) (bool, error) { + b, ok := rec.Bindings[key] + // An address this pool never allocated is not ours to annotate, and an + // unchanged owner is not worth a KV write on every re-association. + if !ok || b.ENIID == eniID { + return false, nil + } + b.ENIID = eniID + rec.Bindings[key] = b + return true, nil + }) +} + +// BindingFor returns the OCI pair behind an allocated public address. The host +// plumbing needs the private half: inbound packets arrive carrying it, not the +// public address, because OCI has already NATed by the time the instance sees +// them. +func (a *PoolAllocator) BindingFor(ctx context.Context, ip netip.Addr) (Binding, bool, error) { + rec, err := a.store.Get(ctx, a.cfg.Pool.Name) + if errors.Is(err, kvstore.ErrNotFound) { + return Binding{}, false, nil + } + if err != nil { + return Binding{}, false, err + } + b, ok := rec.Bindings[ip.String()] + return b, ok, nil +} + +// waitAssigned polls until the public IP reports ASSIGNED, or the budget runs +// out. A create that has already settled returns without a single extra call. +func (a *PoolAllocator) waitAssigned(ctx context.Context, pub oci.PublicIP) (oci.PublicIP, error) { + if pub.IsAssigned() { + return pub, nil + } + deadline := time.Now().Add(a.budget) + for i := 0; ; i++ { + wait := a.schedule[min(i, len(a.schedule)-1)] + if j := jitter(assignJitter); wait+j > 0 { + wait += j + } + if time.Now().Add(wait).After(deadline) { + return pub, fmt.Errorf("ocinet: public ip %s still %s after %s", + pub.ID, pub.LifecycleState, a.budget) + } + if err := a.sleep(ctx, wait); err != nil { + return pub, err + } + got, err := a.client.GetPublicIP(ctx, pub.ID) + if err != nil { + return pub, fmt.Errorf("ocinet: poll public ip %s: %w", pub.ID, err) + } + if got.IsAssigned() { + return got, nil + } + pub = got + } +} + +// deletePair removes the public IP then the private IP. Public first: deleting +// a private IP that still has a public attached is refused by OCI, and the +// reverse order would strand the reserved address with nothing pointing at it. +// A 404 on either is success — the address is already in the state we wanted. +func (a *PoolAllocator) deletePair(ctx context.Context, publicIPID, privateIPID string) error { + if publicIPID != "" { + if err := a.client.DeletePublicIP(ctx, publicIPID); err != nil && !errors.Is(err, oci.ErrNotFound) { + return fmt.Errorf("ocinet: delete public ip %s: %w", publicIPID, err) + } + } + if privateIPID != "" { + if err := a.client.UnassignPrivateIP(ctx, privateIPID); err != nil && !errors.Is(err, oci.ErrNotFound) { + return fmt.Errorf("ocinet: unassign private ip %s: %w", privateIPID, err) + } + } + return nil +} + +// undoPrivate and undoPublic roll back a partial Allocate. Failures are logged +// and swallowed: the caller is already returning an error, and the object is +// left carrying our prefix so Reconcile collects it on the next pass. Turning a +// cleanup failure into the reported error would hide why the allocation failed. +func (a *PoolAllocator) undoPrivate(ctx context.Context, privateIPID string) { + if err := a.client.UnassignPrivateIP(ctx, privateIPID); err != nil && !errors.Is(err, oci.ErrNotFound) { + slog.WarnContext(ctx, "ocinet: could not roll back private ip; reconcile will collect it", + "private_ip_id", privateIPID, "error", err) + } +} + +func (a *PoolAllocator) undoPublic(ctx context.Context, publicIPID string) { + if publicIPID == "" { + return + } + if err := a.client.DeletePublicIP(ctx, publicIPID); err != nil && !errors.Is(err, oci.ErrNotFound) { + slog.WarnContext(ctx, "ocinet: could not roll back public ip; reconcile will collect it", + "public_ip_id", publicIPID, "error", err) + } +} + +// capacityErr maps an OCI quota refusal onto the AWS error that means the same +// thing, so a customer who has hit the 64-per-VNIC or 50-per-region ceiling is +// told they are out of addresses rather than shown an Oracle error code. +func capacityErr(wrapped, raw error) error { + if errors.Is(raw, oci.ErrLimitExceeded) { + return fmt.Errorf("%w: %w", errors.New(awserrors.ErrorInsufficientAddressCapacity), wrapped) + } + return wrapped +} + +// displayName marks the object as ours and carries the AWS identity, so the OCI +// console shows what claimed the address. +func displayName(req external.AllocateRequest) string { + switch { + case req.AllocationID != "": + return DisplayNamePrefix + req.AllocationID + case req.ENIID != "": + return DisplayNamePrefix + req.ENIID + case req.InstanceID != "": + return DisplayNamePrefix + req.InstanceID + default: + return DisplayNamePrefix + "unclaimed" + } +} + +func jitter(span time.Duration) time.Duration { + if span <= 0 { + return 0 + } + // Spreading the poll ladder, not generating a secret: math/rand is the + // right tool and crypto/rand would be cargo cult here. + return time.Duration(rand.Int64N(int64(2*span))) - span //nolint:gosec // jitter, not a secret +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/spinifex/network/external/ocinet/allocator_test.go b/spinifex/network/external/ocinet/allocator_test.go new file mode 100644 index 000000000..da047dcc6 --- /dev/null +++ b/spinifex/network/external/ocinet/allocator_test.go @@ -0,0 +1,227 @@ +package ocinet_test + +import ( + "context" + "errors" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/awserrors" + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/external" + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" +) + +// newTestAllocator wires a fake OCI and an in-memory store, with a poll ladder +// that does not sleep so the async-assignment tests stay fast. +func newTestAllocator(t *testing.T, fake *oci.Fake) (*ocinet.PoolAllocator, *ocinet.MemStore) { + t.Helper() + store := ocinet.NewMemStore() + a, err := ocinet.New(fake, store, ocinet.Config{ + Pool: external.ExternalPoolConfig{Name: "oci-wan", Source: external.SourceOCI}, + VNICID: "ocid1.vnic.oc1..vnic1", + CompartmentID: "ocid1.compartment.oc1..comp1", + Schedule: []time.Duration{time.Millisecond}, + Budget: time.Second, + Sleep: func(context.Context, time.Duration) error { return nil }, + }) + require.NoError(t, err) + return a, store +} + +func TestAllocateCreatesThePairAndReturnsThePublicAddress(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + got, err := a.Allocate(ctx, external.AllocateRequest{ + PoolName: "oci-wan", Purpose: "eip", AllocationID: "eipalloc-1", ENIID: "eni-1", + }) + require.NoError(t, err) + + // The public address is what AWS semantics are about, so that is what + // Allocate returns — not the private one that rides the wire. + assert.Equal(t, "203.0.113.10", got.String()) + + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + b, ok := rec.Bindings[got.String()] + require.True(t, ok, "binding not recorded under the public address") + assert.Equal(t, "10.200.0.100", b.PrivateAddr, "private half must be recorded for the host plumbing") + assert.Equal(t, "eni-1", b.ENIID) + assert.NotEmpty(t, b.PublicIPID) + assert.NotEmpty(t, b.PrivateIPID) + + require.Len(t, fake.PrivateIPs(), 1) + require.Len(t, fake.PublicIPs(), 1) + assert.Equal(t, ocinet.DisplayNamePrefix+"eipalloc-1", fake.PrivateIPs()[0].DisplayName, + "objects must carry the prefix or reconcile cannot tell them from the operator's") +} + +func TestAllocateWaitsOutAsyncAssignment(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + fake.AssignAfter = 2 // ASSIGNING until the second Get + a, _ := newTestAllocator(t, fake) + + got, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + assert.True(t, got.IsValid()) + + pubs := fake.PublicIPs() + require.Len(t, pubs, 1) + assert.Equal(t, oci.LifecycleStateAssigned, pubs[0].LifecycleState, + "Allocate must not return an address that is still provisioning") +} + +func TestAllocateSurfacesQuotaExhaustionAsInsufficientAddressCapacity(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + fake.PrivateLimit = 1 + a, _ := newTestAllocator(t, fake) + + _, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + _, err = a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-2"}) + require.Error(t, err) + assert.Contains(t, err.Error(), awserrors.ErrorInsufficientAddressCapacity, + "an OCI quota refusal must reach the customer as the AWS error that means the same thing") +} + +// A failure after the private IP exists must not leave it behind: we are still +// running and know exactly what we just created, so rolling back beats leaving +// a billable object for the next reconcile pass. +func TestAllocateRollsBackThePrivateIPWhenThePublicIPFails(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + fake.FailWith["CreatePublicIP"] = errors.New("boom") + a, store := newTestAllocator(t, fake) + + _, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.Error(t, err) + + assert.Empty(t, fake.PrivateIPs(), "private IP was not rolled back") + assert.Empty(t, fake.PublicIPs()) + _, err = store.Get(ctx, "oci-wan") + require.ErrorIs(t, err, kvstore.ErrNotFound, "a failed allocate must leave no record at all") +} + +func TestReleaseDeletesBothObjectsAndDropsTheBinding(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + ip, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1", ENIID: "eni-1"}) + require.NoError(t, err) + + // Unscoped, as ReleaseAddress is: an owner-scoped release may not free an + // EIP, which TestAnOwnerScopedReleaseWillNotFreeAnEIPPair covers. + require.NoError(t, a.Release(ctx, "oci-wan", ip, "")) + + assert.Empty(t, fake.PrivateIPs(), "private IP outlived its release") + assert.Empty(t, fake.PublicIPs(), "reserved public IP outlived its release — this one bills") + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Empty(t, rec.Bindings) +} + +// External addresses are recycled and teardown sweeps re-emit releases, so a +// release naming an owner that no longer holds the binding must be a no-op +// rather than a double-free of an address already handed to another instance. +func TestReleaseWithAStaleOwnerIsANoOp(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + // Auto-assigned, so the stale-owner guard is what decides this and not the + // EIP refusal next to it. + ip, err := a.Allocate(ctx, external.AllocateRequest{ + PoolName: "oci-wan", Purpose: "eni-public", ENIID: "eni-current", InstanceID: "i-1", + }) + require.NoError(t, err) + + require.NoError(t, a.Release(ctx, "oci-wan", ip, "eni-previous")) + + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Len(t, rec.Bindings, 1, "a stale release freed an address belonging to a live ENI") + assert.Len(t, fake.PublicIPs(), 1) +} + +func TestReleaseOfAnUnknownAddressIsANoOpWhenOwnerScoped(t *testing.T) { + ctx := context.Background() + a, _ := newTestAllocator(t, oci.NewFake()) + + // Owner-scoped: a duplicated teardown for an address already freed. + require.NoError(t, a.Release(ctx, "oci-wan", netip.MustParseAddr("203.0.113.99"), "eni-1")) + // Unscoped: nothing claims to know better, so this is a real error. + require.Error(t, a.Release(ctx, "oci-wan", netip.MustParseAddr("203.0.113.99"), "")) +} + +func TestPoolMismatchIsRefused(t *testing.T) { + ctx := context.Background() + a, _ := newTestAllocator(t, oci.NewFake()) + + _, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "other-pool", AllocationID: "eipalloc-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "pool mismatch") + + err = a.Release(ctx, "other-pool", netip.MustParseAddr("203.0.113.10"), "") + require.Error(t, err) + assert.Contains(t, err.Error(), "pool mismatch") +} + +func TestNewRejectsIncompleteConfig(t *testing.T) { + store := ocinet.NewMemStore() + fake := oci.NewFake() + + _, err := ocinet.New(nil, store, ocinet.Config{VNICID: "v", CompartmentID: "c"}) + require.Error(t, err) + + _, err = ocinet.New(fake, nil, ocinet.Config{VNICID: "v", CompartmentID: "c"}) + require.Error(t, err) + + _, err = ocinet.New(fake, store, ocinet.Config{CompartmentID: "c"}) + require.ErrorContains(t, err, "vnic_id") + + _, err = ocinet.New(fake, store, ocinet.Config{VNICID: "v"}) + require.ErrorContains(t, err, "compartment_id") +} + +func TestBindingForReturnsThePrivateHalf(t *testing.T) { + ctx := context.Background() + a, _ := newTestAllocator(t, oci.NewFake()) + + ip, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + b, ok, err := a.BindingFor(ctx, ip) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "10.200.0.100", b.PrivateAddr) + + _, ok, err = a.BindingFor(ctx, netip.MustParseAddr("203.0.113.200")) + require.NoError(t, err) + assert.False(t, ok) +} + +// The VNIC on the binding is what lets a node returning from a failure tell an +// address that is still its own from one that has moved to a surviving node, +// before it reasserts host plumbing for an address it no longer holds. +func TestAllocateRecordsTheOwningVNIC(t *testing.T) { + ctx := context.Background() + a, store := newTestAllocator(t, oci.NewFake()) + + ip, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Equal(t, "ocid1.vnic.oc1..vnic1", rec.Bindings[ip.String()].VNICID) +} diff --git a/spinifex/network/external/ocinet/lookup.go b/spinifex/network/external/ocinet/lookup.go new file mode 100644 index 000000000..01f0d1e66 --- /dev/null +++ b/spinifex/network/external/ocinet/lookup.go @@ -0,0 +1,100 @@ +package ocinet + +import ( + "context" + "errors" + "fmt" + "net/netip" + + "github.com/mulgadc/spinifex/spinifex/kvstore" +) + +// Lookup answers "what address does this external IP ride the wire as" from the +// bindings store alone. No OCI client and no credentials, because the process +// that needs the answer is vpcd: it programs the OVN NAT rule and the host +// ingress route, and both must match the private half of the pair while every +// AWS-facing record holds the public half. +// +// Reading KV rather than asking the allocator is not duplication of state — +// vpcd is a separate process from the daemon that owns the allocator, and the +// bindings record is the only thing either of them would consult. +type Lookup struct { + store Store + pools []string +} + +// NewLookup binds a read-only resolver to the named OCI pools. +func NewLookup(store Store, pools []string) *Lookup { + return &Lookup{store: store, pools: append([]string(nil), pools...)} +} + +// DatapathIP maps externalIP to its on-wire private address, or returns it +// unchanged when no OCI pool holds a binding for it — every address on a +// static or DHCP pool is its own datapath address, and vpcd cannot tell which +// pool an address came from without asking. +// +// A store read failure is an error rather than a pass-through. Falling back to +// the public address would install a NAT rule matching an address that never +// appears on the wire, which is indistinguishable from a working rule until +// the instance turns out to be unreachable. +func (l *Lookup) DatapathIP(ctx context.Context, externalIP string) (string, error) { + if l == nil || externalIP == "" { + return externalIP, nil + } + for _, pool := range l.pools { + rec, err := l.store.Get(ctx, pool) + if errors.Is(err, kvstore.ErrNotFound) { + // A pool that has never allocated holds no bindings, which is a + // miss on this pool rather than a reason to fail the lookup. + continue + } + if err != nil { + return "", fmt.Errorf("ocinet: read bindings for pool %q: %w", pool, err) + } + b, ok := rec.Bindings[externalIP] + if !ok { + continue + } + if _, err := netip.ParseAddr(b.PrivateAddr); err != nil { + return "", fmt.Errorf("ocinet: binding for %s in pool %q has unparseable private addr %q: %w", + externalIP, pool, b.PrivateAddr, err) + } + return b.PrivateAddr, nil + } + return externalIP, nil +} + +// BindGateway records vpcID as the VPC whose NAT gateway answers on externalIP, +// so the affinity pass knows which node the address belongs on. +// +// A NAT gateway's address is the one external address never attached to an ENI: +// it is a logical router's SNAT source, not a guest's. Without this the affinity +// pass has nothing to place it by and leaves it on whichever node served +// AllocateAddress, which OCI then refuses to let it leave from anywhere else. +// +// Written from the reconcile pass rather than from CreateNatGateway because the +// answer is the gateway chassis, which reconcile is what decides. Idempotent: +// an unchanged VPC is not a write, so the steady state costs one read. +func (l *Lookup) BindGateway(ctx context.Context, externalIP, vpcID string) error { + if l == nil || externalIP == "" { + return nil + } + for _, pool := range l.pools { + err := l.store.Mutate(ctx, pool, func(rec *Record) (bool, error) { + b, ok := rec.Bindings[externalIP] + if !ok || b.GatewayVPCID == vpcID { + return false, nil + } + b.GatewayVPCID = vpcID + rec.Bindings[externalIP] = b + return true, nil + }) + if errors.Is(err, kvstore.ErrNotFound) { + continue + } + if err != nil { + return fmt.Errorf("ocinet: record gateway VPC for %s in pool %q: %w", externalIP, pool, err) + } + } + return nil +} diff --git a/spinifex/network/external/ocinet/lookup_test.go b/spinifex/network/external/ocinet/lookup_test.go new file mode 100644 index 000000000..1ed0b8797 --- /dev/null +++ b/spinifex/network/external/ocinet/lookup_test.go @@ -0,0 +1,108 @@ +package ocinet_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" +) + +func seedBinding(t *testing.T, s *ocinet.MemStore, pool, public, private string) { + t.Helper() + require.NoError(t, s.Mutate(context.Background(), pool, func(rec *ocinet.Record) (bool, error) { + rec.Bindings[public] = ocinet.Binding{PrivateAddr: private} + return true, nil + })) +} + +func TestLookupReturnsTheOnWirePrivateAddress(t *testing.T) { + store := ocinet.NewMemStore() + seedBinding(t, store, "oci-pool", "203.0.113.9", "10.200.0.183") + + got, err := ocinet.NewLookup(store, []string{"oci-pool"}).DatapathIP(context.Background(), "203.0.113.9") + require.NoError(t, err) + assert.Equal(t, "10.200.0.183", got) +} + +// vpcd cannot tell which pool an address came from, so an address no OCI pool +// knows about is its own datapath address — every static and DHCP one is. +func TestLookupPassesThroughAnUnboundAddress(t *testing.T) { + store := ocinet.NewMemStore() + seedBinding(t, store, "oci-pool", "203.0.113.9", "10.200.0.183") + + got, err := ocinet.NewLookup(store, []string{"oci-pool"}).DatapathIP(context.Background(), "72.52.77.240") + require.NoError(t, err) + assert.Equal(t, "72.52.77.240", got) +} + +func TestLookupSearchesEveryConfiguredPool(t *testing.T) { + store := ocinet.NewMemStore() + seedBinding(t, store, "pool-b", "203.0.113.9", "10.200.0.183") + + got, err := ocinet.NewLookup(store, []string{"pool-a", "pool-b"}).DatapathIP(context.Background(), "203.0.113.9") + require.NoError(t, err) + assert.Equal(t, "10.200.0.183", got) +} + +// Passing the public address through on a read failure installs a rule that +// looks healthy and blackholes the instance. +func TestLookupStoreFailureIsAnError(t *testing.T) { + got, err := ocinet.NewLookup(failingStore{}, []string{"oci-pool"}).DatapathIP(context.Background(), "203.0.113.9") + require.Error(t, err) + assert.Empty(t, got) +} + +type failingStore struct{} + +func (failingStore) Mutate(context.Context, string, func(*ocinet.Record) (bool, error)) error { + return errors.New("unavailable") +} + +func (failingStore) Get(context.Context, string) (ocinet.Record, error) { + return ocinet.Record{}, errors.New("unavailable") +} + +// The affinity pass places a NAT gateway's address by the VPC it serves, and +// this is the only writer of that field. +func TestBindGatewayRecordsTheVPCOnTheBinding(t *testing.T) { + ctx := context.Background() + store := ocinet.NewMemStore() + seedBinding(t, store, "oci-pool", "203.0.113.9", "10.200.0.183") + + require.NoError(t, ocinet.NewLookup(store, []string{"oci-pool"}).BindGateway(ctx, "203.0.113.9", "vpc-1")) + + rec, err := store.Get(ctx, "oci-pool") + require.NoError(t, err) + assert.Equal(t, "vpc-1", rec.Bindings["203.0.113.9"].GatewayVPCID) + assert.Equal(t, "10.200.0.183", rec.Bindings["203.0.113.9"].PrivateAddr, "the pair must survive the annotation") +} + +// Deleting the gateway hands the address back, and a stale VPC would keep the +// affinity pass moving it to a chassis nothing forwards from. +func TestBindGatewayClearsTheVPC(t *testing.T) { + ctx := context.Background() + store := ocinet.NewMemStore() + seedBinding(t, store, "oci-pool", "203.0.113.9", "10.200.0.183") + l := ocinet.NewLookup(store, []string{"oci-pool"}) + require.NoError(t, l.BindGateway(ctx, "203.0.113.9", "vpc-1")) + + require.NoError(t, l.BindGateway(ctx, "203.0.113.9", "")) + + rec, err := store.Get(ctx, "oci-pool") + require.NoError(t, err) + assert.Empty(t, rec.Bindings["203.0.113.9"].GatewayVPCID) +} + +// Every node runs this every pass, so an address no OCI pool holds must not be +// an error — that is every static and DHCP address on a mixed cluster. +func TestBindGatewayIgnoresAnUnboundAddress(t *testing.T) { + ctx := context.Background() + store := ocinet.NewMemStore() + seedBinding(t, store, "oci-pool", "203.0.113.9", "10.200.0.183") + + require.NoError(t, ocinet.NewLookup(store, []string{"oci-pool"}).BindGateway(ctx, "72.52.77.240", "vpc-1")) +} diff --git a/spinifex/network/external/ocinet/owner_test.go b/spinifex/network/external/ocinet/owner_test.go new file mode 100644 index 000000000..5a47030e5 --- /dev/null +++ b/spinifex/network/external/ocinet/owner_test.go @@ -0,0 +1,140 @@ +package ocinet_test + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/network/external" + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" +) + +// allocateBare is an EIP as AWS makes one: an address with no interface behind +// it yet. Everything about the owner has to arrive later. +func allocateBare(t *testing.T, a *ocinet.PoolAllocator) netip.Addr { + t.Helper() + ip, err := a.Allocate(context.Background(), external.AllocateRequest{ + PoolName: "oci-wan", Purpose: "eip", AllocationID: "eipalloc-1", + }) + require.NoError(t, err) + return ip +} + +func ownerOf(t *testing.T, store *ocinet.MemStore, ip netip.Addr) string { + t.Helper() + rec, err := store.Get(context.Background(), "oci-wan") + require.NoError(t, err) + return rec.Bindings[ip.String()].ENIID +} + +// Without this the binding of every EIP names no ENI, and the affinity pass +// cannot tell which node the address has to be delivered to. +func TestBindOwnerRecordsTheENIAnEIPWasAssociatedWith(t *testing.T) { + ctx := context.Background() + a, store := newTestAllocator(t, oci.NewFake()) + ip := allocateBare(t, a) + assert.Empty(t, ownerOf(t, store, ip), "a bare allocation should name no owner") + + require.NoError(t, a.BindOwner(ctx, "oci-wan", ip, "eni-1")) + assert.Equal(t, "eni-1", ownerOf(t, store, ip)) +} + +// A disassociated address is still allocated and still billing, but no guest +// answers on it, so no node should claim it. +func TestBindOwnerClearsTheOwnerOnDisassociate(t *testing.T) { + ctx := context.Background() + a, store := newTestAllocator(t, oci.NewFake()) + ip := allocateBare(t, a) + + require.NoError(t, a.BindOwner(ctx, "oci-wan", ip, "eni-1")) + require.NoError(t, a.BindOwner(ctx, "oci-wan", ip, "")) + assert.Empty(t, ownerOf(t, store, ip)) +} + +// A re-association to the same ENI is the steady state of every reconcile, so +// it must not churn the record. +func TestBindOwnerDoesNotWriteWhenTheOwnerIsUnchanged(t *testing.T) { + ctx := context.Background() + a, store := newTestAllocator(t, oci.NewFake()) + ip := allocateBare(t, a) + require.NoError(t, a.BindOwner(ctx, "oci-wan", ip, "eni-1")) + + before := store.Writes() + require.NoError(t, a.BindOwner(ctx, "oci-wan", ip, "eni-1")) + assert.Equal(t, before, store.Writes(), "an unchanged owner cost a KV write") +} + +// An address this pool never handed out is not ours to annotate — inventing a +// binding for it would have Reconcile collect an address belonging to nobody. +func TestBindOwnerIgnoresAnAddressThisPoolNeverAllocated(t *testing.T) { + ctx := context.Background() + a, store := newTestAllocator(t, oci.NewFake()) + allocateBare(t, a) + + stranger := netip.MustParseAddr("198.51.100.7") + require.NoError(t, a.BindOwner(ctx, "oci-wan", stranger, "eni-9")) + + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.NotContains(t, rec.Bindings, stranger.String()) +} + +func TestBindOwnerRefusesAnotherPoolsName(t *testing.T) { + a, _ := newTestAllocator(t, oci.NewFake()) + ip := allocateBare(t, a) + + err := a.BindOwner(context.Background(), "some-other-pool", ip, "eni-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "pool mismatch") +} + +// The whole point of recording the owner: an EIP associated to a guest running +// here is then claimable, which it never was before. +func TestAnEIPBecomesClaimableOnceItsOwnerIsRecorded(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newAffinityAllocator(t, fake, "port-eni-1") + pubAddr, privID := seedForeignBinding(t, fake, store, "") + + res, err := a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + require.Empty(t, res.Claimed, "an unassociated address has no owning node") + + require.NoError(t, a.BindOwner(ctx, "oci-wan", netip.MustParseAddr(pubAddr), "eni-1")) + + res, err = a.ClaimLocalAddresses(ctx) + require.NoError(t, err) + assert.Equal(t, []string{pubAddr}, res.Claimed) + + got, err := fake.GetPrivateIP(ctx, privID) + require.NoError(t, err) + assert.Equal(t, thisVNIC, got.VNICID, "OCI still delivers the EIP to another node") +} + +// On OCI the address is a reserved public IP object, so an owner-scoped release +// of one does not merely return it to a pool — it destroys it at the provider +// and the customer cannot get it back. +func TestAnOwnerScopedReleaseWillNotFreeAnEIPPair(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + ip, err := a.Allocate(ctx, external.AllocateRequest{ + PoolName: "oci-wan", Purpose: "eip", AllocationID: "eipalloc-1", + }) + require.NoError(t, err) + require.NoError(t, a.BindOwner(ctx, "oci-wan", ip, "eni-1")) + + // What instance teardown does once the EIP is what the instance holds. + require.NoError(t, a.Release(ctx, "oci-wan", ip, "eni-1")) + + assert.Len(t, fake.PublicIPs(), 1, + "terminating an instance destroyed a customer's reserved public IP") + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Len(t, rec.Bindings, 1) +} diff --git a/spinifex/network/external/ocinet/reconcile.go b/spinifex/network/external/ocinet/reconcile.go new file mode 100644 index 000000000..b3ec2181e --- /dev/null +++ b/spinifex/network/external/ocinet/reconcile.go @@ -0,0 +1,153 @@ +package ocinet + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/kvstore" +) + +// ReconcileResult reports what a pass found, so a caller can log it and a test +// can assert on it without reading the fake's internals. +type ReconcileResult struct { + // Collected is the OCIDs of private IPs deleted as leaks. + Collected []string + // Stale is the binding keys dropped because OCI no longer has the objects. + Stale []string + // Skipped is the private IPs left alone because they are not ours. + Skipped int +} + +// Reconcile squares our record against OCI, in both directions. It is required +// on startup rather than optional: Allocate creates OCI objects before writing +// the record, so a crash in between leaves a reserved public IP that costs +// money and holds one of the 50 per-region slots, with nothing referencing it. +// Nothing else will ever find that address. +// +// The two directions fix different faults: +// +// - An object on the VNIC carrying our prefix with no binding is a leak from +// an interrupted Allocate. Delete it. +// - A binding naming objects OCI no longer has is a stale record from an +// interrupted Release, or from someone deleting the address in the console. +// Drop it, so the address is not reported to a customer as theirs. +// +// **It never touches an object without DisplayNamePrefix.** The reference host +// carries operator-created secondary addresses on the same VNIC, and a +// reconcile that collected those would take the node's own networking down. The +// prefix is the entire safety property here, so an object whose name we cannot +// read is skipped, not guessed at. +func (a *PoolAllocator) Reconcile(ctx context.Context) (ReconcileResult, error) { + var res ReconcileResult + + live, err := a.client.ListPrivateIPs(ctx, a.cfg.VNICID) + if err != nil { + return res, fmt.Errorf("ocinet reconcile: list private ips on %s: %w", a.cfg.VNICID, err) + } + // A pool that has never allocated has no record, which is an empty binding + // set rather than a failure — and the reconcile still has work to do, since + // the leak this pass exists to find is precisely an OCI object created + // before its binding was written. + rec, err := a.store.Get(ctx, a.cfg.Pool.Name) + if err != nil && !errors.Is(err, kvstore.ErrNotFound) { + return res, fmt.Errorf("ocinet reconcile: read bindings: %w", err) + } + + claimed := make(map[string]struct{}, len(rec.Bindings)) + for _, b := range rec.Bindings { + claimed[b.PrivateIPID] = struct{}{} + } + + // Direction 1: OCI has objects we do not claim. + for _, p := range live { + if p.IsPrimary || !strings.HasPrefix(p.DisplayName, DisplayNamePrefix) { + res.Skipped++ + continue + } + if _, ok := claimed[p.ID]; ok { + continue + } + publicIPID := "" + if pub, err := a.client.GetPublicIPByPrivateIPID(ctx, p.ID); err == nil { + publicIPID = pub.ID + } else if !errors.Is(err, oci.ErrNotFound) { + return res, fmt.Errorf("ocinet reconcile: look up public ip for %s: %w", p.ID, err) + } + if err := a.deletePair(ctx, publicIPID, p.ID); err != nil { + return res, fmt.Errorf("ocinet reconcile: collect leaked %s: %w", p.ID, err) + } + slog.WarnContext(ctx, "ocinet reconcile collected a leaked address", + "pool", a.cfg.Pool.Name, "private_ip_id", p.ID, "private_ip", p.Address.String(), + "public_ip_id", publicIPID, "display_name", p.DisplayName) + res.Collected = append(res.Collected, p.ID) + } + + // Direction 2: we claim objects OCI does not have. + byID := make(map[string]struct{}, len(live)) + for _, p := range live { + byID[p.ID] = struct{}{} + } + // Only this node's own bindings are this node's to judge. The record is one + // key per pool and therefore cluster-wide, while `live` is one VNIC — so a + // binding held by another node looks exactly like a binding whose objects + // OCI has lost. Dropping those makes every node delete its peers' addresses + // on startup, and direction 1 on the owning node then collects the now + // unclaimed objects as leaks: a live address is destroyed under a running + // instance. A binding naming no VNIC cannot be attributed, so it is left + // alone rather than guessed at. + var stale []string + var foreign int + for key, b := range rec.Bindings { + if _, ok := byID[b.PrivateIPID]; ok { + continue + } + if b.VNICID != a.cfg.VNICID { + foreign++ + continue + } + stale = append(stale, key) + } + if foreign > 0 { + slog.DebugContext(ctx, "ocinet reconcile left other nodes' bindings alone", + "pool", a.cfg.Pool.Name, "vnic_id", a.cfg.VNICID, "foreign", foreign) + } + if len(stale) > 0 { + err = a.store.Mutate(ctx, a.cfg.Pool.Name, func(r *Record) (bool, error) { + changed := false + for _, key := range stale { + // Re-check under CAS: another node may have re-allocated this + // address between our list and this mutation, in which case the + // binding is live again and dropping it would strand a real one. + b, ok := r.Bindings[key] + if !ok { + continue + } + if _, still := byID[b.PrivateIPID]; still { + continue + } + // Re-check ownership too: the re-allocation may have been to + // another node's VNIC, which is a live address elsewhere. + if b.VNICID != a.cfg.VNICID { + continue + } + delete(r.Bindings, key) + changed = true + } + return changed, nil + }) + if err != nil { + return res, fmt.Errorf("ocinet reconcile: drop stale bindings: %w", err) + } + for _, key := range stale { + slog.WarnContext(ctx, "ocinet reconcile dropped a stale binding", + "pool", a.cfg.Pool.Name, "public_ip", key) + } + res.Stale = stale + } + + return res, nil +} diff --git a/spinifex/network/external/ocinet/reconcile_test.go b/spinifex/network/external/ocinet/reconcile_test.go new file mode 100644 index 000000000..c90e481bb --- /dev/null +++ b/spinifex/network/external/ocinet/reconcile_test.go @@ -0,0 +1,229 @@ +package ocinet_test + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/kvstore" + "github.com/mulgadc/spinifex/spinifex/network/external" + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" +) + +// A crash between creating the OCI objects and writing the binding leaves a +// reserved public IP that bills and holds one of the 50 per-region slots, with +// nothing referencing it. Reconcile is the only thing that can ever find it. +func TestReconcileCollectsALeakedPairFromAnInterruptedAllocate(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + // Simulate the crash: the objects exist and carry our prefix, but the + // binding was never written. + priv, err := fake.AssignPrivateIP(ctx, "ocid1.vnic.oc1..vnic1", netip.Addr{}, ocinet.DisplayNamePrefix+"eipalloc-lost") + require.NoError(t, err) + _, err = fake.CreatePublicIP(ctx, "ocid1.compartment.oc1..comp1", priv.ID, ocinet.DisplayNamePrefix+"eipalloc-lost") + require.NoError(t, err) + require.Len(t, fake.PublicIPs(), 1) + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + + assert.Equal(t, []string{priv.ID}, res.Collected) + assert.Empty(t, fake.PrivateIPs(), "leaked private IP was not collected") + assert.Empty(t, fake.PublicIPs(), "leaked reserved public IP was not collected — this one bills") + + // The leak this pass collects is an OCI pair created before its binding was + // ever written, so the pool still holds no record afterwards. + _, err = store.Get(ctx, "oci-wan") + require.ErrorIs(t, err, kvstore.ErrNotFound) +} + +// The safety property of the whole pass. mulga-poc carries operator-created +// secondary addresses on the same VNIC that Spinifex allocates from, and a +// reconcile that collected those would take the node's own networking down. +func TestReconcileNeverTouchesAddressesThatAreNotOurs(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, _ := newTestAllocator(t, fake) + + // The operator's own: the VNIC's primary, and a hand-made secondary. + fake.SeedPrivateIP(oci.PrivateIP{ + ID: "ocid1.privateip.oc1..primary", Address: netip.MustParseAddr("10.200.0.29"), + VNICID: "ocid1.vnic.oc1..vnic1", DisplayName: "vnic-1", IsPrimary: true, + }) + fake.SeedPrivateIP(oci.PrivateIP{ + ID: "ocid1.privateip.oc1..operator", Address: netip.MustParseAddr("10.200.0.81"), + VNICID: "ocid1.vnic.oc1..vnic1", DisplayName: "vnic-v3", + }) + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, res.Collected, "reconcile collected an address it did not create") + assert.Equal(t, 2, res.Skipped) + assert.Len(t, fake.PrivateIPs(), 2, "operator addresses must survive a reconcile pass") +} + +// An interrupted Release, or someone deleting the address in the OCI console, +// leaves a binding naming objects that no longer exist. Left in place it would +// be reported to a customer as their address. +func TestReconcileDropsABindingWhoseObjectsAreGone(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + ip, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + // Remove the objects behind our back. + for _, p := range fake.PrivateIPs() { + require.NoError(t, fake.UnassignPrivateIP(ctx, p.ID)) + } + for _, p := range fake.PublicIPs() { + require.NoError(t, fake.DeletePublicIP(ctx, p.ID)) + } + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + + assert.Equal(t, []string{ip.String()}, res.Stale) + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Empty(t, rec.Bindings, "stale binding survived reconcile") +} + +func TestReconcileLeavesAHealthyAllocationAlone(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, store := newTestAllocator(t, fake) + + ip, err := a.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, res.Collected) + assert.Empty(t, res.Stale) + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Contains(t, rec.Bindings, ip.String()) + assert.Len(t, fake.PrivateIPs(), 1) + assert.Len(t, fake.PublicIPs(), 1) +} + +// A leaked private IP that never got a public IP attached — the crash window +// between the two creates — must still be collected. +func TestReconcileCollectsALeakedPrivateIPWithNoPublicIP(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + a, _ := newTestAllocator(t, fake) + + priv, err := fake.AssignPrivateIP(ctx, "ocid1.vnic.oc1..vnic1", netip.Addr{}, ocinet.DisplayNamePrefix+"eipalloc-half") + require.NoError(t, err) + + res, err := a.Reconcile(ctx) + require.NoError(t, err) + + assert.Equal(t, []string{priv.ID}, res.Collected) + assert.Empty(t, fake.PrivateIPs()) +} + +// A pool that has never allocated has no record. That is an empty binding set, +// not a failure — and the pass still has to run, since the leak it exists to +// find is an OCI object created before its binding was ever written. +func TestReconcileOnAPoolThatHasNeverAllocated(t *testing.T) { + fake := oci.NewFake() + alloc, store := newTestAllocator(t, fake) + + if _, err := store.Get(context.Background(), "oci-wan"); err == nil { + t.Fatal("precondition: expected no record for a pool that never allocated") + } + + res, err := alloc.Reconcile(context.Background()) + if err != nil { + t.Fatalf("Reconcile on an empty pool: %v", err) + } + if len(res.Collected) != 0 || len(res.Stale) != 0 { + t.Errorf("expected nothing to collect: %+v", res) + } +} + +// newPeerAllocator builds a second node's allocator over the same store: one +// KV record per pool, one VNIC per node, which is the shape a multi-node +// cluster actually has. +func newPeerAllocator(t *testing.T, fake *oci.Fake, store ocinet.Store, vnicID string) *ocinet.PoolAllocator { + t.Helper() + a, err := ocinet.New(fake, store, ocinet.Config{ + Pool: external.ExternalPoolConfig{Name: "oci-wan", Source: external.SourceOCI}, + VNICID: vnicID, + CompartmentID: "ocid1.compartment.oc1..comp1", + Schedule: []time.Duration{time.Millisecond}, + Budget: time.Second, + Sleep: func(context.Context, time.Duration) error { return nil }, + }) + require.NoError(t, err) + return a +} + +// The record is one key per pool and so cluster-wide, while the live list is +// one VNIC. A peer's binding therefore looks exactly like a binding whose +// objects OCI has lost, and dropping it makes every node delete its peers' +// addresses on startup — after which direction 1 on the owning node collects +// the now-unclaimed objects as leaks and a running instance loses its address. +func TestReconcileLeavesAnotherNodesBindingAlone(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + node1, store := newTestAllocator(t, fake) + node2 := newPeerAllocator(t, fake, store, "ocid1.vnic.oc1..vnic2") + + ip, err := node1.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + + res, err := node2.Reconcile(ctx) + require.NoError(t, err) + + assert.Empty(t, res.Stale, "node2 dropped node1's binding") + assert.Empty(t, res.Collected, "node2 collected node1's address") + + rec, err := store.Get(ctx, "oci-wan") + require.NoError(t, err) + assert.Contains(t, rec.Bindings, ip.String(), "node1's binding did not survive node2's reconcile") + + // The destructive half: with the binding gone, node1's own next pass would + // see an unclaimed object carrying our prefix and delete it. + res, err = node1.Reconcile(ctx) + require.NoError(t, err) + assert.Empty(t, res.Collected, "node1 collected its own live address as a leak") + assert.Len(t, fake.PublicIPs(), 1, "the reserved public IP of a running instance was deleted") +} + +// A node must still drop its own stale bindings, or an interrupted Release +// leaves an address reported to a customer as theirs forever. +func TestReconcileStillDropsItsOwnStaleBinding(t *testing.T) { + ctx := context.Background() + fake := oci.NewFake() + node1, store := newTestAllocator(t, fake) + node2 := newPeerAllocator(t, fake, store, "ocid1.vnic.oc1..vnic2") + + ip, err := node1.Allocate(ctx, external.AllocateRequest{PoolName: "oci-wan", AllocationID: "eipalloc-1"}) + require.NoError(t, err) + for _, p := range fake.PrivateIPs() { + require.NoError(t, fake.UnassignPrivateIP(ctx, p.ID)) + } + + // node2 is not the owner, so it must not be the one to decide. + res, err := node2.Reconcile(ctx) + require.NoError(t, err) + assert.Empty(t, res.Stale) + + res, err = node1.Reconcile(ctx) + require.NoError(t, err) + assert.Equal(t, []string{ip.String()}, res.Stale) +} diff --git a/spinifex/network/external/ocinet/store.go b/spinifex/network/external/ocinet/store.go new file mode 100644 index 000000000..94f0d1fa0 --- /dev/null +++ b/spinifex/network/external/ocinet/store.go @@ -0,0 +1,158 @@ +package ocinet + +import ( + "context" + "fmt" + "maps" + "sync" + + "github.com/mulgadc/spinifex/spinifex/kvstore" + "github.com/nats-io/nats.go/jetstream" +) + +const ( + // KVBucketOCIBindings holds the public-address → OCI object-pair mapping. + // A bucket of its own rather than a corner of the static-pool bucket: + // nothing about it is range math, its records are keyed differently, and + // mixing the two would make the static allocator's migrations answerable + // for a schema it knows nothing about. + KVBucketOCIBindings = "spinifex-oci-bindings" + // KVBucketOCIBindingsHistory matches the static pool bucket. + KVBucketOCIBindingsHistory = 5 + // kvCASRetries bounds contention on one pool key. + kvCASRetries = 10 +) + +func bucketConfig() kvstore.Config { + return kvstore.Config{ + Name: KVBucketOCIBindings, + History: KVBucketOCIBindingsHistory, + Attempts: kvCASRetries, + Missing: "ocinet: no JetStream client configured", + Exhausted: func(key string, attempts int) error { + return fmt.Errorf("ocinet: pool %s contended after %d attempts", key, attempts) + }, + } +} + +// KVStore is the JetStream-backed Store. +type KVStore struct { + store *kvstore.Store[Record] +} + +var _ Store = (*KVStore)(nil) + +// NewKVStore creates the bindings bucket if it is missing. +func NewKVStore(js jetstream.JetStream) *KVStore { + return &KVStore{store: kvstore.New[Record](js, bucketConfig())} +} + +// NewKVStoreOver builds a store over an already-open bucket. +func NewKVStoreOver(kv jetstream.KeyValue) *KVStore { + return &KVStore{store: kvstore.Over[Record](nil, kv, bucketConfig())} +} + +// Mutate implements Store. Upsert rather than Mutate: the first allocation on +// a pool is what creates its record, and Mutate reports ErrNotFound for an +// absent key — so every pool would fail its own first allocation, after OCI +// had already created and billed the pair. +func (s *KVStore) Mutate(ctx context.Context, poolName string, fn func(*Record) (bool, error)) error { + return s.store.Upsert(ctx, poolName, func(rec *Record) (bool, error) { + // A record written before the field existed, or a freshly created one, + // decodes to a nil map that the callers below would panic assigning to. + if rec.Bindings == nil { + rec.Bindings = map[string]Binding{} + } + return fn(rec) + }) +} + +// Get implements Store. A pool with no record yet reports kvstore.ErrNotFound, +// which callers read as an empty binding set — surfacing it rather than hiding +// it keeps a bucket that has gone missing distinguishable from a pool that has +// simply never allocated. +func (s *KVStore) Get(ctx context.Context, poolName string) (Record, error) { + rec, _, err := s.store.Get(ctx, poolName) + if err != nil { + return Record{}, err + } + if rec == nil { + return Record{Bindings: map[string]Binding{}}, nil + } + if rec.Bindings == nil { + rec.Bindings = map[string]Binding{} + } + return *rec, nil +} + +// MemStore is an in-memory Store for tests. Concurrency-safe so a test can +// exercise the CAS-retry paths without NATS. +type MemStore struct { + mu sync.Mutex + records map[string]Record + // mutateErr, when set, fails every Mutate. Lets a test drive the paths + // where OCI has already been changed and only the record write fails. + mutateErr error + // writes counts committed mutations, so a test can assert that a pass which + // changes nothing costs no write. + writes int +} + +var _ Store = (*MemStore)(nil) + +// NewMemStore returns an empty in-memory store. +func NewMemStore() *MemStore { return &MemStore{records: map[string]Record{}} } + +// FailMutate makes every subsequent Mutate return err. +func (m *MemStore) FailMutate(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.mutateErr = err +} + +// Mutate implements Store. +func (m *MemStore) Mutate(_ context.Context, poolName string, fn func(*Record) (bool, error)) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.mutateErr != nil { + return m.mutateErr + } + rec := m.copyLocked(poolName) + changed, err := fn(&rec) + if err != nil { + return err + } + if changed { + m.records[poolName] = rec + m.writes++ + } + return nil +} + +// Writes returns how many mutations have committed. +func (m *MemStore) Writes() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.writes +} + +// Get implements Store, including the not-found error the KV store returns for +// a pool that has never allocated. Returning an empty record instead made the +// fake disagree with the real store on the one case every pool starts in. +func (m *MemStore) Get(_ context.Context, poolName string) (Record, error) { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.records[poolName]; !ok { + return Record{}, fmt.Errorf("%w: %s", kvstore.ErrNotFound, poolName) + } + return m.copyLocked(poolName), nil +} + +// copyLocked returns a deep-enough copy that a caller mutating the returned +// record cannot reach into the stored one — the failure a map would otherwise +// hand out by reference, making a rolled-back Mutate look like it committed. +func (m *MemStore) copyLocked(poolName string) Record { + rec := Record{Bindings: map[string]Binding{}} + maps.Copy(rec.Bindings, m.records[poolName].Bindings) + return rec +} diff --git a/spinifex/network/external/ocinet/wiring.go b/spinifex/network/external/ocinet/wiring.go new file mode 100644 index 000000000..e52fb6f29 --- /dev/null +++ b/spinifex/network/external/ocinet/wiring.go @@ -0,0 +1,94 @@ +package ocinet + +import ( + "context" + "fmt" + "log/slog" + + "github.com/nats-io/nats.go/jetstream" + + "github.com/mulgadc/spinifex/spinifex/cloud/oci" + "github.com/mulgadc/spinifex/spinifex/network/external" + "github.com/mulgadc/spinifex/spinifex/network/host" + "github.com/mulgadc/spinifex/spinifex/network/topology" +) + +// FromPoolConfig builds a live allocator for one source="oci" pool: API-key +// auth from an ~/.oci/config profile, the VNIC resolved from the host interface +// when the config names one rather than an OCID, and the JetStream-backed +// bindings store. +// +// v1 authenticates from the config file rather than as an instance principal. +// It needs no dynamic group and no IAM policy from a tenancy admin, and it +// reads no instance metadata — which matters, because Spinifex's own IMDS +// endpoints claim 169.254.169.254 on the host and take the cloud's metadata +// service down with it. Configure oci_vnic_id rather than oci_vnic_iface for +// the same reason: resolving by interface is a metadata lookup. +// +// It does not reconcile. The caller decides when that pass runs, because it +// deletes OCI objects and a startup path that does so before the store is +// readable would collect live addresses. +// sbAddr is the OVN Southbound address the gateway-chassis question is asked +// of; empty uses the local socket. +func FromPoolConfig(ctx context.Context, js jetstream.JetStream, pool external.ExternalPoolConfig, sbAddr string) (*PoolAllocator, error) { + if !pool.IsOCI() { + return nil, fmt.Errorf("ocinet: pool %q has source %q, not %q", pool.Name, pool.Source, external.SourceOCI) + } + + client, err := oci.NewConfigFileClient(pool.OCIConfigFile, pool.OCIConfigProfile) + if err != nil { + return nil, fmt.Errorf("ocinet: pool %q: %w", pool.Name, err) + } + + vnicID, err := resolveVNICID(ctx, pool) + if err != nil { + return nil, err + } + + compartmentID := pool.OCICompartmentID + if compartmentID == "" { + // Config validation requires the key, so this is the belt to that + // brace rather than a supported configuration. + inst, mdErr := oci.NewMetadataClient().Instance(ctx) + if mdErr != nil { + return nil, fmt.Errorf("ocinet: pool %q: no oci_compartment_id and metadata lookup failed: %w", pool.Name, mdErr) + } + compartmentID = inst.CompartmentID + } + + return New(client, NewKVStore(js), Config{ + Pool: pool, + VNICID: vnicID, + CompartmentID: compartmentID, + // Local OVS decides which addresses this node should hold, so the + // affinity pass asks the host rather than any shared record. + LocalPorts: func(ctx context.Context) (map[string]struct{}, error) { + return host.ListLocalPorts(ctx, host.NewExecRunner()) + }, + // The same question for an address with no guest behind it: a NAT + // gateway's belongs wherever its VPC's chassisredirect port is claimed, + // which the local ovn-controller's own binding answers. + LocalGateway: func(ctx context.Context, vpcID string) (bool, error) { + return host.NewGatewayClaimProber(sbAddr). + GatewayPortLocal(ctx, topology.GatewayChassisRedirectPort(vpcID)) + }, + }) +} + +// resolveVNICID turns whichever VNIC key the operator set into an OCID. +func resolveVNICID(ctx context.Context, pool external.ExternalPoolConfig) (string, error) { + if pool.OCIVNICID != "" { + return pool.OCIVNICID, nil + } + if pool.OCIVNICIface == "" { + return "", fmt.Errorf("ocinet: pool %q sets neither oci_vnic_id nor oci_vnic_iface", pool.Name) + } + vnic, err := oci.ResolveVNICByInterface(ctx, oci.NewMetadataClient(), pool.OCIVNICIface) + if err != nil { + return "", fmt.Errorf("ocinet: pool %q: %w", pool.Name, err) + } + slog.InfoContext(ctx, "ocinet resolved the external VNIC from its interface", + "pool", pool.Name, "iface", pool.OCIVNICIface, "vnic_id", vnic.VNICID, + "private_ip", vnic.PrivateIP, "subnet", vnic.SubnetCIDRBlock) + return vnic.VNICID, nil +} diff --git a/spinifex/network/external/static_pool.go b/spinifex/network/external/static_pool.go index a8cc2b4f9..496bc58af 100644 --- a/spinifex/network/external/static_pool.go +++ b/spinifex/network/external/static_pool.go @@ -31,6 +31,10 @@ const ( // allocator can reserve the gateway slot without importing handlers. // The string value is frozen by migration 002 — do not change it. purposeIGWLRP = "igw-lrp" + + // purposeEIP duplicates handlers/ec2/vpc.PurposeEIP, for the same reason + // and under the same freeze. + purposeEIP = "eip" ) // ExternalIPAllocation describes how an external IP is being used. @@ -185,6 +189,19 @@ func (a *StaticPoolAllocator) Release(ctx context.Context, poolName string, ip n if alloc.Purpose == purposeIGWLRP { return false, fmt.Errorf("cannot release gateway IP %s in pool %s", target, poolName) } + // An EIP outlives every interface it is ever associated with, so only an + // unscoped release — ReleaseAddress, which has no interface to name — may + // free one. The ownership guard above cannot cover this: an EIP is + // allocated bare and associated later, so the stored ENI is empty and the + // guard never fires. Without this, terminating an instance hands the + // customer's Elastic IP back to the pool. + // AllocationID is set only for an EIP, so it stands in when a caller + // allocated without naming a purpose. + if ownerENIID != "" && (alloc.Purpose == purposeEIP || alloc.AllocationID != "") { + slog.InfoContext(ctx, "external IPAM release skip — address belongs to an Elastic IP allocation", + "pool", poolName, "ip", target, "allocation_id", alloc.AllocationID, "owner_eni", ownerENIID) + return false, nil + } delete(record.Allocated, target) released = true diff --git a/spinifex/network/external/types.go b/spinifex/network/external/types.go index c31a7b1c1..797b4aa06 100644 --- a/spinifex/network/external/types.go +++ b/spinifex/network/external/types.go @@ -24,6 +24,19 @@ type ExternalPoolConfig struct { AZ string GwLrpRangeStart string GwLrpRangeEnd string + // OCICompartmentID, OCIVNICID and OCIVNICIface identify where the provider + // creates addresses (Source="oci"). Exactly one of the VNIC fields is set; + // the interface name is resolved to an OCID against IMDS at startup. + OCICompartmentID string + OCIVNICID string + OCIVNICIface string + OCISubnetID string + OCIPublicIPPool string + // OCIConfigFile and OCIConfigProfile select the API-key credentials. + // v1 authenticates from a config file rather than as an instance + // principal: no dynamic group, no IAM policy, and no metadata read. + OCIConfigFile string + OCIConfigProfile string } // IsDHCP reports whether the pool sources IPs from an upstream DHCP server. @@ -31,11 +44,21 @@ func (p *ExternalPoolConfig) IsDHCP() bool { return p != nil && p.Source == SourceDHCP } +// IsOCI reports whether the pool sources IPs from the Oracle Cloud API. +func (p *ExternalPoolConfig) IsOCI() bool { + return p != nil && p.Source == SourceOCI +} + const ( // SourceStatic is the default pool source (inline range math, KV-backed). SourceStatic = "static" // SourceDHCP delegates allocation to vpcd's DHCPManager. SourceDHCP = "dhcp" + // SourceOCI delegates allocation to the Oracle Cloud API. An OCI VNIC + // drops any source address that is not a registered private IP object on + // it, so the addresses have to be created through the provider rather than + // computed from a range. + SourceOCI = "oci" // DHCPMACDerived leases with deterministic per-client-id 02:xx MACs. DHCPMACDerived = "derived" diff --git a/spinifex/network/host/eip_ingress.go b/spinifex/network/host/eip_ingress.go index ed3fff973..53a60af6f 100644 --- a/spinifex/network/host/eip_ingress.go +++ b/spinifex/network/host/eip_ingress.go @@ -74,32 +74,55 @@ func eipForwardRules(eip string) [][]string { } } -// EnsureEIPIngress makes an EIP reachable from the uplink LAN in routed-NAT -// mode: a /32 host route steers the EIP into OVN via the VPC gateway LRP's -// transit IP, a proxy-ARP neighbor entry answers ARP for the EIP on the -// uplink (L3 only — same MAC, so WiFi-safe), and per-EIP FORWARD accepts -// cover DROP-policy hosts. The route carries `src ` so host-local -// traffic to the EIP still DNATs (host source would match the exempt set). -// Idempotent; call on every EIP bind and reconcile pass. -func EnsureEIPIngress(ctx context.Context, r Runner, eip, gwLrpIP, poolGateway, uplinkHint string) error { - if eip == "" { - return fmt.Errorf("EnsureEIPIngress: eip required") - } - if gwLrpIP == "" { - return fmt.Errorf("EnsureEIPIngress: gwLrpIP required") - } +// EIPIngress is one EIP's host-side plumbing request in routed-NAT mode. +type EIPIngress struct { + // EIP is the address as it appears on the transit segment. + EIP string - uplink, srcIP, err := resolveUplink(ctx, r, poolGateway, uplinkHint) + // GwLrpIP is the VPC gateway LRP's transit address, the next hop for a + // centralised rule. Empty means the rule is distributed: OVN answers ARP + // for the EIP on this node's own transit veth with the instance's ENI MAC, + // so the route is on-link and no VPC-wide address is involved. + GwLrpIP string + + // PoolGateway and UplinkHint locate the LAN-facing interface that has to + // proxy-ARP for the EIP. Static pools route toward the gateway; dhcp pools + // DORA on the uplink itself and name it directly. + PoolGateway string + UplinkHint string +} + +// EnsureEIPIngress makes an EIP reachable from the uplink LAN in routed-NAT +// mode: a /32 host route steers the EIP into OVN across the transit veth, a +// proxy-ARP neighbor entry answers ARP for the EIP on the uplink (L3 only — +// same MAC, so WiFi-safe), and per-EIP FORWARD accepts cover DROP-policy +// hosts. The route carries `src ` so host-local traffic to the EIP +// still DNATs (host source would match the exempt set). +// Idempotent; call on every EIP bind and reconcile pass. +func EnsureEIPIngress(ctx context.Context, r Runner, in EIPIngress) error { + if in.EIP == "" { + return fmt.Errorf("EnsureEIPIngress: EIP required") + } + eip := in.EIP + + uplink, srcIP, err := resolveUplink(ctx, r, in.PoolGateway, in.UplinkHint) if err != nil { return fmt.Errorf("EnsureEIPIngress %s: %w", eip, err) } - routeArgs := []string{"route", "replace", eip + "/32", "via", gwLrpIP, "dev", NATTransitHostEnd} + // A distributed rule is answered on this node's transit segment, so the + // EIP is on-link and naming a next hop would send it to a gateway LRP that + // may be on another chassis entirely. A centralised rule still hops there. + routeArgs := []string{"route", "replace", eip + "/32"} + if in.GwLrpIP != "" { + routeArgs = append(routeArgs, "via", in.GwLrpIP) + } + routeArgs = append(routeArgs, "dev", NATTransitHostEnd) if srcIP != "" { routeArgs = append(routeArgs, "src", srcIP) } if out, err := r.Run(ctx, "ip", routeArgs...); err != nil { - return fmt.Errorf("install EIP route %s via %s: %s: %w", eip, gwLrpIP, string(out), err) + return fmt.Errorf("install EIP route %s (next hop %q): %s: %w", eip, in.GwLrpIP, string(out), err) } if uplink != "" { @@ -116,16 +139,30 @@ func EnsureEIPIngress(ctx context.Context, r Runner, eip, gwLrpIP, poolGateway, } } + // Insert at the head, never append: a distro's catch-all REJECT in FORWARD + // (Oracle's cloud image ships one) precedes anything appended, so the EIP + // is reachable on the wire and dropped by the host. Probe first rather than + // drain-and-reinsert as the egress rules do — this runs on every bind and + // reconcile, and re-inserting would blip a live EIP. for _, spec := range eipForwardRules(eip) { if _, err := r.Run(ctx, "iptables", natRuleArgs("-C", "filter", "FORWARD", spec)...); err == nil { continue } - if out, err := r.Run(ctx, "iptables", natRuleArgs("-A", "filter", "FORWARD", spec)...); err != nil { + if out, err := r.Run(ctx, "iptables", natInsertArgs("filter", "FORWARD", spec)...); err != nil { return fmt.Errorf("install EIP FORWARD rule for %s: %s: %w", eip, string(out), err) } } - slog.Info("EIP ingress installed", "eip", eip, "gw_lrp_ip", gwLrpIP, "uplink", uplink, "src", srcIP) + // A reply leaves with the EIP as its source, so on a host that source-routes + // its uplink the EIP needs the same rule. Without it the reply takes the + // default route, and a cloud VNIC that enforces its registered addresses + // drops it — ingress reaches the guest and nothing comes back. + if err := EnsureEIPSourceRoute(ctx, r, eip); err != nil { + return fmt.Errorf("EnsureEIPIngress %s: %w", eip, err) + } + + slog.Info("EIP ingress installed", "eip", eip, "gw_lrp_ip", in.GwLrpIP, + "distributed", in.GwLrpIP == "", "uplink", uplink, "src", srcIP) return nil } @@ -186,6 +223,10 @@ func RemoveEIPIngress(ctx context.Context, r Runner, eip, poolGateway, uplinkHin } } + if err := RemoveEIPSourceRoute(ctx, r, eip); err != nil { + slog.Debug("host: EIP source route not removed", "eip", eip, "err", err) + } + slog.Info("EIP ingress removed", "eip", eip) return nil } diff --git a/spinifex/network/host/eip_ingress_test.go b/spinifex/network/host/eip_ingress_test.go index 2281c9cb1..f5211bcba 100644 --- a/spinifex/network/host/eip_ingress_test.go +++ b/spinifex/network/host/eip_ingress_test.go @@ -60,14 +60,24 @@ func newEIPStubRunner(routeGet string) *stubRunner { r.expect("ip neigh replace proxy", nil, nil) r.expect("sysctl -w", nil, nil) r.expect("arping", nil, nil) - r.expect("iptables -t filter -C", []byte("iptables: No chain/target/match by that name."), fmt.Errorf("exit 1")) - r.expect("iptables -t filter -A", nil, nil) + // A host with no policy routing, so there is no uplink rule to mirror. + r.expect("ip -4 -o addr show scope global", []byte(plainAddrShow), nil) + r.expect("ip rule show", []byte(plainRuleShow), nil) + r.expect("ip route del", nil, nil) + r.expect("ip neigh del proxy", nil, nil) + r.expect("iptables -t filter -D", nil, nil) + // Two rules, each probed then inserted at the head — a distro's catch-all + // REJECT in FORWARD precedes anything appended. + for range 2 { + r.expect("iptables -t filter -C", []byte("iptables: No chain/target/match by that name."), fmt.Errorf("exit 1")) + r.expect("iptables -t filter -I", nil, nil) + } return r } func TestEnsureEIPIngress_FullPlumbing(t *testing.T) { r := newEIPStubRunner(wiredRouteGet) - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "100.127.0.10", "192.168.1.1", ""); err != nil { + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10", PoolGateway: "192.168.1.1"}); err != nil { t.Fatalf("EnsureEIPIngress: %v", err) } for _, want := range []string{ @@ -75,8 +85,8 @@ func TestEnsureEIPIngress_FullPlumbing(t *testing.T) { "ip neigh replace proxy 192.168.1.200 dev eth0", "sysctl -w net.ipv4.neigh.eth0.proxy_delay=0", "arping -U -c 2 -I eth0 192.168.1.200", - "iptables -t filter -A FORWARD -i " + NATTransitHostEnd + " -s 192.168.1.200/32", - "iptables -t filter -A FORWARD -o " + NATTransitHostEnd + " -d 192.168.1.200/32", + "iptables -t filter -I FORWARD 1 -i " + NATTransitHostEnd + " -s 192.168.1.200/32", + "iptables -t filter -I FORWARD 1 -o " + NATTransitHostEnd + " -d 192.168.1.200/32", } { if !r.called(want) { t.Errorf("missing call:\n want %q\n got %v", want, r.calls) @@ -86,7 +96,7 @@ func TestEnsureEIPIngress_FullPlumbing(t *testing.T) { func TestEnsureEIPIngress_WiFiUplink(t *testing.T) { r := newEIPStubRunner(wifiRouteGet) - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "100.127.0.10", "192.168.1.1", ""); err != nil { + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10", PoolGateway: "192.168.1.1"}); err != nil { t.Fatalf("EnsureEIPIngress: %v", err) } if !r.called("ip neigh replace proxy 192.168.1.200 dev wlan0") { @@ -100,7 +110,7 @@ func TestEnsureEIPIngress_WiFiUplink(t *testing.T) { func TestEnsureEIPIngress_IdempotentSkipsExistingRules(t *testing.T) { r := newEIPStubRunner(wiredRouteGet) r.expect("iptables -t filter -C", nil, nil) - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "100.127.0.10", "192.168.1.1", ""); err != nil { + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10", PoolGateway: "192.168.1.1"}); err != nil { t.Fatalf("EnsureEIPIngress: %v", err) } if r.called("iptables -t filter -A") { @@ -112,7 +122,7 @@ func TestEnsureEIPIngress_ArpingFailureNonFatal(t *testing.T) { r := newEIPStubRunner(wiredRouteGet) r.expect("arping", []byte("arping: command not found"), fmt.Errorf("exit 127")) r.expect("sysctl -w", []byte("permission denied"), fmt.Errorf("exit 1")) - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "100.127.0.10", "192.168.1.1", ""); err != nil { + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10", PoolGateway: "192.168.1.1"}); err != nil { t.Fatalf("arping/sysctl failures must be non-fatal: %v", err) } } @@ -121,7 +131,9 @@ func TestEnsureEIPIngress_NoGatewaySkipsUplinkPlumbing(t *testing.T) { r := newStubRunner() r.expect("ip route replace", nil, nil) r.expect("iptables -t filter -C", nil, nil) - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "100.127.0.10", "", ""); err != nil { + r.expect("ip -4 -o addr show scope global", []byte(plainAddrShow), nil) + r.expect("ip rule show", []byte(plainRuleShow), nil) + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10"}); err != nil { t.Fatalf("EnsureEIPIngress: %v", err) } if !r.called("ip route replace 192.168.1.200/32 via 100.127.0.10 dev " + NATTransitHostEnd) { @@ -138,7 +150,7 @@ func TestEnsureEIPIngress_NoGatewaySkipsUplinkPlumbing(t *testing.T) { func TestEnsureEIPIngress_UplinkHintForDHCPPool(t *testing.T) { r := newEIPStubRunner("") r.expect("ip -4 -o addr show", []byte("3: wlan0 inet 192.168.1.87/24 brd 192.168.1.255 scope global dynamic wlan0\n"), nil) - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "100.127.0.10", "", "wlan0"); err != nil { + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10", UplinkHint: "wlan0"}); err != nil { t.Fatalf("EnsureEIPIngress: %v", err) } if r.called("ip route get") { @@ -154,17 +166,55 @@ func TestEnsureEIPIngress_UplinkHintForDHCPPool(t *testing.T) { func TestEnsureEIPIngress_ValidatesArgs(t *testing.T) { r := newStubRunner() - if err := EnsureEIPIngress(context.Background(), r, "", "100.127.0.10", "", ""); err == nil { + if err := EnsureEIPIngress(context.Background(), r, EIPIngress{EIP: "", GwLrpIP: "100.127.0.10"}); err == nil { t.Error("empty eip: expected error") } - if err := EnsureEIPIngress(context.Background(), r, "192.168.1.200", "", "", ""); err == nil { - t.Error("empty gwLrpIP: expected error") - } if len(r.calls) != 0 { t.Errorf("validation failures must not run commands: %v", r.calls) } } +// A distributed rule is processed on the instance's own chassis and answers ARP +// on that node's transit veth, so the EIP is on-link. Naming a next hop would +// point at a gateway LRP that may live on another chassis entirely, which is +// exactly what made every non-gateway node's EIPs unreachable. +func TestEnsureEIPIngress_DistributedRouteIsOnLink(t *testing.T) { + r := newEIPStubRunner(wiredRouteGet) + in := EIPIngress{EIP: "192.168.1.200", PoolGateway: "192.168.1.1"} + if err := EnsureEIPIngress(context.Background(), r, in); err != nil { + t.Fatalf("EnsureEIPIngress: %v", err) + } + if !r.called("ip route replace 192.168.1.200/32 dev " + NATTransitHostEnd + " src 192.168.1.42") { + t.Errorf("distributed EIP must route on-link: %v", r.calls) + } + if strings.Contains(strings.Join(r.calls, " "), " via ") { + t.Errorf("distributed EIP must name no next hop: %v", r.calls) + } + // Ingress from the LAN is unchanged by distribution: the host still has to + // answer ARP for the EIP and still has to forward it. + for _, want := range []string{ + "ip neigh replace proxy 192.168.1.200 dev eth0", + "iptables -t filter -I FORWARD 1 -i " + NATTransitHostEnd + " -s 192.168.1.200/32", + } { + if !r.called(want) { + t.Errorf("missing call:\n want %q\n got %v", want, r.calls) + } + } +} + +// The two shapes coexist: a centralised rule keeps its next hop, so a cluster +// part-way through an upgrade is never in a state neither side handles. +func TestEnsureEIPIngress_CentralisedRouteKeepsItsNextHop(t *testing.T) { + r := newEIPStubRunner(wiredRouteGet) + in := EIPIngress{EIP: "192.168.1.200", GwLrpIP: "100.127.0.10", PoolGateway: "192.168.1.1"} + if err := EnsureEIPIngress(context.Background(), r, in); err != nil { + t.Fatalf("EnsureEIPIngress: %v", err) + } + if !r.called("ip route replace 192.168.1.200/32 via 100.127.0.10 dev " + NATTransitHostEnd) { + t.Errorf("centralised EIP must route via the gateway LRP: %v", r.calls) + } +} + func TestRemoveEIPIngress_TearsDownAll(t *testing.T) { r := newStubRunner() r.expect("ip route get", []byte(wiredRouteGet), nil) diff --git a/spinifex/network/host/eip_source_route.go b/spinifex/network/host/eip_source_route.go new file mode 100644 index 000000000..52775a829 --- /dev/null +++ b/spinifex/network/host/eip_source_route.go @@ -0,0 +1,209 @@ +package host + +import ( + "context" + "fmt" + "net/netip" + "strings" +) + +// eipSourceRule is the policy-routing rule an EIP needs when the host already +// source-routes the uplink address that shares the EIP's subnet. +type eipSourceRule struct { + Table string + Priority string +} + +// uplinkSourceRuleFor finds the policy-routing rule governing replies that +// leave with eip as their source, by looking for a `from lookup
` +// rule whose address sits in the same subnet as the EIP. +// +// A cloud VNIC accepts only the addresses registered on it, so a reply sourced +// from an EIP has to leave through the same interface as the uplink address it +// shares a subnet with. Where the host enforces that with a source rule, every +// EIP on that interface needs the same rule; where no such rule exists there is +// nothing to mirror and nothing to do. +// +// The rule is the selector, not the interface list, because on OCI both VNICs +// sit in one subnet and "the interface owning the EIP's subnet" names two of +// them. The rule names exactly the one whose replies the operator has already +// pinned. +func uplinkSourceRuleFor(ctx context.Context, r Runner, eip string) (eipSourceRule, bool, error) { + addr, err := netip.ParseAddr(eip) + if err != nil { + return eipSourceRule{}, false, fmt.Errorf("parse EIP %q: %w", eip, err) + } + + prefixes, err := hostPrefixes(ctx, r) + if err != nil { + return eipSourceRule{}, false, err + } + + out, err := r.Run(ctx, "ip", "rule", "show") + if err != nil { + return eipSourceRule{}, false, fmt.Errorf("list ip rules: %s: %w", string(out), err) + } + + var found []eipSourceRule + var owners []string + var byTable []eipSourceRule + var byTableOwners []string + for line := range strings.SplitSeq(string(out), "\n") { + prio, from, table, ok := parseIPRule(line) + if !ok { + continue + } + prefix, held := prefixes[from] + if !held || from == eip { + continue + } + if prefix.Contains(addr) { + found = append(found, eipSourceRule{Table: table, Priority: prio}) + owners = append(owners, from+" -> table "+table) + continue + } + // The address alone does not always carry the subnet. A cloud host that + // must keep the uplink out of the main routing table holds it as a /32 + // and puts the on-link route in the source-routing table instead, so + // the prefix contains nothing but itself. The table is then the only + // place that records which interface owns the EIP's subnet. + covers, cerr := tableRoutesOnLink(ctx, r, table, addr) + if cerr != nil { + return eipSourceRule{}, false, cerr + } + if covers { + byTable = append(byTable, eipSourceRule{Table: table, Priority: prio}) + byTableOwners = append(byTableOwners, from+" -> table "+table) + } + } + // Only fall back when the address prefixes named nobody, so a host that + // already resolved this the direct way keeps its existing answer. + if len(found) == 0 { + found, owners = byTable, byTableOwners + } + + switch len(found) { + case 0: + return eipSourceRule{}, false, nil + case 1: + return found[0], true, nil + default: + // Picking one would send this EIP's replies out an interface that does + // not hold its address, which the provider drops without a word. + return eipSourceRule{}, false, fmt.Errorf( + "source routing for %s is ambiguous: %s", eip, strings.Join(owners, ", ")) + } +} + +// hostPrefixes maps each global IPv4 the host holds to the prefix it was +// configured with, so a rule's source address can be tested for the EIP's +// subnet without assuming a mask. +func hostPrefixes(ctx context.Context, r Runner) (map[string]netip.Prefix, error) { + out, err := r.Run(ctx, "ip", "-4", "-o", "addr", "show", "scope", "global") + if err != nil { + return nil, fmt.Errorf("list host addresses: %s: %w", string(out), err) + } + prefixes := map[string]netip.Prefix{} + for line := range strings.SplitSeq(string(out), "\n") { + fields := strings.Fields(line) + for i := 0; i < len(fields)-1; i++ { + if fields[i] != "inet" { + continue + } + prefix, perr := netip.ParsePrefix(fields[i+1]) + if perr != nil { + continue + } + prefixes[prefix.Addr().String()] = prefix + } + } + return prefixes, nil +} + +// tableRoutesOnLink reports whether table holds a scope-link route covering +// addr — that is, whether the interface this table steers to owns the address's +// subnet. Only on-link routes count: a default route covers every address and +// would make every source-routing table an equally good answer. +func tableRoutesOnLink(ctx context.Context, r Runner, table string, addr netip.Addr) (bool, error) { + out, err := r.Run(ctx, "ip", "route", "show", "table", table) + if err != nil { + // A rule naming a table with no routes is not an error here; it simply + // tells us nothing about who owns the subnet. + return false, nil + } + for line := range strings.SplitSeq(string(out), "\n") { + if !strings.Contains(line, "scope link") { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + prefix, perr := netip.ParsePrefix(fields[0]) + if perr != nil { + continue + } + if prefix.Contains(addr) { + return true, nil + } + } + return false, nil +} + +// parseIPRule pulls the priority, source address and table out of one line of +// `ip rule show`. Source-address rules only; anything else reports !ok. +func parseIPRule(line string) (priority, from, table string, ok bool) { + fields := strings.Fields(line) + if len(fields) < 4 || !strings.HasSuffix(fields[0], ":") { + return "", "", "", false + } + priority = strings.TrimSuffix(fields[0], ":") + var oif bool + for i := 1; i < len(fields)-1; i++ { + switch fields[i] { + case "from": + // The kernel prints a host address bare and a network with its + // prefix; both mean the same thing for a /32. + from = strings.TrimSuffix(fields[i+1], "/32") + case "oif", "iif": + oif = true + case "lookup": + table = fields[i+1] + } + } + if oif || from == "" || from == "all" || table == "" { + return "", "", "", false + } + return priority, from, table, true +} + +// EnsureEIPSourceRoute mirrors the uplink's source-routing rule onto eip, so a +// reply carrying the EIP leaves through the interface that holds the address. +// No-op on a host that does not source-route its uplink. Idempotent: the rule +// is deleted before it is added, because `ip rule add` appends duplicates. +func EnsureEIPSourceRoute(ctx context.Context, r Runner, eip string) error { + rule, found, err := uplinkSourceRuleFor(ctx, r, eip) + if err != nil || !found { + return err + } + _, _ = r.Run(ctx, "ip", "rule", "del", "from", eip+"/32", "lookup", rule.Table) + args := []string{"rule", "add", "from", eip + "/32", "lookup", rule.Table} + if rule.Priority != "" { + args = append(args, "priority", rule.Priority) + } + if out, err := r.Run(ctx, "ip", args...); err != nil { + return fmt.Errorf("install source route for %s via table %s: %s: %w", eip, rule.Table, string(out), err) + } + return nil +} + +// RemoveEIPSourceRoute drops the rule EnsureEIPSourceRoute installed. Absence +// is not an error: the address may never have needed one. +func RemoveEIPSourceRoute(ctx context.Context, r Runner, eip string) error { + rule, found, err := uplinkSourceRuleFor(ctx, r, eip) + if err != nil || !found { + return err + } + _, _ = r.Run(ctx, "ip", "rule", "del", "from", eip+"/32", "lookup", rule.Table) + return nil +} diff --git a/spinifex/network/host/eip_source_route_test.go b/spinifex/network/host/eip_source_route_test.go new file mode 100644 index 000000000..1fc482d97 --- /dev/null +++ b/spinifex/network/host/eip_source_route_test.go @@ -0,0 +1,225 @@ +//test:in-package — parseIPRule is unexported, and the stub Runner these tests +//drive lives in host_test.go alongside every other test in this package. + +package host + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// The reference OCI topology: br-wan holds the secondary VNIC's own address in +// a /23, and the host source-routes replies from it through table 200. enp0s9 +// is the primary VNIC and carries the default route. +const ( + ociAddrShow = `2: enp0s9 inet 10.200.0.215/23 brd 10.200.1.255 scope global enp0s9\ valid_lft forever preferred_lft forever +3: br-wan inet 10.200.1.119/23 brd 10.200.1.255 scope global br-wan\ valid_lft forever preferred_lft forever +` + ociRuleShow = `0: from all lookup local +1000: from 10.200.1.119 lookup 200 proto static +32766: from all lookup main +32767: from all lookup default +` + // A host with no policy routing at all, which is prod and dev-prod. + plainAddrShow = `2: eth0 inet 192.168.1.42/24 brd 192.168.1.255 scope global eth0\ valid_lft forever preferred_lft forever +` + plainRuleShow = `0: from all lookup local +32766: from all lookup main +32767: from all lookup default +` +) + +func newSourceRouteRunner(addrShow, ruleShow string) *stubRunner { + r := newStubRunner() + r.expect("ip -4 -o addr show scope global", []byte(addrShow), nil) + r.expect("ip rule show", []byte(ruleShow), nil) + r.expect("ip rule del", nil, nil) + r.expect("ip rule add", nil, nil) + return r +} + +func TestEnsureEIPSourceRoute_MirrorsTheUplinkRule(t *testing.T) { + r := newSourceRouteRunner(ociAddrShow, ociRuleShow) + if err := EnsureEIPSourceRoute(context.Background(), r, "10.200.1.207"); err != nil { + t.Fatalf("EnsureEIPSourceRoute: %v", err) + } + // Deleted before added: `ip rule add` appends a duplicate every call, and + // this runs on every bind and every reconcile pass. + for _, want := range []string{ + "ip rule del from 10.200.1.207/32 lookup 200", + "ip rule add from 10.200.1.207/32 lookup 200 priority 1000", + } { + if !r.called(want) { + t.Errorf("missing call:\n want %q\n got %v", want, r.calls) + } + } +} + +func TestEnsureEIPSourceRoute_NoRuleToMirrorIsANoOp(t *testing.T) { + r := newSourceRouteRunner(plainAddrShow, plainRuleShow) + if err := EnsureEIPSourceRoute(context.Background(), r, "192.168.1.200"); err != nil { + t.Fatalf("EnsureEIPSourceRoute: %v", err) + } + if r.called("ip rule add") || r.called("ip rule del") { + t.Errorf("touched ip rule on a host with no source routing: %v", r.calls) + } +} + +// Two source rules covering the same subnet name two different interfaces, and +// picking one would send the replies out an interface that does not hold the +// address — which the provider drops without a word. +func TestEnsureEIPSourceRoute_AmbiguousRulesRefuse(t *testing.T) { + const twoRules = `0: from all lookup local +1000: from 10.200.1.119 lookup 200 proto static +1001: from 10.200.0.215 lookup 201 proto static +32766: from all lookup main +` + r := newSourceRouteRunner(ociAddrShow, twoRules) + err := EnsureEIPSourceRoute(context.Background(), r, "10.200.1.207") + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected an ambiguity error, got: %v", err) + } + if r.called("ip rule add") { + t.Errorf("added a rule despite the ambiguity: %v", r.calls) + } +} + +// An EIP outside every local subnet belongs to no interface here, so there is +// nothing to mirror — and guessing an interface would send its replies out the +// wrong VNIC, which is the failure this whole file exists to prevent. +func TestEnsureEIPSourceRoute_ForeignSubnetIsANoOp(t *testing.T) { + r := newSourceRouteRunner(ociAddrShow, ociRuleShow) + if err := EnsureEIPSourceRoute(context.Background(), r, "203.0.113.7"); err != nil { + t.Fatalf("EnsureEIPSourceRoute: %v", err) + } + if r.called("ip rule add") { + t.Errorf("added a rule for an address on no local subnet: %v", r.calls) + } +} + +func TestRemoveEIPSourceRoute_DeletesWithoutAdding(t *testing.T) { + r := newSourceRouteRunner(ociAddrShow, ociRuleShow) + if err := RemoveEIPSourceRoute(context.Background(), r, "10.200.1.207"); err != nil { + t.Fatalf("RemoveEIPSourceRoute: %v", err) + } + if !r.called("ip rule del from 10.200.1.207/32 lookup 200") { + t.Errorf("rule not deleted: %v", r.calls) + } + if r.called("ip rule add") { + t.Errorf("teardown added a rule: %v", r.calls) + } +} + +func TestEnsureEIPSourceRoute_AddFailureIsFatal(t *testing.T) { + r := newSourceRouteRunner(ociAddrShow, ociRuleShow) + r.expect("ip rule add", []byte("RTNETLINK answers: File exists"), fmt.Errorf("exit 2")) + err := EnsureEIPSourceRoute(context.Background(), r, "10.200.1.207") + if err == nil || !strings.Contains(err.Error(), "File exists") { + t.Fatalf("expected the kernel's message in the error, got: %v", err) + } +} + +func TestEnsureEIPSourceRoute_UnparseableEIP(t *testing.T) { + r := newSourceRouteRunner(ociAddrShow, ociRuleShow) + if err := EnsureEIPSourceRoute(context.Background(), r, "not-an-ip"); err == nil { + t.Fatal("expected a parse error") + } +} + +func TestParseIPRule(t *testing.T) { + tests := []struct { + name string + line string + wantPrio, wantFrom, wantTable string + wantOK bool + }{ + {name: "source rule", line: "1000:\tfrom 10.200.1.119 lookup 200 proto static", + wantPrio: "1000", wantFrom: "10.200.1.119", wantTable: "200", wantOK: true}, + {name: "prefix form", line: "1000:\tfrom 10.200.1.207/32 lookup 200", + wantPrio: "1000", wantFrom: "10.200.1.207", wantTable: "200", wantOK: true}, + {name: "named table", line: "1000:\tfrom 10.200.1.119 lookup wan", + wantPrio: "1000", wantFrom: "10.200.1.119", wantTable: "wan", wantOK: true}, + {name: "from all", line: "32766:\tfrom all lookup main"}, + {name: "oif rule", line: "998:\tfrom all oif ime-6a0b60c5 lookup 1140435084"}, + {name: "blank", line: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prio, from, table, ok := parseIPRule(tt.line) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + if prio != tt.wantPrio || from != tt.wantFrom || table != tt.wantTable { + t.Errorf("got (%q, %q, %q), want (%q, %q, %q)", + prio, from, table, tt.wantPrio, tt.wantFrom, tt.wantTable) + } + }) + } +} + +// The OCI cluster holds br-wan's address as a /32, because both VNICs sit in +// one subnet and a /23 there would put the subnet's on-link route in the main +// table and take it off the primary VNIC. The prefix then contains nothing but +// itself, so the address can no longer say who owns the EIP's subnet — table +// 200's on-link route is the only record of it, and without this the reply +// leaves by the primary VNIC and OCI drops it. +const ( + ociSlash32AddrShow = `2: enp0s9 inet 10.200.1.244/23 brd 10.200.1.255 scope global enp0s9\ valid_lft forever preferred_lft forever +3: br-wan inet 10.200.0.204/32 scope global br-wan\ valid_lft forever preferred_lft forever +` + ociSlash32RuleShow = `0: from all lookup local +1000: from 10.200.0.204 lookup 200 proto static +32766: from all lookup main +32767: from all lookup default +` + ociTable200 = `default via 10.200.0.1 dev br-wan proto static +10.200.0.0/23 dev br-wan proto static scope link +` +) + +func TestEnsureEIPSourceRoute_UplinkHeldAsASlash32(t *testing.T) { + r := newSourceRouteRunner(ociSlash32AddrShow, ociSlash32RuleShow) + r.expect("ip route show table 200", []byte(ociTable200), nil) + + if err := EnsureEIPSourceRoute(context.Background(), r, "10.200.0.106"); err != nil { + t.Fatalf("EnsureEIPSourceRoute: %v", err) + } + want := "ip rule add from 10.200.0.106/32 lookup 200 priority 1000" + if !r.called(want) { + t.Errorf("missing call:\n want %q\n got %v", want, r.calls) + } +} + +// Only on-link routes identify the owner. A table holding nothing but a default +// route covers every address, and mirroring on that would pin replies to an +// interface that does not hold the address. +func TestEnsureEIPSourceRoute_DefaultRouteAloneDoesNotClaimTheSubnet(t *testing.T) { + r := newSourceRouteRunner(ociSlash32AddrShow, ociSlash32RuleShow) + r.expect("ip route show table 200", []byte("default via 10.200.0.1 dev br-wan proto static\n"), nil) + + if err := EnsureEIPSourceRoute(context.Background(), r, "10.200.0.106"); err != nil { + t.Fatalf("EnsureEIPSourceRoute: %v", err) + } + if r.called("ip rule add") { + t.Errorf("mirrored a rule on a table that only has a default route: %v", r.calls) + } +} + +// An address that does carry its subnet must keep resolving the direct way, so +// prod and dev-prod are unaffected by the table fallback. +func TestEnsureEIPSourceRoute_AddressPrefixStillWinsWhenItCovers(t *testing.T) { + r := newSourceRouteRunner(ociAddrShow, ociRuleShow) + r.expect("ip route show table 200", []byte(""), fmt.Errorf("should not be consulted")) + + if err := EnsureEIPSourceRoute(context.Background(), r, "10.200.1.207"); err != nil { + t.Fatalf("EnsureEIPSourceRoute: %v", err) + } + if !r.called("ip rule add from 10.200.1.207/32 lookup 200 priority 1000") { + t.Errorf("address-prefix match stopped working: %v", r.calls) + } +} diff --git a/spinifex/network/host/gateway_claim.go b/spinifex/network/host/gateway_claim.go index 1edf55d1f..c87353573 100644 --- a/spinifex/network/host/gateway_claim.go +++ b/spinifex/network/host/gateway_claim.go @@ -27,18 +27,94 @@ func NewGatewayClaimProber(sbAddr string) *GatewayClaimProber { // non-empty chassis. Uses the chassisredirect (cr-) port — the bare LRP stays // chassis-less. Unclaimed binding means flows are not installed and EIPs unreachable. func (p *GatewayClaimProber) GatewayPortClaimed(_ context.Context, crPortName string) (bool, error) { + chassis, err := p.boundChassis(crPortName) + if err != nil { + return false, err + } + return chassis != "", nil +} + +// GatewayPortLocal reports whether the chassisredirect port is claimed by this +// host. The datapath probes are host-local — a ping and an ARP off this node's +// own uplink — so anywhere but the gateway chassis they measure a segment the +// gateway is not on. Under routed NAT that segment carries the same +// 100.127.0.1/24 on every node, so a remote probe does not merely fail to +// inform: it reports a healthy gateway as dead. +func (p *GatewayClaimProber) GatewayPortLocal(_ context.Context, crPortName string) (bool, error) { + chassis, err := p.boundChassis(crPortName) + if err != nil || chassis == "" { + return false, err + } + local, err := p.localChassis() + if err != nil { + return false, err + } + // The binding holds the Chassis row's UUID while system-id is its name, so + // resolve one to the other rather than comparing across identifiers. + name, err := p.chassisName(chassis) + if err != nil { + return false, err + } + return name != "" && name == local, nil +} + +// GatewayPortElsewhere reports whether the chassisredirect port is claimed by +// some other host. An unclaimed port is not elsewhere: nothing holds it, so the +// question is unanswered rather than answered no, and a caller deciding whether +// to tear host state down must keep what it has. +func (p *GatewayClaimProber) GatewayPortElsewhere(_ context.Context, crPortName string) (bool, error) { + chassis, err := p.boundChassis(crPortName) + if err != nil || chassis == "" { + return false, err + } + local, err := p.localChassis() + if err != nil { + return false, err + } + name, err := p.chassisName(chassis) + if err != nil { + return false, err + } + return name != "" && name != local, nil +} + +// boundChassis returns the SB Chassis UUID a logical port is bound to, empty if +// unbound. Output() not CombinedOutput(): sudo PAM noise on stderr would be +// misread as a non-empty chassis value. +func (p *GatewayClaimProber) boundChassis(crPortName string) (string, error) { + out, err := utils.SudoCommand("ovn-sbctl", p.sbArgs("--bare", "--columns=chassis", "find", "Port_Binding", "logical_port="+crPortName)...).Output() + if err != nil { + return "", fmt.Errorf("ovn-sbctl find Port_Binding %s: %w", crPortName, err) + } + return strings.TrimSpace(string(out)), nil +} + +// localChassis returns this host's OVN chassis name, which ovn-controller takes +// from the OVSDB external_ids:system-id it registered with. +func (p *GatewayClaimProber) localChassis() (string, error) { + out, err := utils.SudoCommand("ovs-vsctl", "--if-exists", "get", "Open_vSwitch", ".", "external_ids:system-id").Output() + if err != nil { + return "", fmt.Errorf("ovs-vsctl get system-id: %w", err) + } + return strings.Trim(strings.TrimSpace(string(out)), `"`), nil +} + +// chassisName resolves an SB Chassis row UUID to its name. +func (p *GatewayClaimProber) chassisName(uuid string) (string, error) { + out, err := utils.SudoCommand("ovn-sbctl", p.sbArgs("--bare", "--columns=name", "list", "Chassis", uuid)...).Output() + if err != nil { + return "", fmt.Errorf("ovn-sbctl list Chassis %s: %w", uuid, err) + } + return strings.Trim(strings.TrimSpace(string(out)), `"`), nil +} + +// sbArgs prefixes ovn-sbctl arguments with the database selection. +func (p *GatewayClaimProber) sbArgs(rest ...string) []string { args := []string{"--no-leader-only"} if p.sbAddr != "" { args = append(args, "--db="+p.sbAddr) } - args = append(args, "--bare", "--columns=chassis", "find", "Port_Binding", "logical_port="+crPortName) - // Output() not CombinedOutput(): sudo PAM noise on stderr would be - // misread as a non-empty chassis value. - out, err := utils.SudoCommand("ovn-sbctl", args...).Output() - if err != nil { - return false, fmt.Errorf("ovn-sbctl find Port_Binding %s: %w", crPortName, err) - } - return strings.TrimSpace(string(out)) != "", nil + return append(args, rest...) } // GuestPortUp reports whether the SB Port_Binding for a guest ENI LSP is up — diff --git a/spinifex/network/host/imds_attach.go b/spinifex/network/host/imds_attach.go index c4cf8843d..6f123d2fe 100644 --- a/spinifex/network/host/imds_attach.go +++ b/spinifex/network/host/imds_attach.go @@ -74,6 +74,9 @@ func installIMDSDatapath(ctx context.Context, r Runner, d IMDSTapDatapath) error if err := EnsureIMDSBridge(ctx, r); err != nil { return err } + // The INPUT accept and the remap DNAT are wildcarded over every endpoint and + // installed once by vpcd at start. They are not attempted here: the daemon + // has no iptables grant and no CAP_NET_ADMIN, so trying aborts every launch. if err := clearIMDSFlowsByCookie(ctx, r, imdsFlowCookie(d.Endpoint)); err != nil { return err } diff --git a/spinifex/network/host/imds_bridge.go b/spinifex/network/host/imds_bridge.go index ea19d8c0a..24a4d2b3d 100644 --- a/spinifex/network/host/imds_bridge.go +++ b/spinifex/network/host/imds_bridge.go @@ -33,6 +33,100 @@ func EnsureIMDSBridge(ctx context.Context, r Runner) error { return nil } +// imdsInputComment tags the INPUT accept so an operator can find it and the +// uninstaller can remove it. +const imdsInputComment = "spinifex-imds" + +// imdsInputSpec accepts guest traffic arriving on any IMDS endpoint. The "+" +// is an iptables interface wildcard, so one rule covers every ime- device. +var imdsInputSpec = []string{ + "-i", IMDSEndpointPrefix + "+", + "-m", "comment", "--comment", imdsInputComment, "-j", "ACCEPT", +} + +// EnsureIMDSInputRule opens INPUT for the IMDS endpoints. The responder binds +// .254/.253 on each ime- device, so guest requests are locally delivered and +// meet INPUT — which on a cloud image is default-deny. Oracle's Ubuntu image +// ships a catch-all REJECT, so without this every guest loses IMDS and VPC DNS, +// and cloud-init spends 490s timing out before giving up. +// +// Inserted at the head for the same reason, and probed with -C first: no +// earlier build appended this rule, so a present one is always one of ours in +// the right place, and re-inserting per launch would blip IMDS for guests +// already running. +func EnsureIMDSInputRule(ctx context.Context, r Runner) error { + if _, err := r.Run(ctx, "iptables", natRuleArgs("-C", "filter", "INPUT", imdsInputSpec)...); err == nil { + return nil + } + if out, err := r.Run(ctx, "iptables", natInsertArgs("filter", "INPUT", imdsInputSpec)...); err != nil { + return fmt.Errorf("install IMDS input rule: %s: %w", string(out), err) + } + slog.Info("host: installed IMDS input rule", "iface", IMDSEndpointPrefix+"+") + return nil +} + +// RemoveIMDSInputRule drops the INPUT accept; a missing rule is not an error. +func RemoveIMDSInputRule(ctx context.Context, r Runner) { + if _, err := r.Run(ctx, "iptables", natRuleArgs("-D", "filter", "INPUT", imdsInputSpec)...); err != nil { + slog.Debug("host: IMDS input rule not present on delete", "err", err) + } +} + +// imdsRemapComment tags the DNAT rules so an operator can find them and the +// uninstaller can remove them. +const imdsRemapComment = "spinifex-imds-remap" + +// imdsRemapSpecs are the PREROUTING DNATs that let a host serve IMDS and VPC +// DNS on addresses of its own while guests keep addressing the standard pair. +// Scoped to the ime- endpoints by interface wildcard, so nothing the host +// originates is touched — which is the entire point, since the host's own +// 169.254.169.254 is the cloud's metadata service. +// +// conntrack reverses the translation on the reply, so the guest sees the answer +// sourced from the address it sent to and needs no knowledge of the remap. +func imdsRemapSpecs() [][]string { + if !imdsHostAddrsRemapped() { + return nil + } + specs := make([][]string, 0, len(imdsCaptureAddrs)) + for i, guestAddr := range imdsCaptureAddrs { + specs = append(specs, []string{ + "-i", IMDSEndpointPrefix + "+", "-d", guestAddr, + "-m", "comment", "--comment", imdsRemapComment, + "-j", "DNAT", "--to-destination", imdsHostAddrs[i], + }) + } + return specs +} + +// EnsureIMDSRemapRules installs the DNATs when the endpoint addresses have been +// moved, and is a no-op otherwise — which is every bare-metal deployment. +// +// Inserted at the head and probed with -C first, for the reasons +// EnsureIMDSInputRule gives: a cloud image's nat table is not ours, and +// re-inserting per launch would blip IMDS for guests already running. +func EnsureIMDSRemapRules(ctx context.Context, r Runner) error { + for _, spec := range imdsRemapSpecs() { + if _, err := r.Run(ctx, "iptables", natRuleArgs("-C", "nat", "PREROUTING", spec)...); err == nil { + continue + } + if out, err := r.Run(ctx, "iptables", natInsertArgs("nat", "PREROUTING", spec)...); err != nil { + return fmt.Errorf("install IMDS remap rule: %s: %w", string(out), err) + } + slog.Info("host: installed IMDS remap rule", "guest_addr", spec[3], "host_addr", spec[len(spec)-1]) + } + return nil +} + +// RemoveIMDSRemapRules drops the DNATs; a missing rule is not an error. +func RemoveIMDSRemapRules(ctx context.Context, r Runner) { + for _, spec := range imdsRemapSpecs() { + if _, err := r.Run(ctx, "iptables", natRuleArgs("-D", "nat", "PREROUTING", spec)...); err != nil { + slog.Debug("host: IMDS remap rule not present on delete", "err", err) + } + } +} + // RemoveIMDSBridge deletes the IMDS bridge and every flow on it. Idempotent: // --if-exists tolerates an already-absent bridge. func RemoveIMDSBridge(ctx context.Context, r Runner) error { diff --git a/spinifex/network/host/imds_bridge_test.go b/spinifex/network/host/imds_bridge_test.go index ef52a04b9..aad2ded86 100644 --- a/spinifex/network/host/imds_bridge_test.go +++ b/spinifex/network/host/imds_bridge_test.go @@ -113,3 +113,130 @@ func TestClearIMDSFlowsByCookie(t *testing.T) { t.Errorf("expected cookie-scoped del-flows; calls: %v", s.calls) } } + +// Oracle's cloud image ships a catch-all REJECT in INPUT, so an appended +// accept never matches and every guest silently loses IMDS and VPC DNS. +func TestEnsureIMDSInputRuleInsertsAtTheHeadOfINPUT(t *testing.T) { + r := newStubRunner() + r.expect("iptables -t filter -C", nil, errors.New("no match")) + r.expect("iptables -t filter -I", nil, nil) + + if err := EnsureIMDSInputRule(context.Background(), r); err != nil { + t.Fatalf("EnsureIMDSInputRule: %v", err) + } + want := "iptables -t filter -I INPUT 1 -i " + IMDSEndpointPrefix + + "+ -m comment --comment spinifex-imds -j ACCEPT" + if !r.called(want) { + t.Errorf("missing insert:\n want %q\n got %v", want, r.calls) + } +} + +// Re-inserting on every launch would blip IMDS for guests already running, so +// a rule that is already there is left alone. +func TestEnsureIMDSInputRuleLeavesAnExistingRuleAlone(t *testing.T) { + r := newStubRunner() + r.expect("iptables -t filter -C", nil, nil) + + if err := EnsureIMDSInputRule(context.Background(), r); err != nil { + t.Fatalf("EnsureIMDSInputRule: %v", err) + } + for _, c := range r.calls { + if strings.Contains(c, " -I ") { + t.Errorf("re-inserted an existing rule: %q", c) + } + } +} + +// One wildcard rule has to cover every per-instance endpoint; a per-device rule +// would leak one INPUT entry per launch. +func TestIMDSInputRuleWildcardMatchesEveryEndpointName(t *testing.T) { + name := IMDSEndpointName("eni-0123456789abcdef0") + if !strings.HasPrefix(name, IMDSEndpointPrefix) { + t.Fatalf("endpoint %q does not carry prefix %q", name, IMDSEndpointPrefix) + } +} + +// withIMDSHostAddrs moves the endpoint addresses for one test and restores +// them, so a remapped case cannot leak into the default-path tests. +func withIMDSHostAddrs(t *testing.T, meta, dns string) { + t.Helper() + prev := imdsHostAddrs + t.Cleanup(func() { imdsHostAddrs = prev }) + SetIMDSHostAddrs(meta, dns) +} + +// The default is no remap at all: a bare-metal host owns .254/.253 outright +// and a DNAT there would be pure overhead. +func TestEnsureIMDSRemapRulesInstallsNothingByDefault(t *testing.T) { + r := newStubRunner() + + if err := EnsureIMDSRemapRules(context.Background(), r); err != nil { + t.Fatalf("EnsureIMDSRemapRules: %v", err) + } + if len(r.calls) != 0 { + t.Errorf("expected no rules on an unremapped host; calls: %v", r.calls) + } +} + +// The guest must keep addressing 169.254.169.254 whatever the host binds, so +// the DNAT matches the standard address and rewrites to the endpoint's. +func TestEnsureIMDSRemapRulesTranslatesTheGuestFacingPair(t *testing.T) { + withIMDSHostAddrs(t, "169.254.42.254", "169.254.42.253") + r := newStubRunner() + r.expect("iptables -t nat -C", nil, errors.New("no match")) + r.expect("iptables -t nat -I", nil, nil) + r.expect("iptables -t nat -C", nil, errors.New("no match")) + r.expect("iptables -t nat -I", nil, nil) + + if err := EnsureIMDSRemapRules(context.Background(), r); err != nil { + t.Fatalf("EnsureIMDSRemapRules: %v", err) + } + for _, want := range []string{ + "iptables -t nat -I PREROUTING 1 -i " + IMDSEndpointPrefix + + "+ -d " + imdsMetaAddr + " -m comment --comment spinifex-imds-remap -j DNAT --to-destination 169.254.42.254", + "iptables -t nat -I PREROUTING 1 -i " + IMDSEndpointPrefix + + "+ -d " + imdsDNSAddr + " -m comment --comment spinifex-imds-remap -j DNAT --to-destination 169.254.42.253", + } { + if !r.called(want) { + t.Errorf("missing rule:\n want %q\n got %v", want, r.calls) + } + } +} + +// Half a remap binds one address the host has already taken, so an incomplete +// pair is ignored rather than half-applied. +func TestSetIMDSHostAddrsIgnoresAHalfPair(t *testing.T) { + withIMDSHostAddrs(t, "169.254.42.254", "169.254.42.253") + SetIMDSHostAddrs("169.254.42.1", "") + + meta, dns := IMDSHostAddrs() + if meta != "169.254.42.254" || dns != "169.254.42.253" { + t.Errorf("half a pair was applied: meta=%q dns=%q", meta, dns) + } +} + +// The endpoint owns the bind addresses; the flows still match what the guest +// sends, or the demux would never fire. +func TestIMDSEndpointOwnsTheHostAddressesNotTheGuestOnes(t *testing.T) { + withIMDSHostAddrs(t, "169.254.42.254", "169.254.42.253") + r := newStubRunner() + for range 7 { + r.expect("", nil, nil) + } + + d := IMDSTapDatapath{ + Tap: "tap0", Endpoint: "ime-abcd1234", EndpointMAC: "02:00:00:00:00:01", + GuestMAC: "02:00:00:00:00:02", GatewayMAC: "02:00:00:00:00:03", + } + if err := ensureIMDSEndpoint(context.Background(), r, d); err != nil { + t.Fatalf("ensureIMDSEndpoint: %v", err) + } + if !r.called("ip addr replace 169.254.42.254/32 dev ime-abcd1234") { + t.Errorf("endpoint did not take the remapped metadata address; calls: %v", r.calls) + } + for _, c := range r.calls { + if strings.Contains(c, "addr replace "+imdsMetaAddr) { + t.Errorf("endpoint took %s, shadowing the host's own metadata route: %q", imdsMetaAddr, c) + } + } +} diff --git a/spinifex/network/host/imds_datapath.go b/spinifex/network/host/imds_datapath.go index 7b952dd38..f6e7b6024 100644 --- a/spinifex/network/host/imds_datapath.go +++ b/spinifex/network/host/imds_datapath.go @@ -21,9 +21,46 @@ const ( ) // imdsCaptureAddrs are the link-local addresses demuxed to the per-tap endpoint. -// Kept in one place so the endpoint addresses and the ingress flows stay in sync. +// Guest-facing and fixed: the ARP responder and the ingress demux flows match +// these whatever the host binds, because they are what the guest addresses. var imdsCaptureAddrs = []string{imdsMetaAddr, imdsDNSAddr} +// imdsHostAddrs are the addresses actually configured on each endpoint, and so +// what the responder binds. Equal to imdsCaptureAddrs unless the host has a use +// of its own for them — see SetIMDSHostAddrs. +var imdsHostAddrs = []string{imdsMetaAddr, imdsDNSAddr} + +// SetIMDSHostAddrs moves the endpoint's own addresses off the guest-facing +// pair, for a host that needs 169.254.169.254 and .253 for itself. A cloud +// guest does: those are its metadata service and its resolver, and a /32 on an +// ime- endpoint shadows the route to both, so the node loses DNS and its cloud +// API for as long as any guest is running. +// +// Guests are unaffected. They keep addressing .254/.253, the OVS flows keep +// matching on that, and a DNAT on the endpoint's ingress rewrites the +// destination — conntrack un-NATs the reply, so the guest never sees the +// substitution. +// +// Process-wide and set once at startup, before any tap is attached: both the +// daemon (which configures the endpoint) and vpcd (which binds the socket) read +// it from the same config key, and a change under a running tap would leave the +// endpoint and the responder disagreeing about the address. +func SetIMDSHostAddrs(meta, dns string) { + if meta == "" || dns == "" { + return + } + imdsHostAddrs = []string{meta, dns} +} + +// IMDSHostAddrs returns the endpoint-side metadata and DNS addresses. +func IMDSHostAddrs() (meta, dns string) { return imdsHostAddrs[0], imdsHostAddrs[1] } + +// imdsHostAddrsRemapped reports whether the endpoint serves on addresses other +// than the ones guests send to, and so needs the DNAT that hides the move. +func imdsHostAddrsRemapped() bool { + return imdsHostAddrs[0] != imdsMetaAddr || imdsHostAddrs[1] != imdsDNSAddr +} + // Per-tap OpenFlow priorities on IMDSBridge. The ARP responder, demux // (.254/.253 interception) and egress sit above the forward flows so IMDS // traffic is captured and everything else is bridged tap<->patch to br-int. @@ -42,10 +79,14 @@ func shortENIID(eniID string) string { return fmt.Sprintf("%08x", h.Sum32()) } +// IMDSEndpointPrefix tags every per-tap IMDS endpoint, so one wildcarded +// firewall rule covers them all. +const IMDSEndpointPrefix = "ime-" + // IMDSEndpointName returns the per-tap endpoint port on IMDSBridge — the // SO_BINDTODEVICE target the responder binds. "ime-" + 8-char short ENI = 12 // chars, within the 15-char IFNAMSIZ limit. -func IMDSEndpointName(eniID string) string { return "ime-" + shortENIID(eniID) } +func IMDSEndpointName(eniID string) string { return IMDSEndpointPrefix + shortENIID(eniID) } // IMDSPatchPort returns the IMDSBridge end of the per-tap patch to br-int. // "imp-" + 8-char short ENI = 12 chars. @@ -196,7 +237,7 @@ func ensureIMDSEndpoint(ctx context.Context, r Runner, d IMDSTapDatapath) error // `replace` is idempotent — adds the /32 if absent, no-op if the endpoint // already owns it (a recovery/stop re-attach reuses a surviving endpoint). // `add` errored on the duplicate, and its kernel message varies by version. - for _, addr := range imdsCaptureAddrs { + for _, addr := range imdsHostAddrs { if _, err := r.Run(ctx, "ip", "addr", "replace", addr+"/32", "dev", d.Endpoint); err != nil { return fmt.Errorf("add %s to IMDS endpoint %s: %w", addr, d.Endpoint, err) } diff --git a/spinifex/network/host/natrules.go b/spinifex/network/host/natrules.go index d0d37f8a6..807f85799 100644 --- a/spinifex/network/host/natrules.go +++ b/spinifex/network/host/natrules.go @@ -8,9 +8,14 @@ import ( const natEgressComment = "spinifex-nat-egress" +// maxDuplicateRules caps the drain loop. iptables has no delete-all, and a +// chain holding more copies than this has a problem no reconcile should paper +// over. +const maxDuplicateRules = 8 + // natEgressRules are the kernel rules giving routed-NAT VMs outbound WAN: // masquerade the transit /24 out any uplink, and accept forwarded transit -// traffic even when the FORWARD policy is DROP (Docker/firewalld hosts). +// traffic even when FORWARD would otherwise refuse it. var natEgressRules = []struct { table string chain string @@ -32,15 +37,29 @@ func natRuleArgs(op, table, chain string, spec []string) []string { return append(args, spec...) } -// EnsureNATEgressRules idempotently installs the routed-NAT egress rules: -// probe with -C, append with -A only when missing. vpcd calls this on every +// natInsertArgs builds an insert at the head of the chain. +func natInsertArgs(table, chain string, spec []string) []string { + args := []string{"-t", table, "-I", chain, "1"} + return append(args, spec...) +} + +// EnsureNATEgressRules installs the routed-NAT egress rules at the head of +// their chains, removing any existing copies first. vpcd calls this on every // start, so rules survive reboots without iptables-persistent. +// +// Appending is not enough and probing with -C is worse than useless: Ubuntu +// ships a catch-all REJECT in FORWARD, so an appended ACCEPT never matches, +// and a rule an earlier build appended still satisfies -C — which would leave +// a broken host broken across an upgrade. Every spec carries our comment, so +// the drain only ever deletes our own rules. func EnsureNATEgressRules(ctx context.Context, r Runner) error { for _, rule := range natEgressRules { - if _, err := r.Run(ctx, "iptables", natRuleArgs("-C", rule.table, rule.chain, rule.spec)...); err == nil { - continue + for range maxDuplicateRules { + if _, err := r.Run(ctx, "iptables", natRuleArgs("-D", rule.table, rule.chain, rule.spec)...); err != nil { + break + } } - if out, err := r.Run(ctx, "iptables", natRuleArgs("-A", rule.table, rule.chain, rule.spec)...); err != nil { + if out, err := r.Run(ctx, "iptables", natInsertArgs(rule.table, rule.chain, rule.spec)...); err != nil { return fmt.Errorf("install NAT egress rule (%s %s): %s: %w", rule.table, rule.chain, string(out), err) } slog.Info("host: installed NAT egress rule", "table", rule.table, "chain", rule.chain) diff --git a/spinifex/network/host/natrules_test.go b/spinifex/network/host/natrules_test.go index 1e3e9f8ed..d0cfafb43 100644 --- a/spinifex/network/host/natrules_test.go +++ b/spinifex/network/host/natrules_test.go @@ -7,54 +7,74 @@ import ( "testing" ) -func TestEnsureNATEgressRules_InstallsAllWhenMissing(t *testing.T) { +func TestEnsureNATEgressRules_InstallsAllAtTheHeadOfTheChain(t *testing.T) { r := newStubRunner() - r.expect("iptables -t nat -C", nil, fmt.Errorf("no match")) - r.expect("iptables -t filter -C", nil, fmt.Errorf("no match")) - r.expect("iptables -t nat -A", nil, nil) - r.expect("iptables -t filter -A", nil, nil) + r.expect("iptables -t nat -D", nil, fmt.Errorf("no match")) + r.expect("iptables -t filter -D", nil, fmt.Errorf("no match")) + r.expect("iptables -t nat -I", nil, nil) + r.expect("iptables -t filter -I", nil, nil) if err := EnsureNATEgressRules(context.Background(), r); err != nil { t.Fatalf("EnsureNATEgressRules: %v", err) } - wantAppends := []string{ - "iptables -t nat -A POSTROUTING -s " + NATTransitCIDR + " ! -d " + NATTransitCIDR + + // Position 1, not -A: Ubuntu ships a catch-all REJECT in FORWARD that an + // appended ACCEPT sits uselessly behind. + wantInserts := []string{ + "iptables -t nat -I POSTROUTING 1 -s " + NATTransitCIDR + " ! -d " + NATTransitCIDR + " -m comment --comment spinifex-nat-egress -j MASQUERADE", - "iptables -t filter -A FORWARD -i " + NATTransitHostEnd + " -s " + NATTransitCIDR + + "iptables -t filter -I FORWARD 1 -i " + NATTransitHostEnd + " -s " + NATTransitCIDR + " -m comment --comment spinifex-nat-egress -j ACCEPT", - "iptables -t filter -A FORWARD -o " + NATTransitHostEnd + " -m conntrack --ctstate RELATED,ESTABLISHED" + + "iptables -t filter -I FORWARD 1 -o " + NATTransitHostEnd + " -m conntrack --ctstate RELATED,ESTABLISHED" + " -m comment --comment spinifex-nat-egress -j ACCEPT", } - for _, want := range wantAppends { + for _, want := range wantInserts { if !r.called(want) { - t.Errorf("missing append call:\n want %q\n got %v", want, r.calls) + t.Errorf("missing insert call:\n want %q\n got %v", want, r.calls) } } -} - -func TestEnsureNATEgressRules_SkipsWhenPresent(t *testing.T) { - r := newStubRunner() - r.expect("iptables -t nat -C", nil, nil) - r.expect("iptables -t filter -C", nil, nil) - - if err := EnsureNATEgressRules(context.Background(), r); err != nil { - t.Fatalf("EnsureNATEgressRules: %v", err) - } for _, c := range r.calls { if strings.Contains(c, " -A ") { - t.Errorf("unexpected append when rule present: %q", c) + t.Errorf("appended instead of inserted: %q", c) } } } -func TestEnsureNATEgressRules_AppendFailure(t *testing.T) { +// A rule an earlier build appended satisfies -C, so probing would leave it +// stranded behind the REJECT forever. Ensure has to delete and re-insert. +func TestEnsureNATEgressRules_RepositionsAnAlreadyPresentRule(t *testing.T) { r := newStubRunner() - r.expect("iptables -t nat -C", nil, fmt.Errorf("no match")) - r.expect("iptables -t nat -A", []byte("iptables: permission denied"), fmt.Errorf("exit 4")) + r.expect("iptables -t nat -D", nil, nil) + r.expect("iptables -t filter -D", nil, nil) + r.expect("iptables -t nat -I", nil, nil) + r.expect("iptables -t filter -I", nil, nil) + + if err := EnsureNATEgressRules(context.Background(), r); err != nil { + t.Fatalf("EnsureNATEgressRules: %v", err) + } + if !r.called("iptables -t filter -I FORWARD 1 -i " + NATTransitHostEnd + " -s " + NATTransitCIDR + + " -m comment --comment spinifex-nat-egress -j ACCEPT") { + t.Errorf("present rule was not re-inserted: %v", r.calls) + } + // A delete that keeps succeeding must not loop forever. + var deletes int + for _, c := range r.calls { + if strings.Contains(c, " -D ") { + deletes++ + } + } + if deletes > len(natEgressRules)*maxDuplicateRules { + t.Errorf("drain loop is unbounded: %d deletes", deletes) + } +} + +func TestEnsureNATEgressRules_InsertFailure(t *testing.T) { + r := newStubRunner() + r.expect("iptables -t nat -D", nil, fmt.Errorf("no match")) + r.expect("iptables -t nat -I", []byte("iptables: permission denied"), fmt.Errorf("exit 4")) err := EnsureNATEgressRules(context.Background(), r) if err == nil || !strings.Contains(err.Error(), "permission denied") { - t.Fatalf("expected append failure error, got: %v", err) + t.Fatalf("expected insert failure error, got: %v", err) } } diff --git a/spinifex/network/host/ovs_local_port.go b/spinifex/network/host/ovs_local_port.go new file mode 100644 index 000000000..99b7b06c6 --- /dev/null +++ b/spinifex/network/host/ovs_local_port.go @@ -0,0 +1,54 @@ +package host + +import ( + "context" + "fmt" +) + +// HasLocalPort reports whether an OVN logical port is bound to an interface on +// this host, which is how a node decides an instance is its own. +// +// The Southbound DB knows this too, but local OVS is the authority on what is +// plugged in here and cannot be wrong about it. portName is the LSP name, which +// is also the value the daemon writes as external_ids:iface-id when it creates +// the tap, so the comparison is exact rather than a prefix guess. +func HasLocalPort(ctx context.Context, r Runner, portName string) (bool, error) { + if portName == "" { + return false, fmt.Errorf("HasLocalPort: portName required") + } + out, err := r.Run(ctx, "ovs-vsctl", "--format=json", "--columns=name,external_ids", "list", "Interface") + if err != nil { + return false, fmt.Errorf("list OVS interfaces: %s: %w", string(out), err) + } + rows, err := parseOVSInterfaceRows(out) + if err != nil { + return false, err + } + for _, row := range rows { + if row.externalIDs["iface-id"] == portName { + return true, nil + } + } + return false, nil +} + +// ListLocalPorts returns every OVN logical port bound to an interface on this +// host, keyed by LSP name. The same authority HasLocalPort uses, listed once +// instead of probed per port, for callers deciding about many ports at a time. +func ListLocalPorts(ctx context.Context, r Runner) (map[string]struct{}, error) { + out, err := r.Run(ctx, "ovs-vsctl", "--format=json", "--columns=name,external_ids", "list", "Interface") + if err != nil { + return nil, fmt.Errorf("list OVS interfaces: %s: %w", string(out), err) + } + rows, err := parseOVSInterfaceRows(out) + if err != nil { + return nil, err + } + ports := make(map[string]struct{}, len(rows)) + for _, row := range rows { + if id := row.externalIDs["iface-id"]; id != "" { + ports[id] = struct{}{} + } + } + return ports, nil +} diff --git a/spinifex/network/host/ovs_local_port_test.go b/spinifex/network/host/ovs_local_port_test.go new file mode 100644 index 000000000..8a4807a9a --- /dev/null +++ b/spinifex/network/host/ovs_local_port_test.go @@ -0,0 +1,69 @@ +//test:in-package — exercises parseOVSInterfaceRows, the unexported parser +// HasLocalPort's verdict rests on, against raw ovs-vsctl JSON. + +package host + +import ( + "context" + "fmt" + "testing" +) + +type ovsIface = struct { + name string + ids map[string]string +} + +// A node plumbs a distributed EIP only for the instances it is running, and +// the iface-id on the tap is how it knows. Matching must be exact: "port-eni-1" +// and "port-eni-10" are different instances, and a prefix match would make one +// node claim the other's address. +func TestHasLocalPort(t *testing.T) { + rows := []ovsIface{ + {name: "tap-eni-1", ids: map[string]string{"iface-id": "port-eni-1", "attached-mac": "52:54:00:aa:bb:cc"}}, + {name: "tap-eni-10", ids: map[string]string{"iface-id": "port-eni-10"}}, + {name: "br-int", ids: nil}, + } + for _, tc := range []struct { + name string + port string + want bool + }{ + {"bound here", "port-eni-1", true}, + {"a longer id sharing the prefix", "port-eni-10", true}, + {"bound on another chassis", "port-eni-2", false}, + {"prefix of a local id is not a match", "port-eni-", false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newStubRunner() + r.expect(listInterfacesCmd, ovsListJSON(t, rows), nil) + got, err := HasLocalPort(context.Background(), r, tc.port) + if err != nil { + t.Fatalf("HasLocalPort: %v", err) + } + if got != tc.want { + t.Errorf("HasLocalPort(%q) = %v, want %v", tc.port, got, tc.want) + } + }) + } +} + +// A failed query must not read as "not mine". That answer makes the node drop +// an EIP it is in fact hosting, and the next pass would do it again. +func TestHasLocalPortReportsQueryFailure(t *testing.T) { + r := newStubRunner() + r.expect(listInterfacesCmd, []byte("ovs-vsctl: database connection failed"), fmt.Errorf("exit 1")) + if _, err := HasLocalPort(context.Background(), r, "port-eni-1"); err == nil { + t.Error("a failed OVS query must surface as an error, not as a false verdict") + } +} + +func TestHasLocalPortRequiresAPort(t *testing.T) { + r := newStubRunner() + if _, err := HasLocalPort(context.Background(), r, ""); err == nil { + t.Error("empty port name: expected error") + } + if len(r.calls) != 0 { + t.Errorf("validation failure must not query OVS: %v", r.calls) + } +} diff --git a/spinifex/network/host/routed.go b/spinifex/network/host/routed.go index 5ab332f28..faa8a862f 100644 --- a/spinifex/network/host/routed.go +++ b/spinifex/network/host/routed.go @@ -32,6 +32,12 @@ const ( // NATTransitOVSEnd is the veth peer attached to the OVS uplink bridge. NATTransitOVSEnd = "spx-nat-ovs" + + // NATTransitHostMAC is the same on every node: OVN holds one MAC binding for + // the transit nexthop cluster-wide, so a per-node MAC sends every chassis's + // egress to whichever node seeded it. The veth pair is node-local, so one + // address everywhere is unambiguous. + NATTransitHostMAC = "02:00:64:7f:00:01" ) // Routed implements Wiring for the routed-NAT model: no WAN NIC is bridged; @@ -92,6 +98,10 @@ func (r *Routed) EnsureUplinkPort(ctx context.Context) (net.HardwareAddr, error) return nil, fmt.Errorf("host.Routed: %q has %s, expected %s", NATTransitHostEnd, cidr, want) } + if err := EnsureTransitHostMAC(ctx, r.runner()); err != nil { + return nil, err + } + mac, err := r.reader().LinkMAC(NATTransitOVSEnd) if err != nil { return nil, fmt.Errorf("read MAC for %q: %w", NATTransitOVSEnd, err) @@ -102,6 +112,29 @@ func (r *Routed) EnsureUplinkPort(ctx context.Context) (net.HardwareAddr, error) return mac, nil } +// EnsureTransitHostMAC makes the host end carry the cluster-wide transit MAC. +// setup-ovn.sh sets it at creation; this repairs a veth made before that, and +// until it is corrected the node's own guests are unreachable. +func EnsureTransitHostMAC(ctx context.Context, r Runner) error { + if r == nil { + r = NewExecRunner() + } + out, err := r.Run(ctx, "ip", "-o", "link", "show", "dev", NATTransitHostEnd) + if err != nil { + return fmt.Errorf("ip -o link show dev %s: %s: %w", NATTransitHostEnd, string(out), err) + } + have := parseLinkEtherMAC(string(out)) + if have == NATTransitHostMAC { + return nil + } + if out, err := r.Run(ctx, "ip", "link", "set", NATTransitHostEnd, "address", NATTransitHostMAC); err != nil { + return fmt.Errorf("set %q MAC to %s: %s: %w", NATTransitHostEnd, NATTransitHostMAC, string(out), err) + } + slog.Info("host: transit host MAC corrected", + "dev", NATTransitHostEnd, "was", have, "now", NATTransitHostMAC) + return nil +} + // UplinkMode returns UplinkModeRouted (routed NAT). func (r *Routed) UplinkMode() UplinkMode { return UplinkModeRouted } diff --git a/spinifex/network/host/routed_test.go b/spinifex/network/host/routed_test.go index a65761e8d..d09e7f9a9 100644 --- a/spinifex/network/host/routed_test.go +++ b/spinifex/network/host/routed_test.go @@ -2,6 +2,7 @@ package host import ( "context" + "errors" "net/netip" "strings" "testing" @@ -12,6 +13,8 @@ func TestRouted_EnsureUplinkPort(t *testing.T) { r.expect("ovs-vsctl port-to-br spx-nat-ovs", []byte("br-ext\n"), nil) rd := newStubReader() rd.macs[NATTransitOVSEnd] = mustMAC(t, "02:aa:bb:cc:dd:ee") + r.expect("ip -o link show dev "+NATTransitHostEnd, + []byte("5: "+NATTransitHostEnd+": mtu 1500 link/ether "+NATTransitHostMAC+" brd ff:ff:ff:ff:ff:ff"), nil) rd.cidrs[NATTransitHostEnd] = netip.MustParsePrefix(NATTransitGatewayCIDR) w := &Routed{UplinkBridge: "br-ext", Runner: r, Reader: rd} @@ -25,6 +28,57 @@ func TestRouted_EnsureUplinkPort(t *testing.T) { if w.UplinkMode() != UplinkModeRouted { t.Errorf("UplinkMode = %v, want routed", w.UplinkMode()) } + if r.called("ip link set " + NATTransitHostEnd + " address") { + t.Error("a host end already on the shared MAC must not be rewritten") + } +} + +// OVN keeps one MAC binding for the transit nexthop across the whole cluster, +// so a node whose veth kept its random MAC receives none of the egress that +// binding points at. Measured on three nodes: two of three guests were +// unreachable until every host end carried the same address. +func TestEnsureTransitHostMAC(t *testing.T) { + for _, tc := range []struct { + name string + have string + wantWrite bool + }{ + {"a veth made before the MAC was pinned", "ee:81:c7:35:f7:b9", true}, + {"already on the shared address", NATTransitHostMAC, false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newStubRunner() + r.expect("ip -o link show dev "+NATTransitHostEnd, linkShow(tc.have), nil) + r.expect("ip link set "+NATTransitHostEnd+" address", nil, nil) + + if err := EnsureTransitHostMAC(context.Background(), r); err != nil { + t.Fatalf("EnsureTransitHostMAC: %v", err) + } + wrote := r.called("ip link set " + NATTransitHostEnd + " address " + NATTransitHostMAC) + if wrote != tc.wantWrite { + t.Errorf("wrote MAC = %v, want %v (calls: %v)", wrote, tc.wantWrite, r.calls) + } + }) + } +} + +// A host end that cannot be given the shared MAC is a node nothing can reach +// through, so continuing would hide it behind a datapath failure with no +// control-plane signal at all. +func TestEnsureTransitHostMACFailureSurfaces(t *testing.T) { + r := newStubRunner() + r.expect("ip -o link show dev "+NATTransitHostEnd, linkShow("ee:81:c7:35:f7:b9"), nil) + r.expect("ip link set "+NATTransitHostEnd+" address", []byte("RTNETLINK: busy"), errors.New("exit 2")) + + err := EnsureTransitHostMAC(context.Background(), r) + if err == nil || !strings.Contains(err.Error(), NATTransitHostMAC) { + t.Fatalf("expected a MAC-set failure naming %s, got: %v", NATTransitHostMAC, err) + } +} + +func linkShow(mac string) []byte { + return []byte("5: " + NATTransitHostEnd + ": mtu 1500 link/ether " + + mac + " brd ff:ff:ff:ff:ff:ff\n") } func TestRouted_EnsureUplinkPort_WrongBridge(t *testing.T) { diff --git a/spinifex/network/policy/nat.go b/spinifex/network/policy/nat.go index 714b49f50..a8d679507 100644 --- a/spinifex/network/policy/nat.go +++ b/spinifex/network/policy/nat.go @@ -27,6 +27,10 @@ type EIPSpec struct { LogicalIP string PortName string MAC string + // NATGateway marks an address belonging to a NAT gateway rather than an + // ENI. It has no port to locate it by, so the VPC's gateway chassis is what + // says which node's host state it belongs in. + NATGateway bool } // NATGWSpec is a NAT Gateway SNAT rule keyed by (snat, SubnetCIDR). @@ -66,6 +70,12 @@ type NATManager interface { // (owner undeterminable). Returns the number of rows removed. PruneOrphanEIPs(ctx context.Context, live LiveEIPs) (int, error) + // BindHostEIPs installs the host-side plumbing for every EIP this node + // owns and prunes what specs no longer asks for. specs must be the whole + // intent, since anything missing from it is pruned. No-op outside routed + // mode. + BindHostEIPs(ctx context.Context, specs []EIPSpec) error + // AddNATGateway installs the (snat, SubnetCIDR) rule; rejects overlap. AddNATGateway(ctx context.Context, gw NATGWSpec) error @@ -144,6 +154,18 @@ type HostEIPBinder struct { Bind func(eip EIPSpec, gwLrpIP string) error Unbind func(externalIP string) error List func() ([]string, error) + // Owns reports whether portName is on this chassis. Bind asks the same + // question before plumbing anything; the prune has to ask it again because a + // guest can move to another node long after the bind, leaving this host with + // a route and a proxy-ARP entry for an address it can no longer serve. Nil + // keeps the older behaviour of pruning on cluster-wide intent alone. + Owns func(portName string) (bool, error) + // GatewayElsewhere is Owns for an address with no port: it reports whether + // another chassis holds the VPC's gateway, which is where a NAT gateway's + // datapath runs. True only when some other node has claimed it, so an + // unclaimed gateway leaves the binding alone. Nil keeps the older behaviour + // of every node plumbing every NAT gateway. + GatewayElsewhere func(vpcID string) (bool, error) } // WithHostEIPBinder injects the routed-mode host plumbing hooks fired on EIP @@ -173,6 +195,28 @@ func WithSNATExemptSet(setName string, cidrs []string) Option { } } +// DatapathResolver maps an external IP to the address that actually rides the +// wire. Everywhere but OCI the two are the same and the resolver returns its +// input; on OCI an allocation is a pair whose public half is NAT'd upstream and +// never appears on any interface, so a dnat_and_snat or a host /32 built from +// it matches nothing. +// +// It sits here rather than at each publisher because this is the only place +// every path converges: the launch commit, the reconciler's re-assert and the +// orphan prune all reach OVN through natManager, and a mapping applied at some +// of them would have the prune sweeping the rules the others installed. +type DatapathResolver func(ctx context.Context, externalIP string) (string, error) + +// WithDatapathResolver installs the external-IP → on-wire-address mapping. +// Unset means identity, which is every deployment that is not on OCI. +func WithDatapathResolver(r DatapathResolver) Option { + return func(m *natManager) { + if r != nil { + m.datapath = r + } + } +} + type natManager struct { ovn ovn.Client mode NATMode @@ -182,6 +226,7 @@ type natManager struct { exemptSetName string exemptCIDRs []string hostBinder *HostEIPBinder + datapath DatapathResolver } var _ NATManager = (*natManager)(nil) @@ -198,6 +243,7 @@ func NewNATManager(client ovn.Client, mode NATMode, opts ...Option) (NATManager, barrier: func() error { return nil }, neigh: func(string) error { return nil }, neighPrime: func(EIPSpec) error { return nil }, + datapath: func(_ context.Context, ip string) (string, error) { return ip, nil }, } for _, opt := range opts { opt(m) @@ -222,6 +268,17 @@ func (m *natManager) exemptSetUUID(ctx context.Context) (*string, error) { func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error { router := topology.VPCRouter(eip.VPCID) + // Everything below works in datapath addresses, so the row, the idempotency + // lookups, the predecessor scrub and the host bind all agree. The public + // address survives only as the external-ID stamp, which is where an operator + // reads back what the instance is reachable on. + publicIP := eip.ExternalIP + dpIP, err := m.datapath(ctx, publicIP) + if err != nil { + return fmt.Errorf("resolve datapath address for EIP %s: %w", publicIP, err) + } + eip.ExternalIP = dpIP + exemptUUID, err := m.exemptSetUUID(ctx) if err != nil { return err @@ -233,7 +290,7 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error { LogicalIP: eip.LogicalIP, ExternalIDs: map[string]string{ "spinifex:vpc_id": eip.VPCID, - "spinifex:public_ip": eip.ExternalIP, + "spinifex:public_ip": publicIP, }, } // Stamp the owning ENI port so DeleteEIP can owner-scope a stale delete. @@ -243,7 +300,7 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error { natRule.ExternalIDs["spinifex:logical_port"] = eip.PortName } natRule.ExemptedExtIps = exemptUUID - distributed := m.mode == NATModeDistributed && eip.PortName != "" && eip.MAC != "" + distributed := m.distributes(eip) if distributed { mac := eip.MAC port := eip.PortName @@ -333,6 +390,23 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error { return m.bindHostEIP(ctx, eip) } +// distributes reports whether OVN processes this rule on the instance's own +// chassis rather than on the VPC's gateway chassis. +// +// Routed mode qualifies for the same reason pool mode does: OVN's ARP +// responder answers for the external IP on the external segment using the +// per-rule external MAC. Routed mode's external segment is the spx-nat veth +// pair, which is per node, so only the node running the instance can answer — +// the topology gates what the rule would otherwise have to. +func (m *natManager) distributes(eip EIPSpec) bool { + switch m.mode { + case NATModeDistributed, NATModeRouted: + return eip.PortName != "" && eip.MAC != "" + default: + return false + } +} + // bindHostEIP fires the routed-mode host plumbing hook for an EIP. No-op in // other modes or when no binder is configured. Errors are returned so a // half-plumbed EIP surfaces to the caller (reconcile retries the bind). @@ -340,16 +414,76 @@ func (m *natManager) bindHostEIP(ctx context.Context, eip EIPSpec) error { if m.mode != NATModeRouted || m.hostBinder == nil { return nil } - gwLrpIP := m.gatewayPortIP(ctx, eip.VPCID) - if gwLrpIP == "" { - return fmt.Errorf("bind host EIP %s: gateway LRP IP unknown for %s (IGW attached?)", eip.ExternalIP, eip.VPCID) + // A NAT gateway has no port for the binder's own locality test to find, so + // ask the gateway chassis instead; every other node plumbing it is what + // hijacks traffic on a cloud that puts the address in the nodes' subnet. + if m.gatewayElsewhere(eip) { + slog.Debug("policy: NAT gateway runs on another chassis, leaving its host binding alone", + "external_ip", eip.ExternalIP, "vpc_id", eip.VPCID) + return nil + } + // A distributed EIP answers ARP on the local transit veth, so the host + // reaches it on-link and no gateway LRP is involved. Demanding one here is + // what stopped a non-gateway node ever binding an EIP: the address it would + // have found belongs to a segment only the gateway chassis can reach. + var gwLrpIP string + if !m.distributes(eip) { + gwLrpIP = m.gatewayPortIP(ctx, eip.VPCID) + if gwLrpIP == "" { + return fmt.Errorf("bind host EIP %s: gateway LRP IP unknown for %s (IGW attached?)", eip.ExternalIP, eip.VPCID) + } } if err := m.hostBinder.Bind(eip, gwLrpIP); err != nil { - return fmt.Errorf("bind host EIP %s via %s: %w", eip.ExternalIP, gwLrpIP, err) + return fmt.Errorf("bind host EIP %s (next hop %q): %w", eip.ExternalIP, gwLrpIP, err) } return nil } +// BindHostEIPs is documented on the NATManager interface. +// +// The NB rows are one writer's job, but routes and proxy-ARP are per node and +// Bind already leaves alone an EIP whose ENI is on another chassis, so two +// nodes never contend for the same one. Running this only where the NB writes +// run left a node that never won the reconcile lease with no route to its own +// guests. +func (m *natManager) BindHostEIPs(ctx context.Context, specs []EIPSpec) error { + if m.mode != NATModeRouted || m.hostBinder == nil { + return nil + } + live := LiveEIPs{ExternalIPs: make(map[string]struct{}, len(specs))} + // Addresses still wanted cluster-wide whose guest is not on this chassis. + // Bind skips them; without the same test the sweep below reads them as + // wanted and leaves this node holding a route and proxy-ARP for a guest that + // has moved, which on a shared segment answers ARP for someone else's guest. + foreign := map[string]struct{}{} + var errs []error + incomplete := false + for _, eip := range specs { + dpIP, err := m.datapath(ctx, eip.ExternalIP) + if err != nil { + // live is now short an address that is still wanted, so the sweep + // below would read it as stale and tear its plumbing down. + incomplete = true + errs = append(errs, fmt.Errorf("resolve datapath address for EIP %s: %w", eip.ExternalIP, err)) + continue + } + eip.ExternalIP = dpIP + live.ExternalIPs[dpIP] = struct{}{} + if m.foreignToThisChassis(eip) { + foreign[dpIP] = struct{}{} + } + if err := m.bindHostEIP(ctx, eip); err != nil { + errs = append(errs, err) + } + } + if !incomplete { + if err := m.pruneHostEIPs(live, foreign); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + // primeReachability programs the host neigh entry to the MAC owning the EIP on // the WAN segment — external_mac (distributed) or gateway router-port MAC // (centralised) — falling back to an ARP flush when no MAC resolves. @@ -408,6 +542,13 @@ func (m *natManager) DeleteEIP(ctx context.Context, vpcID, externalIP, logicalIP if externalIP == "" { return nil } + // The row and the host plumbing were both written in datapath addresses, so + // a delete naming the public half would find neither. + dpIP, err := m.datapath(ctx, externalIP) + if err != nil { + return fmt.Errorf("resolve datapath address for EIP %s: %w", externalIP, err) + } + externalIP = dpIP // Scope the delete to the (external_ip, logical_ip) pair and, when known, the // owning ENI port. External IPs are recycled from the pool as instances come // and go, and vpc.delete-nat is fire-and-forget plus re-emitted by the GC @@ -462,11 +603,45 @@ func (m *natManager) DeleteEIP(ctx context.Context, vpcID, externalIP, logicalIP return nil } +// ErrEmptyEIPIntent reports a sweep declined because the live set it would have +// deleted against was wholly empty — the signature of an unreadable intent, not +// of a node that genuinely holds no ports and no addresses. +var ErrEmptyEIPIntent = errors.New("EIP intent holds no ports or external IPs; sweep would delete every live row") + +// countStampedDNATs counts dnat_and_snat rows this sweep could delete: stamped +// with an owning port, so their absence from intent is a real signal. +func countStampedDNATs(nats []nbdb.NAT) int { + n := 0 + for i := range nats { + if nats[i].Type == "dnat_and_snat" && nats[i].ExternalIDs["spinifex:logical_port"] != "" { + n++ + } + } + return n +} + func (m *natManager) PruneOrphanEIPs(ctx context.Context, live LiveEIPs) (int, error) { + // Intent is built from AWS records and so names public addresses, while the + // rows and host bindings hold datapath ones. Compared unmapped, every OCI + // address looks orphaned and the sweep would blackhole every live instance. + live, err := m.datapathLive(ctx, live) + if err != nil { + return 0, err + } nats, err := m.ovn.ListNATs(ctx) if err != nil { return 0, fmt.Errorf("list NATs for orphan EIP prune: %w", err) } + // An empty live set is indistinguishable from a failed intent read, and every + // stamped row orphaned at once is that rather than a node whose guests and + // EIPs all vanished together. Leaking a stale row is the recoverable half. + if len(live.Ports) == 0 && len(live.ExternalIPs) == 0 { + if stamped := countStampedDNATs(nats); stamped > 0 { + slog.Error("policy: refusing orphan EIP sweep — intent holds no ports or external IPs at all", + "live_dnat_rows", stamped) + return 0, ErrEmptyEIPIntent + } + } pruned := 0 for i := range nats { n := nats[i] @@ -504,14 +679,83 @@ func (m *natManager) PruneOrphanEIPs(ctx context.Context, live LiveEIPs) (int, e "reason", reason, "external_ip", n.ExternalIP, "logical_ip", n.LogicalIP, "logical_port", port, "rows_removed", removed) } - return pruned, m.pruneHostEIPs(live) + // No chassis filter here: this sweep is leader-gated and cluster-wide, while + // "has the guest moved off this node" is a per-node question that + // BindHostEIPs asks on every node on its own loop. + return pruned, m.pruneHostEIPs(live, nil) +} + +// foreignToThisChassis reports whether an EIP's guest has left this node, the +// same question Bind asks and by the same authority. Only a distributed EIP is +// answerable: a centralised one hairpins through the gateway chassis and every +// node legitimately plumbs it. +// +// An unanswerable question is not a yes. An unreadable local OVS is a lost +// signal, and tearing down a live address on a guess is worse than leaving a +// stale route the next pass can still remove. +func (m *natManager) foreignToThisChassis(eip EIPSpec) bool { + if eip.NATGateway { + return m.gatewayElsewhere(eip) + } + // A guest EIP is located by its port, never by the gateway chassis: asking + // the wrong authority takes every address off every node but one. + if m.hostBinder == nil || m.hostBinder.Owns == nil || + eip.PortName == "" || eip.MAC == "" || !m.distributes(eip) { + return false + } + local, err := m.hostBinder.Owns(eip.PortName) + if err != nil { + slog.Warn("policy: cannot tell whether an EIP's guest is still on this chassis; leaving its host binding alone", + "external_ip", eip.ExternalIP, "logical_port", eip.PortName, "err", err) + return false + } + return !local +} + +// gatewayElsewhere answers foreignToThisChassis for a NAT gateway address. Its +// traffic is SNAT'd on the VPC's gateway chassis and leaves that node's uplink, +// so anywhere else the /32 route points into a datapath that will never carry +// it — and where the address shares the nodes' own subnet, as on OCI, it +// outranks the connected route and swallows node-to-node traffic. +func (m *natManager) gatewayElsewhere(eip EIPSpec) bool { + if !eip.NATGateway || m.hostBinder == nil || m.hostBinder.GatewayElsewhere == nil || eip.VPCID == "" { + return false + } + elsewhere, err := m.hostBinder.GatewayElsewhere(eip.VPCID) + if err != nil { + slog.Warn("policy: cannot tell which chassis a NAT gateway runs on; leaving its host binding alone", + "external_ip", eip.ExternalIP, "vpc_id", eip.VPCID, "err", err) + return false + } + return elsewhere +} + +// datapathLive rewrites the prune's wanted-address set into datapath addresses. +// Ports are untouched — a logical port name is the same on either side. +// +// A resolver failure aborts the prune rather than degrading to the unmapped +// set: a half-mapped comparison deletes the rules for exactly the addresses it +// could not resolve, which is the worst available outcome. +func (m *natManager) datapathLive(ctx context.Context, live LiveEIPs) (LiveEIPs, error) { + mapped := LiveEIPs{ + Ports: live.Ports, + ExternalIPs: make(map[string]struct{}, len(live.ExternalIPs)), + } + for ip := range live.ExternalIPs { + dpIP, err := m.datapath(ctx, ip) + if err != nil { + return LiveEIPs{}, fmt.Errorf("resolve datapath address for %s during orphan prune: %w", ip, err) + } + mapped.ExternalIPs[dpIP] = struct{}{} + } + return mapped, nil } // pruneHostEIPs removes host plumbing for every external IP intent no longer // asks for. The NAT row is not the record of that plumbing — AddEIP's // predecessor scrub, a lost teardown or a crash mid-delete each leave a route // and its proxy-ARP behind with no row left to find them by. -func (m *natManager) pruneHostEIPs(live LiveEIPs) error { +func (m *natManager) pruneHostEIPs(live LiveEIPs, foreign map[string]struct{}) error { if m.mode != NATModeRouted || m.hostBinder == nil || m.hostBinder.List == nil { return nil } @@ -521,12 +765,18 @@ func (m *natManager) pruneHostEIPs(live LiveEIPs) error { } kept := make([]string, 0, len(bound)) for _, eip := range bound { - if _, wanted := live.ExternalIPs[eip]; wanted { + _, wanted := live.ExternalIPs[eip] + _, elsewhere := foreign[eip] + if wanted && !elsewhere { kept = append(kept, eip) continue } - m.releaseHostEIP(eip, "host binding for an external IP absent from intent") - slog.Info("policy: pruned stale host EIP ingress", "external_ip", eip) + reason := "host binding for an external IP absent from intent" + if elsewhere { + reason = "owning guest has moved to another chassis" + } + m.releaseHostEIP(eip, reason) + slog.Info("policy: pruned stale host EIP ingress", "external_ip", eip, "reason", reason) } // The keep decision was silent, so a route that outlived its address could not // be told from one the sweep never saw. Both sides are logged now. @@ -557,6 +807,17 @@ func (m *natManager) releaseHostEIP(externalIP, reason string) { func (m *natManager) AddNATGateway(ctx context.Context, gw NATGWSpec) error { router := topology.VPCRouter(gw.VPCID) + // The SNAT source and the host bind both have to name the address that rides + // the wire, the same way an EIP does. Where the public half is NAT'd upstream + // it is on no interface anywhere, so egress leaves with a source the cloud + // drops and the host EIP sweep prunes the bind straight back off as foreign. + publicIP := gw.PublicIP + dpIP, err := m.datapath(ctx, publicIP) + if err != nil { + return fmt.Errorf("resolve datapath address for NAT gateway %s: %w", publicIP, err) + } + gw.PublicIP = dpIP + snatRule := &nbdb.NAT{ Type: "snat", ExternalIP: gw.PublicIP, @@ -564,6 +825,9 @@ func (m *natManager) AddNATGateway(ctx context.Context, gw NATGWSpec) error { ExternalIDs: map[string]string{ "spinifex:vpc_id": gw.VPCID, "spinifex:nat_gateway_id": gw.NATGatewayID, + // The row holds the on-wire half, so the public one survives here as + // the address an operator reads the NAT gateway back as. + "spinifex:public_ip": publicIP, }, } // Reconcile the existing row, keyed on (router, subnet CIDR) — DeleteNAT's key — @@ -574,7 +838,8 @@ func (m *natManager) AddNATGateway(ctx context.Context, gw NATGWSpec) error { } else if existing != nil && existing.ExternalIP == gw.PublicIP { slog.Info("policy: AddNATGateway idempotent skip — rule already current", "router", router, "public_ip", gw.PublicIP, "subnet_cidr", gw.SubnetCIDR) - return nil + // Host state is volatile even when the OVN row survives a reboot. + return m.bindNATGatewayHost(ctx, gw) } else if existing != nil { // Same subnet CIDR, different public IP (e.g. a dropped delete then a recreate // with a new EIP). Scrub the stale row(s) so the new EIP does not leak egress @@ -594,7 +859,24 @@ func (m *natManager) AddNATGateway(ctx context.Context, gw NATGWSpec) error { slog.Warn("policy: AddNATGateway flows barrier failed", "public_ip", gw.PublicIP, "subnet_cidr", gw.SubnetCIDR, "err", err) } - return nil + return m.bindNATGatewayHost(ctx, gw) +} + +// bindNATGatewayHost plumbs the routed-mode host state for a NAT gateway's +// public address: the same /32 route into OVN, proxy-ARP and FORWARD accepts +// an EIP gets. Without it a private guest's egress leaves with a source the +// host has no route back to, and the masquerade rule matches the transit /24 +// alone, so nothing rewrites it either. +// +// The address is centralised by construction — SNAT on the VPC router owns it, +// not any one port — so the spec carries no port or MAC and the bind resolves +// the gateway LRP as its next hop. Fired here as well as from the host EIP +// pass so a fresh gateway is not dark until the next tick. +func (m *natManager) bindNATGatewayHost(ctx context.Context, gw NATGWSpec) error { + if gw.PublicIP == "" { + return nil + } + return m.bindHostEIP(ctx, EIPSpec{VPCID: gw.VPCID, ExternalIP: gw.PublicIP, NATGateway: true}) } func (m *natManager) DeleteNATGateway(ctx context.Context, vpcID, subnetCIDR string) error { diff --git a/spinifex/network/policy/nat_binder_test.go b/spinifex/network/policy/nat_binder_test.go index 7af063909..65086f48d 100644 --- a/spinifex/network/policy/nat_binder_test.go +++ b/spinifex/network/policy/nat_binder_test.go @@ -190,3 +190,216 @@ func TestNATManager_AddEIP_NonRoutedNeverBinds(t *testing.T) { assert.Empty(t, b.unbinds, "mode %v must not touch host EIP plumbing", mode) } } + +// Routed mode distributes an EIP the same way pool mode does, and for the same +// reason: OVN's ARP responder answers for the external IP on the external +// segment using the per-rule external MAC. In routed mode that segment is the +// per-node transit veth, so answering it can only be the node running the +// instance. +func TestNATManager_AddEIP_Routed_DistributedWhenPortAndMACAreKnown(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + seedGatewayPortIP(t, m, "vpc-1", "100.127.0.10", nil) + b := &recordedBinder{} + mgr, err := NewNATManager(m, NATModeRouted, WithHostEIPBinder(b.hooks())) + require.NoError(t, err) + + require.NoError(t, mgr.AddEIP(context.Background(), EIPSpec{ + VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc", + })) + + row := findNAT(m, "dnat_and_snat", "10.0.1.5") + require.NotNil(t, row) + require.NotNil(t, row.ExternalMAC, "a distributed rule carries the ENI MAC") + assert.Equal(t, "52:54:00:aa:bb:cc", *row.ExternalMAC) + require.NotNil(t, row.LogicalPort, "a distributed rule carries the owning ENI port") + assert.Equal(t, "port-eni-1", *row.LogicalPort) + + // The bind must name no next hop. A gateway LRP address lives on one + // chassis's transit veth, so routing through it is exactly what made every + // non-gateway node's EIPs unreachable. + require.Len(t, b.binds, 1) + assert.Equal(t, "192.168.1.200 via ", b.binds[0]) +} + +// A distributed EIP needs no gateway LRP, so a VPC whose IGW has not yet +// produced one still binds. Before this, the missing address failed the bind on +// every node including the one running the instance. +func TestNATManager_AddEIP_Routed_DistributedBindsWithoutAGatewayLRP(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + b := &recordedBinder{} + mgr, err := NewNATManager(m, NATModeRouted, WithHostEIPBinder(b.hooks())) + require.NoError(t, err) + + require.NoError(t, mgr.AddEIP(context.Background(), EIPSpec{ + VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc", + })) + require.Len(t, b.binds, 1) + assert.Equal(t, "192.168.1.200 via ", b.binds[0]) +} + +// An EIP with no MAC — a record written before the ENI was known, or an ENI +// that never reported one — stays centralised and keeps demanding its next hop. +// The two shapes coexist so a cluster part-way through an upgrade is never in a +// state neither side handles. +func TestNATManager_AddEIP_Routed_NoMACStaysCentralised(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + seedGatewayPortIP(t, m, "vpc-1", "100.127.0.10", nil) + b := &recordedBinder{} + mgr, err := NewNATManager(m, NATModeRouted, WithHostEIPBinder(b.hooks())) + require.NoError(t, err) + + require.NoError(t, mgr.AddEIP(context.Background(), EIPSpec{ + VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", + })) + + row := findNAT(m, "dnat_and_snat", "10.0.1.5") + require.NotNil(t, row) + assert.Nil(t, row.ExternalMAC, "no MAC means no distributed rule") + require.Len(t, b.binds, 1) + assert.Equal(t, "192.168.1.200 via 100.127.0.10", b.binds[0]) +} + +// The constraint this change is held to: distributing the routed path must not +// move pool or static mode. NATModeDistributed and NATModeCentralized are +// asserted here against the same input, so a later edit to the shared predicate +// that widened either of them would fail rather than ship. +func TestNATManager_AddEIP_OtherModesAreUnmoved(t *testing.T) { + eip := EIPSpec{ + VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc", + } + for _, tc := range []struct { + name string + mode NATMode + wantDistributed bool + }{ + {"pool and veth uplinks stay distributed", NATModeDistributed, true}, + {"centralised stays centralised", NATModeCentralized, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + b := &recordedBinder{} + mgr, err := NewNATManager(m, tc.mode, WithHostEIPBinder(b.hooks())) + require.NoError(t, err) + require.NoError(t, mgr.AddEIP(context.Background(), eip)) + + row := findNAT(m, "dnat_and_snat", "10.0.1.5") + require.NotNil(t, row) + assert.Equal(t, tc.wantDistributed, row.ExternalMAC != nil, + "external_mac presence must not change outside routed mode") + assert.Empty(t, b.binds, "only routed mode plumbs host state") + }) + } +} + +// listingBinder adds List to recordedBinder so the prune half of BindHostEIPs +// can be exercised. +type listingBinder struct { + recordedBinder + + bound []string +} + +func (b *listingBinder) listHooks() HostEIPBinder { + h := b.hooks() + h.Unbind = func(externalIP string) error { + b.unbinds = append(b.unbinds, externalIP) + return nil + } + h.List = func() ([]string, error) { return b.bound, nil } + return h +} + +// Every node has to plumb its own guests. Leader-gating this left the node that +// never won the reconcile lease with no route to the guest it was running, +// which is two of three guests dark on a three-node cluster. +func TestNATManager_BindHostEIPs_BindsEveryIntentEIPAndPrunesTheRest(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + seedGatewayPortIP(t, m, "vpc-1", "100.127.0.10", nil) + b := &listingBinder{bound: []string{"192.168.1.200", "192.168.1.201", "192.168.1.250"}} + mgr, err := NewNATManager(m, NATModeRouted, WithHostEIPBinder(b.listHooks())) + require.NoError(t, err) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), []EIPSpec{ + {VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc"}, + {VPCID: "vpc-1", ExternalIP: "192.168.1.201", LogicalIP: "10.0.1.6", + PortName: "port-eni-2", MAC: "52:54:00:aa:bb:dd"}, + })) + + assert.Equal(t, []string{"192.168.1.200 via ", "192.168.1.201 via "}, b.binds, + "a distributed EIP binds on-link, with no gateway LRP next hop") + assert.Equal(t, []string{"192.168.1.250"}, b.unbinds, + "only the binding intent no longer asks for is swept") +} + +// BindHostEIPs writes no NB rows, so it must leave the OVN state it reads +// alone — the leader-gated pass is still the only writer of dnat_and_snat. +func TestNATManager_BindHostEIPs_WritesNoNATRows(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + seedGatewayPortIP(t, m, "vpc-1", "100.127.0.10", nil) + b := &listingBinder{} + mgr, err := NewNATManager(m, NATModeRouted, WithHostEIPBinder(b.listHooks())) + require.NoError(t, err) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), []EIPSpec{ + {VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc"}, + })) + + nat, err := m.FindNATByExternalIP(context.Background(), "dnat_and_snat", "192.168.1.200") + require.NoError(t, err) + assert.Nil(t, nat, "BindHostEIPs must not create a NAT row") +} + +// A resolver failure leaves the wanted set short of an address that is still +// live, so sweeping against it would tear down plumbing a guest is using. +func TestNATManager_BindHostEIPs_SkipsThePruneWhenIntentCouldNotBeResolved(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + seedGatewayPortIP(t, m, "vpc-1", "100.127.0.10", nil) + b := &listingBinder{bound: []string{"10.200.0.57"}} + mgr, err := NewNATManager(m, NATModeRouted, + WithHostEIPBinder(b.listHooks()), + WithDatapathResolver(func(_ context.Context, _ string) (string, error) { + return "", fmt.Errorf("OCI: private IP lookup failed") + })) + require.NoError(t, err) + + err = mgr.BindHostEIPs(context.Background(), []EIPSpec{ + {VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc"}, + }) + require.Error(t, err) + assert.Empty(t, b.unbinds, "an unresolved intent must not drive a prune") +} + +// Pool and static deployments keep the host out of the EIP path entirely, so +// this pass must stay inert there however often it runs. +func TestNATManager_BindHostEIPs_IsInertOutsideRoutedMode(t *testing.T) { + for _, mode := range []NATMode{NATModeDistributed, NATModeCentralized} { + t.Run(mode.String(), func(t *testing.T) { + m := mock.New() + seedRouter(t, m, "vpc-1") + b := &listingBinder{bound: []string{"192.168.1.250"}} + mgr, err := NewNATManager(m, mode, WithHostEIPBinder(b.listHooks())) + require.NoError(t, err) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), []EIPSpec{ + {VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc"}, + })) + assert.Empty(t, b.binds) + assert.Empty(t, b.unbinds) + }) + } +} diff --git a/spinifex/network/policy/nat_datapath_test.go b/spinifex/network/policy/nat_datapath_test.go new file mode 100644 index 000000000..d01d61f08 --- /dev/null +++ b/spinifex/network/policy/nat_datapath_test.go @@ -0,0 +1,185 @@ +//test:in-package — reuses nat_test.go's in-package seedRouter and findNAT +//fixtures, which drive the same mock NB client these cases assert against. + +package policy + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" +) + +// pairResolver maps one public address to its on-wire private one, the shape +// an OCI address pair takes. +func pairResolver(public, private string) DatapathResolver { + return func(_ context.Context, ip string) (string, error) { + if ip == public { + return private, nil + } + return ip, nil + } +} + +// The public half of an OCI pair is NAT'd upstream and never reaches the wire, +// so a rule carrying it matches nothing and the instance is dark. +func TestNATManager_AddEIP_RowCarriesTheDatapathAddress(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeCentralized, + WithDatapathResolver(pairResolver("203.0.113.9", "10.200.0.183"))) + require.NoError(t, err) + + require.NoError(t, nm.AddEIP(ctx, EIPSpec{ + VPCID: "vpc-1", ExternalIP: "203.0.113.9", LogicalIP: "172.31.0.4", + PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff", + })) + + row := findNAT(m, "dnat_and_snat", "172.31.0.4") + require.NotNil(t, row) + assert.Equal(t, "10.200.0.183", row.ExternalIP, + "OVN must match the address OCI actually puts on the VNIC") + assert.Equal(t, "203.0.113.9", row.ExternalIDs["spinifex:public_ip"], + "the public half is what an operator reads the instance back as") +} + +// Intent is built from AWS records and names public addresses; the rows hold +// private ones. Compared unmapped, every live OCI instance looks orphaned. +func TestNATManager_PruneOrphanEIPs_KeepsALiveMappedAddress(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeCentralized, + WithDatapathResolver(pairResolver("203.0.113.9", "10.200.0.183"))) + require.NoError(t, err) + + spec := EIPSpec{ + VPCID: "vpc-1", ExternalIP: "203.0.113.9", LogicalIP: "172.31.0.4", + PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff", + } + require.NoError(t, nm.AddEIP(ctx, spec)) + + pruned, err := nm.PruneOrphanEIPs(ctx, LiveEIPs{ + Ports: map[string]struct{}{"port-eni-abc": {}}, + ExternalIPs: map[string]struct{}{"203.0.113.9": {}}, + }) + require.NoError(t, err) + assert.Zero(t, pruned, "a live instance must survive the sweep") + assert.NotNil(t, findNAT(m, "dnat_and_snat", "172.31.0.4")) +} + +// A delete naming the public half would find neither the row nor the host +// plumbing, and the address would go back to the pool still wired up. +func TestNATManager_DeleteEIP_RemovesTheMappedRow(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeCentralized, + WithDatapathResolver(pairResolver("203.0.113.9", "10.200.0.183"))) + require.NoError(t, err) + + require.NoError(t, nm.AddEIP(ctx, EIPSpec{ + VPCID: "vpc-1", ExternalIP: "203.0.113.9", LogicalIP: "172.31.0.4", + PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff", + })) + require.NoError(t, nm.DeleteEIP(ctx, "vpc-1", "203.0.113.9", "172.31.0.4", "port-eni-abc")) + + assert.Nil(t, findNAT(m, "dnat_and_snat", "172.31.0.4")) +} + +// Falling back to the public address on a lookup failure installs a rule that +// is indistinguishable from a working one until the instance is unreachable. +func TestNATManager_AddEIP_ResolverFailureIsFatal(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeCentralized, + WithDatapathResolver(func(context.Context, string) (string, error) { + return "", errors.New("bindings unavailable") + })) + require.NoError(t, err) + + err = nm.AddEIP(ctx, EIPSpec{ + VPCID: "vpc-1", ExternalIP: "203.0.113.9", LogicalIP: "172.31.0.4", + PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff", + }) + require.Error(t, err) + assert.Nil(t, findNAT(m, "dnat_and_snat", "172.31.0.4")) +} + +// Half a mapped comparison sweeps exactly the addresses it could not resolve. +func TestNATManager_PruneOrphanEIPs_ResolverFailureAbortsTheSweep(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeCentralized) + require.NoError(t, err) + require.NoError(t, nm.AddEIP(ctx, EIPSpec{ + VPCID: "vpc-1", ExternalIP: "203.0.113.9", LogicalIP: "172.31.0.4", + PortName: "port-eni-abc", MAC: "aa:bb:cc:dd:ee:ff", + })) + + broken, err := NewNATManager(m, NATModeCentralized, + WithDatapathResolver(func(context.Context, string) (string, error) { + return "", errors.New("bindings unavailable") + })) + require.NoError(t, err) + + _, err = broken.PruneOrphanEIPs(ctx, LiveEIPs{ + Ports: map[string]struct{}{"port-eni-abc": {}}, + ExternalIPs: map[string]struct{}{"203.0.113.9": {}}, + }) + require.Error(t, err) + assert.NotNil(t, findNAT(m, "dnat_and_snat", "172.31.0.4"), + "an unresolvable sweep must change nothing") +} + +// A NAT gateway's SNAT source is the same kind of address an EIP is, so it needs +// the same mapping. Carrying the public half means every private-subnet guest +// egresses with a source the cloud drops, while the gateway reports available. +func TestNATManager_AddNATGateway_RowCarriesTheDatapathAddress(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeRouted, + WithDatapathResolver(pairResolver("203.0.113.9", "10.200.0.183"))) + require.NoError(t, err) + + require.NoError(t, nm.AddNATGateway(ctx, NATGWSpec{ + VPCID: "vpc-1", NATGatewayID: "nat-abc", + PublicIP: "203.0.113.9", SubnetCIDR: "10.0.1.0/24", + })) + + row := findNAT(m, "snat", "10.0.1.0/24") + require.NotNil(t, row) + assert.Equal(t, "10.200.0.183", row.ExternalIP, + "SNAT must rewrite to the address the cloud actually accepts as a source") + assert.Equal(t, "203.0.113.9", row.ExternalIDs["spinifex:public_ip"], + "the public half is what an operator reads the NAT gateway back as") +} + +// A resolver that cannot answer must fail the call rather than fall through to +// the public address, which would install a rule that silently drops egress. +func TestNATManager_AddNATGateway_ResolverErrorFails(t *testing.T) { + ctx := context.Background() + m := mock.New() + seedRouter(t, m, "vpc-1") + nm, err := NewNATManager(m, NATModeRouted, + WithDatapathResolver(func(context.Context, string) (string, error) { + return "", errors.New("lookup unavailable") + })) + require.NoError(t, err) + + err = nm.AddNATGateway(ctx, NATGWSpec{ + VPCID: "vpc-1", NATGatewayID: "nat-abc", + PublicIP: "203.0.113.9", SubnetCIDR: "10.0.1.0/24", + }) + require.Error(t, err) + assert.Nil(t, findNAT(m, "snat", "10.0.1.0/24"), + "no rule at all beats a rule pointing at an address that is on no wire") +} diff --git a/spinifex/network/policy/nat_gateway_chassis_test.go b/spinifex/network/policy/nat_gateway_chassis_test.go new file mode 100644 index 000000000..8b7ff6c2f --- /dev/null +++ b/spinifex/network/policy/nat_gateway_chassis_test.go @@ -0,0 +1,104 @@ +//test:in-package — drives BindHostEIPs against nat_binder_test.go's in-package +// listingBinder and seedRouter fixtures, none of which the package exports. + +package policy + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// gatewayOn makes the binder answer "does this VPC's gateway run elsewhere" +// from a fixed verdict, standing in for the local ovn-controller binding. +func gatewayOn(b *listingBinder, elsewhere bool) HostEIPBinder { + h := b.listHooks() + h.GatewayElsewhere = func(string) (bool, error) { return elsewhere, nil } + return h +} + +func natGatewayAddress() []EIPSpec { + return []EIPSpec{{VPCID: "vpc-1", ExternalIP: "192.168.1.200", NATGateway: true}} +} + +// The gateway chassis is where the SNAT runs and where egress leaves, so it is +// the only node whose /32 route reaches anything. +func TestBindHostEIPsKeepsANATGatewayAddressOnItsGatewayChassis(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, gatewayOn(b, false)) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), natGatewayAddress())) + assert.Equal(t, []string{"192.168.1.200 via 100.127.0.10"}, b.binds, + "the gateway chassis did not plumb its own NAT gateway") + assert.Empty(t, b.unbinds) +} + +// Anywhere else the route points into a datapath that never carries the +// address — and where it shares the nodes' own subnet, as on OCI, it outranks +// the connected route and swallows node-to-node traffic. +func TestBindHostEIPsDropsANATGatewayAddressWhoseChassisIsElsewhere(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, gatewayOn(b, true)) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), natGatewayAddress())) + assert.Empty(t, b.binds, "a node that is not the gateway chassis plumbed a NAT gateway") + assert.Equal(t, []string{"192.168.1.200"}, b.unbinds, + "the stale route was left behind on a node that lost the gateway") +} + +// An unreadable Southbound is a lost signal, not a verdict: guessing +// "elsewhere" would take the gateway down on the node actually running it. +func TestBindHostEIPsKeepsANATGatewayAddressWhenTheChassisCannotBeRead(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + h := b.listHooks() + h.GatewayElsewhere = func(string) (bool, error) { + return false, fmt.Errorf("ovn-sbctl: connection refused") + } + mgr := routedManager(t, h) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), natGatewayAddress())) + assert.Empty(t, b.unbinds, "an unreadable chassis signal drove a teardown") + assert.NotEmpty(t, b.binds, "an unreadable chassis signal blocked the bind") +} + +// A binder with no gateway test is every deployment built before this existed, +// and a single-node cluster whose chassisredirect port is not yet claimed. +func TestBindHostEIPsIsUnchangedForABinderThatCannotAnswerTheGateway(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, b.listHooks()) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), natGatewayAddress())) + assert.NotEmpty(t, b.binds) + assert.Empty(t, b.unbinds) +} + +// The gateway test keys on the NAT gateway marker, not on the absent port: a +// centralised guest EIP is also portless and every node still plumbs it. +// Asking the gateway authority about a guest takes every address off every node +// but the one chassis that happens to hold the gateway. +func TestBindHostEIPsLeavesACentralisedGuestEIPOutOfTheGatewayTest(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, gatewayOn(b, true)) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), []EIPSpec{ + {VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5"}, + })) + assert.NotEmpty(t, b.binds, "a centralised guest EIP was skipped as a foreign gateway") + assert.Empty(t, b.unbinds, "a centralised guest EIP was pruned as a foreign gateway") +} + +// The same for a distributed guest EIP on the node running it, which is the +// shape every guest with a public address has. +func TestBindHostEIPsBindsADistributedGuestEIPOffTheGatewayChassis(t *testing.T) { + b := &listingBinder{} + h := ownedBy(b, "port-eni-1") + h.GatewayElsewhere = func(string) (bool, error) { return true, nil } + mgr := routedManager(t, h) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), movedGuest())) + assert.Equal(t, []string{"192.168.1.200 via "}, b.binds, + "the node running the guest skipped its EIP because the VPC gateway is elsewhere") +} diff --git a/spinifex/network/policy/nat_host_chassis_test.go b/spinifex/network/policy/nat_host_chassis_test.go new file mode 100644 index 000000000..35675aef2 --- /dev/null +++ b/spinifex/network/policy/nat_host_chassis_test.go @@ -0,0 +1,102 @@ +//test:in-package — drives BindHostEIPs against nat_binder_test.go's in-package +// listingBinder and seedRouter fixtures, none of which the package exports. + +package policy + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" +) + +// ownedBy makes the binder answer "is this port on this chassis" from a fixed +// set, standing in for local OVS. +func ownedBy(b *listingBinder, local ...string) HostEIPBinder { + h := b.listHooks() + here := make(map[string]struct{}, len(local)) + for _, p := range local { + here[p] = struct{}{} + } + h.Owns = func(portName string) (bool, error) { + _, ok := here[portName] + return ok, nil + } + return h +} + +func routedManager(t *testing.T, hooks HostEIPBinder) *natManager { + t.Helper() + m := mock.New() + seedRouter(t, m, "vpc-1") + seedGatewayPortIP(t, m, "vpc-1", "100.127.0.10", nil) + mgr, err := NewNATManager(m, NATModeRouted, WithHostEIPBinder(hooks)) + require.NoError(t, err) + return mgr.(*natManager) +} + +func movedGuest() []EIPSpec { + return []EIPSpec{{ + VPCID: "vpc-1", ExternalIP: "192.168.1.200", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc", + }} +} + +// The address is still live cluster-wide, so the intent test alone keeps it and +// this node went on answering ARP for a guest that had moved away. +func TestBindHostEIPsDropsTheRouteWhenTheGuestHasMovedOff(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, ownedBy(b)) // no local ports: the guest is elsewhere + + require.NoError(t, mgr.BindHostEIPs(context.Background(), movedGuest())) + assert.Equal(t, []string{"192.168.1.200"}, b.unbinds, + "a node kept the host plumbing for a guest running elsewhere") +} + +// The node actually running the guest must keep it, or the sweep takes down the +// address it is responsible for. +func TestBindHostEIPsKeepsTheRouteOnTheNodeRunningTheGuest(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, ownedBy(b, "port-eni-1")) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), movedGuest())) + assert.Empty(t, b.unbinds, "the owning node tore down its own guest's address") +} + +// An unreadable local OVS is a lost signal, not a verdict. Guessing "foreign" +// would take a live address down on every node at once. +func TestBindHostEIPsKeepsTheRouteWhenOwnershipCannotBeRead(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + h := b.listHooks() + h.Owns = func(string) (bool, error) { return false, fmt.Errorf("ovs-vsctl: connection refused") } + mgr := routedManager(t, h) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), movedGuest())) + assert.Empty(t, b.unbinds, "an unreadable liveness signal drove a teardown") +} + +// A binder with no chassis test is every deployment built before this existed, +// and it must behave exactly as it did. +func TestBindHostEIPsIsUnchangedForABinderThatCannotAnswerOwnership(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, b.listHooks()) + + require.NoError(t, mgr.BindHostEIPs(context.Background(), movedGuest())) + assert.Empty(t, b.unbinds) +} + +// A centralised EIP hairpins through the gateway chassis, so every node plumbs +// it legitimately and the chassis test must not fire. +func TestBindHostEIPsKeepsACentralisedAddressOnEveryNode(t *testing.T) { + b := &listingBinder{bound: []string{"192.168.1.200"}} + mgr := routedManager(t, ownedBy(b)) + + specs := movedGuest() + specs[0].MAC = "" // no external MAC: the row is not distributed + require.NoError(t, mgr.BindHostEIPs(context.Background(), specs)) + assert.Empty(t, b.unbinds, "a centralised address was pruned as foreign") +} diff --git a/spinifex/network/policy/nat_prune_guard_test.go b/spinifex/network/policy/nat_prune_guard_test.go new file mode 100644 index 000000000..1394a3560 --- /dev/null +++ b/spinifex/network/policy/nat_prune_guard_test.go @@ -0,0 +1,80 @@ +package policy_test + +import ( + "context" + "errors" + "testing" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/mock" + "github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb" + "github.com/mulgadc/spinifex/spinifex/network/policy" +) + +// An empty live set is what a failed intent read looks like from here, and the +// sweep deletes on absence, so it must refuse rather than clear every row. +// Unstamped rows are excluded from the count: they are never swept anyway, and a +// NAT gateway's snat is one of them. +func TestPruneOrphanEIPs_RefusesAnEmptyLiveSet(t *testing.T) { + m := mock.New() + nm, err := policy.NewNATManager(m, policy.NATModeDistributed) + if err != nil { + t.Fatalf("NewNATManager: %v", err) + } + ctx := context.Background() + if err := m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: "vpc-a"}); err != nil { + t.Fatalf("CreateLogicalRouter: %v", err) + } + if err := m.AddNAT(ctx, "vpc-a", &nbdb.NAT{ + Type: "dnat_and_snat", ExternalIP: "192.168.1.10", LogicalIP: "10.0.1.10", + ExternalIDs: map[string]string{"spinifex:logical_port": "port-eni-a"}, + }); err != nil { + t.Fatalf("AddNAT: %v", err) + } + + empty := policy.LiveEIPs{Ports: map[string]struct{}{}, ExternalIPs: map[string]struct{}{}} + pruned, err := nm.PruneOrphanEIPs(ctx, empty) + if !errors.Is(err, policy.ErrEmptyEIPIntent) { + t.Fatalf("PruneOrphanEIPs err = %v, want policy.ErrEmptyEIPIntent", err) + } + if pruned != 0 { + t.Errorf("pruned = %d, want 0", pruned) + } + nats, err := m.ListNATs(ctx) + if err != nil { + t.Fatalf("ListNATs: %v", err) + } + if len(nats) != 1 { + t.Errorf("rows = %d, want the row left intact", len(nats)) + } +} + +// With no stamped rows to lose there is nothing to protect, so an empty live set +// is just an idle node and the sweep proceeds normally. +func TestPruneOrphanEIPs_EmptyLiveSetIsFineWithNoStampedRows(t *testing.T) { + m := mock.New() + nm, err := policy.NewNATManager(m, policy.NATModeDistributed) + if err != nil { + t.Fatalf("NewNATManager: %v", err) + } + ctx := context.Background() + if err := m.CreateLogicalRouter(ctx, &nbdb.LogicalRouter{Name: "vpc-a"}); err != nil { + t.Fatalf("CreateLogicalRouter: %v", err) + } + if err := m.AddNAT(ctx, "vpc-a", &nbdb.NAT{ + Type: "snat", ExternalIP: "192.168.1.240", LogicalIP: "10.99.2.0/24", + }); err != nil { + t.Fatalf("AddNAT snat: %v", err) + } + + empty := policy.LiveEIPs{Ports: map[string]struct{}{}, ExternalIPs: map[string]struct{}{}} + if _, err := nm.PruneOrphanEIPs(ctx, empty); err != nil { + t.Fatalf("PruneOrphanEIPs: %v", err) + } + nats, err := m.ListNATs(ctx) + if err != nil { + t.Fatalf("ListNATs: %v", err) + } + if len(nats) != 1 { + t.Errorf("NAT gateway snat row was touched by the dnat_and_snat sweep") + } +} diff --git a/spinifex/network/policy/policy.go b/spinifex/network/policy/policy.go index df5587211..b74a3d34d 100644 --- a/spinifex/network/policy/policy.go +++ b/spinifex/network/policy/policy.go @@ -23,9 +23,10 @@ const ( // owns SNAT/DNAT and its LRP takes a real pool address. NATModeCentralized - // NATModeRouted is centralised-shaped: gateway chassis SNATs the VPC CIDR - // to its transit LRP IP; the host masquerades egress. Required by - // UplinkModeRouted. Outbound-only — no EIP/public-IP support. + // NATModeRouted SNATs the VPC CIDR to the gateway chassis's transit LRP IP + // and lets the host masquerade egress. Required by UplinkModeRouted. EIPs + // are distributed like NATModeDistributed's, processed on the instance's + // own chassis; only default egress is centralised. NATModeRouted ) diff --git a/spinifex/network/reconcile/apply.go b/spinifex/network/reconcile/apply.go index 5a40ff71d..dcf620089 100644 --- a/spinifex/network/reconcile/apply.go +++ b/spinifex/network/reconcile/apply.go @@ -110,6 +110,21 @@ func eniIDFromPort(lspName string) string { return strings.TrimPrefix(lspName, "port-") } +// errEmptyIntentSweep marks a sweep declined because the keep set it would have +// deleted against was wholly empty. Recorded on the pass so the refusal is +// visible as an unconverged class rather than only as a log line. +var errEmptyIntentSweep = errors.New("intent holds no records of this class; sweep would delete every live row") + +// pluggedPorts lists the guest LSPs with a live tap on this host. An unwired +// hook returns an empty set, not an error: callers with no OVS (unit tests) then +// fall back to the empty-intent refusal alone. +func (r *reconciler) pluggedPorts(ctx context.Context) (map[string]struct{}, error) { + if r.localPorts == nil { + return nil, nil + } + return r.localPorts(ctx) +} + // reloadForPrune re-reads intent for an orphan sweep to compare against. Every // sweep matches live OVN rows against the start-of-pass snapshot, which the apply // phase can leave tens of seconds behind KV, so a resource created mid-pass looks @@ -289,6 +304,7 @@ func (r *reconciler) applySGs(ctx context.Context, intent IntentState, actual Ac for groupID := range fresh.SGs { wantPGs[topology.SecurityGroupPortGroup(groupID)] = struct{}{} } + orphanPGs := make([]string, 0, len(actual.PortGroups)) for pgName := range actual.PortGroups { if !portGroupIsManaged(pgName) { continue @@ -296,6 +312,17 @@ func (r *reconciler) applySGs(ctx context.Context, intent IntentState, actual Ac if _, ok := wantPGs[pgName]; ok { continue } + orphanPGs = append(orphanPGs, pgName) + } + // Same shape as the ENI port sweep: every VPC has a default SG, so no SGs at + // all alongside live port groups is an unreadable bucket, not a real state. + if len(orphanPGs) > 0 && len(wantPGs) == 0 { + slog.Error("reconcile/apply: refusing orphan port group sweep — intent holds no SGs at all", + "live_port_groups", len(orphanPGs)) + res.fail(classSG, "orphan-prune", errEmptyIntentSweep) + return + } + for _, pgName := range orphanPGs { if err := r.topology.DeleteSGPortGroupByName(ctx, pgName); err != nil { slog.Warn("reconcile/apply: orphan DeleteSGPortGroupByName failed", "pg", pgName, "err", err) res.fail(classSG, pgName, err) @@ -417,6 +444,18 @@ func (r *reconciler) pruneOrphanPorts(ctx context.Context, intent IntentState, r res.fail(classPort, "orphan-prune", err) return } + // Local OVS is the one liveness signal that does not come through the KV read + // path this sweep is otherwise entirely driven by, so it is what catches a + // read that failed rather than genuinely returning nothing. Skip the sweep if + // it cannot be read: the fallback is deleting on intent alone. + plugged, err := r.pluggedPorts(ctx) + if err != nil { + slog.Warn("reconcile/apply: local OVS port list failed; skipping orphan ENI port prune", "err", err) + res.fail(classPort, "orphan-prune", err) + return + } + + orphans := make([]int, 0, len(lsps)) for i := range lsps { eniID := lsps[i].ExternalIDs["spinifex:eni_id"] if eniID == "" { @@ -428,6 +467,27 @@ func (r *reconciler) pruneOrphanPorts(ctx context.Context, intent IntentState, r if _, ok := fresh.Ports[eniID]; ok { continue } + // A tap on this host means a running guest, which no absence from intent + // can outvote. + if _, live := plugged[lsps[i].Name]; live { + slog.Warn("reconcile/apply: ENI absent from intent but its tap is plugged in here; keeping the port", + "port", lsps[i].Name, "eni_id", eniID) + continue + } + orphans = append(orphans, i) + } + // A failed read is indistinguishable from an empty bucket — a wedged KV + // returned no keys while writes were still landing, and this swept every + // guest. Every port orphaned at once against no ENIs at all is that, not a + // fleet that vanished; leaking a stale row is the recoverable half. + if len(orphans) > 0 && len(intent.Ports) == 0 && len(fresh.Ports) == 0 { + slog.Error("reconcile/apply: refusing orphan ENI port sweep — intent holds no ENIs at all", + "live_guest_ports", len(orphans)) + res.fail(classPort, "orphan-prune", errEmptyIntentSweep) + return + } + for _, i := range orphans { + eniID := lsps[i].ExternalIDs["spinifex:eni_id"] spec := topology.PortSpec{PortID: eniID, SubnetID: lsps[i].ExternalIDs["spinifex:subnet_id"]} if err := r.topology.DeletePort(ctx, spec); err != nil { slog.Warn("reconcile/apply: orphan ENI DeletePort failed", "port", lsps[i].Name, "err", err) @@ -518,8 +578,9 @@ func (r *reconciler) rebindGatewayChassis(ctx context.Context, vpcID, eipIP stri bound = false } } - claimed := r.ensureGatewayClaimed(ctx, topology.GatewayChassisRedirectPort(vpcID)) - forwarding := r.ensureGatewayDatapath(ctx, vpcID, gatewayLRPIP(lrp), eipIP) + crPortName := topology.GatewayChassisRedirectPort(vpcID) + claimed := r.ensureGatewayClaimed(ctx, crPortName) + forwarding := r.ensureGatewayDatapath(ctx, vpcID, crPortName, gatewayLRPIP(lrp), eipIP) return bound && claimed && forwarding } @@ -552,11 +613,37 @@ func gatewayLRPIP(lrp *nbdb.LogicalRouterPort) string { // stays green even when the EIP datapath is dead. Fall back to the LRP IP when the // VPC has no EIP. On a miss repair the uplink + recompute, then re-probe until a // short deadline. Reports whether the datapath was observed forwarding; an -// unwired verifier or unresolved probe target gates the check off and passes. -func (r *reconciler) ensureGatewayDatapath(ctx context.Context, vpcID, gwIP, eipIP string) bool { +// unwired verifier, an unresolved probe target or a gateway on another chassis +// gates the check off and passes. +func (r *reconciler) ensureGatewayDatapath(ctx context.Context, vpcID, crPortName, gwIP, eipIP string) bool { if r.gwClaim == nil || (gwIP == "" && eipIP == "") { return true } + // Both probes leave from this host's own uplink, so off the gateway chassis + // they answer about the wrong node. Probing anyway is worse than not probing: + // a repair would be aimed at a datapath this node does not own. + local, err := r.gwClaim.GatewayPortLocal(ctx, crPortName) + if err != nil { + slog.Warn("reconcile/apply: gateway chassis locality check failed", "vpc_id", vpcID, "port", crPortName, "err", err) + return true + } + if !local { + slog.Info("reconcile/apply: gateway is on another chassis; skipping host-local datapath probe", + "vpc_id", vpcID, "port", crPortName) + return true + } + // The probe ARPs its target, so it has to name the address that is on the + // wire — the same mapping the NAT rule is built from. On OCI the public half + // of an allocation is on no interface anywhere and never answers. + if eipIP != "" && r.datapathIP != nil { + wire, err := r.datapathIP(ctx, eipIP) + if err != nil { + slog.Warn("reconcile/apply: resolving EIP datapath address failed; skipping datapath probe", + "vpc_id", vpcID, "eip", eipIP, "err", err) + return true + } + eipIP = wire + } target := eipIP if target == "" { target = gwIP @@ -810,6 +897,46 @@ func (r *reconciler) floatingIPSpecs(intent IntentState) []policy.EIPSpec { return specs } +// hostBindSpecs is every public address this node must plumb host state for: +// the floating IPs above, plus each NAT gateway's public address. +// +// A NAT gateway's address needs the same /32 route into OVN and the same +// proxy-ARP on the uplink as an EIP, and got neither: its OVN snat row was the +// only thing installed, so an egressing packet left with a source the host +// held no route back to and the masquerade rule — which matches the transit +// /24 alone — never saw. It is centralised by construction, carrying no port +// or MAC, so it takes the gateway-LRP path and every node plumbs it. +// +// Kept apart from floatingIPSpecs deliberately: that set is the complete list +// of addresses allowed to hold a dnat_and_snat row, and a NAT gateway holds an +// snat instead. Widening it would stop the sweep reclaiming a stale row on an +// address later reused as a gateway's. +func (r *reconciler) hostBindSpecs(intent IntentState) []policy.EIPSpec { + specs := r.floatingIPSpecs(intent) + + seen := make(map[string]struct{}, len(specs)) + for _, s := range specs { + seen[s.ExternalIP] = struct{}{} + } + // One NAT gateway emits a spec per associated subnet, so the same address + // arrives repeatedly; bind it once. + for _, gw := range intent.NATGWs { + if gw.PublicIP == "" { + continue + } + if _, dup := seen[gw.PublicIP]; dup { + continue + } + seen[gw.PublicIP] = struct{}{} + specs = append(specs, policy.EIPSpec{ + VPCID: gw.VPCID, + ExternalIP: gw.PublicIP, + NATGateway: true, + }) + } + return specs +} + // pruneOrphanEIPs sweeps dnat_and_snat rows intent no longer accounts for. // vpc.delete-nat is fire-and-forget and can be lost, so a row survives its own // teardown in two shapes: the whole ENI went away (VPC torn down, instance @@ -866,7 +993,12 @@ func (r *reconciler) addLive(live policy.LiveEIPs, intent IntentState) { live.Ports[e.PortName] = struct{}{} } } - for _, spec := range r.floatingIPSpecs(intent) { + // hostBindSpecs, not floatingIPSpecs: the set also gates the host-ingress + // sweep this prune runs, and a NAT gateway's address holds host state with + // no ENI and no dnat_and_snat row behind it. Built from the narrower set it + // read as absent from intent, so every drift pass tore down the ingress of + // every live NAT gateway and left it dark until the next host EIP tick. + for _, spec := range r.hostBindSpecs(intent) { live.ExternalIPs[spec.ExternalIP] = struct{}{} } } @@ -1001,7 +1133,10 @@ func (r *reconciler) ensureGuestPortDatapath(ctx context.Context, spec policy.EI } } -// applyNATGWs runs every intent NAT gateway through NATManager.AddNATGateway. +// applyNATGWs runs every intent NAT gateway through NATManager.AddNATGateway, +// and records which VPC each gateway's address serves. The address is the SNAT +// source for a logical router rather than a guest, so this is the only thing +// that says which node it has to be delivered to. func (r *reconciler) applyNATGWs(ctx context.Context, intent IntentState, _ ActualState, res *passResult) { for _, spec := range intent.NATGWs { if err := r.nat.AddNATGateway(ctx, spec); err != nil { @@ -1009,6 +1144,20 @@ func (r *reconciler) applyNATGWs(ctx context.Context, intent IntentState, _ Actu "natgw_id", spec.NATGatewayID, "subnet_cidr", spec.SubnetCIDR, "err", err) res.fail(classNATGW, spec.NATGatewayID, err) } + r.bindGatewayVPC(ctx, spec) + } +} + +// bindGatewayVPC tells the address pool which VPC's gateway answers on a NAT +// gateway's address. Logged rather than failed: the OVN rule is already in and +// is what forwards, and the next pass writes it again. +func (r *reconciler) bindGatewayVPC(ctx context.Context, spec policy.NATGWSpec) { + if r.bindGateway == nil || spec.PublicIP == "" || spec.VPCID == "" { + return + } + if err := r.bindGateway(ctx, spec.PublicIP, spec.VPCID); err != nil { + slog.Warn("reconcile/apply: recording the NAT gateway's VPC with its pool failed", + "natgw_id", spec.NATGatewayID, "public_ip", spec.PublicIP, "vpc_id", spec.VPCID, "err", err) } } diff --git a/spinifex/network/reconcile/apply_eip_concurrency_test.go b/spinifex/network/reconcile/apply_eip_concurrency_test.go index 4ac4dc0aa..b0f419c99 100644 --- a/spinifex/network/reconcile/apply_eip_concurrency_test.go +++ b/spinifex/network/reconcile/apply_eip_concurrency_test.go @@ -53,6 +53,9 @@ func (f *concurrentClaimVerifier) whenLiveSeen() time.Time { func (f *concurrentClaimVerifier) GatewayPortClaimed(context.Context, string) (bool, error) { return true, nil } +func (f *concurrentClaimVerifier) GatewayPortLocal(context.Context, string) (bool, error) { + return true, nil +} func (f *concurrentClaimVerifier) GatewayReachable(context.Context, string) (bool, error) { return true, nil } diff --git a/spinifex/network/reconcile/apply_gateway_claim_test.go b/spinifex/network/reconcile/apply_gateway_claim_test.go index d7bccec68..7b13a49dc 100644 --- a/spinifex/network/reconcile/apply_gateway_claim_test.go +++ b/spinifex/network/reconcile/apply_gateway_claim_test.go @@ -23,12 +23,15 @@ type fakeClaimVerifier struct { nudgeErr error reachErr error guestErr error + gatewayRemote bool // the chassisredirect port is claimed by another chassis + localErr error // GatewayPortLocal probe error sbNotConnected bool // SBConnectionState reports the wedge ("not connected") sbStateErr error // SBConnectionState probe error sbResetErr error // ResetSBClusterState error checks int nudges int repairs int + localChecks int reachChecks int guestChecks int sbChecks int @@ -51,6 +54,17 @@ func (f *fakeClaimVerifier) GatewayPortClaimed(_ context.Context, port string) ( return f.nudges >= f.claimedAfter, nil } +// GatewayPortLocal defaults to local, so the existing cases keep probing; a test +// that wants the remote-chassis skip sets gatewayRemote. +func (f *fakeClaimVerifier) GatewayPortLocal(_ context.Context, port string) (bool, error) { + f.localChecks++ + f.lastPort = port + if f.localErr != nil { + return false, f.localErr + } + return !f.gatewayRemote, nil +} + func (f *fakeClaimVerifier) NudgeRecompute(_ context.Context) error { f.nudges++ return f.nudgeErr @@ -263,7 +277,7 @@ func TestEnsureGatewayClaimed_ContextCancelStops(t *testing.T) { func TestEnsureGatewayDatapath_NoVerifierIsNoop(t *testing.T) { r := &reconciler{} // gwClaim nil - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") // Reaching here without panic is the assertion. } @@ -271,19 +285,75 @@ func TestEnsureGatewayDatapath_EmptyIPIsNoop(t *testing.T) { f := &fakeClaimVerifier{} r := &reconciler{gwClaim: f} - r.ensureGatewayDatapath(context.Background(), "vpc-a", "", "") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "", "") if f.reachChecks != 0 { t.Errorf("reachChecks = %d, want 0 (no probe target must skip the probe)", f.reachChecks) } } +// A node that does not own the chassisredirect port must not probe: under routed +// NAT the transit subnet exists identically on every node, so the probe would +// fail and a repair would be aimed at a datapath this node does not own. +func TestEnsureGatewayDatapath_RemoteChassisSkipsProbe(t *testing.T) { + withFastDatapathBounds(t) + f := &fakeClaimVerifier{gatewayRemote: true, reachableAfter: -1} + r := &reconciler{gwClaim: f} + + if !r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "203.0.113.5") { + t.Error("ensureGatewayDatapath reported degraded for a gateway on another chassis") + } + if f.reachChecks != 0 { + t.Errorf("reachChecks = %d, want 0 (a remote gateway must not be probed host-locally)", f.reachChecks) + } + if f.repairs != 0 { + t.Errorf("repairs = %d, want 0 (nothing to repair on a node that is not the gateway)", f.repairs) + } +} + +// A failed locality probe must not be read as "remote" or as "degraded": it says +// nothing either way, so the gate passes and the next pass re-checks. +func TestEnsureGatewayDatapath_LocalityErrorPasses(t *testing.T) { + withFastDatapathBounds(t) + f := &fakeClaimVerifier{localErr: errors.New("sbctl down"), reachableAfter: -1} + r := &reconciler{gwClaim: f} + + if !r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") { + t.Error("ensureGatewayDatapath reported degraded on a locality probe error") + } + if f.reachChecks != 0 { + t.Errorf("reachChecks = %d, want 0 (an unanswerable locality probe must not lead to a probe)", f.reachChecks) + } +} + +// On OCI an EIP is a pair whose public half is NAT'd upstream, so the probe has +// to ARP the private half or it reports a working gateway as dead. +func TestEnsureGatewayDatapath_ProbesEIPDatapathAddress(t *testing.T) { + withFastDatapathBounds(t) + f := &fakeClaimVerifier{reachableAfter: 0} + r := &reconciler{ + gwClaim: f, + datapathIP: func(_ context.Context, ip string) (string, error) { + if ip != "203.0.113.5" { + t.Errorf("resolver asked about %q, want the external IP", ip) + } + return "10.200.0.81", nil + }, + } + + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "203.0.113.5") + + if f.lastEIP != "10.200.0.81" { + t.Errorf("probed %q, want the on-wire address 10.200.0.81", f.lastEIP) + } +} + func TestEnsureGatewayDatapath_ReachableNoNudge(t *testing.T) { withFastDatapathBounds(t) f := &fakeClaimVerifier{reachableAfter: 0} r := &reconciler{gwClaim: f} - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") if f.nudges != 0 { t.Errorf("reachable datapath nudged %d times, want 0", f.nudges) @@ -302,7 +372,7 @@ func TestEnsureGatewayDatapath_NudgeThenRecover(t *testing.T) { f := &fakeClaimVerifier{reachableAfter: 1} r := &reconciler{gwClaim: f} - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") if f.repairs != 1 { t.Errorf("repairs = %d, want exactly 1 (repair once, then recover)", f.repairs) @@ -319,7 +389,7 @@ func TestEnsureGatewayDatapath_NeverRecoversNudgesOnceThenGivesUp(t *testing.T) done := make(chan struct{}) go func() { - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") close(done) }() select { @@ -341,7 +411,7 @@ func TestEnsureGatewayDatapath_ProbeErrorBailsOut(t *testing.T) { f := &fakeClaimVerifier{reachErr: errors.New("ping unavailable")} r := &reconciler{gwClaim: f} - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") if f.nudges != 0 { t.Errorf("nudges = %d, want 0 (bail out on probe error, do not nudge blindly)", f.nudges) @@ -360,7 +430,7 @@ func TestEnsureGatewayDatapath_ContextCancelStops(t *testing.T) { done := make(chan struct{}) go func() { - r.ensureGatewayDatapath(ctx, "vpc-a", "192.168.1.241", "") + r.ensureGatewayDatapath(ctx, "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "") close(done) }() time.Sleep(20 * time.Millisecond) @@ -377,7 +447,7 @@ func TestEnsureGatewayDatapath_PrefersEIPProbe(t *testing.T) { f := &fakeClaimVerifier{reachableAfter: 0} r := &reconciler{gwClaim: f} - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "203.0.113.5") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "203.0.113.5") if f.lastEIP != "203.0.113.5" { t.Errorf("lastEIP = %q, want 203.0.113.5 (EIP must be the probe target when present)", f.lastEIP) @@ -393,7 +463,7 @@ func TestEnsureGatewayDatapath_EIPUnreachableRepairs(t *testing.T) { f := &fakeClaimVerifier{reachableAfter: 1} r := &reconciler{gwClaim: f} - r.ensureGatewayDatapath(context.Background(), "vpc-a", "192.168.1.241", "203.0.113.5") + r.ensureGatewayDatapath(context.Background(), "vpc-a", "cr-gw-vpc-a", "192.168.1.241", "203.0.113.5") if f.repairs != 1 { t.Errorf("repairs = %d, want exactly 1 (a stranded EIP datapath must trigger repair)", f.repairs) diff --git a/spinifex/network/reconcile/apply_natgw_live_test.go b/spinifex/network/reconcile/apply_natgw_live_test.go new file mode 100644 index 000000000..bc91f0089 --- /dev/null +++ b/spinifex/network/reconcile/apply_natgw_live_test.go @@ -0,0 +1,42 @@ +//test:in-package — reads the unexported live set the orphan prune is built +// from, which no exported entry point returns. + +package reconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mulgadc/spinifex/spinifex/network/policy" +) + +// The live set also gates the host-ingress sweep that prune runs, and a NAT +// gateway's address holds host state with no ENI and no dnat_and_snat row +// behind it. Built from ENI and user EIPs alone it read as absent from intent, +// so every drift pass tore down the ingress of every live NAT gateway. +func TestAddLiveCountsANATGatewayAddressAsLive(t *testing.T) { + r, _ := newTestReconciler(t) + + intent := freshIntent(t) + intent.NATGWs["nat-1"] = policy.NATGWSpec{ + VPCID: "vpc-a", NATGatewayID: "nat-1", + PublicIP: "192.168.1.240", SubnetCIDR: "10.0.2.0/24", + } + + live := policy.LiveEIPs{Ports: map[string]struct{}{}, ExternalIPs: map[string]struct{}{}} + r.addLive(live, intent) + + assert.Contains(t, live.ExternalIPs, "192.168.1.240", + "a live NAT gateway's address read as absent from intent") +} + +// A released gateway is still swept: the set is intent, not history. +func TestAddLiveOmitsANATGatewayIntentNoLongerCarries(t *testing.T) { + r, _ := newTestReconciler(t) + + live := policy.LiveEIPs{Ports: map[string]struct{}{}, ExternalIPs: map[string]struct{}{}} + r.addLive(live, freshIntent(t)) + + assert.NotContains(t, live.ExternalIPs, "192.168.1.240") +} diff --git a/spinifex/network/reconcile/apply_prune_safety_test.go b/spinifex/network/reconcile/apply_prune_safety_test.go new file mode 100644 index 000000000..833a2f4cc --- /dev/null +++ b/spinifex/network/reconcile/apply_prune_safety_test.go @@ -0,0 +1,154 @@ +//test:in-package — drives the unexported pruneOrphanPorts and pruneOrphanEIPs +// against errEmptyIntentSweep and the reconciler's own localPorts hook, none of +// which the package exports. + +package reconcile + +// The orphan sweeps read absence from intent as permission to delete, and a +// failed KV read is indistinguishable from an empty bucket: a wedged store +// returned no keys while writes were still landing, and the sweep took every +// running guest's port with it. These lock both defences — a port with a live +// local tap is never an orphan, and a sweep is refused outright when the keep +// set it would delete against holds nothing at all. + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "testing" + + "github.com/mulgadc/spinifex/spinifex/network/ovn/nbdb" + "github.com/mulgadc/spinifex/spinifex/network/policy" + "github.com/mulgadc/spinifex/spinifex/network/topology" +) + +// seedGuestPorts converges every named ENI in one pass, so no intermediate +// Reconcile sweeps a port the next one was going to need. +func seedGuestPorts(t *testing.T, r *reconciler, eniIDs ...string) IntentState { + t.Helper() + intent := freshIntent(t) + intent.Ports = map[string]topology.PortSpec{} + for i, eniID := range eniIDs { + mac, err := net.ParseMAC(fmt.Sprintf("02:00:00:00:00:%02x", i+1)) + if err != nil { + t.Fatalf("ParseMAC: %v", err) + } + intent.Ports[eniID] = topology.PortSpec{PortID: eniID, SubnetID: "subnet-a", VPCID: "vpc-a", + PrivateIP: netip.MustParseAddr(fmt.Sprintf("10.0.1.%d", 10+i)), MAC: mac, SGIDs: []string{"sg-a"}} + } + if err := r.Reconcile(context.Background(), intent); err != nil { + t.Fatalf("seed Reconcile: %v", err) + } + return intent +} + +func TestPruneOrphanPorts_RefusesWhenIntentHoldsNoENIsAtAll(t *testing.T) { + r, m := newTestReconciler(t) + ctx := context.Background() + intent := seedGuestPorts(t, r, "eni-a") + + intent.Ports = map[string]topology.PortSpec{} + res := &passResult{} + r.pruneOrphanPorts(ctx, intent, res) + + if _, err := m.GetLogicalSwitchPort(ctx, topology.Port("eni-a")); err != nil { + t.Errorf("guest port swept against an empty ENI intent: %v", err) + } + if len(res.failures) != 1 || !errors.Is(res.failures[0].err, errEmptyIntentSweep) { + t.Errorf("refusal not recorded on the pass: %+v", res.failures) + } +} + +// The precise defence, and the one that holds when intent is only partly empty: +// dropping a single VPC filters out every ENI in it without emptying the set, so +// the count guard never fires and only the tap can save those guests. +func TestPruneOrphanPorts_KeepsAPortWhoseTapIsPluggedInHere(t *testing.T) { + r, m := newTestReconciler(t) + ctx := context.Background() + intent := seedGuestPorts(t, r, "eni-a", "eni-b") + + r.localPorts = func(context.Context) (map[string]struct{}, error) { + return map[string]struct{}{topology.Port("eni-a"): {}}, nil + } + // eni-b stays in intent, so the set is non-empty and the count guard is silent. + delete(intent.Ports, "eni-a") + + r.pruneOrphanPorts(ctx, intent, &passResult{}) + + if _, err := m.GetLogicalSwitchPort(ctx, topology.Port("eni-a")); err != nil { + t.Errorf("plugged-in guest port swept: %v", err) + } +} + +// Without a tap the same port is a real orphan and must still go, or the sweep +// stops reclaiming anything. +func TestPruneOrphanPorts_SweepsAPortWithNoLocalTap(t *testing.T) { + r, m := newTestReconciler(t) + ctx := context.Background() + intent := seedGuestPorts(t, r, "eni-a", "eni-b") + + r.localPorts = func(context.Context) (map[string]struct{}, error) { + return map[string]struct{}{topology.Port("eni-b"): {}}, nil + } + delete(intent.Ports, "eni-a") + + r.pruneOrphanPorts(ctx, intent, &passResult{}) + + if _, err := m.GetLogicalSwitchPort(ctx, topology.Port("eni-a")); err == nil { + t.Errorf("orphan ENI port with no local tap was not swept") + } + if _, err := m.GetLogicalSwitchPort(ctx, topology.Port("eni-b")); err != nil { + t.Errorf("live ENI port swept alongside the orphan: %v", err) + } +} + +// An unreadable OVS is a lost liveness signal, so the sweep must stand down +// rather than fall back to deciding on intent alone. +func TestPruneOrphanPorts_SkipsWhenLocalOVSCannotBeRead(t *testing.T) { + r, m := newTestReconciler(t) + ctx := context.Background() + intent := seedGuestPorts(t, r, "eni-a", "eni-b") + + boom := errors.New("ovs-vsctl: connection refused") + r.localPorts = func(context.Context) (map[string]struct{}, error) { return nil, boom } + delete(intent.Ports, "eni-a") + + res := &passResult{} + r.pruneOrphanPorts(ctx, intent, res) + + if _, err := m.GetLogicalSwitchPort(ctx, topology.Port("eni-a")); err != nil { + t.Errorf("port swept with no liveness signal available: %v", err) + } + if len(res.failures) != 1 || !errors.Is(res.failures[0].err, boom) { + t.Errorf("skip not recorded on the pass: %+v", res.failures) + } +} + +// A NAT gateway's snat row is not stamped with an owning port, so it must not +// count toward the refusal and must never be swept by the dnat_and_snat pass. +func TestPruneOrphanEIPs_RefusesWhenLiveSetIsEmpty(t *testing.T) { + r, m := newTestReconciler(t) + ctx := context.Background() + seedGuestPorts(t, r, "eni-a") + if err := m.AddNAT(ctx, topology.VPCRouter("vpc-a"), &nbdb.NAT{ + Type: "dnat_and_snat", ExternalIP: "192.168.1.10", LogicalIP: "10.0.1.10", + ExternalIDs: map[string]string{"spinifex:logical_port": topology.Port("eni-a")}, + }); err != nil { + t.Fatalf("AddNAT: %v", err) + } + + empty := freshIntent(t) + empty.Ports = map[string]topology.PortSpec{} + empty.EIPs = map[string]policy.EIPSpec{} + res := &passResult{} + r.pruneOrphanEIPs(ctx, empty, res) + + if len(res.failures) == 0 { + t.Errorf("empty-intent EIP sweep was not refused") + } + if findNATByExternal(m, "dnat_and_snat", "192.168.1.10") == nil { + t.Errorf("dnat_and_snat row swept against an empty live set") + } +} diff --git a/spinifex/network/reconcile/apply_test.go b/spinifex/network/reconcile/apply_test.go index 07f4d1d4b..974ed90f4 100644 --- a/spinifex/network/reconcile/apply_test.go +++ b/spinifex/network/reconcile/apply_test.go @@ -188,6 +188,21 @@ func aclUUIDSet(m *mock.Client) map[string]struct{} { return out } +// orphanPGIntent seeds one real SG alongside the orphan. A wholly empty SG set +// is the signature of an unreadable bucket, which the sweep now refuses, so a +// fixture with no SGs would prove the refusal rather than the prune. +func orphanPGIntent() IntentState { + return IntentState{ + VPCs: map[string]topology.VPCSpec{"vpc-a": {VPCID: "vpc-a", CIDR: netip.MustParsePrefix("10.0.0.0/16"), VNI: 100}}, + Subnets: map[string]topology.SubnetSpec{}, + Ports: map[string]topology.PortSpec{}, + SGs: map[string]policy.SGSpec{"sg-a": {GroupID: "sg-a", VPCID: "vpc-a"}}, + IGWs: map[string]external.IGWSpec{}, + EIPs: map[string]policy.EIPSpec{}, + NATGWs: map[string]policy.NATGWSpec{}, + } +} + func TestReconcile_OrphanPortGroupRemoved(t *testing.T) { rec, m := newTestReconciler(t) ctx := context.Background() @@ -196,23 +211,36 @@ func TestReconcile_OrphanPortGroupRemoved(t *testing.T) { t.Fatalf("seed orphan port group: %v", err) } - intent := IntentState{ - VPCs: map[string]topology.VPCSpec{}, - Subnets: map[string]topology.SubnetSpec{}, - Ports: map[string]topology.PortSpec{}, - SGs: map[string]policy.SGSpec{}, - IGWs: map[string]external.IGWSpec{}, - EIPs: map[string]policy.EIPSpec{}, - NATGWs: map[string]policy.NATGWSpec{}, - } - - if err := rec.Reconcile(ctx, intent); err != nil { + if err := rec.Reconcile(ctx, orphanPGIntent()); err != nil { t.Fatalf("Reconcile: %v", err) } if _, ok := m.PortGroups["sg_orphan"]; ok { t.Errorf("orphan port group not removed") } + if _, ok := m.PortGroups[topology.SecurityGroupPortGroup("sg-a")]; !ok { + t.Errorf("live port group swept alongside the orphan") + } +} + +// A sweep that would delete every managed port group is refused: intent holding +// no SGs at all is an unreadable bucket, not a VPC without a default SG. +func TestReconcile_OrphanPortGroupPruneRefusedOnEmptyIntent(t *testing.T) { + rec, m := newTestReconciler(t) + ctx := context.Background() + + if err := m.CreatePortGroup(ctx, "sg_orphan", nil); err != nil { + t.Fatalf("seed orphan port group: %v", err) + } + + empty := orphanPGIntent() + empty.SGs = map[string]policy.SGSpec{} + if err := rec.Reconcile(ctx, empty); !errors.Is(err, ErrPassIncomplete) { + t.Fatalf("Reconcile err = %v, want ErrPassIncomplete", err) + } + if _, ok := m.PortGroups["sg_orphan"]; !ok { + t.Errorf("port group swept against an empty SG intent") + } } // ReconcileApplyOnly must not prune sg_* PGs on empty intent (startup race); @@ -225,15 +253,7 @@ func TestReconcile_ApplyOnlyKeepsOrphanPortGroup(t *testing.T) { t.Fatalf("seed orphan port group: %v", err) } - intent := IntentState{ - VPCs: map[string]topology.VPCSpec{}, - Subnets: map[string]topology.SubnetSpec{}, - Ports: map[string]topology.PortSpec{}, - SGs: map[string]policy.SGSpec{}, - IGWs: map[string]external.IGWSpec{}, - EIPs: map[string]policy.EIPSpec{}, - NATGWs: map[string]policy.NATGWSpec{}, - } + intent := orphanPGIntent() if err := rec.ReconcileApplyOnly(ctx, intent); err != nil { t.Fatalf("ReconcileApplyOnly: %v", err) @@ -1186,6 +1206,96 @@ func TestFloatingIPSpecs(t *testing.T) { if len(specs) != 3 { t.Errorf("want 3 specs (2 EIP + 1 auto), got %d: %+v", len(specs), specs) } + + // A NAT gateway's address holds an snat, never a dnat_and_snat, so it must + // not reach this set: the prune treats it as the complete list of addresses + // allowed to hold one. + intent.NATGWs = map[string]policy.NATGWSpec{ + "nat-1|10.99.2.0/24": {VPCID: "vpc-a", NATGatewayID: "nat-1", PublicIP: "192.168.1.240", SubnetCIDR: "10.99.2.0/24"}, + } + for _, s := range r.floatingIPSpecs(intent) { + if s.ExternalIP == "192.168.1.240" { + t.Errorf("NAT gateway address leaked into floatingIPSpecs: %+v", s) + } + } +} + +// A NAT gateway's public address needs the same host plumbing as an EIP — the +// /32 route into OVN, proxy-ARP on the uplink and the FORWARD accepts — and had +// none, so an egressing private guest left with a source the host held no route +// back to and the masquerade rule, which matches the transit /24 alone, never saw. +func TestHostBindSpecs_CarriesNATGatewayAddresses(t *testing.T) { + r := &reconciler{} + + intent := IntentState{ + EIPs: map[string]policy.EIPSpec{ + "172.31.0.9": {VPCID: "vpc-a", ExternalIP: "192.168.1.200", LogicalIP: "172.31.0.9", PortName: topology.Port("eni-eip")}, + }, + NATGWs: map[string]policy.NATGWSpec{ + // One gateway, two associated subnets: the same address arrives + // twice and must be bound once. + "nat-1|10.99.2.0/24": {VPCID: "vpc-a", NATGatewayID: "nat-1", PublicIP: "192.168.1.240", SubnetCIDR: "10.99.2.0/24"}, + "nat-1|10.99.3.0/24": {VPCID: "vpc-a", NATGatewayID: "nat-1", PublicIP: "192.168.1.240", SubnetCIDR: "10.99.3.0/24"}, + // A second gateway in another VPC. + "nat-2|10.98.1.0/24": {VPCID: "vpc-b", NATGatewayID: "nat-2", PublicIP: "192.168.1.241", SubnetCIDR: "10.98.1.0/24"}, + // Not yet allocated an address. + "nat-3|10.97.1.0/24": {VPCID: "vpc-b", NATGatewayID: "nat-3", SubnetCIDR: "10.97.1.0/24"}, + }, + } + + counts := map[string]int{} + byExternal := map[string]policy.EIPSpec{} + for _, s := range r.hostBindSpecs(intent) { + counts[s.ExternalIP]++ + byExternal[s.ExternalIP] = s + } + + if counts["192.168.1.200"] != 1 { + t.Errorf("user EIP bound %d times, want 1", counts["192.168.1.200"]) + } + if counts["192.168.1.240"] != 1 { + t.Errorf("NAT gateway address bound %d times, want 1 despite two subnets", counts["192.168.1.240"]) + } + if counts["192.168.1.241"] != 1 { + t.Errorf("second NAT gateway address bound %d times, want 1", counts["192.168.1.241"]) + } + if len(counts) != 3 { + t.Errorf("want 3 addresses (1 EIP + 2 NAT gateways), got %d: %+v", len(counts), counts) + } + + // Centralised by construction: no port and no MAC, so bindHostEIP resolves + // the gateway LRP as the next hop and every node plumbs it. + gw := byExternal["192.168.1.240"] + if gw.PortName != "" || gw.MAC != "" { + t.Errorf("NAT gateway spec must carry no port identity, got %+v", gw) + } + if gw.VPCID != "vpc-a" { + t.Errorf("NAT gateway spec VPCID = %q, want vpc-a", gw.VPCID) + } +} + +// An address serving as both a user EIP and a NAT gateway's is bound once, and +// keeps the EIP's port identity so it stays on the distributed path. +func TestHostBindSpecs_DedupesAgainstAnExistingEIP(t *testing.T) { + r := &reconciler{} + mac, _ := net.ParseMAC("02:00:00:00:00:01") + + specs := r.hostBindSpecs(IntentState{ + EIPs: map[string]policy.EIPSpec{ + "172.31.0.9": {VPCID: "vpc-a", ExternalIP: "192.168.1.240", LogicalIP: "172.31.0.9", + PortName: topology.Port("eni-eip"), MAC: mac.String()}, + }, + NATGWs: map[string]policy.NATGWSpec{ + "nat-1|10.99.2.0/24": {VPCID: "vpc-a", NATGatewayID: "nat-1", PublicIP: "192.168.1.240", SubnetCIDR: "10.99.2.0/24"}, + }, + }) + + if len(specs) != 1 { + t.Fatalf("want 1 spec, got %d: %+v", len(specs), specs) + } + if specs[0].PortName != topology.Port("eni-eip") { + t.Errorf("the EIP's port identity must survive the merge, got %+v", specs[0]) + } } // A DHCPOptions row is created once and then never revisited by the original diff --git a/spinifex/network/reconcile/drift_test.go b/spinifex/network/reconcile/drift_test.go index 53a394e60..df91041e2 100644 --- a/spinifex/network/reconcile/drift_test.go +++ b/spinifex/network/reconcile/drift_test.go @@ -39,6 +39,10 @@ func (s *stubReconciler) ReconcileApplyOnly(ctx context.Context, intent IntentSt return s.Reconcile(ctx, intent) } +// Deliberately not counted: the host EIP pass runs on its own loop, and folding +// it into the drift count would hide whether the leader gate still holds. +func (s *stubReconciler) ReconcileHostEIPs(context.Context) error { return nil } + func (s *stubReconciler) callCount() int { s.mu.Lock() defer s.mu.Unlock() diff --git a/spinifex/network/reconcile/intent.go b/spinifex/network/reconcile/intent.go index e9b701339..4607758cf 100644 --- a/spinifex/network/reconcile/intent.go +++ b/spinifex/network/reconcile/intent.go @@ -250,10 +250,13 @@ func loadVPCs(ctx context.Context, js jetstream.JetStream, localAZ string, out m if keyIsVersion(key) { continue } + // Fail the pass; skipping narrows localVPCs, which drops every ENI in the + // VPC, which the port sweep reads as permission to delete every live + // guest's port. Unmarshal failures below stay skips — poison record, not + // an unhealthy store. entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: VPC read failed", "key", key, "err", err) - continue + return nil, fmt.Errorf("read VPC %s: %w", key, err) } var rec handlers_ec2_vpc.VPCRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -297,8 +300,7 @@ func loadSubnets(ctx context.Context, js jetstream.JetStream, localVPCs map[stri } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: subnet read failed", "key", key, "err", err) - continue + return fmt.Errorf("read subnet %s: %w", key, err) } var rec handlers_ec2_vpc.SubnetRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -341,8 +343,7 @@ func loadSGs(ctx context.Context, js jetstream.JetStream, localVPCs map[string]s } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: SG read failed", "key", key, "err", err) - continue + return fmt.Errorf("read security group %s: %w", key, err) } var rec handlers_ec2_vpc.SecurityGroupRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -381,8 +382,7 @@ func loadPorts(ctx context.Context, js jetstream.JetStream, localVPCs map[string } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: ENI read failed", "key", key, "err", err) - continue + return fmt.Errorf("read ENI %s: %w", key, err) } var rec handlers_ec2_vpc.ENIRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -499,8 +499,7 @@ func loadIGWs(ctx context.Context, js jetstream.JetStream, localVPCs map[string] } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: IGW read failed", "key", key, "err", err) - continue + return fmt.Errorf("read IGW %s: %w", key, err) } var rec handlers_ec2_igw.IGWRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -542,8 +541,7 @@ func loadEIPs(ctx context.Context, js jetstream.JetStream, localVPCs map[string] } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: EIP read failed", "key", key, "err", err) - continue + return fmt.Errorf("read EIP %s: %w", key, err) } var rec handlers_ec2_eip.EIPRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -592,8 +590,7 @@ func loadRouteTables(ctx context.Context, js jetstream.JetStream, localVPCs map[ } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: route table read failed", "key", key, "err", err) - continue + return nil, fmt.Errorf("read route table %s: %w", key, err) } var rec handlers_ec2_routetable.RouteTableRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { @@ -641,8 +638,7 @@ func loadNATGWs( } entry, err := kv.Get(ctx, key) if err != nil { - slog.Warn("reconcile/intent: NAT GW read failed", "key", key, "err", err) - continue + return fmt.Errorf("read NAT gateway %s: %w", key, err) } var rec handlers_ec2_natgw.NatGatewayRecord if err := json.Unmarshal(entry.Value(), &rec); err != nil { diff --git a/spinifex/network/reconcile/intent_test.go b/spinifex/network/reconcile/intent_test.go index 1f89e02e5..f48183e21 100644 --- a/spinifex/network/reconcile/intent_test.go +++ b/spinifex/network/reconcile/intent_test.go @@ -647,3 +647,64 @@ func TestSGRulesToPolicyRules_DropsIPv6(t *testing.T) { t.Fatalf("wrong rules survived: %+v", out) } } + +// A read failure and an unparseable record are different faults and get +// different answers: a failed Get fails the pass, because a dropped record +// narrows intent and the orphan sweeps read that as permission to delete live +// rows. A record that does not unmarshal is a poison record, not an unhealthy +// store, so it is skipped — failing on it would wedge reconcile forever. +func TestLoadIntentFromKV_PoisonRecordIsSkippedNotFatal(t *testing.T) { + js := startKV(t) + + good := handlers_ec2_vpc.VPCRecord{ + VpcId: "vpc-good", CidrBlock: "10.0.0.0/16", State: "available", VNI: 100, AZ: "us-east-1a", CreatedAt: time.Now(), + } + testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, map[string][]byte{ + "acct/vpc-good": mustJSON(t, good), + "acct/vpc-poison": []byte("{not json"), + }) + + intent, err := LoadIntentFromKV(context.Background(), js, "us-east-1a") + if err != nil { + t.Fatalf("LoadIntentFromKV: %v", err) + } + if _, ok := intent.VPCs["vpc-good"]; !ok { + t.Errorf("readable VPC dropped because a sibling record was unparseable") + } + if len(intent.VPCs) != 1 { + t.Errorf("VPCs = %d, want only the readable one", len(intent.VPCs)) + } +} + +// An ENI whose VPC never loaded must not silently vanish from intent — that is +// the amplifier that turns one unreadable VPC into every guest port in it being +// swept. Its VPC failing to read now fails the pass instead. +func TestLoadIntentFromKV_ENIsFollowTheirVPC(t *testing.T) { + js := startKV(t) + + vpc := handlers_ec2_vpc.VPCRecord{ + VpcId: "vpc-a", CidrBlock: "10.0.0.0/16", State: "available", VNI: 100, AZ: "us-east-1a", CreatedAt: time.Now(), + } + testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, map[string][]byte{"acct/vpc-a": mustJSON(t, vpc)}) + testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketENIs, map[string][]byte{ + "acct/eni-local": mustJSON(t, handlers_ec2_vpc.ENIRecord{ + NetworkInterfaceId: "eni-local", VpcId: "vpc-a", SubnetId: "subnet-a", + PrivateIpAddress: "10.0.1.10", MacAddress: "02:00:00:00:00:01", + }), + "acct/eni-foreign": mustJSON(t, handlers_ec2_vpc.ENIRecord{ + NetworkInterfaceId: "eni-foreign", VpcId: "vpc-absent", SubnetId: "subnet-z", + PrivateIpAddress: "10.9.1.10", MacAddress: "02:00:00:00:00:02", + }), + }) + + intent, err := LoadIntentFromKV(context.Background(), js, "us-east-1a") + if err != nil { + t.Fatalf("LoadIntentFromKV: %v", err) + } + if _, ok := intent.Ports["eni-local"]; !ok { + t.Errorf("ENI in a loaded VPC missing from intent") + } + if _, ok := intent.Ports["eni-foreign"]; ok { + t.Errorf("ENI in an unknown VPC admitted to intent") + } +} diff --git a/spinifex/network/reconcile/reconcile.go b/spinifex/network/reconcile/reconcile.go index a65fb527d..8c64980e4 100644 --- a/spinifex/network/reconcile/reconcile.go +++ b/spinifex/network/reconcile/reconcile.go @@ -39,6 +39,10 @@ type Reconciler interface { // deciding and abandons the sweep if that read fails, and a stale row there // is a released public address still delivering traffic. ReconcileApplyOnly(ctx context.Context, intent IntentState) error + // ReconcileHostEIPs plumbs this node's host-side EIP state — routes, + // proxy-ARP, filter rules — from freshly read intent. Unlike the two above + // it runs on every node, because what it writes is node-local. + ReconcileHostEIPs(ctx context.Context) error } // GatewayClaimVerifier confirms ovn-controller has claimed the SB chassisredirect @@ -48,6 +52,10 @@ type GatewayClaimVerifier interface { // GatewayPortClaimed reports whether the SB Port_Binding for crPortName (the // chassisredirect port) has a non-empty chassis. GatewayPortClaimed(ctx context.Context, crPortName string) (bool, error) + // GatewayPortLocal reports whether crPortName is claimed by *this* chassis. + // The datapath probes below are host-local — a ping and an ARP off this + // node's own uplink — so on any node but the gateway's they measure nothing. + GatewayPortLocal(ctx context.Context, crPortName string) (bool, error) // NudgeRecompute asks the local ovn-controller to re-evaluate logical flows. NudgeRecompute(ctx context.Context) error // GatewayReachable reports whether the external datapath actually forwards to @@ -126,11 +134,32 @@ type Config struct { // Optional: nil leaves the start-of-pass snapshot as the sole liveness source // (unit tests, or callers with no store). FreshIntent func(ctx context.Context) (IntentState, error) + // LocalPorts returns the LSP names plugged into OVS on this host, keyed by + // external_ids:iface-id. A port with a live tap belongs to a running guest and + // can never be an orphan, whatever intent says — which is the one liveness + // signal that does not go through the KV read path the orphan sweep is + // otherwise entirely driven by. Optional: nil leaves intent as the sole source + // and the sweep falls back to refusing a wholly-empty one. + LocalPorts func(ctx context.Context) (map[string]struct{}, error) // MarkIGWAttached reports a confirmed IGW attachment back to the control // plane, so DescribeInternetGateways stops claiming an attachment exists // before one does. Called with the record key and VPC carried on the IGW // spec. Optional: nil leaves the record untouched. MarkIGWAttached func(ctx context.Context, recordKey, vpcID string) error + + // DatapathIP maps an external IP to the address that rides the wire, the + // same mapping the NAT manager applies. The EIP datapath probe ARPs its + // target, so on OCI it has to ask about the private half of the pair: the + // public half is NAT'd upstream and is on no interface anywhere, so probing + // it reports a working gateway as dead. Optional: nil means identity. + DatapathIP policy.DatapathResolver + + // BindGatewayVPC records which VPC's gateway answers on an external IP. A + // NAT gateway's address is never attached to an ENI, so on OCI nothing else + // says which node it has to be delivered to, and the answer is the gateway + // chassis this pass is what decides. Optional: nil is every environment + // where an address is on the wire from any node. + BindGatewayVPC func(ctx context.Context, externalIP, vpcID string) error } type reconciler struct { @@ -148,7 +177,10 @@ type reconciler struct { ipsecEnabled bool underlayMTU int reloadIntent func(ctx context.Context) (IntentState, error) + localPorts func(ctx context.Context) (map[string]struct{}, error) markAttached func(ctx context.Context, recordKey, vpcID string) error + datapathIP policy.DatapathResolver + bindGateway func(ctx context.Context, externalIP, vpcID string) error // Guest ports that burned their convergence deadline, so a port whose guest // is gone stops paying the full nudge sequence every cycle. @@ -195,6 +227,10 @@ func New(cfg Config) (Reconciler, error) { if dnsServer == "" { dnsServer = topology.FormatDNSServerList(nil) } + datapathIP := cfg.DatapathIP + if datapathIP == nil { + datapathIP = func(_ context.Context, ip string) (string, error) { return ip, nil } + } return &reconciler{ ovn: cfg.OVN, sg: cfg.SG, @@ -210,7 +246,10 @@ func New(cfg Config) (Reconciler, error) { ipsecEnabled: !cfg.IPSecDisabled, underlayMTU: cfg.UnderlayMTU, reloadIntent: cfg.FreshIntent, + localPorts: cfg.LocalPorts, markAttached: cfg.MarkIGWAttached, + datapathIP: datapathIP, + bindGateway: cfg.BindGatewayVPC, }, nil } @@ -275,6 +314,20 @@ func (r *reconciler) ReconcileApplyOnly(ctx context.Context, intent IntentState) return r.reconcile(ctx, intent, false) } +// ReconcileHostEIPs is documented on the Reconciler interface. Intent is +// re-read rather than passed in: a stale list would prune the routes of a +// guest launched since, and a failed read must abandon the pass, not shrink it. +func (r *reconciler) ReconcileHostEIPs(ctx context.Context) error { + if r.reloadIntent == nil { + return nil + } + intent, err := r.reloadIntent(ctx) + if err != nil { + return fmt.Errorf("re-read intent for host EIP pass: %w", err) + } + return r.nat.BindHostEIPs(ctx, r.hostBindSpecs(intent)) +} + // reconcile applies intent. pruneTopology gates the port-group and ENI-port // sweeps only; the EIP sweep runs on every pass, including startup. Startup is // the pass that follows the window where a KV watch (UpdatesOnly) and a diff --git a/spinifex/network/reconcile/scenario_prune_live_test.go b/spinifex/network/reconcile/scenario_prune_live_test.go index 6ccbbaf32..0271c5999 100644 --- a/spinifex/network/reconcile/scenario_prune_live_test.go +++ b/spinifex/network/reconcile/scenario_prune_live_test.go @@ -8,6 +8,8 @@ package reconcile import ( "context" + "net" + "net/netip" "slices" "testing" @@ -19,8 +21,19 @@ func TestScenario_OrphanPrune_Live(t *testing.T) { rec, cli := newLiveReconciler(t) ctx := context.Background() + // A survivor of each class, because a sweep against an intent holding none at + // all is refused as an unreadable bucket rather than run. + full := func() IntentState { + s := freshIntent(t) + mac, _ := net.ParseMAC("02:00:00:00:00:02") + s.SGs["sg-keep"] = policy.SGSpec{GroupID: "sg-keep", VPCID: "vpc-a"} + s.Ports["eni-keep"] = topology.PortSpec{PortID: "eni-keep", SubnetID: "subnet-a", VPCID: "vpc-a", + PrivateIP: netip.MustParseAddr("10.0.1.11"), MAC: mac, SGIDs: []string{"sg-keep"}} + return s + } + // 1. Converge the full state and capture the ENI LSP UUID. - if err := rec.Reconcile(ctx, freshIntent(t)); err != nil { + if err := rec.Reconcile(ctx, full()); err != nil { t.Fatalf("Reconcile (create): %v", err) } pgName := topology.SecurityGroupPortGroup("sg-a") @@ -33,10 +46,10 @@ func TestScenario_OrphanPrune_Live(t *testing.T) { t.Fatalf("precondition: SG port group absent after create: %v", err) } - // 2. Intent with the ENI and SG removed (VPC + subnet retained). - reduced := freshIntent(t) - reduced.Ports = map[string]topology.PortSpec{} - reduced.SGs = map[string]policy.SGSpec{} + // 2. Intent with eni-a and sg-a removed (VPC, subnet and the survivors retained). + reduced := full() + delete(reduced.Ports, "eni-a") + delete(reduced.SGs, "sg-a") // 3. ReconcileApplyOnly must NOT prune (startup-race guard). if err := rec.ReconcileApplyOnly(ctx, reduced); err != nil { diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_auth-Cg86DRhs.js b/spinifex/services/spinifexui/frontend/dist/assets/_auth-Cg86DRhs.js new file mode 100644 index 000000000..788561d83 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/_auth-Cg86DRhs.js @@ -0,0 +1 @@ +import{n as e,o as t,r as n,t as r}from"./compiler-runtime-D_vZfn79.js";import{t as i}from"./useQuery-2EKf7AyV.js";import{t as a}from"./link-Dhy4xOXd.js";import{sn as o}from"./cluster-config-xw_Q7X1e.js";import{t as s}from"./createLucideIcon-BQgzNCBR.js";import{t as c}from"./chevron-down-Dt4q6d24.js";import{t as l}from"./chevron-right-c57whaks.js";import{a as u,i as d,n as f,o as p,r as ee,s as m,t as h}from"./waypoints-DE7eIn9V.js";import{t as g}from"./network-RAx0KOlf.js";import{n as te}from"./admin-context-CXmiyIcc.js";import{A as ne,M as _,_ as re,d as v,l as ie,n as y,p as ae,w as b}from"./ec2-CWEplLq4.js";import{n as x}from"./s3-DFfxBBj-.js";import{t as S}from"./page-heading-vksh_67w.js";import{t as oe}from"./badge-B_ExXcw2.js";import{a as se,n as C,o as w,r as T,s as ce,t as E}from"./card-BXl0Qff8.js";import{o as le,r as D}from"./admin-BUkrFIlI.js";import{a as O,c as k,i as A,l as j,n as ue,o as M,s as N,t as P}from"./alert-dialog-kCQ1W4Rz.js";import{t as F}from"./state-badge-D1Z6q_OA.js";var I={name:`box`,size:24,node:[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]};I.node;var L=s(I),R={name:`flask-conical`,size:24,node:[[`path`,{d:`M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2`,key:`18mbvz`}],[`path`,{d:`M6.453 15h11.094`,key:`3shlmq`}],[`path`,{d:`M8.5 2h7`,key:`csnxdl`}]]};R.node;var z=s(R),B={name:`folder-open`,size:24,node:[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]};B.node;var V=s(B),H={name:`lock`,size:24,node:[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]};H.node;var de=s(H),U={name:`monitor`,size:24,node:[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]};U.node;var fe=s(U),W={name:`ship`,size:24,node:[[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 9.189V13`,key:`yp40p3`}],[`path`,{d:`M19 12V6a2 2 0 00-2-2H7a2 2 0 00-2 2v6`,key:`1wcigf`}],[`path`,{d:`M19.38 19A11.6 11.6 0 0021 13l-8.188-3.639a2 2 0 00-1.624 0L3 13.001a11.6 11.6 0 002.81 7.76`,key:`1kdp0u`}],[`path`,{d:`M2 20c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1`,key:`1l8w7g`}]]};W.node;var pe=s(W),G=r(),K=t(n(),1),q=e(),me=[{name:`Air-gap Support`,key:`airgap`},{name:`NVIDIA Bluefield DPU`,key:`bluefield`},{name:`NVIDIA MIG Support`,key:`mig`}];function J(e){let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);if(t>0)return`${t}d ${n}h`;let r=Math.floor(e%3600/60);return n>0?`${n}h ${r}m`:`${r}m`}function he(){let e=(0,G.c)(129),{isAdmin:t,version:n,license:r}=te(),[a,o]=(0,K.useState)(!1),s;e[0]===t?s=e[1]:(s={...D,enabled:t},e[0]=t,e[1]=s);let{data:u}=i(s),d;e[2]===t?d=e[3]:(d={...le,enabled:t},e[2]=t,e[3]=d);let{data:f}=i(d);if(!t||!n)return null;let p;e[4]===u?.nodes?p=e[5]:(p=u?.nodes??[],e[4]=u?.nodes,e[5]=p);let m=p,h;e[6]===f?.vms?h=e[7]:(h=f?.vms??[],e[6]=f?.vms,e[7]=h);let g=h,ne=u?.cluster_mode??`single-node`,_=r===`open-source`,re=m[0]?.region??null,v;e[8]===m?v=e[9]:(v=m.reduce(ye,0),e[8]=m,e[9]=v);let ie=v,y;e[10]===m?y=e[11]:(y=m.reduce(ve,0),e[10]=m,e[11]=y);let ae=y,b,x,S,se,C,O,k,A,j,ue,M,N,P,F,I,L,R;if(e[12]!==ae||e[13]!==ne||e[14]!==_||e[15]!==m||e[16]!==re||e[17]!==ie||e[18]!==n.arch||e[19]!==n.commit||e[20]!==n.os||e[21]!==n.version||e[22]!==g.length){let t=m.reduce(_e,0),r=m.reduce(ge,0);P=`mb-6 space-y-4`,ue=`grid grid-cols-1 gap-4 md:grid-cols-3`;let i;e[40]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,q.jsx)(w,{className:`pb-2`,children:(0,q.jsxs)(ce,{className:`flex items-center gap-2 text-sm`,children:[(0,q.jsx)(fe,{className:`size-3.5`}),`Spinifex Info`]})}),e[40]=i):i=e[40];let a;e[41]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Version`}),e[41]=a):a=e[41];let o;e[42]===n.version?o=e[43]:(o=(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[a,(0,q.jsx)(`span`,{className:`font-mono`,children:n.version})]}),e[42]=n.version,e[43]=o);let s;e[44]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Commit`}),e[44]=s):s=e[44];let c;e[45]===n.commit?c=e[46]:(c=(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[s,(0,q.jsx)(`span`,{className:`font-mono`,children:n.commit})]}),e[45]=n.commit,e[46]=c);let l;e[47]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Platform`}),e[47]=l):l=e[47];let u;e[48]!==n.arch||e[49]!==n.os?(u=(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[l,(0,q.jsxs)(`span`,{children:[n.os,`/`,n.arch]})]}),e[48]=n.arch,e[49]=n.os,e[50]=u):u=e[50];let d;e[51]===re?d=e[52]:(d=re&&(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Region`}),(0,q.jsx)(`span`,{children:re})]}),e[51]=re,e[52]=d),e[53]!==o||e[54]!==c||e[55]!==u||e[56]!==d?(M=(0,q.jsxs)(E,{children:[i,(0,q.jsxs)(T,{className:`space-y-1 text-xs`,children:[o,c,u,d]})]}),e[53]=o,e[54]=c,e[55]=u,e[56]=d,e[57]=M):M=e[57];let f;e[58]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,q.jsx)(w,{className:`pb-2`,children:(0,q.jsx)(ce,{className:`text-sm`,children:`License & Support`})}),e[58]=f):f=e[58];let p;e[59]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`License`}),e[59]=p):p=e[59];let h=_?`secondary`:`default`,te=_?`Open Source (AGPLv3)`:`Commercial`,v;e[60]!==h||e[61]!==te?(v=(0,q.jsxs)(`div`,{className:`flex items-center justify-between`,children:[p,(0,q.jsx)(oe,{variant:h,children:te})]}),e[60]=h,e[61]=te,e[62]=v):v=e[62];let y;e[63]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Support`}),e[63]=y):y=e[63];let le=_?`Community`:`Standard`,D;e[64]===le?D=e[65]:(D=(0,q.jsxs)(`div`,{className:`flex items-center justify-between`,children:[y,(0,q.jsx)(`span`,{children:le})]}),e[64]=le,e[65]=D);let z;e[66]===_?z=e[67]:(z=_&&(0,q.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-2 block text-xs font-medium text-primary hover:underline`,children:`Upgrade to Commercial →`}),e[66]=_,e[67]=z),e[68]!==v||e[69]!==D||e[70]!==z?(N=(0,q.jsxs)(E,{children:[f,(0,q.jsxs)(T,{className:`space-y-2 text-xs`,children:[v,D,z]})]}),e[68]=v,e[69]=D,e[70]=z,e[71]=N):N=e[71],x=E,e[72]===Symbol.for(`react.memo_cache_sentinel`)?(j=(0,q.jsx)(w,{className:`pb-2`,children:(0,q.jsxs)(ce,{className:`flex items-center gap-2 text-sm`,children:[(0,q.jsx)(ee,{className:`size-3.5`}),`Cluster Status`]})}),e[72]=j):j=e[72],b=T,se=`space-y-2 text-xs`,e[73]===ne?C=e[74]:(C=(0,q.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,q.jsx)(oe,{variant:`outline`,children:ne})}),e[73]=ne,e[74]=C);let B;e[75]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Nodes`}),e[75]=B):B=e[75],e[76]===m.length?O=e[77]:(O=(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[B,(0,q.jsx)(`span`,{children:m.length})]}),e[76]=m.length,e[77]=O);let V;e[78]===Symbol.for(`react.memo_cache_sentinel`)?(V=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Instances`}),e[78]=V):V=e[78],e[79]===g.length?k=e[80]:(k=(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[V,(0,q.jsx)(`span`,{children:g.length})]}),e[79]=g.length,e[80]=k);let H;e[81]===Symbol.for(`react.memo_cache_sentinel`)?(H=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`vCPU`}),e[81]=H):H=e[81],e[82]!==ae||e[83]!==ie?(A=(0,q.jsxs)(`div`,{className:`flex justify-between`,children:[H,(0,q.jsxs)(`span`,{children:[ae,` / `,ie]})]}),e[82]=ae,e[83]=ie,e[84]=A):A=e[84],R=`flex justify-between`,e[85]===Symbol.for(`react.memo_cache_sentinel`)?(S=(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:`Memory`}),e[85]=S):S=e[85],F=r.toFixed(1),I=` / `,L=t.toFixed(1),e[12]=ae,e[13]=ne,e[14]=_,e[15]=m,e[16]=re,e[17]=ie,e[18]=n.arch,e[19]=n.commit,e[20]=n.os,e[21]=n.version,e[22]=g.length,e[23]=b,e[24]=x,e[25]=S,e[26]=se,e[27]=C,e[28]=O,e[29]=k,e[30]=A,e[31]=j,e[32]=ue,e[33]=M,e[34]=N,e[35]=P,e[36]=F,e[37]=I,e[38]=L,e[39]=R}else b=e[23],x=e[24],S=e[25],se=e[26],C=e[27],O=e[28],k=e[29],A=e[30],j=e[31],ue=e[32],M=e[33],N=e[34],P=e[35],F=e[36],I=e[37],L=e[38],R=e[39];let z;e[86]!==F||e[87]!==I||e[88]!==L?(z=(0,q.jsxs)(`span`,{children:[F,I,L,` GB`]}),e[86]=F,e[87]=I,e[88]=L,e[89]=z):z=e[89];let B;e[90]!==S||e[91]!==z||e[92]!==R?(B=(0,q.jsxs)(`div`,{className:R,children:[S,z]}),e[90]=S,e[91]=z,e[92]=R,e[93]=B):B=e[93];let V;e[94]!==b||e[95]!==se||e[96]!==C||e[97]!==O||e[98]!==k||e[99]!==A||e[100]!==B?(V=(0,q.jsxs)(b,{className:se,children:[C,O,k,A,B]}),e[94]=b,e[95]=se,e[96]=C,e[97]=O,e[98]=k,e[99]=A,e[100]=B,e[101]=V):V=e[101];let H;e[102]!==x||e[103]!==j||e[104]!==V?(H=(0,q.jsxs)(x,{children:[j,V]}),e[102]=x,e[103]=j,e[104]=V,e[105]=H):H=e[105];let U;e[106]!==ue||e[107]!==M||e[108]!==N||e[109]!==H?(U=(0,q.jsxs)(`div`,{className:ue,children:[M,N,H]}),e[106]=ue,e[107]=M,e[108]=N,e[109]=H,e[110]=U):U=e[110];let W;e[111]!==m||e[112]!==a?(W=m.length>0&&(0,q.jsxs)(E,{children:[(0,q.jsx)(w,{className:`pb-0`,children:(0,q.jsxs)(`button`,{type:`button`,className:`flex items-center gap-2 text-sm font-medium`,onClick:()=>{o(!a)},children:[a?(0,q.jsx)(c,{className:`size-3.5`}):(0,q.jsx)(l,{className:`size-3.5`}),`Nodes (`,m.length,`)`]})}),a&&(0,q.jsx)(T,{className:`pt-2`,children:(0,q.jsx)(be,{nodes:m})})]}),e[111]=m,e[112]=a,e[113]=W):W=e[113];let pe;e[114]===Symbol.for(`react.memo_cache_sentinel`)?(pe=(0,q.jsx)(w,{className:`pb-2`,children:(0,q.jsx)(ce,{className:`text-sm`,children:`Commercial Add-ons`})}),e[114]=pe):pe=e[114];let J;e[115]===_?J=e[116]:(J=me.map(e=>(0,q.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,q.jsx)(`span`,{className:_?`text-muted-foreground`:void 0,children:e.name}),_?(0,q.jsx)(de,{className:`size-3 text-muted-foreground`}):(0,q.jsx)(oe,{variant:`outline`,className:`text-[0.625rem]`,children:`Active`})]},e.key)),e[115]=_,e[116]=J);let he;e[117]===J?he=e[118]:(he=(0,q.jsx)(`div`,{className:`space-y-2`,children:J}),e[117]=J,e[118]=he);let xe;e[119]===_?xe=e[120]:(xe=_&&(0,q.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-3 block text-xs font-medium text-primary hover:underline`,children:`Learn more about commercial features →`}),e[119]=_,e[120]=xe);let Se;e[121]!==he||e[122]!==xe?(Se=(0,q.jsxs)(E,{children:[pe,(0,q.jsxs)(T,{children:[he,xe]})]}),e[121]=he,e[122]=xe,e[123]=Se):Se=e[123];let Ce;return e[124]!==P||e[125]!==U||e[126]!==W||e[127]!==Se?(Ce=(0,q.jsxs)(`div`,{className:P,children:[U,W,Se]}),e[124]=P,e[125]=U,e[126]=W,e[127]=Se,e[128]=Ce):Ce=e[128],Ce}function ge(e,t){return e+t.alloc_mem_gb}function _e(e,t){return e+t.total_mem_gb}function ve(e,t){return e+t.alloc_vcpu}function ye(e,t){return e+t.total_vcpu}function be(e){let t=(0,G.c)(5),{nodes:n}=e,r;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,q.jsx)(`thead`,{children:(0,q.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Name`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Status`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Roles`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`IP`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Region`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`AZ`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Uptime`}),(0,q.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`EC2`}),(0,q.jsx)(`th`,{className:`pb-1 font-medium`,children:`Services`})]})}),t[0]=r):r=t[0];let i;t[1]===n?i=t[2]:(i=n.map(xe),t[1]=n,t[2]=i);let a;return t[3]===i?a=t[4]:(a=(0,q.jsx)(`div`,{className:`overflow-x-auto`,children:(0,q.jsxs)(`table`,{className:`w-full text-xs`,children:[r,(0,q.jsx)(`tbody`,{children:i})]})}),t[3]=i,t[4]=a),a}function xe(e){return(0,q.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,q.jsx)(`td`,{className:`py-1.5 pr-4 font-mono font-medium`,children:e.node}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,q.jsx)(oe,{variant:e.status===`Ready`?`default`:`secondary`,className:`text-[0.625rem]`,children:e.status})}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,q.jsxs)(`div`,{className:`flex gap-1`,children:[e.nats_role&&(0,q.jsxs)(`span`,{className:`text-muted-foreground`,children:[`nats:`,e.nats_role]}),e.predastore_role&&(0,q.jsxs)(`span`,{className:`text-muted-foreground`,children:[`ps:`,e.predastore_role]})]})}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4 font-mono`,children:e.host}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.region}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.az}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4`,children:J(e.uptime)}),(0,q.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.vm_count}),(0,q.jsx)(`td`,{className:`py-1.5`,children:(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.services.length})})]},e.node)}var Se=`console.spx3.com`,Ce=`spinifex:v1:sandbox-welcome`,we=[{to:`/ec2/run-instances`,icon:(0,q.jsx)(p,{className:`size-3.5`}),label:`Launch an instance`,hint:`Boot a VM on real hardware`},{to:`/s3/ls`,icon:(0,q.jsx)(V,{className:`size-3.5`}),label:`Create a bucket`,hint:`S3-compatible object storage`},{to:`/ec2/describe-vpcs`,icon:(0,q.jsx)(g,{className:`size-3.5`}),label:`Networking`,hint:`VPCs, subnets and security groups`},{to:`/eks/list-clusters`,icon:(0,q.jsx)(pe,{className:`size-3.5`}),label:`Kubernetes`,hint:`Managed EKS-compatible clusters`},{to:`/iam/list-users`,icon:(0,q.jsx)(u,{className:`size-3.5`}),label:`Access keys`,hint:`Credentials for the AWS CLI and SDKs`}];function Te(){try{return localStorage.getItem(Ce)!==null}catch{}return!1}function Ee(){try{localStorage.setItem(Ce,`1`)}catch{}}function De(){let e=(0,G.c)(7),[t,n]=(0,K.useState)(Oe),r;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=e=>{e||Ee(),n(e)},e[0]=r):r=e[0];let i=r,o;e[1]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,q.jsxs)(N,{children:[(0,q.jsx)(k,{children:(0,q.jsx)(z,{className:`text-primary`})}),(0,q.jsx)(j,{children:`Welcome to the Mulga sandbox`}),(0,q.jsx)(O,{children:`You have a real account on a shared evaluation cluster. The APIs are the AWS ones, so the CLI and the SDKs work against it unchanged. Resources here may be reclaimed, so please don't keep anything you need in it.`})]}),e[1]=o):o=e[1];let s;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,q.jsx)(`div`,{className:`space-y-0.5`,children:we.map(e=>(0,q.jsxs)(a,{className:`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted`,onClick:()=>{i(!1)},to:e.to,children:[e.icon,(0,q.jsx)(`span`,{className:`font-medium`,children:e.label}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.hint})]},e.to))}),e[2]=s):s=e[2];let c;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,q.jsx)(`a`,{className:`text-xs text-muted-foreground underline underline-offset-3 hover:text-foreground sm:mr-auto sm:self-center`,href:`https://docs.mulgadc.com`,rel:`noreferrer`,target:`_blank`,children:`Read the docs`}),e[3]=c):c=e[3];let l;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,q.jsxs)(A,{className:`max-w-lg`,children:[o,s,(0,q.jsxs)(M,{children:[c,(0,q.jsx)(ue,{onClick:()=>{i(!1)},children:`Get started`})]})]}),e[4]=l):l=e[4];let u;return e[5]===t?u=e[6]:(u=(0,q.jsx)(P,{onOpenChange:i,open:t,children:l}),e[5]=t,e[6]=u),u}function Oe(){return window.location.hostname===Se&&!Te()}var Y=5;function ke(){let e=(0,G.c)(12),t,n,r;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,q.jsx)(S,{title:`Dashboard`}),n=(0,q.jsx)(De,{}),r=(0,q.jsx)(he,{}),e[0]=t,e[1]=n,e[2]=r):(t=e[0],n=e[1],r=e[2]);let i;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(Ae,{})}),e[3]=i):i=e[3];let a;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(Le,{})}),e[4]=a):a=e[4];let o;e[5]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(He,{})}),e[5]=o):o=e[5];let s;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(We,{})}),e[6]=s):s=e[6];let c;e[7]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(Ke,{})}),e[7]=c):c=e[7];let l;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(Je,{})}),e[8]=l):l=e[8];let u;e[9]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(Xe,{})}),e[9]=u):u=e[9];let d;e[10]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(et,{})}),e[10]=d):d=e[10];let f;return e[11]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,q.jsxs)(q.Fragment,{children:[t,n,r,(0,q.jsxs)(`div`,{className:`grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3`,children:[i,a,o,s,c,l,u,d,(0,q.jsx)(K.Suspense,{fallback:(0,q.jsx)(X,{}),children:(0,q.jsx)(Qe,{})})]})]}),e[11]=f):f=e[11],f}function X(){let e=(0,G.c)(2),t;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,q.jsx)(w,{children:(0,q.jsx)(`div`,{className:`h-4 w-32 animate-pulse rounded bg-muted`})}),e[0]=t):t=e[0];let n;return e[1]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsxs)(E,{children:[t,(0,q.jsx)(T,{children:(0,q.jsxs)(`div`,{className:`space-y-2`,children:[(0,q.jsx)(`div`,{className:`h-3 w-48 animate-pulse rounded bg-muted`}),(0,q.jsx)(`div`,{className:`h-3 w-36 animate-pulse rounded bg-muted`})]})})]}),e[1]=n):n=e[1],n}function Z(e){let t=(0,G.c)(16),{title:n,icon:r,count:i,to:o,children:s}=e,c;t[0]!==r||t[1]!==n?(c=(0,q.jsxs)(ce,{className:`flex items-center gap-2`,children:[r,n]}),t[0]=r,t[1]=n,t[2]=c):c=t[2];let l;t[3]===i?l=t[4]:(l=(0,q.jsx)(C,{children:(0,q.jsx)(`span`,{className:`rounded-md bg-muted px-2 py-0.5 text-xs font-semibold tabular-nums`,children:i})}),t[3]=i,t[4]=l);let u;t[5]!==c||t[6]!==l?(u=(0,q.jsxs)(w,{children:[c,l]}),t[5]=c,t[6]=l,t[7]=u):u=t[7];let d;t[8]===s?d=t[9]:(d=(0,q.jsx)(T,{children:s}),t[8]=s,t[9]=d);let f;t[10]===o?f=t[11]:(f=(0,q.jsx)(se,{children:(0,q.jsx)(a,{to:o,className:`text-xs font-medium text-primary hover:underline`,children:`View all →`})}),t[10]=o,t[11]=f);let p;return t[12]!==u||t[13]!==d||t[14]!==f?(p=(0,q.jsxs)(E,{children:[u,d,f]}),t[12]=u,t[13]=d,t[14]=f,t[15]=p):p=t[15],p}function Q(e){let t=(0,G.c)(2),{total:n}=e;if(n<=Y)return null;let r=n-Y,i;return t[0]===r?i=t[1]:(i=(0,q.jsxs)(`p`,{className:`mt-1 text-[0.625rem] text-muted-foreground`,children:[`+`,r,` more`]}),t[0]=r,t[1]=i),i}function $(e){let t=(0,G.c)(2),{children:n}=e,r;return t[0]===n?r=t[1]:(r=(0,q.jsx)(`div`,{className:`flex items-center justify-between py-0.5 text-xs`,children:n}),t[0]=n,t[1]=r),r}function Ae(){let e=(0,G.c)(16),{data:t}=i(ie),n,r,a,o,s;if(e[0]!==t?.Reservations){let i=t?.Reservations?.flatMap(Ie)??[];n=i.filter(Fe),a=i.filter(Pe),o=i.filter(Ne),r=i.filter(Me),s=[...a,...r].slice(0,Y),e[0]=t?.Reservations,e[1]=n,e[2]=r,e[3]=a,e[4]=o,e[5]=s}else n=e[1],r=e[2],a=e[3],o=e[4],s=e[5];let c=s,l;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,q.jsx)(p,{className:`size-3.5`}),e[6]=l):l=e[6];let u;e[7]!==n.length||e[8]!==c||e[9]!==r||e[10]!==a||e[11]!==o?(u=n.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No instances`}):(0,q.jsxs)(q.Fragment,{children:[(0,q.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,q.jsxs)(`span`,{children:[a.length,` running`]}),(0,q.jsxs)(`span`,{children:[o.length,` stopped`]})]}),(0,q.jsx)(`div`,{className:`space-y-0.5`,children:c.map(je)}),(0,q.jsx)(Q,{total:a.length+r.length})]}),e[7]=n.length,e[8]=c,e[9]=r,e[10]=a,e[11]=o,e[12]=u):u=e[12];let d;return e[13]!==n.length||e[14]!==u?(d=(0,q.jsx)(Z,{title:`Instances`,icon:l,count:n.length,to:`/ec2/describe-instances`,children:u}),e[13]=n.length,e[14]=u,e[15]=d):d=e[15],d}function je(e){return(0,q.jsxs)($,{children:[(0,q.jsx)(`span`,{className:`font-mono`,children:e.InstanceId}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.InstanceType})]},e.InstanceId)}function Me(e){return e.State?.Name===`pending`}function Ne(e){return e.State?.Name===`stopped`}function Pe(e){return e.State?.Name===`running`}function Fe(e){return e.State?.Name!==`terminated`}function Ie(e){return e.Instances??[]}function Le(){let e=(0,G.c)(14),{data:t}=i(ne),n;e[0]===t?.Volumes?n=e[1]:(n=t?.Volumes??[],e[0]=t?.Volumes,e[1]=n);let r=n,a;e[2]===r?a=e[3]:(a=r.reduce(Ve,0),e[2]=r,e[3]=a);let o=a,s;e[4]===r?s=e[5]:(s=r.filter(Be),e[4]=r,e[5]=s);let c=s,l;e[6]===r?l=e[7]:(l=r.filter(ze),e[6]=r,e[7]=l);let u=l,d;if(e[8]!==u||e[9]!==c||e[10]!==o||e[11]!==r){let t=r.slice(0,Y),n;e[13]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(L,{className:`size-3.5`}),e[13]=n):n=e[13],d=(0,q.jsx)(Z,{title:`Volumes`,icon:n,count:r.length,to:`/ec2/describe-volumes`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No volumes`}):(0,q.jsxs)(q.Fragment,{children:[(0,q.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,q.jsxs)(`span`,{children:[o,` GiB total`]}),(0,q.jsxs)(`span`,{children:[c.length,` in-use`]}),(0,q.jsxs)(`span`,{children:[u.length,` available`]})]}),(0,q.jsx)(`div`,{className:`space-y-0.5`,children:t.map(Re)}),(0,q.jsx)(Q,{total:r.length})]})}),e[8]=u,e[9]=c,e[10]=o,e[11]=r,e[12]=d}else d=e[12];return d}function Re(e){return(0,q.jsxs)($,{children:[(0,q.jsx)(`span`,{className:`font-mono`,children:e.VolumeId}),(0,q.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,q.jsxs)(`span`,{className:`text-muted-foreground`,children:[e.Size,` GiB`]}),(0,q.jsx)(F,{state:e.State})]})]},e.VolumeId)}function ze(e){return e.State===`available`}function Be(e){return e.State===`in-use`}function Ve(e,t){return e+(t.Size??0)}function He(){let e=(0,G.c)(5),{data:t}=i(_),n;e[0]===t?.Vpcs?n=e[1]:(n=t?.Vpcs??[],e[0]=t?.Vpcs,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(g,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`VPCs`,icon:n,count:r.length,to:`/ec2/describe-vpcs`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No VPCs`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ue),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ue(e){let t=o(e.Tags);return(0,q.jsxs)($,{children:[(0,q.jsxs)(`span`,{className:`font-mono`,children:[e.VpcId,t&&(0,q.jsxs)(`span`,{className:`ml-1 font-sans text-muted-foreground`,children:[`(`,t,`)`]})]}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.CidrBlock})]},e.VpcId)}function We(){let e=(0,G.c)(5),{data:t}=i(ae),n;e[0]===t?.KeyPairs?n=e[1]:(n=t?.KeyPairs??[],e[0]=t?.KeyPairs,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(u,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`Key Pairs`,icon:n,count:r.length,to:`/ec2/describe-key-pairs`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No key pairs`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ge),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ge(e){return(0,q.jsx)($,{children:(0,q.jsx)(`span`,{className:`font-mono`,children:e.KeyName})},e.KeyPairId)}function Ke(){let e=(0,G.c)(5),{data:t}=i(x),n;e[0]===t?.Buckets?n=e[1]:(n=t?.Buckets??[],e[0]=t?.Buckets,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(V,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`S3 Buckets`,icon:n,count:r.length,to:`/s3/ls`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No buckets`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(qe),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function qe(e){return(0,q.jsx)($,{children:(0,q.jsx)(`span`,{className:`font-mono`,children:e.Name})},e.Name)}function Je(){let e=(0,G.c)(5),{data:t}=i(b),n;e[0]===t?.SecurityGroups?n=e[1]:(n=t?.SecurityGroups??[],e[0]=t?.SecurityGroups,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(f,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`Security Groups`,icon:n,count:r.length,to:`/ec2/describe-security-groups`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ye),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ye(e){return(0,q.jsxs)($,{children:[(0,q.jsx)(`span`,{className:`font-mono`,children:e.GroupName}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.VpcId})]},e.GroupId)}function Xe(){let e=(0,G.c)(5),{data:t}=i(re),n;e[0]===t?.NatGateways?n=e[1]:(n=t?.NatGateways??[],e[0]=t?.NatGateways,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(h,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`NAT Gateways`,icon:n,count:r.length,to:`/ec2/describe-nat-gateways`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No NAT gateways`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ze),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ze(e){return(0,q.jsxs)($,{children:[(0,q.jsx)(`span`,{className:`font-mono`,children:e.NatGatewayId}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.State})]},e.NatGatewayId)}function Qe(){let e=(0,G.c)(5),{data:t}=i(v),n;e[0]===t?.InternetGateways?n=e[1]:(n=t?.InternetGateways??[],e[0]=t?.InternetGateways,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(d,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`Internet Gateways`,icon:n,count:r.length,to:`/ec2/describe-internet-gateways`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No internet gateways`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map($e),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function $e(e){return(0,q.jsxs)($,{children:[(0,q.jsx)(`span`,{className:`font-mono`,children:e.InternetGatewayId}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.Attachments?.[0]?.VpcId?`attached`:`detached`})]},e.InternetGatewayId)}function et(){let e=(0,G.c)(5),{data:t}=i(y),n;e[0]===t?.Addresses?n=e[1]:(n=t?.Addresses??[],e[0]=t?.Addresses,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,q.jsx)(m,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,q.jsx)(Z,{title:`Elastic IPs`,icon:n,count:r.length,to:`/ec2/describe-addresses`,children:r.length===0?(0,q.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No Elastic IPs`}):(0,q.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(tt),(0,q.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function tt(e){return(0,q.jsxs)($,{children:[(0,q.jsx)(`span`,{className:`font-mono`,children:e.PublicIp}),(0,q.jsx)(`span`,{className:`text-muted-foreground`,children:e.AssociationId?`associated`:`available`})]},e.AllocationId)}export{ke as component}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_auth-DsyoN1M9.js b/spinifex/services/spinifexui/frontend/dist/assets/_auth-DsyoN1M9.js deleted file mode 100644 index d5c1053b7..000000000 --- a/spinifex/services/spinifexui/frontend/dist/assets/_auth-DsyoN1M9.js +++ /dev/null @@ -1 +0,0 @@ -import{n as e,o as t,r as n,t as r}from"./compiler-runtime-D_vZfn79.js";import{t as i}from"./useQuery-2EKf7AyV.js";import{t as a}from"./link-Dhy4xOXd.js";import{sn as o}from"./cluster-config-xw_Q7X1e.js";import{t as s}from"./createLucideIcon-BQgzNCBR.js";import{t as c}from"./chevron-down-Dt4q6d24.js";import{t as l}from"./chevron-right-c57whaks.js";import{a as u,c as d,i as f,n as p,o as m,r as ee,s as h,t as te}from"./waypoints-CMfoqtKm.js";import{n as ne}from"./admin-context-CXmiyIcc.js";import{A as re,M as g,_ as ie,d as _,l as ae,n as v,p as oe,w as y}from"./ec2-CWEplLq4.js";import{n as b}from"./s3-DFfxBBj-.js";import{t as x}from"./page-heading-vksh_67w.js";import{t as se}from"./badge-B_ExXcw2.js";import{a as ce,n as S,o as C,r as w,s as le,t as T}from"./card-BXl0Qff8.js";import{o as ue,r as E}from"./admin-BUkrFIlI.js";import{a as D,c as O,i as k,l as A,n as de,o as j,s as M,t as N}from"./alert-dialog-kCQ1W4Rz.js";import{t as P}from"./state-badge-D1Z6q_OA.js";var F={name:`box`,size:24,node:[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]};F.node;var I=s(F),L={name:`flask-conical`,size:24,node:[[`path`,{d:`M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2`,key:`18mbvz`}],[`path`,{d:`M6.453 15h11.094`,key:`3shlmq`}],[`path`,{d:`M8.5 2h7`,key:`csnxdl`}]]};L.node;var R=s(L),z={name:`folder-open`,size:24,node:[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]};z.node;var B=s(z),V={name:`lock`,size:24,node:[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]};V.node;var fe=s(V),H={name:`monitor`,size:24,node:[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]};H.node;var pe=s(H),U={name:`ship`,size:24,node:[[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 9.189V13`,key:`yp40p3`}],[`path`,{d:`M19 12V6a2 2 0 00-2-2H7a2 2 0 00-2 2v6`,key:`1wcigf`}],[`path`,{d:`M19.38 19A11.6 11.6 0 0021 13l-8.188-3.639a2 2 0 00-1.624 0L3 13.001a11.6 11.6 0 002.81 7.76`,key:`1kdp0u`}],[`path`,{d:`M2 20c.6.5 1.2 1 2.5 1 2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1s1.2 1 2.5 1c2.5 0 2.5-2 5-2 1.3 0 1.9.5 2.5 1`,key:`1l8w7g`}]]};U.node;var me=s(U),W=r(),G=t(n(),1),K=e(),he=[{name:`Air-gap Support`,key:`airgap`},{name:`NVIDIA Bluefield DPU`,key:`bluefield`},{name:`NVIDIA MIG Support`,key:`mig`}];function q(e){let t=Math.floor(e/86400),n=Math.floor(e%86400/3600);if(t>0)return`${t}d ${n}h`;let r=Math.floor(e%3600/60);return n>0?`${n}h ${r}m`:`${r}m`}function J(){let e=(0,W.c)(129),{isAdmin:t,version:n,license:r}=ne(),[a,o]=(0,G.useState)(!1),s;e[0]===t?s=e[1]:(s={...E,enabled:t},e[0]=t,e[1]=s);let{data:u}=i(s),d;e[2]===t?d=e[3]:(d={...ue,enabled:t},e[2]=t,e[3]=d);let{data:f}=i(d);if(!t||!n)return null;let p;e[4]===u?.nodes?p=e[5]:(p=u?.nodes??[],e[4]=u?.nodes,e[5]=p);let m=p,h;e[6]===f?.vms?h=e[7]:(h=f?.vms??[],e[6]=f?.vms,e[7]=h);let te=h,re=u?.cluster_mode??`single-node`,g=r===`open-source`,ie=m[0]?.region??null,_;e[8]===m?_=e[9]:(_=m.reduce(ye,0),e[8]=m,e[9]=_);let ae=_,v;e[10]===m?v=e[11]:(v=m.reduce(ve,0),e[10]=m,e[11]=v);let oe=v,y,b,x,ce,S,D,O,k,A,de,j,M,N,P,F,I,L;if(e[12]!==oe||e[13]!==re||e[14]!==g||e[15]!==m||e[16]!==ie||e[17]!==ae||e[18]!==n.arch||e[19]!==n.commit||e[20]!==n.os||e[21]!==n.version||e[22]!==te.length){let t=m.reduce(_e,0),r=m.reduce(ge,0);N=`mb-6 space-y-4`,de=`grid grid-cols-1 gap-4 md:grid-cols-3`;let i;e[40]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,K.jsx)(C,{className:`pb-2`,children:(0,K.jsxs)(le,{className:`flex items-center gap-2 text-sm`,children:[(0,K.jsx)(pe,{className:`size-3.5`}),`Spinifex Info`]})}),e[40]=i):i=e[40];let a;e[41]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Version`}),e[41]=a):a=e[41];let o;e[42]===n.version?o=e[43]:(o=(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[a,(0,K.jsx)(`span`,{className:`font-mono`,children:n.version})]}),e[42]=n.version,e[43]=o);let s;e[44]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Commit`}),e[44]=s):s=e[44];let c;e[45]===n.commit?c=e[46]:(c=(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[s,(0,K.jsx)(`span`,{className:`font-mono`,children:n.commit})]}),e[45]=n.commit,e[46]=c);let l;e[47]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Platform`}),e[47]=l):l=e[47];let u;e[48]!==n.arch||e[49]!==n.os?(u=(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[l,(0,K.jsxs)(`span`,{children:[n.os,`/`,n.arch]})]}),e[48]=n.arch,e[49]=n.os,e[50]=u):u=e[50];let d;e[51]===ie?d=e[52]:(d=ie&&(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Region`}),(0,K.jsx)(`span`,{children:ie})]}),e[51]=ie,e[52]=d),e[53]!==o||e[54]!==c||e[55]!==u||e[56]!==d?(j=(0,K.jsxs)(T,{children:[i,(0,K.jsxs)(w,{className:`space-y-1 text-xs`,children:[o,c,u,d]})]}),e[53]=o,e[54]=c,e[55]=u,e[56]=d,e[57]=j):j=e[57];let f;e[58]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,K.jsx)(C,{className:`pb-2`,children:(0,K.jsx)(le,{className:`text-sm`,children:`License & Support`})}),e[58]=f):f=e[58];let p;e[59]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`License`}),e[59]=p):p=e[59];let h=g?`secondary`:`default`,ne=g?`Open Source (AGPLv3)`:`Commercial`,_;e[60]!==h||e[61]!==ne?(_=(0,K.jsxs)(`div`,{className:`flex items-center justify-between`,children:[p,(0,K.jsx)(se,{variant:h,children:ne})]}),e[60]=h,e[61]=ne,e[62]=_):_=e[62];let v;e[63]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Support`}),e[63]=v):v=e[63];let ue=g?`Community`:`Standard`,E;e[64]===ue?E=e[65]:(E=(0,K.jsxs)(`div`,{className:`flex items-center justify-between`,children:[v,(0,K.jsx)(`span`,{children:ue})]}),e[64]=ue,e[65]=E);let R;e[66]===g?R=e[67]:(R=g&&(0,K.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-2 block text-xs font-medium text-primary hover:underline`,children:`Upgrade to Commercial →`}),e[66]=g,e[67]=R),e[68]!==_||e[69]!==E||e[70]!==R?(M=(0,K.jsxs)(T,{children:[f,(0,K.jsxs)(w,{className:`space-y-2 text-xs`,children:[_,E,R]})]}),e[68]=_,e[69]=E,e[70]=R,e[71]=M):M=e[71],b=T,e[72]===Symbol.for(`react.memo_cache_sentinel`)?(A=(0,K.jsx)(C,{className:`pb-2`,children:(0,K.jsxs)(le,{className:`flex items-center gap-2 text-sm`,children:[(0,K.jsx)(ee,{className:`size-3.5`}),`Cluster Status`]})}),e[72]=A):A=e[72],y=w,ce=`space-y-2 text-xs`,e[73]===re?S=e[74]:(S=(0,K.jsx)(`div`,{className:`flex items-center gap-2`,children:(0,K.jsx)(se,{variant:`outline`,children:re})}),e[73]=re,e[74]=S);let z;e[75]===Symbol.for(`react.memo_cache_sentinel`)?(z=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Nodes`}),e[75]=z):z=e[75],e[76]===m.length?D=e[77]:(D=(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[z,(0,K.jsx)(`span`,{children:m.length})]}),e[76]=m.length,e[77]=D);let B;e[78]===Symbol.for(`react.memo_cache_sentinel`)?(B=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Instances`}),e[78]=B):B=e[78],e[79]===te.length?O=e[80]:(O=(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[B,(0,K.jsx)(`span`,{children:te.length})]}),e[79]=te.length,e[80]=O);let V;e[81]===Symbol.for(`react.memo_cache_sentinel`)?(V=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`vCPU`}),e[81]=V):V=e[81],e[82]!==oe||e[83]!==ae?(k=(0,K.jsxs)(`div`,{className:`flex justify-between`,children:[V,(0,K.jsxs)(`span`,{children:[oe,` / `,ae]})]}),e[82]=oe,e[83]=ae,e[84]=k):k=e[84],L=`flex justify-between`,e[85]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:`Memory`}),e[85]=x):x=e[85],P=r.toFixed(1),F=` / `,I=t.toFixed(1),e[12]=oe,e[13]=re,e[14]=g,e[15]=m,e[16]=ie,e[17]=ae,e[18]=n.arch,e[19]=n.commit,e[20]=n.os,e[21]=n.version,e[22]=te.length,e[23]=y,e[24]=b,e[25]=x,e[26]=ce,e[27]=S,e[28]=D,e[29]=O,e[30]=k,e[31]=A,e[32]=de,e[33]=j,e[34]=M,e[35]=N,e[36]=P,e[37]=F,e[38]=I,e[39]=L}else y=e[23],b=e[24],x=e[25],ce=e[26],S=e[27],D=e[28],O=e[29],k=e[30],A=e[31],de=e[32],j=e[33],M=e[34],N=e[35],P=e[36],F=e[37],I=e[38],L=e[39];let R;e[86]!==P||e[87]!==F||e[88]!==I?(R=(0,K.jsxs)(`span`,{children:[P,F,I,` GB`]}),e[86]=P,e[87]=F,e[88]=I,e[89]=R):R=e[89];let z;e[90]!==x||e[91]!==R||e[92]!==L?(z=(0,K.jsxs)(`div`,{className:L,children:[x,R]}),e[90]=x,e[91]=R,e[92]=L,e[93]=z):z=e[93];let B;e[94]!==y||e[95]!==ce||e[96]!==S||e[97]!==D||e[98]!==O||e[99]!==k||e[100]!==z?(B=(0,K.jsxs)(y,{className:ce,children:[S,D,O,k,z]}),e[94]=y,e[95]=ce,e[96]=S,e[97]=D,e[98]=O,e[99]=k,e[100]=z,e[101]=B):B=e[101];let V;e[102]!==b||e[103]!==A||e[104]!==B?(V=(0,K.jsxs)(b,{children:[A,B]}),e[102]=b,e[103]=A,e[104]=B,e[105]=V):V=e[105];let H;e[106]!==de||e[107]!==j||e[108]!==M||e[109]!==V?(H=(0,K.jsxs)(`div`,{className:de,children:[j,M,V]}),e[106]=de,e[107]=j,e[108]=M,e[109]=V,e[110]=H):H=e[110];let U;e[111]!==m||e[112]!==a?(U=m.length>0&&(0,K.jsxs)(T,{children:[(0,K.jsx)(C,{className:`pb-0`,children:(0,K.jsxs)(`button`,{type:`button`,className:`flex items-center gap-2 text-sm font-medium`,onClick:()=>{o(!a)},children:[a?(0,K.jsx)(c,{className:`size-3.5`}):(0,K.jsx)(l,{className:`size-3.5`}),`Nodes (`,m.length,`)`]})}),a&&(0,K.jsx)(w,{className:`pt-2`,children:(0,K.jsx)(be,{nodes:m})})]}),e[111]=m,e[112]=a,e[113]=U):U=e[113];let me;e[114]===Symbol.for(`react.memo_cache_sentinel`)?(me=(0,K.jsx)(C,{className:`pb-2`,children:(0,K.jsx)(le,{className:`text-sm`,children:`Commercial Add-ons`})}),e[114]=me):me=e[114];let q;e[115]===g?q=e[116]:(q=he.map(e=>(0,K.jsxs)(`div`,{className:`flex items-center justify-between text-xs`,children:[(0,K.jsx)(`span`,{className:g?`text-muted-foreground`:void 0,children:e.name}),g?(0,K.jsx)(fe,{className:`size-3 text-muted-foreground`}):(0,K.jsx)(se,{variant:`outline`,className:`text-[0.625rem]`,children:`Active`})]},e.key)),e[115]=g,e[116]=q);let J;e[117]===q?J=e[118]:(J=(0,K.jsx)(`div`,{className:`space-y-2`,children:q}),e[117]=q,e[118]=J);let xe;e[119]===g?xe=e[120]:(xe=g&&(0,K.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:`mt-3 block text-xs font-medium text-primary hover:underline`,children:`Learn more about commercial features →`}),e[119]=g,e[120]=xe);let Se;e[121]!==J||e[122]!==xe?(Se=(0,K.jsxs)(T,{children:[me,(0,K.jsxs)(w,{children:[J,xe]})]}),e[121]=J,e[122]=xe,e[123]=Se):Se=e[123];let Ce;return e[124]!==N||e[125]!==H||e[126]!==U||e[127]!==Se?(Ce=(0,K.jsxs)(`div`,{className:N,children:[H,U,Se]}),e[124]=N,e[125]=H,e[126]=U,e[127]=Se,e[128]=Ce):Ce=e[128],Ce}function ge(e,t){return e+t.alloc_mem_gb}function _e(e,t){return e+t.total_mem_gb}function ve(e,t){return e+t.alloc_vcpu}function ye(e,t){return e+t.total_vcpu}function be(e){let t=(0,W.c)(5),{nodes:n}=e,r;t[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,K.jsx)(`thead`,{children:(0,K.jsxs)(`tr`,{className:`border-b text-left text-muted-foreground`,children:[(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Name`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Status`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Roles`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`IP`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Region`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`AZ`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`Uptime`}),(0,K.jsx)(`th`,{className:`pr-4 pb-1 font-medium`,children:`EC2`}),(0,K.jsx)(`th`,{className:`pb-1 font-medium`,children:`Services`})]})}),t[0]=r):r=t[0];let i;t[1]===n?i=t[2]:(i=n.map(xe),t[1]=n,t[2]=i);let a;return t[3]===i?a=t[4]:(a=(0,K.jsx)(`div`,{className:`overflow-x-auto`,children:(0,K.jsxs)(`table`,{className:`w-full text-xs`,children:[r,(0,K.jsx)(`tbody`,{children:i})]})}),t[3]=i,t[4]=a),a}function xe(e){return(0,K.jsxs)(`tr`,{className:`border-b last:border-0`,children:[(0,K.jsx)(`td`,{className:`py-1.5 pr-4 font-mono font-medium`,children:e.node}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,K.jsx)(se,{variant:e.status===`Ready`?`default`:`secondary`,className:`text-[0.625rem]`,children:e.status})}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4`,children:(0,K.jsxs)(`div`,{className:`flex gap-1`,children:[e.nats_role&&(0,K.jsxs)(`span`,{className:`text-muted-foreground`,children:[`nats:`,e.nats_role]}),e.predastore_role&&(0,K.jsxs)(`span`,{className:`text-muted-foreground`,children:[`ps:`,e.predastore_role]})]})}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4 font-mono`,children:e.host}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.region}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.az}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4`,children:q(e.uptime)}),(0,K.jsx)(`td`,{className:`py-1.5 pr-4`,children:e.vm_count}),(0,K.jsx)(`td`,{className:`py-1.5`,children:(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.services.length})})]},e.node)}var Se=`console.spx3.com`,Ce=`spinifex:v1:sandbox-welcome`,we=[{to:`/ec2/run-instances`,icon:(0,K.jsx)(h,{className:`size-3.5`}),label:`Launch an instance`,hint:`Boot a VM on real hardware`},{to:`/s3/ls`,icon:(0,K.jsx)(B,{className:`size-3.5`}),label:`Create a bucket`,hint:`S3-compatible object storage`},{to:`/ec2/describe-vpcs`,icon:(0,K.jsx)(u,{className:`size-3.5`}),label:`Networking`,hint:`VPCs, subnets and security groups`},{to:`/eks/list-clusters`,icon:(0,K.jsx)(me,{className:`size-3.5`}),label:`Kubernetes`,hint:`Managed EKS-compatible clusters`},{to:`/iam/list-users`,icon:(0,K.jsx)(m,{className:`size-3.5`}),label:`Access keys`,hint:`Credentials for the AWS CLI and SDKs`}];function Te(){try{return localStorage.getItem(Ce)!==null}catch{}return!1}function Ee(){try{localStorage.setItem(Ce,`1`)}catch{}}function De(){let e=(0,W.c)(7),[t,n]=(0,G.useState)(Oe),r;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(r=e=>{e||Ee(),n(e)},e[0]=r):r=e[0];let i=r,o;e[1]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,K.jsxs)(M,{children:[(0,K.jsx)(O,{children:(0,K.jsx)(R,{className:`text-primary`})}),(0,K.jsx)(A,{children:`Welcome to the Mulga sandbox`}),(0,K.jsx)(D,{children:`You have a real account on a shared evaluation cluster. The APIs are the AWS ones, so the CLI and the SDKs work against it unchanged. Resources here may be reclaimed, so please don't keep anything you need in it.`})]}),e[1]=o):o=e[1];let s;e[2]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,K.jsx)(`div`,{className:`space-y-0.5`,children:we.map(e=>(0,K.jsxs)(a,{className:`flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted`,onClick:()=>{i(!1)},to:e.to,children:[e.icon,(0,K.jsx)(`span`,{className:`font-medium`,children:e.label}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.hint})]},e.to))}),e[2]=s):s=e[2];let c;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,K.jsx)(`a`,{className:`text-xs text-muted-foreground underline underline-offset-3 hover:text-foreground sm:mr-auto sm:self-center`,href:`https://docs.mulgadc.com`,rel:`noreferrer`,target:`_blank`,children:`Read the docs`}),e[3]=c):c=e[3];let l;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,K.jsxs)(k,{className:`max-w-lg`,children:[o,s,(0,K.jsxs)(j,{children:[c,(0,K.jsx)(de,{onClick:()=>{i(!1)},children:`Get started`})]})]}),e[4]=l):l=e[4];let u;return e[5]===t?u=e[6]:(u=(0,K.jsx)(N,{onOpenChange:i,open:t,children:l}),e[5]=t,e[6]=u),u}function Oe(){return window.location.hostname===Se&&!Te()}var Y=5;function ke(){let e=(0,W.c)(12),t,n,r;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,K.jsx)(x,{title:`Dashboard`}),n=(0,K.jsx)(De,{}),r=(0,K.jsx)(J,{}),e[0]=t,e[1]=n,e[2]=r):(t=e[0],n=e[1],r=e[2]);let i;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(i=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(Ae,{})}),e[3]=i):i=e[3];let a;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(Le,{})}),e[4]=a):a=e[4];let o;e[5]===Symbol.for(`react.memo_cache_sentinel`)?(o=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(He,{})}),e[5]=o):o=e[5];let s;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(We,{})}),e[6]=s):s=e[6];let c;e[7]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(Ke,{})}),e[7]=c):c=e[7];let l;e[8]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(Je,{})}),e[8]=l):l=e[8];let u;e[9]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(Xe,{})}),e[9]=u):u=e[9];let d;e[10]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(et,{})}),e[10]=d):d=e[10];let f;return e[11]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,K.jsxs)(K.Fragment,{children:[t,n,r,(0,K.jsxs)(`div`,{className:`grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3`,children:[i,a,o,s,c,l,u,d,(0,K.jsx)(G.Suspense,{fallback:(0,K.jsx)(X,{}),children:(0,K.jsx)(Qe,{})})]})]}),e[11]=f):f=e[11],f}function X(){let e=(0,W.c)(2),t;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,K.jsx)(C,{children:(0,K.jsx)(`div`,{className:`h-4 w-32 animate-pulse rounded bg-muted`})}),e[0]=t):t=e[0];let n;return e[1]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsxs)(T,{children:[t,(0,K.jsx)(w,{children:(0,K.jsxs)(`div`,{className:`space-y-2`,children:[(0,K.jsx)(`div`,{className:`h-3 w-48 animate-pulse rounded bg-muted`}),(0,K.jsx)(`div`,{className:`h-3 w-36 animate-pulse rounded bg-muted`})]})})]}),e[1]=n):n=e[1],n}function Z(e){let t=(0,W.c)(16),{title:n,icon:r,count:i,to:o,children:s}=e,c;t[0]!==r||t[1]!==n?(c=(0,K.jsxs)(le,{className:`flex items-center gap-2`,children:[r,n]}),t[0]=r,t[1]=n,t[2]=c):c=t[2];let l;t[3]===i?l=t[4]:(l=(0,K.jsx)(S,{children:(0,K.jsx)(`span`,{className:`rounded-md bg-muted px-2 py-0.5 text-xs font-semibold tabular-nums`,children:i})}),t[3]=i,t[4]=l);let u;t[5]!==c||t[6]!==l?(u=(0,K.jsxs)(C,{children:[c,l]}),t[5]=c,t[6]=l,t[7]=u):u=t[7];let d;t[8]===s?d=t[9]:(d=(0,K.jsx)(w,{children:s}),t[8]=s,t[9]=d);let f;t[10]===o?f=t[11]:(f=(0,K.jsx)(ce,{children:(0,K.jsx)(a,{to:o,className:`text-xs font-medium text-primary hover:underline`,children:`View all →`})}),t[10]=o,t[11]=f);let p;return t[12]!==u||t[13]!==d||t[14]!==f?(p=(0,K.jsxs)(T,{children:[u,d,f]}),t[12]=u,t[13]=d,t[14]=f,t[15]=p):p=t[15],p}function Q(e){let t=(0,W.c)(2),{total:n}=e;if(n<=Y)return null;let r=n-Y,i;return t[0]===r?i=t[1]:(i=(0,K.jsxs)(`p`,{className:`mt-1 text-[0.625rem] text-muted-foreground`,children:[`+`,r,` more`]}),t[0]=r,t[1]=i),i}function $(e){let t=(0,W.c)(2),{children:n}=e,r;return t[0]===n?r=t[1]:(r=(0,K.jsx)(`div`,{className:`flex items-center justify-between py-0.5 text-xs`,children:n}),t[0]=n,t[1]=r),r}function Ae(){let e=(0,W.c)(16),{data:t}=i(ae),n,r,a,o,s;if(e[0]!==t?.Reservations){let i=t?.Reservations?.flatMap(Ie)??[];n=i.filter(Fe),a=i.filter(Pe),o=i.filter(Ne),r=i.filter(Me),s=[...a,...r].slice(0,Y),e[0]=t?.Reservations,e[1]=n,e[2]=r,e[3]=a,e[4]=o,e[5]=s}else n=e[1],r=e[2],a=e[3],o=e[4],s=e[5];let c=s,l;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,K.jsx)(h,{className:`size-3.5`}),e[6]=l):l=e[6];let u;e[7]!==n.length||e[8]!==c||e[9]!==r||e[10]!==a||e[11]!==o?(u=n.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No instances`}):(0,K.jsxs)(K.Fragment,{children:[(0,K.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,K.jsxs)(`span`,{children:[a.length,` running`]}),(0,K.jsxs)(`span`,{children:[o.length,` stopped`]})]}),(0,K.jsx)(`div`,{className:`space-y-0.5`,children:c.map(je)}),(0,K.jsx)(Q,{total:a.length+r.length})]}),e[7]=n.length,e[8]=c,e[9]=r,e[10]=a,e[11]=o,e[12]=u):u=e[12];let d;return e[13]!==n.length||e[14]!==u?(d=(0,K.jsx)(Z,{title:`Instances`,icon:l,count:n.length,to:`/ec2/describe-instances`,children:u}),e[13]=n.length,e[14]=u,e[15]=d):d=e[15],d}function je(e){return(0,K.jsxs)($,{children:[(0,K.jsx)(`span`,{className:`font-mono`,children:e.InstanceId}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.InstanceType})]},e.InstanceId)}function Me(e){return e.State?.Name===`pending`}function Ne(e){return e.State?.Name===`stopped`}function Pe(e){return e.State?.Name===`running`}function Fe(e){return e.State?.Name!==`terminated`}function Ie(e){return e.Instances??[]}function Le(){let e=(0,W.c)(14),{data:t}=i(re),n;e[0]===t?.Volumes?n=e[1]:(n=t?.Volumes??[],e[0]=t?.Volumes,e[1]=n);let r=n,a;e[2]===r?a=e[3]:(a=r.reduce(Ve,0),e[2]=r,e[3]=a);let o=a,s;e[4]===r?s=e[5]:(s=r.filter(Be),e[4]=r,e[5]=s);let c=s,l;e[6]===r?l=e[7]:(l=r.filter(ze),e[6]=r,e[7]=l);let u=l,d;if(e[8]!==u||e[9]!==c||e[10]!==o||e[11]!==r){let t=r.slice(0,Y),n;e[13]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(I,{className:`size-3.5`}),e[13]=n):n=e[13],d=(0,K.jsx)(Z,{title:`Volumes`,icon:n,count:r.length,to:`/ec2/describe-volumes`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No volumes`}):(0,K.jsxs)(K.Fragment,{children:[(0,K.jsxs)(`div`,{className:`mb-2 flex gap-3 text-[0.625rem] text-muted-foreground`,children:[(0,K.jsxs)(`span`,{children:[o,` GiB total`]}),(0,K.jsxs)(`span`,{children:[c.length,` in-use`]}),(0,K.jsxs)(`span`,{children:[u.length,` available`]})]}),(0,K.jsx)(`div`,{className:`space-y-0.5`,children:t.map(Re)}),(0,K.jsx)(Q,{total:r.length})]})}),e[8]=u,e[9]=c,e[10]=o,e[11]=r,e[12]=d}else d=e[12];return d}function Re(e){return(0,K.jsxs)($,{children:[(0,K.jsx)(`span`,{className:`font-mono`,children:e.VolumeId}),(0,K.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,K.jsxs)(`span`,{className:`text-muted-foreground`,children:[e.Size,` GiB`]}),(0,K.jsx)(P,{state:e.State})]})]},e.VolumeId)}function ze(e){return e.State===`available`}function Be(e){return e.State===`in-use`}function Ve(e,t){return e+(t.Size??0)}function He(){let e=(0,W.c)(5),{data:t}=i(g),n;e[0]===t?.Vpcs?n=e[1]:(n=t?.Vpcs??[],e[0]=t?.Vpcs,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(u,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`VPCs`,icon:n,count:r.length,to:`/ec2/describe-vpcs`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No VPCs`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ue),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ue(e){let t=o(e.Tags);return(0,K.jsxs)($,{children:[(0,K.jsxs)(`span`,{className:`font-mono`,children:[e.VpcId,t&&(0,K.jsxs)(`span`,{className:`ml-1 font-sans text-muted-foreground`,children:[`(`,t,`)`]})]}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.CidrBlock})]},e.VpcId)}function We(){let e=(0,W.c)(5),{data:t}=i(oe),n;e[0]===t?.KeyPairs?n=e[1]:(n=t?.KeyPairs??[],e[0]=t?.KeyPairs,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(m,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`Key Pairs`,icon:n,count:r.length,to:`/ec2/describe-key-pairs`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No key pairs`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ge),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ge(e){return(0,K.jsx)($,{children:(0,K.jsx)(`span`,{className:`font-mono`,children:e.KeyName})},e.KeyPairId)}function Ke(){let e=(0,W.c)(5),{data:t}=i(b),n;e[0]===t?.Buckets?n=e[1]:(n=t?.Buckets??[],e[0]=t?.Buckets,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(B,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`S3 Buckets`,icon:n,count:r.length,to:`/s3/ls`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No buckets`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(qe),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function qe(e){return(0,K.jsx)($,{children:(0,K.jsx)(`span`,{className:`font-mono`,children:e.Name})},e.Name)}function Je(){let e=(0,W.c)(5),{data:t}=i(y),n;e[0]===t?.SecurityGroups?n=e[1]:(n=t?.SecurityGroups??[],e[0]=t?.SecurityGroups,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(p,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`Security Groups`,icon:n,count:r.length,to:`/ec2/describe-security-groups`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No security groups`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ye),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ye(e){return(0,K.jsxs)($,{children:[(0,K.jsx)(`span`,{className:`font-mono`,children:e.GroupName}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.VpcId})]},e.GroupId)}function Xe(){let e=(0,W.c)(5),{data:t}=i(ie),n;e[0]===t?.NatGateways?n=e[1]:(n=t?.NatGateways??[],e[0]=t?.NatGateways,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(te,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`NAT Gateways`,icon:n,count:r.length,to:`/ec2/describe-nat-gateways`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No NAT gateways`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(Ze),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function Ze(e){return(0,K.jsxs)($,{children:[(0,K.jsx)(`span`,{className:`font-mono`,children:e.NatGatewayId}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.State})]},e.NatGatewayId)}function Qe(){let e=(0,W.c)(5),{data:t}=i(_),n;e[0]===t?.InternetGateways?n=e[1]:(n=t?.InternetGateways??[],e[0]=t?.InternetGateways,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(f,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`Internet Gateways`,icon:n,count:r.length,to:`/ec2/describe-internet-gateways`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No internet gateways`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map($e),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function $e(e){return(0,K.jsxs)($,{children:[(0,K.jsx)(`span`,{className:`font-mono`,children:e.InternetGatewayId}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.Attachments?.[0]?.VpcId?`attached`:`detached`})]},e.InternetGatewayId)}function et(){let e=(0,W.c)(5),{data:t}=i(v),n;e[0]===t?.Addresses?n=e[1]:(n=t?.Addresses??[],e[0]=t?.Addresses,e[1]=n);let r=n,a;if(e[2]!==r){let t=r.slice(0,Y),n;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(n=(0,K.jsx)(d,{className:`size-3.5`}),e[4]=n):n=e[4],a=(0,K.jsx)(Z,{title:`Elastic IPs`,icon:n,count:r.length,to:`/ec2/describe-addresses`,children:r.length===0?(0,K.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`No Elastic IPs`}):(0,K.jsxs)(`div`,{className:`space-y-0.5`,children:[t.map(tt),(0,K.jsx)(Q,{total:r.length})]})}),e[2]=r,e[3]=a}else a=e[3];return a}function tt(e){return(0,K.jsxs)($,{children:[(0,K.jsx)(`span`,{className:`font-mono`,children:e.PublicIp}),(0,K.jsx)(`span`,{className:`text-muted-foreground`,children:e.AssociationId?`associated`:`available`})]},e.AllocationId)}export{ke as component}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id-BnUjyn0E.js b/spinifex/services/spinifexui/frontend/dist/assets/_id-BnUjyn0E.js deleted file mode 100644 index daf2c4791..000000000 --- a/spinifex/services/spinifexui/frontend/dist/assets/_id-BnUjyn0E.js +++ /dev/null @@ -1 +0,0 @@ -import{n as e,o as t,r as n,t as r}from"./compiler-runtime-D_vZfn79.js";import{t as i}from"./useQuery-2EKf7AyV.js";import{t as a}from"./useSuspenseQuery-Bc7E1P-f.js";import{t as o}from"./link-Dhy4xOXd.js";import{in as s,on as c}from"./cluster-config-xw_Q7X1e.js";import{t as l}from"./createLucideIcon-BQgzNCBR.js";import{t as u}from"./back-link-CjKy1JE5.js";import{c as d,i as f,n as p,s as m,t as h}from"./select-B01jpDaX.js";import{t as g}from"./rotate-cw-FTUcK_f-.js";import{t as _}from"./trash-DVab4pEz.js";import{t as v}from"./button-BarTr35m.js";import{t as y}from"./input-6kdUb_DH.js";import{n as b}from"./admin-context-CXmiyIcc.js";import{a as x,c as S,i as C,s as w}from"./ec2-CWEplLq4.js";import{J as T,K as E,W as D,Z as O,d as k,i as ee,it as A,nt as j,rt as te}from"./ec2-Ctto2xjU.js";import{f as ne}from"./iam-CVg27y22.js";import{t as re}from"./_id-U4BZhcdn.js";import{t as ie}from"./page-heading-vksh_67w.js";import{o as ae}from"./admin-BUkrFIlI.js";import{t as M}from"./error-banner-DeOeVBKS.js";import{s as oe,t as se}from"./field-c7GHI5st.js";import{t as ce}from"./textarea-n04EavUm.js";import{a as le,i as ue,l as de,n as fe,o as pe,r as me,s as he,t as ge}from"./alert-dialog-kCQ1W4Rz.js";import{t as _e}from"./delete-confirmation-dialog-SdDN4LiF.js";import{n as N,t as P}from"./detail-row-DNBRlVeK.js";import{t as ve}from"./state-badge-D1Z6q_OA.js";import{t as ye}from"./ami-details-ChpQ4w1b.js";import{t as be}from"./gpu-instance-type-select-BD2r8UcO.js";var xe={name:`image-plus`,size:24,node:[[`path`,{d:`M16 5h6`,key:`1vod17`}],[`path`,{d:`M19 2v6`,key:`4bpg5p`}],[`path`,{d:`M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5`,key:`1ue2ih`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}]]};xe.node;var Se=l(xe),Ce={name:`pause`,size:24,node:[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]};Ce.node;var we=l(Ce),Te={name:`play`,size:24,node:[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]};Te.node;var F=l(Te),I={name:`settings-2`,size:24,node:[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]};I.node;var Ee=l(I),L={name:`terminal`,size:24,node:[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]};L.node;var De=l(L),Oe=r(),R=t(n(),1),z=e();function ke(e){let t=(0,Oe.c)(36),{instanceId:n,open:r,onOpenChange:i}=e,a=k(),[o,s]=(0,R.useState)(``),[c,l]=(0,R.useState)(``),[u,d]=(0,R.useState)(null),f;t[0]===i?f=t[1]:(f=function(e){e||(s(``),l(``),d(null)),i(e)},t[0]=i,t[1]=f);let p=f,m;t[2]!==a||t[3]!==c||t[4]!==o||t[5]!==n?(m=async function(){try{let e=await a.mutateAsync({instanceId:n,name:o,description:c||void 0});d(e.ImageId??null),s(``),l(``)}catch{}},t[2]=a,t[3]=c,t[4]=o,t[5]=n,t[6]=m):m=t[6];let h=m,g=u?`Image Created`:`Create Image (AMI)`,_;t[7]===g?_=t[8]:(_=(0,z.jsx)(de,{children:g}),t[7]=g,t[8]=_);let v=u?`Image "${u}" was created successfully.`:`Create an AMI from instance "${n}".`,b;t[9]===v?b=t[10]:(b=(0,z.jsx)(le,{children:v}),t[9]=v,t[10]=b);let x;t[11]!==_||t[12]!==b?(x=(0,z.jsxs)(he,{children:[_,b]}),t[11]=_,t[12]=b,t[13]=x):x=t[13];let S;t[14]!==u||t[15]!==c||t[16]!==o?(S=!u&&(0,z.jsxs)(z.Fragment,{children:[(0,z.jsxs)(se,{children:[(0,z.jsx)(oe,{children:(0,z.jsx)(`label`,{htmlFor:`imageName`,children:`Name`})}),(0,z.jsx)(y,{id:`imageName`,onChange:e=>{s(e.target.value)},placeholder:`my-image`,value:o})]}),(0,z.jsxs)(se,{children:[(0,z.jsx)(oe,{children:(0,z.jsx)(`label`,{htmlFor:`imageDescription`,children:`Description`})}),(0,z.jsx)(ce,{id:`imageDescription`,onChange:e=>{l(e.target.value)},placeholder:`Optional description`,rows:2,value:c})]})]}),t[14]=u,t[15]=c,t[16]=o,t[17]=S):S=t[17];let C=u?`Close`:`Cancel`,w;t[18]===C?w=t[19]:(w=(0,z.jsx)(me,{children:C}),t[18]=C,t[19]=w);let T;t[20]!==a||t[21]!==u||t[22]!==h||t[23]!==o?(T=!u&&(0,z.jsx)(fe,{disabled:a.isPending||!o.trim(),onClick:h,children:a.isPending?`Creating…`:`Create Image`}),t[20]=a,t[21]=u,t[22]=h,t[23]=o,t[24]=T):T=t[24];let E;t[25]!==w||t[26]!==T?(E=(0,z.jsxs)(pe,{children:[w,T]}),t[25]=w,t[26]=T,t[27]=E):E=t[27];let D;t[28]!==E||t[29]!==x||t[30]!==S?(D=(0,z.jsxs)(ue,{children:[x,S,E]}),t[28]=E,t[29]=x,t[30]=S,t[31]=D):D=t[31];let O;return t[32]!==p||t[33]!==r||t[34]!==D?(O=(0,z.jsx)(ge,{onOpenChange:p,open:r,children:D}),t[32]=p,t[33]=r,t[34]=D,t[35]=O):O=t[35],O}function Ae(e){let t=(0,Oe.c)(9),{gpus:n}=e;if(!n?.length)return null;let r=n.length===1?`GPU`:`GPUs (${n.length})`,i;t[0]===r?i=t[1]:(i=(0,z.jsx)(N.Header,{children:r}),t[0]=r,t[1]=i);let a;if(t[2]!==n){let e;t[4]===n.length?e=t[5]:(e=(e,t)=>(0,z.jsxs)(`div`,{className:`border-b last:border-b-0`,children:[n.length>1&&(0,z.jsxs)(`h3`,{className:`bg-muted/30 px-4 py-2 text-sm font-medium`,children:[`GPU `,t+1]}),(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Model`,value:e.model}),(0,z.jsx)(P,{label:`VRAM`,value:c(e.vram_mib)}),(0,z.jsx)(P,{label:`Attachment`,value:e.profile?`MIG slice`:`PCIe passthrough`}),e.profile&&(0,z.jsx)(P,{label:`Profile`,value:e.profile}),e.mdev_path&&(0,z.jsx)(P,{label:`Mdev path`,value:e.mdev_path}),e.pci_address&&(0,z.jsx)(P,{label:`PCI address`,value:e.pci_address})]})]},e.mdev_path??e.pci_address??`${e.model}-${t}`),t[4]=n.length,t[5]=e),a=n.map(e),t[2]=n,t[3]=a}else a=t[3];let o;return t[6]!==i||t[7]!==a?(o=(0,z.jsxs)(N,{children:[i,a]}),t[6]=i,t[7]=a,t[8]=o):o=t[8],o}function je(e){let t=(0,Oe.c)(31),{instanceId:n}=e,r;t[0]===n?r=t[1]:(r=C(n),t[0]=n,t[1]=r);let{data:a}=i(r),{data:s}=i(ne),c=ee(),l=D(),[u,f]=(0,R.useState)(``),g;t[2]===a?.IamInstanceProfileAssociations?g=t[3]:(g=a?.IamInstanceProfileAssociations?.find(Ne),t[2]=a?.IamInstanceProfileAssociations,t[3]=g);let _=g,y;t[4]===s?.InstanceProfiles?y=t[5]:(y=s?.InstanceProfiles??[],t[4]=s?.InstanceProfiles,t[5]=y);let b=y,x;t[6]!==c||t[7]!==n||t[8]!==u?(x=async()=>{u&&(await c.mutateAsync({instanceId:n,instanceProfileName:u}),f(``))},t[6]=c,t[7]=n,t[8]=u,t[9]=x):x=t[9];let S=x,w;t[10]!==_||t[11]!==l||t[12]!==n?(w=async()=>{_?.AssociationId&&await l.mutateAsync({associationId:_.AssociationId,instanceId:n})},t[10]=_,t[11]=l,t[12]=n,t[13]=w):w=t[13];let T=w,E;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(E=(0,z.jsx)(N.Header,{children:`IAM Role`}),t[14]=E):E=t[14];let O;t[15]===c.error?O=t[16]:(O=c.error&&(0,z.jsx)(`div`,{className:`col-span-2`,children:(0,z.jsx)(M,{error:c.error,msg:`Failed to associate instance profile`})}),t[15]=c.error,t[16]=O);let k;t[17]===l.error?k=t[18]:(k=l.error&&(0,z.jsx)(`div`,{className:`col-span-2`,children:(0,z.jsx)(M,{error:l.error,msg:`Failed to disassociate instance profile`})}),t[17]=l.error,t[18]=k);let A;t[19]!==c.isPending||t[20]!==_||t[21]!==l.isPending||t[22]!==S||t[23]!==T||t[24]!==b||t[25]!==u?(A=_?(0,z.jsxs)(z.Fragment,{children:[(0,z.jsx)(P,{label:`Instance Profile ARN`,value:_.IamInstanceProfile?.Arn}),(0,z.jsx)(P,{label:`State`,value:(0,z.jsx)(ve,{state:_.State})}),(0,z.jsx)(`div`,{className:`col-span-2 flex justify-end`,children:(0,z.jsx)(v,{disabled:l.isPending,onClick:T,size:`sm`,variant:`destructive`,children:l.isPending?`Detaching…`:`Detach`})})]}):(0,z.jsxs)(`div`,{className:`col-span-2 space-y-3`,children:[(0,z.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No instance profile is associated. Associate one to flow role credentials to this instance via IMDS.`}),b.length>0?(0,z.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,z.jsxs)(h,{onValueChange:e=>{f(e??``)},value:u,children:[(0,z.jsx)(m,{className:`w-full`,children:(0,z.jsx)(d,{placeholder:`Select an instance profile`})}),(0,z.jsx)(p,{children:b.map(Me)})]}),(0,z.jsx)(v,{disabled:!u||c.isPending,onClick:S,size:`sm`,children:c.isPending?`Associating…`:`Associate`})]}):(0,z.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`No instance profiles exist.`,` `,(0,z.jsx)(o,{className:`text-primary hover:underline`,to:`/iam/create-instance-profile`,children:`Create one`}),` `,`first.`]})]}),t[19]=c.isPending,t[20]=_,t[21]=l.isPending,t[22]=S,t[23]=T,t[24]=b,t[25]=u,t[26]=A):A=t[26];let j;return t[27]!==O||t[28]!==k||t[29]!==A?(j=(0,z.jsxs)(N,{children:[E,(0,z.jsxs)(N.Content,{children:[O,k,A]})]}),t[27]=O,t[28]=k,t[29]=A,t[30]=j):j=t[30],j}function Me(e){return(0,z.jsx)(f,{value:e.InstanceProfileName??``,children:e.InstanceProfileName},e.InstanceProfileName)}function Ne(e){return e.State===`associating`||e.State===`associated`}var Pe=new Set([`pending`,`stopping`,`shutting-down`,`terminated`]);function Fe(e){let t=(0,Oe.c)(43),{instanceId:n,state:r}=e,i=j(),a=te(),o=O(),s=A(),[c,l]=(0,R.useState)(!1),u=Pe.has(r??``),d;t[0]!==n||t[1]!==s?(d=()=>{s.mutate(n,{onSettled:()=>{l(!1)}})},t[0]=n,t[1]=s,t[2]=d):d=t[2];let f=d;if(u&&r!==`terminated`){let e;return t[3]===r?e=t[4]:(e=(0,z.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,z.jsxs)(`p`,{className:`text-center text-sm text-muted-foreground`,children:[`Instance is `,r,`. Actions will be available once the operation completes.`]})}),t[3]=r,t[4]=e),e}if(r===`terminated`){let e;return t[5]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,z.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,z.jsx)(`p`,{className:`text-center text-sm text-muted-foreground`,children:`This instance has been terminated and cannot be managed.`})}),t[5]=e):e=t[5],e}let p;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,z.jsx)(`h2`,{className:`mb-4 font-semibold`,children:`Instance Actions`}),t[6]=p):p=t[6];let m;t[7]===i.error?m=t[8]:(m=i.error&&(0,z.jsx)(M,{error:i.error,msg:`Failed to start instance`}),t[7]=i.error,t[8]=m);let h;t[9]===a.error?h=t[10]:(h=a.error&&(0,z.jsx)(M,{error:a.error,msg:`Failed to stop instance`}),t[9]=a.error,t[10]=h);let y;t[11]===o.error?y=t[12]:(y=o.error&&(0,z.jsx)(M,{error:o.error,msg:`Failed to reboot instance`}),t[11]=o.error,t[12]=y);let b;t[13]===s.error?b=t[14]:(b=s.error&&(0,z.jsx)(M,{error:s.error,msg:`Failed to terminate instance`}),t[13]=s.error,t[14]=b);let x;t[15]!==n||t[16]!==i||t[17]!==r?(x=r===`stopped`&&(0,z.jsxs)(v,{disabled:i.isPending,onClick:()=>{i.mutate(n)},size:`sm`,variant:`default`,children:[(0,z.jsx)(F,{className:`size-4`}),i.isPending?`Starting`:`Start`]}),t[15]=n,t[16]=i,t[17]=r,t[18]=x):x=t[18];let S;t[19]!==n||t[20]!==o||t[21]!==r||t[22]!==a?(S=r===`running`&&(0,z.jsxs)(z.Fragment,{children:[(0,z.jsxs)(v,{disabled:a.isPending,onClick:()=>{a.mutate(n)},size:`sm`,variant:`outline`,children:[(0,z.jsx)(we,{className:`size-4`}),a.isPending?`Stopping`:`Stop`]}),(0,z.jsxs)(v,{disabled:o.isPending,onClick:()=>{o.mutate(n)},size:`sm`,variant:`outline`,children:[(0,z.jsx)(g,{className:`size-4`}),o.isPending?`Rebooting`:`Reboot`]})]}),t[19]=n,t[20]=o,t[21]=r,t[22]=a,t[23]=S):S=t[23];let C;t[24]!==r||t[25]!==s.isPending?(C=(r===`stopped`||r===`running`)&&(0,z.jsxs)(v,{disabled:s.isPending,onClick:()=>{l(!0)},size:`sm`,variant:`destructive`,children:[(0,z.jsx)(_,{className:`size-4`}),s.isPending?`Terminating`:`Terminate`]}),t[24]=r,t[25]=s.isPending,t[26]=C):C=t[26];let w;t[27]!==x||t[28]!==S||t[29]!==C?(w=(0,z.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[x,S,C]}),t[27]=x,t[28]=S,t[29]=C,t[30]=w):w=t[30];let T=`Are you sure you want to terminate the instance "${n}"? This action cannot be undone.`,E;t[31]!==f||t[32]!==c||t[33]!==T||t[34]!==s.isPending?(E=(0,z.jsx)(_e,{confirmLabel:`Terminate`,description:T,isPending:s.isPending,onConfirm:f,onOpenChange:l,open:c,pendingLabel:`Terminating…`,title:`Terminate Instance`}),t[31]=f,t[32]=c,t[33]=T,t[34]=s.isPending,t[35]=E):E=t[35];let D;return t[36]!==w||t[37]!==E||t[38]!==m||t[39]!==h||t[40]!==y||t[41]!==b?(D=(0,z.jsxs)(`div`,{className:`rounded-lg border bg-card p-4`,children:[p,m,h,y,b,w,E]}),t[36]=w,t[37]=E,t[38]=m,t[39]=h,t[40]=y,t[41]=b,t[42]=D):D=t[42],D}function Ie(){let e=(0,Oe.c)(136),{id:t}=re.useParams(),{isAdmin:n}=b(),r;e[0]===t?r=e[1]:(r=w(t),e[0]=t,e[1]=r);let{data:c}=a(r),l=c.Reservations?.[0]?.Instances?.[0],d=l?.ImageId,f;e[2]===d?f=e[3]:(f=x(d),e[2]=d,e[3]=f);let{data:p}=a(f),m=p?.Images?.[0],{data:h}=a(S),g;e[4]===n?g=e[5]:(g={...ae,enabled:n,refetchInterval:5e3},e[4]=n,e[5]=g);let{data:_}=i(g),y;e[6]!==t||e[7]!==_?.vms?(y=_?.vms?.find(e=>e.instance_id===t),e[6]=t,e[7]=_?.vms,e[8]=y):y=e[8];let C=y,D=T(),O=E(),[k,ee]=(0,R.useState)(!1),[A,j]=(0,R.useState)(``),[te,ne]=(0,R.useState)(!1),[ce,_e]=(0,R.useState)(null),[xe,Ce]=(0,R.useState)(!1);if(!l?.InstanceId){let t;return e[9]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,z.jsxs)(z.Fragment,{children:[(0,z.jsx)(u,{to:`/ec2/describe-instances`,children:`Back to instances`}),(0,z.jsx)(`p`,{className:`text-muted-foreground`,children:`Instance not found.`})]}),e[9]=t):t=e[9],t}let we;e[10]===l.LaunchTime?we=e[11]:(we=s(l.LaunchTime),e[10]=l.LaunchTime,e[11]=we);let Te=we,F=l.State?.Name===`running`,I=l.State?.Name===`stopped`,L;e[12]!==t||e[13]!==D||e[14]!==A?(L=async function(){try{await D.mutateAsync({instanceId:t,instanceType:A}),ee(!1),j(``)}catch{}},e[12]=t,e[13]=D,e[14]=A,e[15]=L):L=e[15];let Me=L,Ne;e[16]!==O||e[17]!==t?(Ne=async function(){try{let e=await O.mutateAsync(t),n=e.Output?atob(e.Output):`(no output)`;_e(n),ne(!0)}catch{}},e[16]=O,e[17]=t,e[18]=Ne):Ne=e[18];let Pe=Ne,Ie;e[19]===Symbol.for(`react.memo_cache_sentinel`)?(Ie=(0,z.jsx)(u,{to:`/ec2/describe-instances`,children:`Back to instances`}),e[19]=Ie):Ie=e[19];let B;e[20]===D.error?B=e[21]:(B=D.error&&(0,z.jsx)(M,{error:D.error,msg:`Failed to modify instance`}),e[20]=D.error,e[21]=B);let V;e[22]===O.error?V=e[23]:(V=O.error&&(0,z.jsx)(M,{error:O.error,msg:`Failed to get console output`}),e[22]=O.error,e[23]=V);let H;e[24]!==O.isPending||e[25]!==Pe||e[26]!==F?(H=F&&(0,z.jsxs)(v,{disabled:O.isPending,onClick:Pe,size:`sm`,variant:`outline`,children:[(0,z.jsx)(De,{className:`size-4`}),O.isPending?`Fetching…`:`View Console`]}),e[24]=O.isPending,e[25]=Pe,e[26]=F,e[27]=H):H=e[27];let U;e[28]!==F||e[29]!==I?(U=(F||I)&&(0,z.jsxs)(v,{onClick:()=>{Ce(!0)},size:`sm`,variant:`outline`,children:[(0,z.jsx)(Se,{className:`size-4`}),`Create Image`]}),e[28]=F,e[29]=I,e[30]=U):U=e[30];let W;e[31]!==l.InstanceType||e[32]!==I?(W=I&&(0,z.jsxs)(v,{onClick:()=>{j(l.InstanceType??``),ee(!0)},size:`sm`,variant:`outline`,children:[(0,z.jsx)(Ee,{className:`size-4`}),`Modify Instance`]}),e[31]=l.InstanceType,e[32]=I,e[33]=W):W=e[33];let ze=l.State?.Name,G;e[34]===ze?G=e[35]:(G=(0,z.jsx)(ve,{state:ze}),e[34]=ze,e[35]=G);let K;e[36]!==H||e[37]!==U||e[38]!==W||e[39]!==G?(K=(0,z.jsxs)(`div`,{className:`flex items-center gap-2`,children:[H,U,W,G]}),e[36]=H,e[37]=U,e[38]=W,e[39]=G,e[40]=K):K=e[40];let q;e[41]!==l.InstanceId||e[42]!==K?(q=(0,z.jsx)(ie,{actions:K,subtitle:`Instance Details`,title:l.InstanceId}),e[41]=l.InstanceId,e[42]=K,e[43]=q):q=e[43];let Be=l.State?.Name,J;e[44]!==l.InstanceId||e[45]!==Be?(J=(0,z.jsx)(Fe,{instanceId:l.InstanceId,state:Be}),e[44]=l.InstanceId,e[45]=Be,e[46]=J):J=e[46];let Y;e[47]!==ce||e[48]!==te?(Y=te&&ce!==null&&(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:(0,z.jsxs)(`div`,{className:`flex items-center justify-between`,children:[`Console Output`,(0,z.jsx)(v,{onClick:()=>{ne(!1),_e(null)},size:`sm`,variant:`ghost`,children:`Hide`})]})}),(0,z.jsx)(`div`,{className:`p-4`,children:(0,z.jsx)(`pre`,{className:`max-h-96 overflow-y-auto rounded bg-muted p-3 font-mono text-xs whitespace-pre-wrap`,children:ce})})]}),e[47]=ce,e[48]=te,e[49]=Y):Y=e[49];let Ve;e[50]===Symbol.for(`react.memo_cache_sentinel`)?(Ve=(0,z.jsx)(N.Header,{children:`Instance Information`}),e[50]=Ve):Ve=e[50];let X;e[51]===l.InstanceId?X=e[52]:(X=(0,z.jsx)(P,{label:`Instance ID`,value:l.InstanceId}),e[51]=l.InstanceId,e[52]=X);let Z;e[53]===l.InstanceType?Z=e[54]:(Z=(0,z.jsx)(P,{label:`Instance Type`,value:l.InstanceType}),e[53]=l.InstanceType,e[54]=Z);let He=l.State?.Name,Ue;e[55]===He?Ue=e[56]:(Ue=(0,z.jsx)(P,{label:`Instance State`,value:He}),e[55]=He,e[56]=Ue);let We;e[57]===l.State?.Code?We=e[58]:(We=l.State?.Code?.toString(),e[57]=l.State?.Code,e[58]=We);let Ge;e[59]===We?Ge=e[60]:(Ge=(0,z.jsx)(P,{label:`State Code`,value:We}),e[59]=We,e[60]=Ge);let Ke;e[61]===Te?Ke=e[62]:(Ke=(0,z.jsx)(P,{label:`Launch Time`,value:Te}),e[61]=Te,e[62]=Ke);let qe=l.Placement?.AvailabilityZone,Je;e[63]===qe?Je=e[64]:(Je=(0,z.jsx)(P,{label:`Availability Zone`,value:qe}),e[63]=qe,e[64]=Je);let Ye;e[65]===l.Placement?Ye=e[66]:(Ye=l.Placement?.GroupName?(0,z.jsx)(o,{className:`text-primary hover:underline`,to:`/ec2/describe-placement-groups`,children:l.Placement.GroupName}):void 0,e[65]=l.Placement,e[66]=Ye);let Xe;e[67]===Ye?Xe=e[68]:(Xe=(0,z.jsx)(P,{label:`Placement Group`,value:Ye}),e[67]=Ye,e[68]=Xe);let Ze;e[69]!==X||e[70]!==Z||e[71]!==Ue||e[72]!==Ge||e[73]!==Ke||e[74]!==Je||e[75]!==Xe?(Ze=(0,z.jsxs)(N,{children:[Ve,(0,z.jsxs)(N.Content,{children:[X,Z,Ue,Ge,Ke,Je,Xe]})]}),e[69]=X,e[70]=Z,e[71]=Ue,e[72]=Ge,e[73]=Ke,e[74]=Je,e[75]=Xe,e[76]=Ze):Ze=e[76];let Qe;e[77]!==l.KeyName||e[78]!==l.PrivateDnsName||e[79]!==l.PrivateIpAddress||e[80]!==l.PublicDnsName||e[81]!==l.PublicIpAddress||e[82]!==l.SecurityGroups||e[83]!==l.SubnetId||e[84]!==l.VpcId?(Qe=(l.PrivateIpAddress||l.PublicIpAddress||l.VpcId||l.SubnetId||l.SecurityGroups&&l.SecurityGroups.length>0)&&(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:`Network & Security`}),(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Private IP Address`,value:l.PrivateIpAddress}),(0,z.jsx)(P,{label:`Public IP Address`,value:l.PublicIpAddress}),(0,z.jsx)(P,{label:`Private DNS`,value:l.PrivateDnsName}),(0,z.jsx)(P,{label:`Public DNS`,value:l.PublicDnsName}),(0,z.jsx)(P,{label:`VPC ID`,value:l.VpcId?(0,z.jsx)(o,{className:`text-primary hover:underline`,params:{id:l.VpcId},to:`/ec2/describe-vpcs/$id`,children:l.VpcId}):void 0}),(0,z.jsx)(P,{label:`Subnet ID`,value:l.SubnetId?(0,z.jsx)(o,{className:`text-primary hover:underline`,params:{id:l.SubnetId},to:`/ec2/describe-subnets/$id`,children:l.SubnetId}):void 0}),(0,z.jsx)(P,{label:`Key Name`,value:l.KeyName}),(0,z.jsx)(P,{label:`Security Groups`,value:l.SecurityGroups?.map(Re).join(`, `)})]})]}),e[77]=l.KeyName,e[78]=l.PrivateDnsName,e[79]=l.PrivateIpAddress,e[80]=l.PublicDnsName,e[81]=l.PublicIpAddress,e[82]=l.SecurityGroups,e[83]=l.SubnetId,e[84]=l.VpcId,e[85]=Qe):Qe=e[85];let $e;e[86]===l.InstanceId?$e=e[87]:($e=(0,z.jsx)(je,{instanceId:l.InstanceId}),e[86]=l.InstanceId,e[87]=$e);let et;e[88]!==n||e[89]!==C?.gpus?(et=n&&(0,z.jsx)(Ae,{gpus:C?.gpus}),e[88]=n,e[89]=C?.gpus,e[90]=et):et=e[90];let tt;e[91]===l.BlockDeviceMappings?tt=e[92]:(tt=l.BlockDeviceMappings&&l.BlockDeviceMappings.length>0&&(0,z.jsxs)(N,{children:[(0,z.jsx)(N.Header,{children:`Block Device Mappings`}),l.BlockDeviceMappings.map(Le)]}),e[91]=l.BlockDeviceMappings,e[92]=tt);let nt;e[93]===m?nt=e[94]:(nt=m&&(0,z.jsx)(ye,{image:m}),e[93]=m,e[94]=nt);let Q;e[95]!==q||e[96]!==J||e[97]!==Y||e[98]!==Ze||e[99]!==Qe||e[100]!==$e||e[101]!==et||e[102]!==tt||e[103]!==nt?(Q=(0,z.jsxs)(`div`,{className:`space-y-6`,children:[q,J,Y,Ze,Qe,$e,et,tt,nt]}),e[95]=q,e[96]=J,e[97]=Y,e[98]=Ze,e[99]=Qe,e[100]=$e,e[101]=et,e[102]=tt,e[103]=nt,e[104]=Q):Q=e[104];let rt;e[105]===Symbol.for(`react.memo_cache_sentinel`)?(rt=e=>{e||(ee(!1),j(``))},e[105]=rt):rt=e[105];let it;e[106]===Symbol.for(`react.memo_cache_sentinel`)?(it=(0,z.jsx)(de,{children:`Modify Instance`}),e[106]=it):it=e[106];let at;e[107]===l.InstanceId?at=e[108]:(at=(0,z.jsxs)(he,{children:[it,(0,z.jsxs)(le,{children:[`Select a new instance type for "`,l.InstanceId,`". The instance must be stopped to modify its attributes.`]})]}),e[107]=l.InstanceId,e[108]=at);let ot;e[109]===Symbol.for(`react.memo_cache_sentinel`)?(ot=(0,z.jsx)(oe,{children:(0,z.jsx)(`label`,{htmlFor:`instanceType`,children:`Instance Type`})}),e[109]=ot):ot=e[109];let st;e[110]===h.InstanceTypes?st=e[111]:(st=h.InstanceTypes??[],e[110]=h.InstanceTypes,e[111]=st);let ct;e[112]!==A||e[113]!==st?(ct=(0,z.jsxs)(se,{children:[ot,(0,z.jsx)(be,{className:`w-full`,id:`instanceType`,instanceTypes:st,onValueChange:j,value:A})]}),e[112]=A,e[113]=st,e[114]=ct):ct=e[114];let lt;e[115]===Symbol.for(`react.memo_cache_sentinel`)?(lt=(0,z.jsx)(me,{children:`Cancel`}),e[115]=lt):lt=e[115];let ut=D.isPending||!A||A===l.InstanceType,dt=D.isPending?`Saving…`:`Save Changes`,ft;e[116]!==Me||e[117]!==ut||e[118]!==dt?(ft=(0,z.jsxs)(pe,{children:[lt,(0,z.jsx)(fe,{disabled:ut,onClick:Me,children:dt})]}),e[116]=Me,e[117]=ut,e[118]=dt,e[119]=ft):ft=e[119];let pt;e[120]!==at||e[121]!==ct||e[122]!==ft?(pt=(0,z.jsxs)(ue,{children:[at,ct,ft]}),e[120]=at,e[121]=ct,e[122]=ft,e[123]=pt):pt=e[123];let mt;e[124]!==k||e[125]!==pt?(mt=(0,z.jsx)(ge,{onOpenChange:rt,open:k,children:pt}),e[124]=k,e[125]=pt,e[126]=mt):mt=e[126];let $;e[127]!==l.InstanceId||e[128]!==xe?($=(0,z.jsx)(ke,{instanceId:l.InstanceId,onOpenChange:Ce,open:xe}),e[127]=l.InstanceId,e[128]=xe,e[129]=$):$=e[129];let ht;return e[130]!==V||e[131]!==Q||e[132]!==mt||e[133]!==$||e[134]!==B?(ht=(0,z.jsxs)(z.Fragment,{children:[Ie,B,V,Q,mt,$]}),e[130]=V,e[131]=Q,e[132]=mt,e[133]=$,e[134]=B,e[135]=ht):ht=e[135],ht}function Le(e){return(0,z.jsxs)(N.Content,{children:[(0,z.jsx)(P,{label:`Device Name`,value:e.DeviceName}),(0,z.jsx)(P,{label:`Volume ID`,value:e.Ebs?.VolumeId?(0,z.jsx)(o,{className:`text-primary hover:underline`,params:{id:e.Ebs.VolumeId},to:`/ec2/describe-volumes/$id`,children:e.Ebs.VolumeId}):void 0}),(0,z.jsx)(P,{label:`Status`,value:e.Ebs?.Status}),(0,z.jsx)(P,{label:`Attach Time`,value:s(e.Ebs?.AttachTime)}),(0,z.jsx)(P,{label:`Delete on Termination`,value:e.Ebs?.DeleteOnTermination?`Yes`:`No`})]},e.DeviceName)}function Re(e){return e.GroupName}export{Ie as component}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id-CAKeaftF.js b/spinifex/services/spinifexui/frontend/dist/assets/_id-CAKeaftF.js new file mode 100644 index 000000000..c4dd245f0 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/_id-CAKeaftF.js @@ -0,0 +1 @@ +import{n as e,o as t,r as n,t as r}from"./compiler-runtime-D_vZfn79.js";import{t as i}from"./useQuery-2EKf7AyV.js";import{t as a}from"./useSuspenseQuery-Bc7E1P-f.js";import{t as o}from"./link-Dhy4xOXd.js";import{in as s,on as c}from"./cluster-config-xw_Q7X1e.js";import{t as l}from"./createLucideIcon-BQgzNCBR.js";import{t as u}from"./back-link-CjKy1JE5.js";import{t as d}from"./check-BO7W8OSs.js";import{c as f,i as p,n as m,s as h,t as g}from"./select-B01jpDaX.js";import{t as _}from"./copy-CamWo-pg.js";import{t as v}from"./network-RAx0KOlf.js";import{t as y}from"./rotate-cw-FTUcK_f-.js";import{t as b}from"./trash-DVab4pEz.js";import{t as x}from"./triangle-alert-DcjzeKLg.js";import{t as S}from"./button-BarTr35m.js";import{t as C}from"./input-6kdUb_DH.js";import{n as w}from"./admin-context-CXmiyIcc.js";import{a as T,c as E,i as D,s as O}from"./ec2-CWEplLq4.js";import{J as k,K as A,W as j,Z as M,d as N,i as P,it as F,nt as I,rt as L}from"./ec2-Ctto2xjU.js";import{f as ee}from"./iam-CVg27y22.js";import{t as te}from"./_id-Czat7SAc.js";import{t as ne}from"./page-heading-vksh_67w.js";import{o as re}from"./admin-BUkrFIlI.js";import{t as R}from"./error-banner-DeOeVBKS.js";import{s as ie,t as ae}from"./field-c7GHI5st.js";import{t as oe}from"./textarea-n04EavUm.js";import{a as se,i as ce,l as le,n as ue,o as de,r as fe,s as pe,t as me}from"./alert-dialog-kCQ1W4Rz.js";import{t as he}from"./delete-confirmation-dialog-SdDN4LiF.js";import{n as z,t as B}from"./detail-row-DNBRlVeK.js";import{t as ge}from"./state-badge-D1Z6q_OA.js";import{t as V}from"./use-copy-to-clipboard-DjiD3qo6.js";import{t as _e}from"./ami-details-ChpQ4w1b.js";import{t as ve}from"./gpu-instance-type-select-BD2r8UcO.js";var H={name:`image-plus`,size:24,node:[[`path`,{d:`M16 5h6`,key:`1vod17`}],[`path`,{d:`M19 2v6`,key:`4bpg5p`}],[`path`,{d:`M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5`,key:`1ue2ih`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}]]};H.node;var ye=l(H),be={name:`info`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 16v-4`,key:`1dtifu`}],[`path`,{d:`M12 8h.01`,key:`e9boi3`}]]};be.node;var xe=l(be),Se={name:`pause`,size:24,node:[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]};Se.node;var Ce=l(Se),we={name:`play`,size:24,node:[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]};we.node;var U=l(we),W={name:`settings-2`,size:24,node:[[`path`,{d:`M14 17H5`,key:`gfn3mx`}],[`path`,{d:`M19 7h-9`,key:`6i9tg`}],[`circle`,{cx:`17`,cy:`17`,r:`3`,key:`18b49y`}],[`circle`,{cx:`7`,cy:`7`,r:`3`,key:`dfmy0x`}]]};W.node;var Te=l(W),G={name:`terminal`,size:24,node:[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]};G.node;var Ee=l(G),K=r(),q=t(n(),1),J=e();function De(e){let t=(0,K.c)(36),{instanceId:n,open:r,onOpenChange:i}=e,a=N(),[o,s]=(0,q.useState)(``),[c,l]=(0,q.useState)(``),[u,d]=(0,q.useState)(null),f;t[0]===i?f=t[1]:(f=function(e){e||(s(``),l(``),d(null)),i(e)},t[0]=i,t[1]=f);let p=f,m;t[2]!==a||t[3]!==c||t[4]!==o||t[5]!==n?(m=async function(){try{let e=await a.mutateAsync({instanceId:n,name:o,description:c||void 0});d(e.ImageId??null),s(``),l(``)}catch{}},t[2]=a,t[3]=c,t[4]=o,t[5]=n,t[6]=m):m=t[6];let h=m,g=u?`Image Created`:`Create Image (AMI)`,_;t[7]===g?_=t[8]:(_=(0,J.jsx)(le,{children:g}),t[7]=g,t[8]=_);let v=u?`Image "${u}" was created successfully.`:`Create an AMI from instance "${n}".`,y;t[9]===v?y=t[10]:(y=(0,J.jsx)(se,{children:v}),t[9]=v,t[10]=y);let b;t[11]!==_||t[12]!==y?(b=(0,J.jsxs)(pe,{children:[_,y]}),t[11]=_,t[12]=y,t[13]=b):b=t[13];let x;t[14]!==u||t[15]!==c||t[16]!==o?(x=!u&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsxs)(ae,{children:[(0,J.jsx)(ie,{children:(0,J.jsx)(`label`,{htmlFor:`imageName`,children:`Name`})}),(0,J.jsx)(C,{id:`imageName`,onChange:e=>{s(e.target.value)},placeholder:`my-image`,value:o})]}),(0,J.jsxs)(ae,{children:[(0,J.jsx)(ie,{children:(0,J.jsx)(`label`,{htmlFor:`imageDescription`,children:`Description`})}),(0,J.jsx)(oe,{id:`imageDescription`,onChange:e=>{l(e.target.value)},placeholder:`Optional description`,rows:2,value:c})]})]}),t[14]=u,t[15]=c,t[16]=o,t[17]=x):x=t[17];let S=u?`Close`:`Cancel`,w;t[18]===S?w=t[19]:(w=(0,J.jsx)(fe,{children:S}),t[18]=S,t[19]=w);let T;t[20]!==a||t[21]!==u||t[22]!==h||t[23]!==o?(T=!u&&(0,J.jsx)(ue,{disabled:a.isPending||!o.trim(),onClick:h,children:a.isPending?`Creating…`:`Create Image`}),t[20]=a,t[21]=u,t[22]=h,t[23]=o,t[24]=T):T=t[24];let E;t[25]!==w||t[26]!==T?(E=(0,J.jsxs)(de,{children:[w,T]}),t[25]=w,t[26]=T,t[27]=E):E=t[27];let D;t[28]!==E||t[29]!==b||t[30]!==x?(D=(0,J.jsxs)(ce,{children:[b,x,E]}),t[28]=E,t[29]=b,t[30]=x,t[31]=D):D=t[31];let O;return t[32]!==p||t[33]!==r||t[34]!==D?(O=(0,J.jsx)(me,{onOpenChange:p,open:r,children:D}),t[32]=p,t[33]=r,t[34]=D,t[35]=O):O=t[35],O}function Oe(e){let t=(0,K.c)(9),{gpus:n}=e;if(!n?.length)return null;let r=n.length===1?`GPU`:`GPUs (${n.length})`,i;t[0]===r?i=t[1]:(i=(0,J.jsx)(z.Header,{children:r}),t[0]=r,t[1]=i);let a;if(t[2]!==n){let e;t[4]===n.length?e=t[5]:(e=(e,t)=>(0,J.jsxs)(`div`,{className:`border-b last:border-b-0`,children:[n.length>1&&(0,J.jsxs)(`h3`,{className:`bg-muted/30 px-4 py-2 text-sm font-medium`,children:[`GPU `,t+1]}),(0,J.jsxs)(z.Content,{children:[(0,J.jsx)(B,{label:`Model`,value:e.model}),(0,J.jsx)(B,{label:`VRAM`,value:c(e.vram_mib)}),(0,J.jsx)(B,{label:`Attachment`,value:e.profile?`MIG slice`:`PCIe passthrough`}),e.profile&&(0,J.jsx)(B,{label:`Profile`,value:e.profile}),e.mdev_path&&(0,J.jsx)(B,{label:`Mdev path`,value:e.mdev_path}),e.pci_address&&(0,J.jsx)(B,{label:`PCI address`,value:e.pci_address})]})]},e.mdev_path??e.pci_address??`${e.model}-${t}`),t[4]=n.length,t[5]=e),a=n.map(e),t[2]=n,t[3]=a}else a=t[3];let o;return t[6]!==i||t[7]!==a?(o=(0,J.jsxs)(z,{children:[i,a]}),t[6]=i,t[7]=a,t[8]=o):o=t[8],o}function ke(e){let t=(0,K.c)(31),{instanceId:n}=e,r;t[0]===n?r=t[1]:(r=D(n),t[0]=n,t[1]=r);let{data:a}=i(r),{data:s}=i(ee),c=P(),l=j(),[u,d]=(0,q.useState)(``),p;t[2]===a?.IamInstanceProfileAssociations?p=t[3]:(p=a?.IamInstanceProfileAssociations?.find(X),t[2]=a?.IamInstanceProfileAssociations,t[3]=p);let _=p,v;t[4]===s?.InstanceProfiles?v=t[5]:(v=s?.InstanceProfiles??[],t[4]=s?.InstanceProfiles,t[5]=v);let y=v,b;t[6]!==c||t[7]!==n||t[8]!==u?(b=async()=>{u&&(await c.mutateAsync({instanceId:n,instanceProfileName:u}),d(``))},t[6]=c,t[7]=n,t[8]=u,t[9]=b):b=t[9];let x=b,C;t[10]!==_||t[11]!==l||t[12]!==n?(C=async()=>{_?.AssociationId&&await l.mutateAsync({associationId:_.AssociationId,instanceId:n})},t[10]=_,t[11]=l,t[12]=n,t[13]=C):C=t[13];let w=C,T;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(T=(0,J.jsx)(z.Header,{children:`IAM Role`}),t[14]=T):T=t[14];let E;t[15]===c.error?E=t[16]:(E=c.error&&(0,J.jsx)(`div`,{className:`col-span-2`,children:(0,J.jsx)(R,{error:c.error,msg:`Failed to associate instance profile`})}),t[15]=c.error,t[16]=E);let O;t[17]===l.error?O=t[18]:(O=l.error&&(0,J.jsx)(`div`,{className:`col-span-2`,children:(0,J.jsx)(R,{error:l.error,msg:`Failed to disassociate instance profile`})}),t[17]=l.error,t[18]=O);let k;t[19]!==c.isPending||t[20]!==_||t[21]!==l.isPending||t[22]!==x||t[23]!==w||t[24]!==y||t[25]!==u?(k=_?(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(B,{label:`Instance Profile ARN`,value:_.IamInstanceProfile?.Arn}),(0,J.jsx)(B,{label:`State`,value:(0,J.jsx)(ge,{state:_.State})}),(0,J.jsx)(`div`,{className:`col-span-2 flex justify-end`,children:(0,J.jsx)(S,{disabled:l.isPending,onClick:w,size:`sm`,variant:`destructive`,children:l.isPending?`Detaching…`:`Detach`})})]}):(0,J.jsxs)(`div`,{className:`col-span-2 space-y-3`,children:[(0,J.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`No instance profile is associated. Associate one to flow role credentials to this instance via IMDS.`}),y.length>0?(0,J.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,J.jsxs)(g,{onValueChange:e=>{d(e??``)},value:u,children:[(0,J.jsx)(h,{className:`w-full`,children:(0,J.jsx)(f,{placeholder:`Select an instance profile`})}),(0,J.jsx)(m,{children:y.map(Y)})]}),(0,J.jsx)(S,{disabled:!u||c.isPending,onClick:x,size:`sm`,children:c.isPending?`Associating…`:`Associate`})]}):(0,J.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`No instance profiles exist.`,` `,(0,J.jsx)(o,{className:`text-primary hover:underline`,to:`/iam/create-instance-profile`,children:`Create one`}),` `,`first.`]})]}),t[19]=c.isPending,t[20]=_,t[21]=l.isPending,t[22]=x,t[23]=w,t[24]=y,t[25]=u,t[26]=k):k=t[26];let A;return t[27]!==E||t[28]!==O||t[29]!==k?(A=(0,J.jsxs)(z,{children:[T,(0,J.jsxs)(z.Content,{children:[E,O,k]})]}),t[27]=E,t[28]=O,t[29]=k,t[30]=A):A=t[30],A}function Y(e){return(0,J.jsx)(p,{value:e.InstanceProfileName??``,children:e.InstanceProfileName},e.InstanceProfileName)}function X(e){return e.State===`associating`||e.State===`associated`}var Ae=new Set([`pending`,`stopping`,`shutting-down`,`terminated`]);function je(e){let t=(0,K.c)(43),{instanceId:n,state:r}=e,i=I(),a=L(),o=M(),s=F(),[c,l]=(0,q.useState)(!1),u=Ae.has(r??``),d;t[0]!==n||t[1]!==s?(d=()=>{s.mutate(n,{onSettled:()=>{l(!1)}})},t[0]=n,t[1]=s,t[2]=d):d=t[2];let f=d;if(u&&r!==`terminated`){let e;return t[3]===r?e=t[4]:(e=(0,J.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,J.jsxs)(`p`,{className:`text-center text-sm text-muted-foreground`,children:[`Instance is `,r,`. Actions will be available once the operation completes.`]})}),t[3]=r,t[4]=e),e}if(r===`terminated`){let e;return t[5]===Symbol.for(`react.memo_cache_sentinel`)?(e=(0,J.jsx)(`div`,{className:`rounded-lg border bg-muted/50 p-4`,children:(0,J.jsx)(`p`,{className:`text-center text-sm text-muted-foreground`,children:`This instance has been terminated and cannot be managed.`})}),t[5]=e):e=t[5],e}let p;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,J.jsx)(`h2`,{className:`mb-4 font-semibold`,children:`Instance Actions`}),t[6]=p):p=t[6];let m;t[7]===i.error?m=t[8]:(m=i.error&&(0,J.jsx)(R,{error:i.error,msg:`Failed to start instance`}),t[7]=i.error,t[8]=m);let h;t[9]===a.error?h=t[10]:(h=a.error&&(0,J.jsx)(R,{error:a.error,msg:`Failed to stop instance`}),t[9]=a.error,t[10]=h);let g;t[11]===o.error?g=t[12]:(g=o.error&&(0,J.jsx)(R,{error:o.error,msg:`Failed to reboot instance`}),t[11]=o.error,t[12]=g);let _;t[13]===s.error?_=t[14]:(_=s.error&&(0,J.jsx)(R,{error:s.error,msg:`Failed to terminate instance`}),t[13]=s.error,t[14]=_);let v;t[15]!==n||t[16]!==i||t[17]!==r?(v=r===`stopped`&&(0,J.jsxs)(S,{disabled:i.isPending,onClick:()=>{i.mutate(n)},size:`sm`,variant:`default`,children:[(0,J.jsx)(U,{className:`size-4`}),i.isPending?`Starting`:`Start`]}),t[15]=n,t[16]=i,t[17]=r,t[18]=v):v=t[18];let x;t[19]!==n||t[20]!==o||t[21]!==r||t[22]!==a?(x=r===`running`&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsxs)(S,{disabled:a.isPending,onClick:()=>{a.mutate(n)},size:`sm`,variant:`outline`,children:[(0,J.jsx)(Ce,{className:`size-4`}),a.isPending?`Stopping`:`Stop`]}),(0,J.jsxs)(S,{disabled:o.isPending,onClick:()=>{o.mutate(n)},size:`sm`,variant:`outline`,children:[(0,J.jsx)(y,{className:`size-4`}),o.isPending?`Rebooting`:`Reboot`]})]}),t[19]=n,t[20]=o,t[21]=r,t[22]=a,t[23]=x):x=t[23];let C;t[24]!==r||t[25]!==s.isPending?(C=(r===`stopped`||r===`running`)&&(0,J.jsxs)(S,{disabled:s.isPending,onClick:()=>{l(!0)},size:`sm`,variant:`destructive`,children:[(0,J.jsx)(b,{className:`size-4`}),s.isPending?`Terminating`:`Terminate`]}),t[24]=r,t[25]=s.isPending,t[26]=C):C=t[26];let w;t[27]!==v||t[28]!==x||t[29]!==C?(w=(0,J.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[v,x,C]}),t[27]=v,t[28]=x,t[29]=C,t[30]=w):w=t[30];let T=`Are you sure you want to terminate the instance "${n}"? This action cannot be undone.`,E;t[31]!==f||t[32]!==c||t[33]!==T||t[34]!==s.isPending?(E=(0,J.jsx)(he,{confirmLabel:`Terminate`,description:T,isPending:s.isPending,onConfirm:f,onOpenChange:l,open:c,pendingLabel:`Terminating…`,title:`Terminate Instance`}),t[31]=f,t[32]=c,t[33]=T,t[34]=s.isPending,t[35]=E):E=t[35];let D;return t[36]!==w||t[37]!==E||t[38]!==m||t[39]!==h||t[40]!==g||t[41]!==_?(D=(0,J.jsxs)(`div`,{className:`rounded-lg border bg-card p-4`,children:[p,m,h,g,_,w,E]}),t[36]=w,t[37]=E,t[38]=m,t[39]=h,t[40]=g,t[41]=_,t[42]=D):D=t[42],D}var Z=[[`amazonlinux`,`ec2-user`],[`almalinux`,`almalinux`],[`oracle`,`cloud-user`],[`ubuntu`,`ubuntu`],[`debian`,`debian`],[`fedora`,`fedora`],[`centos`,`cloud-user`],[`alpine`,`alpine`],[`rocky`,`rocky`],[`amzn`,`ec2-user`],[`rhel`,`cloud-user`]];function Q(e){let t=new Set(`${e?.Name??``} ${e?.Description??``}`.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)),n=Z.find(([e])=>t.has(e));return n?{user:n[1],distro:n[0]}:{user:``}}function Me(e){return e?.PublicIpAddress?{address:e.PublicIpAddress,reachability:`public`}:e?.PrivateIpAddress?{address:e.PrivateIpAddress,reachability:`private`}:{address:``,reachability:`none`}}var Ne=`path/to/key.pem`;function Pe(e){return`ssh -i ${e.keyName?`path/to/${e.keyName}.pem`:Ne} ${e.user||``}@${e.address}`}function Fe(e){let t=(0,K.c)(49),{instance:n,image:r}=e,{copied:i,copy:a}=V(),o,s,c,l,u,f;if(t[0]!==r||t[1]!==n){u=Symbol.for(`react.early_return_sentinel`);bb0:{if({address:o,reachability:c}=Me(n),{user:f,distro:s}=Q(r),c===`none`){u=null;break bb0}l=Pe({keyName:n.KeyName,user:f,address:o})}t[0]=r,t[1]=n,t[2]=o,t[3]=s,t[4]=c,t[5]=l,t[6]=u,t[7]=f}else o=t[2],s=t[3],c=t[4],l=t[5],u=t[6],f=t[7];if(u!==Symbol.for(`react.early_return_sentinel`))return u;let p=l,m;t[8]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,J.jsx)(z.Header,{children:`Connect over SSH`}),t[8]=m):m=t[8];let h;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(h=(0,J.jsx)(`span`,{className:`text-muted-foreground select-none`,children:`$`}),t[9]=h):h=t[9];let g;t[10]===p?g=t[11]:(g=(0,J.jsx)(`span`,{className:`flex-1 overflow-x-auto whitespace-nowrap text-foreground`,children:p}),t[10]=p,t[11]=g);let y;t[12]!==p||t[13]!==a?(y=()=>{a(p)},t[12]=p,t[13]=a,t[14]=y):y=t[14];let b;t[15]===i?b=t[16]:(b=i?(0,J.jsx)(d,{className:`size-3.5 text-tactical-green`}):(0,J.jsx)(_,{className:`size-3.5`}),t[15]=i,t[16]=b);let S;t[17]!==y||t[18]!==b?(S=(0,J.jsx)(`button`,{"aria-label":`Copy SSH command`,className:`text-muted-foreground transition-colors hover:text-foreground`,onClick:y,type:`button`,children:b}),t[17]=y,t[18]=b,t[19]=S):S=t[19];let C;t[20]!==g||t[21]!==S?(C=(0,J.jsxs)(`div`,{className:`flex items-center gap-2 rounded border border-border bg-background px-3 py-2 font-mono text-xs`,children:[h,g,S]}),t[20]=g,t[21]=S,t[22]=C):C=t[22];let w;t[23]===Symbol.for(`react.memo_cache_sentinel`)?(w=(0,J.jsx)(`dt`,{className:`text-muted-foreground`,children:`Login user`}),t[23]=w):w=t[23];let T=f||`unknown — check the AMI`,E;t[24]===T?E=t[25]:(E=(0,J.jsxs)(`div`,{children:[w,(0,J.jsx)(`dd`,{className:`font-mono text-foreground`,children:T})]}),t[24]=T,t[25]=E);let D;t[26]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,J.jsx)(`dt`,{className:`text-muted-foreground`,children:`Address`}),t[26]=D):D=t[26];let O;t[27]===o?O=t[28]:(O=(0,J.jsxs)(`div`,{children:[D,(0,J.jsx)(`dd`,{className:`font-mono text-foreground`,children:o})]}),t[27]=o,t[28]=O);let k;t[29]===Symbol.for(`react.memo_cache_sentinel`)?(k=(0,J.jsx)(`dt`,{className:`text-muted-foreground`,children:`Key pair`}),t[29]=k):k=t[29];let A=n.KeyName??`none assigned`,j;t[30]===A?j=t[31]:(j=(0,J.jsxs)(`div`,{children:[k,(0,J.jsx)(`dd`,{className:`font-mono text-foreground`,children:A})]}),t[30]=A,t[31]=j);let M;t[32]!==E||t[33]!==O||t[34]!==j?(M=(0,J.jsxs)(`dl`,{className:`grid gap-x-6 gap-y-2 text-xs sm:grid-cols-3`,children:[E,O,j]}),t[32]=E,t[33]=O,t[34]=j,t[35]=M):M=t[35];let N;t[36]!==o||t[37]!==c?(N=c===`private`&&(0,J.jsxs)($,{tone:`amber`,icon:v,children:[`This instance has no public IP address, so`,` `,(0,J.jsx)(`span`,{className:`font-mono`,children:o}),` is reachable only from inside the VPC. Run the command from another instance in the same VPC whose security group allows it, or associate an Elastic IP with this one.`]}),t[36]=o,t[37]=c,t[38]=N):N=t[38];let P;t[39]!==s||t[40]!==f?(P=f?(0,J.jsxs)($,{tone:`muted`,icon:xe,children:[(0,J.jsx)(`span`,{className:`font-mono`,children:f}),` is the default login user for `,s,` images. It comes from the AMI itself — Spinifex does not create or rename the account — so an image built with a different default will want a different name here.`]}):(0,J.jsxs)($,{tone:`amber`,icon:x,children:[`The login user could not be determined from this AMI. Every distribution ships its own default —`,` `,(0,J.jsx)(`span`,{className:`font-mono`,children:`ubuntu`}),`,`,` `,(0,J.jsx)(`span`,{className:`font-mono`,children:`debian`}),`,`,` `,(0,J.jsx)(`span`,{className:`font-mono`,children:`cloud-user`}),` on Oracle Linux and RHEL — so check the image's documentation before connecting.`]}),t[39]=s,t[40]=f,t[41]=P):P=t[41];let F;t[42]===Symbol.for(`react.memo_cache_sentinel`)?(F=(0,J.jsx)(`em`,{children:`you`}),t[42]=F):F=t[42];let I;t[43]===Symbol.for(`react.memo_cache_sentinel`)?(I=(0,J.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Spinifex stores only the public half of a key pair, so the path above is where `,F,` saved the private key when you created it. It must be readable only by you (`,(0,J.jsx)(`span`,{className:`font-mono`,children:`chmod 600`}),`), and the instance's security group must allow TCP 22 from wherever you are connecting.`]}),t[43]=I):I=t[43];let L;return t[44]!==M||t[45]!==N||t[46]!==P||t[47]!==C?(L=(0,J.jsxs)(z,{children:[m,(0,J.jsxs)(`div`,{className:`space-y-4 p-4`,children:[C,M,N,P,I]})]}),t[44]=M,t[45]=N,t[46]=P,t[47]=C,t[48]=L):L=t[48],L}var Ie={amber:`border-tactical-amber/40 bg-tactical-amber/5 text-tactical-amber`,muted:`border-border bg-muted/40 text-muted-foreground`};function $(e){let t=(0,K.c)(8),{tone:n,icon:r,children:i}=e,a=`flex items-start gap-2 rounded-md border p-3 ${Ie[n]}`,o;t[0]===r?o=t[1]:(o=(0,J.jsx)(r,{className:`mt-0.5 size-3.5 shrink-0`}),t[0]=r,t[1]=o);let s;t[2]===i?s=t[3]:(s=(0,J.jsx)(`p`,{className:`text-xs leading-relaxed text-muted-foreground`,children:i}),t[2]=i,t[3]=s);let c;return t[4]!==a||t[5]!==o||t[6]!==s?(c=(0,J.jsxs)(`div`,{className:a,children:[o,s]}),t[4]=a,t[5]=o,t[6]=s,t[7]=c):c=t[7],c}function Le(){let e=(0,K.c)(141),{id:t}=te.useParams(),{isAdmin:n}=w(),r;e[0]===t?r=e[1]:(r=O(t),e[0]=t,e[1]=r);let{data:c}=a(r),l=c.Reservations?.[0]?.Instances?.[0],d=l?.ImageId,f;e[2]===d?f=e[3]:(f=T(d),e[2]=d,e[3]=f);let{data:p}=a(f),m=p?.Images?.[0],{data:h}=a(E),g;e[4]===n?g=e[5]:(g={...re,enabled:n,refetchInterval:5e3},e[4]=n,e[5]=g);let{data:_}=i(g),v;e[6]!==t||e[7]!==_?.vms?(v=_?.vms?.find(e=>e.instance_id===t),e[6]=t,e[7]=_?.vms,e[8]=v):v=e[8];let y=v,b=k(),x=A(),[C,D]=(0,q.useState)(!1),[j,M]=(0,q.useState)(``),[N,P]=(0,q.useState)(!1),[F,I]=(0,q.useState)(null),[L,ee]=(0,q.useState)(!1);if(!l?.InstanceId){let t;return e[9]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(u,{to:`/ec2/describe-instances`,children:`Back to instances`}),(0,J.jsx)(`p`,{className:`text-muted-foreground`,children:`Instance not found.`})]}),e[9]=t):t=e[9],t}let oe;e[10]===l.LaunchTime?oe=e[11]:(oe=s(l.LaunchTime),e[10]=l.LaunchTime,e[11]=oe);let he=oe,V=l.State?.Name===`running`,H=l.State?.Name===`stopped`,be;e[12]!==t||e[13]!==b||e[14]!==j?(be=async function(){try{await b.mutateAsync({instanceId:t,instanceType:j}),D(!1),M(``)}catch{}},e[12]=t,e[13]=b,e[14]=j,e[15]=be):be=e[15];let xe=be,Se;e[16]!==x||e[17]!==t?(Se=async function(){try{let e=await x.mutateAsync(t),n=e.Output?atob(e.Output):`(no output)`;I(n),P(!0)}catch{}},e[16]=x,e[17]=t,e[18]=Se):Se=e[18];let Ce=Se,we;e[19]===Symbol.for(`react.memo_cache_sentinel`)?(we=(0,J.jsx)(u,{to:`/ec2/describe-instances`,children:`Back to instances`}),e[19]=we):we=e[19];let U;e[20]===b.error?U=e[21]:(U=b.error&&(0,J.jsx)(R,{error:b.error,msg:`Failed to modify instance`}),e[20]=b.error,e[21]=U);let W;e[22]===x.error?W=e[23]:(W=x.error&&(0,J.jsx)(R,{error:x.error,msg:`Failed to get console output`}),e[22]=x.error,e[23]=W);let G;e[24]!==x.isPending||e[25]!==Ce||e[26]!==V?(G=V&&(0,J.jsxs)(S,{disabled:x.isPending,onClick:Ce,size:`sm`,variant:`outline`,children:[(0,J.jsx)(Ee,{className:`size-4`}),x.isPending?`Fetching…`:`View Console`]}),e[24]=x.isPending,e[25]=Ce,e[26]=V,e[27]=G):G=e[27];let Y;e[28]!==V||e[29]!==H?(Y=(V||H)&&(0,J.jsxs)(S,{onClick:()=>{ee(!0)},size:`sm`,variant:`outline`,children:[(0,J.jsx)(ye,{className:`size-4`}),`Create Image`]}),e[28]=V,e[29]=H,e[30]=Y):Y=e[30];let X;e[31]!==l.InstanceType||e[32]!==H?(X=H&&(0,J.jsxs)(S,{onClick:()=>{M(l.InstanceType??``),D(!0)},size:`sm`,variant:`outline`,children:[(0,J.jsx)(Te,{className:`size-4`}),`Modify Instance`]}),e[31]=l.InstanceType,e[32]=H,e[33]=X):X=e[33];let Ae=l.State?.Name,Z;e[34]===Ae?Z=e[35]:(Z=(0,J.jsx)(ge,{state:Ae}),e[34]=Ae,e[35]=Z);let Q;e[36]!==G||e[37]!==Y||e[38]!==X||e[39]!==Z?(Q=(0,J.jsxs)(`div`,{className:`flex items-center gap-2`,children:[G,Y,X,Z]}),e[36]=G,e[37]=Y,e[38]=X,e[39]=Z,e[40]=Q):Q=e[40];let Me;e[41]!==l.InstanceId||e[42]!==Q?(Me=(0,J.jsx)(ne,{actions:Q,subtitle:`Instance Details`,title:l.InstanceId}),e[41]=l.InstanceId,e[42]=Q,e[43]=Me):Me=e[43];let Ne=l.State?.Name,Pe;e[44]!==l.InstanceId||e[45]!==Ne?(Pe=(0,J.jsx)(je,{instanceId:l.InstanceId,state:Ne}),e[44]=l.InstanceId,e[45]=Ne,e[46]=Pe):Pe=e[46];let Ie;e[47]!==m||e[48]!==l||e[49]!==V?(Ie=V&&(0,J.jsx)(Fe,{image:m,instance:l}),e[47]=m,e[48]=l,e[49]=V,e[50]=Ie):Ie=e[50];let $;e[51]!==F||e[52]!==N?($=N&&F!==null&&(0,J.jsxs)(z,{children:[(0,J.jsx)(z.Header,{children:(0,J.jsxs)(`div`,{className:`flex items-center justify-between`,children:[`Console Output`,(0,J.jsx)(S,{onClick:()=>{P(!1),I(null)},size:`sm`,variant:`ghost`,children:`Hide`})]})}),(0,J.jsx)(`div`,{className:`p-4`,children:(0,J.jsx)(`pre`,{className:`max-h-96 overflow-y-auto rounded bg-muted p-3 font-mono text-xs whitespace-pre-wrap`,children:F})})]}),e[51]=F,e[52]=N,e[53]=$):$=e[53];let Le;e[54]===Symbol.for(`react.memo_cache_sentinel`)?(Le=(0,J.jsx)(z.Header,{children:`Instance Information`}),e[54]=Le):Le=e[54];let Be;e[55]===l.InstanceId?Be=e[56]:(Be=(0,J.jsx)(B,{label:`Instance ID`,value:l.InstanceId}),e[55]=l.InstanceId,e[56]=Be);let Ve;e[57]===l.InstanceType?Ve=e[58]:(Ve=(0,J.jsx)(B,{label:`Instance Type`,value:l.InstanceType}),e[57]=l.InstanceType,e[58]=Ve);let He=l.State?.Name,Ue;e[59]===He?Ue=e[60]:(Ue=(0,J.jsx)(B,{label:`Instance State`,value:He}),e[59]=He,e[60]=Ue);let We;e[61]===l.State?.Code?We=e[62]:(We=l.State?.Code?.toString(),e[61]=l.State?.Code,e[62]=We);let Ge;e[63]===We?Ge=e[64]:(Ge=(0,J.jsx)(B,{label:`State Code`,value:We}),e[63]=We,e[64]=Ge);let Ke;e[65]===he?Ke=e[66]:(Ke=(0,J.jsx)(B,{label:`Launch Time`,value:he}),e[65]=he,e[66]=Ke);let qe=l.Placement?.AvailabilityZone,Je;e[67]===qe?Je=e[68]:(Je=(0,J.jsx)(B,{label:`Availability Zone`,value:qe}),e[67]=qe,e[68]=Je);let Ye;e[69]===l.Placement?Ye=e[70]:(Ye=l.Placement?.GroupName?(0,J.jsx)(o,{className:`text-primary hover:underline`,to:`/ec2/describe-placement-groups`,children:l.Placement.GroupName}):void 0,e[69]=l.Placement,e[70]=Ye);let Xe;e[71]===Ye?Xe=e[72]:(Xe=(0,J.jsx)(B,{label:`Placement Group`,value:Ye}),e[71]=Ye,e[72]=Xe);let Ze;e[73]!==Be||e[74]!==Ve||e[75]!==Ue||e[76]!==Ge||e[77]!==Ke||e[78]!==Je||e[79]!==Xe?(Ze=(0,J.jsxs)(z,{children:[Le,(0,J.jsxs)(z.Content,{children:[Be,Ve,Ue,Ge,Ke,Je,Xe]})]}),e[73]=Be,e[74]=Ve,e[75]=Ue,e[76]=Ge,e[77]=Ke,e[78]=Je,e[79]=Xe,e[80]=Ze):Ze=e[80];let Qe;e[81]!==l.KeyName||e[82]!==l.PrivateDnsName||e[83]!==l.PrivateIpAddress||e[84]!==l.PublicDnsName||e[85]!==l.PublicIpAddress||e[86]!==l.SecurityGroups||e[87]!==l.SubnetId||e[88]!==l.VpcId?(Qe=(l.PrivateIpAddress||l.PublicIpAddress||l.VpcId||l.SubnetId||l.SecurityGroups&&l.SecurityGroups.length>0)&&(0,J.jsxs)(z,{children:[(0,J.jsx)(z.Header,{children:`Network & Security`}),(0,J.jsxs)(z.Content,{children:[(0,J.jsx)(B,{label:`Private IP Address`,value:l.PrivateIpAddress}),(0,J.jsx)(B,{label:`Public IP Address`,value:l.PublicIpAddress}),(0,J.jsx)(B,{label:`Private DNS`,value:l.PrivateDnsName}),(0,J.jsx)(B,{label:`Public DNS`,value:l.PublicDnsName}),(0,J.jsx)(B,{label:`VPC ID`,value:l.VpcId?(0,J.jsx)(o,{className:`text-primary hover:underline`,params:{id:l.VpcId},to:`/ec2/describe-vpcs/$id`,children:l.VpcId}):void 0}),(0,J.jsx)(B,{label:`Subnet ID`,value:l.SubnetId?(0,J.jsx)(o,{className:`text-primary hover:underline`,params:{id:l.SubnetId},to:`/ec2/describe-subnets/$id`,children:l.SubnetId}):void 0}),(0,J.jsx)(B,{label:`Key Name`,value:l.KeyName}),(0,J.jsx)(B,{label:`Security Groups`,value:l.SecurityGroups?.map(ze).join(`, `)})]})]}),e[81]=l.KeyName,e[82]=l.PrivateDnsName,e[83]=l.PrivateIpAddress,e[84]=l.PublicDnsName,e[85]=l.PublicIpAddress,e[86]=l.SecurityGroups,e[87]=l.SubnetId,e[88]=l.VpcId,e[89]=Qe):Qe=e[89];let $e;e[90]===l.InstanceId?$e=e[91]:($e=(0,J.jsx)(ke,{instanceId:l.InstanceId}),e[90]=l.InstanceId,e[91]=$e);let et;e[92]!==n||e[93]!==y?.gpus?(et=n&&(0,J.jsx)(Oe,{gpus:y?.gpus}),e[92]=n,e[93]=y?.gpus,e[94]=et):et=e[94];let tt;e[95]===l.BlockDeviceMappings?tt=e[96]:(tt=l.BlockDeviceMappings&&l.BlockDeviceMappings.length>0&&(0,J.jsxs)(z,{children:[(0,J.jsx)(z.Header,{children:`Block Device Mappings`}),l.BlockDeviceMappings.map(Re)]}),e[95]=l.BlockDeviceMappings,e[96]=tt);let nt;e[97]===m?nt=e[98]:(nt=m&&(0,J.jsx)(_e,{image:m}),e[97]=m,e[98]=nt);let rt;e[99]!==Me||e[100]!==Pe||e[101]!==Ie||e[102]!==$||e[103]!==Ze||e[104]!==Qe||e[105]!==$e||e[106]!==et||e[107]!==tt||e[108]!==nt?(rt=(0,J.jsxs)(`div`,{className:`space-y-6`,children:[Me,Pe,Ie,$,Ze,Qe,$e,et,tt,nt]}),e[99]=Me,e[100]=Pe,e[101]=Ie,e[102]=$,e[103]=Ze,e[104]=Qe,e[105]=$e,e[106]=et,e[107]=tt,e[108]=nt,e[109]=rt):rt=e[109];let it;e[110]===Symbol.for(`react.memo_cache_sentinel`)?(it=e=>{e||(D(!1),M(``))},e[110]=it):it=e[110];let at;e[111]===Symbol.for(`react.memo_cache_sentinel`)?(at=(0,J.jsx)(le,{children:`Modify Instance`}),e[111]=at):at=e[111];let ot;e[112]===l.InstanceId?ot=e[113]:(ot=(0,J.jsxs)(pe,{children:[at,(0,J.jsxs)(se,{children:[`Select a new instance type for "`,l.InstanceId,`". The instance must be stopped to modify its attributes.`]})]}),e[112]=l.InstanceId,e[113]=ot);let st;e[114]===Symbol.for(`react.memo_cache_sentinel`)?(st=(0,J.jsx)(ie,{children:(0,J.jsx)(`label`,{htmlFor:`instanceType`,children:`Instance Type`})}),e[114]=st):st=e[114];let ct;e[115]===h.InstanceTypes?ct=e[116]:(ct=h.InstanceTypes??[],e[115]=h.InstanceTypes,e[116]=ct);let lt;e[117]!==j||e[118]!==ct?(lt=(0,J.jsxs)(ae,{children:[st,(0,J.jsx)(ve,{className:`w-full`,id:`instanceType`,instanceTypes:ct,onValueChange:M,value:j})]}),e[117]=j,e[118]=ct,e[119]=lt):lt=e[119];let ut;e[120]===Symbol.for(`react.memo_cache_sentinel`)?(ut=(0,J.jsx)(fe,{children:`Cancel`}),e[120]=ut):ut=e[120];let dt=b.isPending||!j||j===l.InstanceType,ft=b.isPending?`Saving…`:`Save Changes`,pt;e[121]!==xe||e[122]!==dt||e[123]!==ft?(pt=(0,J.jsxs)(de,{children:[ut,(0,J.jsx)(ue,{disabled:dt,onClick:xe,children:ft})]}),e[121]=xe,e[122]=dt,e[123]=ft,e[124]=pt):pt=e[124];let mt;e[125]!==ot||e[126]!==lt||e[127]!==pt?(mt=(0,J.jsxs)(ce,{children:[ot,lt,pt]}),e[125]=ot,e[126]=lt,e[127]=pt,e[128]=mt):mt=e[128];let ht;e[129]!==C||e[130]!==mt?(ht=(0,J.jsx)(me,{onOpenChange:it,open:C,children:mt}),e[129]=C,e[130]=mt,e[131]=ht):ht=e[131];let gt;e[132]!==l.InstanceId||e[133]!==L?(gt=(0,J.jsx)(De,{instanceId:l.InstanceId,onOpenChange:ee,open:L}),e[132]=l.InstanceId,e[133]=L,e[134]=gt):gt=e[134];let _t;return e[135]!==W||e[136]!==rt||e[137]!==ht||e[138]!==gt||e[139]!==U?(_t=(0,J.jsxs)(J.Fragment,{children:[we,U,W,rt,ht,gt]}),e[135]=W,e[136]=rt,e[137]=ht,e[138]=gt,e[139]=U,e[140]=_t):_t=e[140],_t}function Re(e){return(0,J.jsxs)(z.Content,{children:[(0,J.jsx)(B,{label:`Device Name`,value:e.DeviceName}),(0,J.jsx)(B,{label:`Volume ID`,value:e.Ebs?.VolumeId?(0,J.jsx)(o,{className:`text-primary hover:underline`,params:{id:e.Ebs.VolumeId},to:`/ec2/describe-volumes/$id`,children:e.Ebs.VolumeId}):void 0}),(0,J.jsx)(B,{label:`Status`,value:e.Ebs?.Status}),(0,J.jsx)(B,{label:`Attach Time`,value:s(e.Ebs?.AttachTime)}),(0,J.jsx)(B,{label:`Delete on Termination`,value:e.Ebs?.DeleteOnTermination?`Yes`:`No`})]},e.DeviceName)}function ze(e){return e.GroupName}export{Le as component}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id-Czat7SAc.js b/spinifex/services/spinifexui/frontend/dist/assets/_id-Czat7SAc.js new file mode 100644 index 000000000..02866f969 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/_id-Czat7SAc.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_id-CAKeaftF.js","assets/compiler-runtime-D_vZfn79.js","assets/useQuery-2EKf7AyV.js","assets/useBaseQuery-Dqg0ViLa.js","assets/removable-Dwr9otKB.js","assets/query-BPr_oSEg.js","assets/useSuspenseQuery-Bc7E1P-f.js","assets/link-Dhy4xOXd.js","assets/useSelector-CRh8wCTv.js","assets/with-selector-DrM041uv.js","assets/cluster-config-xw_Q7X1e.js","assets/hex-encoding-B19xAT_7.js","assets/createLucideIcon-BQgzNCBR.js","assets/back-link-CjKy1JE5.js","assets/check-BO7W8OSs.js","assets/select-B01jpDaX.js","assets/useValueAsRef-F56m8WbP.js","assets/dist-BKgR2qk6.js","assets/button-BarTr35m.js","assets/createBaseUIEventDetails-DL-8egB9.js","assets/chevron-down-Dt4q6d24.js","assets/useValueChanged-B2TUO8IO.js","assets/useScrollLock-NX6x9Pv8.js","assets/usePositioner-DUsvphCt.js","assets/DirectionContext-C9rdm8xn.js","assets/useControlled-DwxdYHYK.js","assets/useCompositeListItem-CEsaEeXb.js","assets/copy-CamWo-pg.js","assets/network-RAx0KOlf.js","assets/rotate-cw-FTUcK_f-.js","assets/trash-DVab4pEz.js","assets/triangle-alert-DcjzeKLg.js","assets/input-6kdUb_DH.js","assets/admin-context-CXmiyIcc.js","assets/sts-B5ZEON3v.js","assets/signed-fetch-B-FFW93q.js","assets/ec2-CWEplLq4.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-b0hcP1Iz.js","assets/index.browser-C3WbWNjZ.js","assets/ec2-Ctto2xjU.js","assets/useMutation-6rdxVQpi.js","assets/mutation-BziiWz5u.js","assets/subnet-calculator-BlCTyGtY.js","assets/iam-CVg27y22.js","assets/commandBuilder-CoLHZwsC.js","assets/json-DsgWHaZY.js","assets/page-heading-vksh_67w.js","assets/admin-BUkrFIlI.js","assets/error-banner-DeOeVBKS.js","assets/field-c7GHI5st.js","assets/separator-B3CHPrlt.js","assets/textarea-n04EavUm.js","assets/alert-dialog-kCQ1W4Rz.js","assets/DialogTitle-DiInIGjk.js","assets/delete-confirmation-dialog-SdDN4LiF.js","assets/detail-row-DNBRlVeK.js","assets/state-badge-D1Z6q_OA.js","assets/use-copy-to-clipboard-DjiD3qo6.js","assets/ami-details-ChpQ4w1b.js","assets/gpu-instance-type-select-BD2r8UcO.js"])))=>i.map(i=>d[i]); +import{n as e,t}from"./lazyRouteComponent-CiZDS8hZ.js";import{lt as n}from"./cluster-config-xw_Q7X1e.js";import{a as r,c as i,s as a}from"./ec2-CWEplLq4.js";var o=e(`/_auth/ec2/(instances)/describe-instances/$id`)({loader:async({context:e,params:t})=>{let[n]=await Promise.all([e.queryClient.query({...a(t.id),staleTime:`static`}),e.queryClient.query({...i,staleTime:`static`})]),o=n.Reservations?.[0]?.Instances?.[0]?.ImageId;return await e.queryClient.query({...r(o),staleTime:`static`}),n},head:({loaderData:e})=>({meta:[{title:`${e?.Reservations?.[0]?.Instances?.[0]?.InstanceId??`Instance`} | EC2 | Mulga`}]}),component:t(()=>n(()=>import(`./_id-CAKeaftF.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60])),`component`)});export{o as t}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/_id-U4BZhcdn.js b/spinifex/services/spinifexui/frontend/dist/assets/_id-U4BZhcdn.js deleted file mode 100644 index 4eb060dc7..000000000 --- a/spinifex/services/spinifexui/frontend/dist/assets/_id-U4BZhcdn.js +++ /dev/null @@ -1,2 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_id-BnUjyn0E.js","assets/compiler-runtime-D_vZfn79.js","assets/useQuery-2EKf7AyV.js","assets/useBaseQuery-Dqg0ViLa.js","assets/removable-Dwr9otKB.js","assets/query-BPr_oSEg.js","assets/useSuspenseQuery-Bc7E1P-f.js","assets/link-Dhy4xOXd.js","assets/useSelector-CRh8wCTv.js","assets/with-selector-DrM041uv.js","assets/cluster-config-xw_Q7X1e.js","assets/hex-encoding-B19xAT_7.js","assets/createLucideIcon-BQgzNCBR.js","assets/back-link-CjKy1JE5.js","assets/select-B01jpDaX.js","assets/useValueAsRef-F56m8WbP.js","assets/dist-BKgR2qk6.js","assets/button-BarTr35m.js","assets/createBaseUIEventDetails-DL-8egB9.js","assets/check-BO7W8OSs.js","assets/chevron-down-Dt4q6d24.js","assets/useValueChanged-B2TUO8IO.js","assets/useScrollLock-NX6x9Pv8.js","assets/usePositioner-DUsvphCt.js","assets/DirectionContext-C9rdm8xn.js","assets/useControlled-DwxdYHYK.js","assets/useCompositeListItem-CEsaEeXb.js","assets/rotate-cw-FTUcK_f-.js","assets/trash-DVab4pEz.js","assets/input-6kdUb_DH.js","assets/admin-context-CXmiyIcc.js","assets/sts-B5ZEON3v.js","assets/signed-fetch-B-FFW93q.js","assets/ec2-CWEplLq4.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-b0hcP1Iz.js","assets/index.browser-C3WbWNjZ.js","assets/ec2-Ctto2xjU.js","assets/useMutation-6rdxVQpi.js","assets/mutation-BziiWz5u.js","assets/subnet-calculator-BlCTyGtY.js","assets/iam-CVg27y22.js","assets/commandBuilder-CoLHZwsC.js","assets/json-DsgWHaZY.js","assets/page-heading-vksh_67w.js","assets/admin-BUkrFIlI.js","assets/error-banner-DeOeVBKS.js","assets/field-c7GHI5st.js","assets/separator-B3CHPrlt.js","assets/textarea-n04EavUm.js","assets/alert-dialog-kCQ1W4Rz.js","assets/DialogTitle-DiInIGjk.js","assets/delete-confirmation-dialog-SdDN4LiF.js","assets/detail-row-DNBRlVeK.js","assets/state-badge-D1Z6q_OA.js","assets/ami-details-ChpQ4w1b.js","assets/gpu-instance-type-select-BD2r8UcO.js"])))=>i.map(i=>d[i]); -import{n as e,t}from"./lazyRouteComponent-CiZDS8hZ.js";import{lt as n}from"./cluster-config-xw_Q7X1e.js";import{a as r,c as i,s as a}from"./ec2-CWEplLq4.js";var o=e(`/_auth/ec2/(instances)/describe-instances/$id`)({loader:async({context:e,params:t})=>{let[n]=await Promise.all([e.queryClient.query({...a(t.id),staleTime:`static`}),e.queryClient.query({...i,staleTime:`static`})]),o=n.Reservations?.[0]?.Instances?.[0]?.ImageId;return await e.queryClient.query({...r(o),staleTime:`static`}),n},head:({loaderData:e})=>({meta:[{title:`${e?.Reservations?.[0]?.Instances?.[0]?.InstanceId??`Instance`} | EC2 | Mulga`}]}),component:t(()=>n(()=>import(`./_id-BnUjyn0E.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56])),`component`)});export{o as t}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index-BrcYJ9RS.js b/spinifex/services/spinifexui/frontend/dist/assets/index-BrcYJ9RS.js deleted file mode 100644 index e96c62ee3..000000000 --- a/spinifex/services/spinifexui/frontend/dist/assets/index-BrcYJ9RS.js +++ /dev/null @@ -1,11 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/route-DPFKZkGs.js","assets/compiler-runtime-D_vZfn79.js","assets/Match-Q4m9gh_H.js","assets/matchContext-DjfNTEDZ.js","assets/useSelector-CRh8wCTv.js","assets/with-selector-DrM041uv.js","assets/_auth-DsyoN1M9.js","assets/useQuery-2EKf7AyV.js","assets/useBaseQuery-Dqg0ViLa.js","assets/removable-Dwr9otKB.js","assets/query-BPr_oSEg.js","assets/link-Dhy4xOXd.js","assets/cluster-config-xw_Q7X1e.js","assets/hex-encoding-B19xAT_7.js","assets/createLucideIcon-BQgzNCBR.js","assets/chevron-down-Dt4q6d24.js","assets/chevron-right-c57whaks.js","assets/waypoints-CMfoqtKm.js","assets/admin-context-CXmiyIcc.js","assets/sts-B5ZEON3v.js","assets/signed-fetch-B-FFW93q.js","assets/ec2-CWEplLq4.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-b0hcP1Iz.js","assets/index.browser-C3WbWNjZ.js","assets/s3-DFfxBBj-.js","assets/page-heading-vksh_67w.js","assets/badge-B_ExXcw2.js","assets/dist-BKgR2qk6.js","assets/useRender-2pqPQOAE.js","assets/card-BXl0Qff8.js","assets/admin-BUkrFIlI.js","assets/alert-dialog-kCQ1W4Rz.js","assets/button-BarTr35m.js","assets/DialogTitle-DiInIGjk.js","assets/useValueAsRef-F56m8WbP.js","assets/createBaseUIEventDetails-DL-8egB9.js","assets/useScrollLock-NX6x9Pv8.js","assets/state-badge-D1Z6q_OA.js","assets/route-n5jLl8qw.js","assets/nodes-DExo87Po.js","assets/redirect-B4OxYbs2.js","assets/service-metrics-C6gBIZci.js","assets/allocate-address-CDvylWBR.js","assets/useNavigate-BH3pQKLF.js","assets/back-link-CjKy1JE5.js","assets/cli-command-panel-CLiFy1rG.js","assets/check-BO7W8OSs.js","assets/copy-CamWo-pg.js","assets/use-copy-to-clipboard-DjiD3qo6.js","assets/input-6kdUb_DH.js","assets/useValueChanged-B2TUO8IO.js","assets/useControlled-DwxdYHYK.js","assets/ec2-Ctto2xjU.js","assets/useMutation-6rdxVQpi.js","assets/mutation-BziiWz5u.js","assets/subnet-calculator-BlCTyGtY.js","assets/zod-DF_n2ejy.js","assets/error-banner-DeOeVBKS.js","assets/form-actions-BUM37An0.js","assets/field-c7GHI5st.js","assets/separator-B3CHPrlt.js","assets/ec2-DhWukC79.js","assets/create-internet-gateway-CGStzV_5.js","assets/create-key-pair-CKL_t5ui.js","assets/select-B01jpDaX.js","assets/usePositioner-DUsvphCt.js","assets/DirectionContext-C9rdm8xn.js","assets/useCompositeListItem-CEsaEeXb.js","assets/download-B-6MA128.js","assets/triangle-alert-DcjzeKLg.js","assets/import-key-pair-kETKjfrj.js","assets/textarea-n04EavUm.js","assets/create-launch-template-vwPKoZ8a.js","assets/launch-template-data-fields-BXmKXe0L.js","assets/useSuspenseQuery-Bc7E1P-f.js","assets/collapsible-k5LqEmTn.js","assets/create-load-balancer-BBKiIxj3.js","assets/acm-BYWQapOv.js","assets/certificate-import-dialog-UyecRuny.js","assets/elbv2-K4aD09h6.js","assets/commandBuilder-DzFUl2wS.js","assets/elbv2-DSjTzB2q.js","assets/tags-field-array-Dk9nPDzt.js","assets/plus-BodhJT2v.js","assets/trash-DVab4pEz.js","assets/target-group-form-DvZOw-26.js","assets/elbv2-CtsfBUSG.js","assets/create-nat-gateway-C0zhuUio.js","assets/create-placement-group-D_3YKZZQ.js","assets/create-route-table-q3_xIq0T.js","assets/create-security-group-BD7bwg9p.js","assets/create-subnet-BXdYKpy-.js","assets/create-target-group-CSrYTK6r.js","assets/create-volume-BamGr8Qm.js","assets/create-vpc-CyDH_vpB.js","assets/task-definitions-qkfdMMhk.js","assets/ecs-gqt7DXoF.js","assets/ecs-8RzYyUs4.js","assets/delete-confirmation-dialog-SdDN4LiF.js","assets/ecs-section-nav-CU0acTSl.js","assets/create-cluster-eQiUzhUk.js","assets/eks-C1P60riG.js","assets/commandBuilder-DOzu_YCE.js","assets/json-DsgWHaZY.js","assets/iam-CKOkzDco.js","assets/commandBuilder-CoLHZwsC.js","assets/iam-CVg27y22.js","assets/system-managed-Dk7ySh_E.js","assets/system-image-required-PXuPdEKF.js","assets/rotate-cw-FTUcK_f-.js","assets/policy-document-viewer-DAhLJAGU.js","assets/create-group-6mVlm4h5.js","assets/iam-Cgrr-Iit.js","assets/create-instance-profile-CgetPfkP.js","assets/create-policy-BJFldcK3.js","assets/json-editor-Bwbz8mfn.js","assets/create-role-DvSx0q-L.js","assets/create-user-Bv8sj586.js","assets/create-db-instance-CY_ivPtb.js","assets/rds-CE3126Ga.js","assets/rds-BkKLEWPo.js","assets/rds-CyiqRExv.js","assets/picker-notice-tK9XfUSR.js","assets/security-group-checkboxes-BkxIRro9.js","assets/create-db-parameter-group-CkK-hDRM.js","assets/create-db-subnet-group-BV65rf5O.js","assets/create-bucket-B5kEkFpz.js","assets/s3-DaPUyf7s.js","assets/ls-XgxEPnDq.js","assets/list-card-DFj_XTL4.js","assets/route-DtXjPAiX.js","assets/list-model-catalog-CPZ8xUbd.js","assets/list-guardrails-DYYlNj8n.js","assets/bedrock-Ct-f5HCJ.js","assets/commandBuilder-CyJu3wq4.js","assets/create-CoZioQAG.js","assets/bedrock-7XDhMm-7.js","assets/guardrail-form-page-C6v-izo3.js","assets/list-knowledge-bases-CH_zlnnr.js","assets/bedrockAgent-D9Za8qAi.js","assets/commandBuilder-BH-JcvCM.js","assets/create-uNWIyxX8.js","assets/bedrockAgent-BL6fVxPp.js","assets/list-model-access-Ds5MpzNH.js","assets/playground-zdI5o66q.js","assets/commandBuilder-BLgOdGhm.js","assets/describe-addresses-DKlK9Anw.js","assets/describe-images-Zp7giKqa.js","assets/describe-instances-BK0FW0LB.js","assets/describe-internet-gateways-CiiBScpk.js","assets/describe-key-pairs-BRKxsJWn.js","assets/describe-launch-templates-BuaCkV_y.js","assets/describe-load-balancers-ayJnDdyt.js","assets/describe-nat-gateways-B8p3GKN1.js","assets/describe-placement-groups-XiQbPRZ5.js","assets/describe-route-tables-Cz9uMOpx.js","assets/describe-security-groups-uryLUVIn.js","assets/describe-snapshots-63g9NU9g.js","assets/describe-subnets-lWLraP1d.js","assets/describe-target-groups-5RCbjKmg.js","assets/describe-volumes-Dj4fTW0o.js","assets/describe-vpcs-cdkXn9V9.js","assets/list-repositories-B-9oI4MN.js","assets/ecr-BFtKzkrF.js","assets/ecr-F-ANqxrm.js","assets/list-clusters-UynY0efZ.js","assets/list-clusters-CIHqWLhW.js","assets/eks-CC8GhK3R.js","assets/list-groups-B-aCbhJM.js","assets/list-instance-profiles-BSt7PimM.js","assets/list-policies-DeMAXGlh.js","assets/list-roles-DKJKr-De.js","assets/list-users-B87cjyLt.js","assets/describe-db-instances-ririu0gg.js","assets/delete-db-instance-dialog-BdhZGMA5.js","assets/describe-db-parameter-groups-Vj5U5CsP.js","assets/delete-db-parameter-group-dialog-DHJeV95z.js","assets/rds-delete-dialog-BtEPTeBR.js","assets/describe-db-snapshots-DXVrzU4j.js","assets/create-db-snapshot-dialog-CiBeyQ69.js","assets/delete-db-snapshot-dialog-D3b3mehr.js","assets/describe-db-subnet-groups-B9VO6RnW.js","assets/delete-db-subnet-group-dialog-BgemfFMN.js"])))=>i.map(i=>d[i]); -import{i as e,n as t,o as n,r,t as i}from"./compiler-runtime-D_vZfn79.js";import{A as a,C as o,E as s,a as c,b as l,c as u,g as d,h as f,k as p,m,o as h,s as g,v as _,x as v,y}from"./removable-Dwr9otKB.js";import{t as b}from"./query-BPr_oSEg.js";import{t as x}from"./mutation-BziiWz5u.js";import{a as S,i as C,n as w,o as T,r as E,t as D}from"./lazyRouteComponent-CiZDS8hZ.js";import{C as O,S as k,T as ee,_ as te,a as ne,b as re,c as ie,d as ae,f as oe,g as se,h as ce,i as le,l as ue,m as de,n as fe,o as pe,p as me,r as he,t as A,u as ge,v as _e,w as j,y as M}from"./link-Dhy4xOXd.js";import{a as ve,i as ye,n as be,o as xe,r as Se,s as N,t as Ce}from"./Match-Q4m9gh_H.js";import{n as we,r as Te}from"./matchContext-DjfNTEDZ.js";import{n as Ee,t as De}from"./redirect-B4OxYbs2.js";import{a as Oe,n as ke,r as Ae,t as je}from"./useSelector-CRh8wCTv.js";import{n as Me}from"./with-selector-DrM041uv.js";import{t as Ne}from"./useNavigate-BH3pQKLF.js";import{f as Pe,i as Fe,n as Ie,t as Le}from"./useValueAsRef-F56m8WbP.js";import{a as Re,f as ze,l as Be,n as Ve,o as He,t as Ue,u as We}from"./dist-BKgR2qk6.js";import{t as Ge}from"./useRender-2pqPQOAE.js";import{a as Ke,lt as P,n as qe,o as Je,r as Ye,tn as Xe}from"./cluster-config-xw_Q7X1e.js";import{t as F}from"./createLucideIcon-BQgzNCBR.js";import{t as Ze}from"./camera-bmANKZ8M.js";import{a as Qe,c as $e,i as et,n as tt,o as nt,r as rt,s as it,t as at}from"./waypoints-CMfoqtKm.js";import{n as ot,t as st}from"./_id-njlqJfO-.js";import{n as ct,t as lt}from"./_clusterName-CoDfUrWf.js";import{n as ut,t as dt}from"./_id--x_QHBJa.js";import{_ as ft,b as pt,n as mt,o as ht,s as gt,t as _t,y as vt}from"./button-BarTr35m.js";import{C as yt,E as bt,S as xt,b as St,f as Ct,g as wt,h as I,i as Tt,m as Et,s as Dt,t as Ot,v as kt,w as At,x as jt,y as Mt}from"./createBaseUIEventDetails-DL-8egB9.js";import{$ as Nt,D as Pt,E as Ft,H as It,L as Lt,M as Rt,O as zt,P as Bt,S as Vt,St as Ht,Y as Ut,at as Wt,b as Gt,ct as Kt,g as L,h as qt,it as Jt,j as Yt,k as Xt,m as Zt,nt as Qt,ot as $t,p as en,st as tn,tt as nn,ut as rn,w as an,wt as on,x as sn,y as cn,z as ln}from"./useScrollLock-NX6x9Pv8.js";import{n as un,r as dn,t as fn}from"./usePositioner-DUsvphCt.js";import"./input-6kdUb_DH.js";import{t as pn}from"./separator-B3CHPrlt.js";import{a as mn,c as hn,i as gn,l as _n,n as vn,o as yn,r as bn,s as xn,t as Sn}from"./DialogTitle-DiInIGjk.js";import{t as Cn}from"./awsClient-b0hcP1Iz.js";import{n as wn,t as Tn}from"./admin-context-CXmiyIcc.js";import{t as En}from"./acm-BYWQapOv.js";import{i as Dn,t as On}from"./bedrock-Ct-f5HCJ.js";import{i as kn}from"./bedrockAgent-D9Za8qAi.js";import{A as An,E as jn,M as Mn,O as Nn,S as Pn,_ as Fn,c as In,d as Ln,h as Rn,l as zn,n as Bn,o as Vn,p as Hn,r as Un,w as Wn,y as Gn}from"./ec2-CWEplLq4.js";import{n as Kn}from"./ecr-BFtKzkrF.js";import{n as qn,o as Jn}from"./ecs-gqt7DXoF.js";import{c as Yn}from"./eks-CC8GhK3R.js";import{a as Xn,o as Zn,u as Qn}from"./elbv2-DSjTzB2q.js";import{C as $n,f as er,l as tr,p as nr,y as rr}from"./iam-CVg27y22.js";import{a as ir,d as ar,h as or,o as sr,r as cr}from"./rds-CyiqRExv.js";import{n as lr}from"./s3-DFfxBBj-.js";import{t as ur}from"./login-Zz7MnUTC.js";import{t as dr}from"./run-instances-CCY2HdNd.js";import{t as fr}from"./create-snapshot-wXcq0RWi.js";import{t as pr}from"./create-service-DExIeTQv.js";import{t as mr}from"./register-task-definition-9gqzIyeT.js";import{t as hr}from"./run-task-Dkc0UyHr.js";import{t as gr}from"./_knowledgeBaseId-7saNV50A.js";import{t as _r}from"./_id-FFfcJrCW.js";import{t as vr}from"./_id-Df06xbgH.js";import{t as yr}from"./_id-U4BZhcdn.js";import{t as br}from"./_id-DpCiHWsF.js";import{t as xr}from"./_id-CXZivkIZ.js";import{t as Sr}from"./_id-UPy9yakk.js";import{t as Cr}from"./_id-jxfo70ry.js";import{t as wr}from"./_id-1XMcJG7p.js";import{t as Tr}from"./_id-BnnKVvOl.js";import{t as Er}from"./_id-CjFrz8tY.js";import{t as Dr}from"./_id-DVMhXCwR.js";import{t as Or}from"./_id-CjZcD-rB.js";import{t as kr}from"./_id-NWQukwhC.js";import{t as Ar}from"./_id-CbghuErm.js";import{t as jr}from"./_id-Cssn1mnV.js";import{t as Mr}from"./_id-v23tv1cK.js";import{t as Nr}from"./_clusterName-Dsur-gaB.js";import{t as Pr}from"./_groupName-ybM6rgP-.js";import{t as Fr}from"./_instanceProfileName-DIUtYtG8.js";import{t as Ir}from"./_policyArn-CxrcnaS5.js";import{t as Lr}from"./_roleName-Cb_y_odC.js";import{t as Rr}from"./_userName-O8ujEE0v.js";import{t as zr}from"./_id-BY8BuAtk.js";import{t as Br}from"./_name-BAZWFAbg.js";import{t as Vr}from"./_id-Oh4JEFTn.js";import{t as Hr}from"./_id-CHu8CrA0.js";import{t as Ur}from"./_name-0Lr5w_9G.js";import{t as Wr}from"./_bucket-CkOBR5mi.js";import{t as Gr}from"./_-DN9VaV2B.js";import{t as Kr}from"./_guardrailId.index-D-j5LmDO.js";import{t as qr}from"./_guardrailId.edit-B0KNJrXP.js";import{t as Jr}from"./_serviceName-BtG8OFfw.js";import{t as Yr}from"./_taskId-sla3KnqA.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var Xr=class extends u{#e;#t;#n;constructor(e={}){super(),this.config=e,this.#e=new Set,this.#t=new Map,this.#n=0}build(e,t,n){let r=new x({client:e,mutationCache:this,mutationId:++this.#n,options:e.defaultMutationOptions(t),state:n});return this.add(r),r}add(e){this.#e.add(e);let t=Zr(e);if(typeof t==`string`){let n=this.#t.get(t);n?n.push(e):this.#t.set(t,[e])}this.notify({type:`added`,mutation:e})}remove(e){if(this.#e.delete(e)){let t=Zr(e);if(typeof t==`string`){let n=this.#t.get(t);if(n){if(n.length>1){let t=n.indexOf(e);t!==-1&&n.splice(t,1)}else n[0]===e&&this.#t.delete(t)}}}this.notify({type:`removed`,mutation:e})}canRun(e){let t=Zr(e);if(typeof t==`string`){let n=this.#t.get(t)?.find(e=>e.state.status===`pending`);return!n||n===e}return!0}runNext(e){let t=Zr(e);return typeof t==`string`?(this.#t.get(t)?.find(t=>t!==e&&t.state.isPaused))?.continue()??Promise.resolve():Promise.resolve()}clear(){h.batch(()=>{this.#e.forEach(e=>{this.notify({type:`removed`,mutation:e})}),this.#e.clear(),this.#t.clear()})}getAll(){return Array.from(this.#e)}find(e){let t={exact:!0,...e};return this.getAll().find(e=>_(t,e))}findAll(e={}){return this.getAll().filter(t=>_(e,t))}notify(e){h.batch(()=>{this.listeners.forEach(t=>{t(e)})})}resumePausedMutations(){let e=this.getAll().filter(e=>e.state.isPaused);return h.batch(()=>Promise.all(e.map(e=>e.continue().catch(l))))}};function Zr(e){return e.options.scope?.id}var Qr=class extends u{#e;constructor(e={}){super(),this.config=e,this.#e=new Map}build(e,t,n){let r=t.queryKey,i=t.queryHash??d(r,t),a=this.get(i);return a||(a=new b({client:e,queryKey:r,queryHash:i,options:e.defaultQueryOptions(t),state:n,defaultOptions:e.getQueryDefaults(r)}),this.add(a)),a}add(e){this.#e.has(e.queryHash)||(this.#e.set(e.queryHash,e),this.notify({type:`added`,query:e}))}remove(e){let t=this.#e.get(e.queryHash);t&&(e.destroy(),t===e&&this.#e.delete(e.queryHash),this.notify({type:`removed`,query:e}))}clear(){h.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return this.#e.get(e)}getAll(){return[...this.#e.values()]}find(e){let t={exact:!0,...e};return this.getAll().find(e=>y(t,e))}findAll(e={}){let t=this.getAll();return Object.keys(e).length>0?t.filter(t=>y(e,t)):t}notify(e){h.batch(()=>{this.listeners.forEach(t=>{t(e)})})}onFocus(){h.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){h.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},$r=class{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e={}){this.#e=e.queryCache||new Qr,this.#t=e.mutationCache||new Xr,this.#n=e.defaultOptions||{},this.#r=new Map,this.#i=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=g.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onFocus())}),this.#s=c.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(e){return this.#e.findAll({...e,fetchStatus:`fetching`}).length}isMutating(e){return this.#t.findAll({...e,status:`pending`}).length}getQueryData(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state.data}ensureQueryData(e){let t=this.defaultQueryOptions(e),n=this.#e.build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(o(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return this.#e.findAll(e).map(({queryKey:e,state:t})=>[e,t.data])}setQueryData(e,t,n){let r=this.defaultQueryOptions({queryKey:e}),i=this.#e.get(r.queryHash)?.state.data,a=m(t,i);if(a!==void 0)return this.#e.build(this,r).setData(a,{...n,manual:!0})}setQueriesData(e,t,n){return h.batch(()=>this.#e.findAll(e).map(({queryKey:e})=>[e,this.setQueryData(e,t,n)]))}getQueryState(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state}removeQueries(e){let t=this.#e;h.batch(()=>{t.findAll(e).forEach(e=>{t.remove(e)})})}resetQueries(e,t){let n=this.#e;return h.batch(()=>{let r=n.findAll(e),i=new Set(r);return r.forEach(e=>{e.reset()}),this.refetchQueries({type:`active`,predicate:e=>i.has(e)},t)})}cancelQueries(e,t={}){let n={revert:!0,...t},r=h.batch(()=>this.#e.findAll(e).map(e=>e.cancel(n)));return Promise.all(r).then(l).catch(l)}invalidateQueries(e,t={}){return h.batch(()=>(this.#e.findAll(e).forEach(e=>{e.invalidate()}),e?.refetchType===`none`?Promise.resolve():this.refetchQueries({...e,type:e?.refetchType??e?.type??`active`},t)))}refetchQueries(e,t={}){let n={...t,cancelRefetch:t.cancelRefetch??!0},r=h.batch(()=>this.#e.findAll(e).filter(e=>!e.isDisabled()&&!e.isStatic()).map(e=>{let t=e.fetch(void 0,n);return n.throwOnError||(t=t.catch(l)),e.state.fetchStatus===`paused`?Promise.resolve():t}));return Promise.all(r).then(l)}async query(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t),r=n.isStaleByTime(o(t.staleTime,n))?await n.fetch(t):n.state.data,i=t.select;return i?i(r):r}fetchQuery(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t);return n.isStaleByTime(o(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(l).catch(l)}infiniteQuery(e){return e._type=`infinite`,this.query(e)}fetchInfiniteQuery(e){return e._type=`infinite`,this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(l).catch(l)}ensureInfiniteQueryData(e){return e._type=`infinite`,this.ensureQueryData(e)}resumePausedMutations(){return c.isOnline()?this.#t.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#e}getMutationCache(){return this.#t}getDefaultOptions(){return this.#n}setDefaultOptions(e){this.#n=e}setQueryDefaults(e,t){this.#r.set(f(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){let t=[...this.#r.values()],n={};return t.forEach(t=>{v(e,t.queryKey)&&Object.assign(n,t.defaultOptions)}),n}setMutationDefaults(e,t){this.#i.set(f(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){let t=[...this.#i.values()],n={};return t.forEach(t=>{v(e,t.mutationKey)&&Object.assign(n,t.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;let t={...this.#n.queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||=d(t.queryKey,t),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!==`always`),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode=`offlineFirst`),t.queryFn===s&&(t.enabled=!1),t}defaultMutationOptions(e){return e?._defaulted?e:{...this.#n.mutations,...e?.mutationKey&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){this.#e.clear(),this.#t.clear()}};function ei(){try{return sessionStorage}catch{return}}var ti=`tsr-scroll-restoration-v1_3`,ni=ei();function ri(){try{return JSON.parse(ni?.getItem(`tsr-scroll-restoration-v1_3`)||`{}`)}catch{return{}}}function ii(){try{ni?.setItem(ti,JSON.stringify(ai))}catch{}}var ai=ri(),oi=`data-scroll-restoration-id`,si=e=>e.state.__TSR_key||e.href;function ci(e){let t=e.getAttribute(oi);if(t)return`[${oi}="${t}"]`;let n=``,r=e,i;for(;i=r.parentNode;){let e=1,t=r;for(;t=t.previousElementSibling;)e++;let a=`${r.localName}:nth-child(${e})`;n=n?`${a} > ${n}`:a,r=i}return n}var li=!1,ui=`window`;function di(e){try{return typeof e==`function`?e():document.querySelector(e)}catch{}}function fi(e){let t=new Set;for(let n of e){if(n===ui)continue;let e=di(n);e&&t.add(e)}return t}function pi(e,t){let n=t??e.options.scrollRestoration,r=e._scroll;n&&(r.restoring=!0);let i=e.options.getScrollRestorationKey||si,a=new Set,o=e=>{let t=ai[e]||={};for(let e of a)e===document?t[ui]={scrollX,scrollY}:e.isConnected&&(t[ci(e)]={scrollX:e.scrollLeft,scrollY:e.scrollTop})};n&&!r.restoration&&(r.restoration=!0,li=!1,history.scrollRestoration=`manual`,document.addEventListener(`scroll`,e=>{li||a.add(e.target)},!0),e.subscribe(`onBeforeLoad`,e=>{e.fromLocation&&o(i(e.fromLocation)),a.clear()}),addEventListener(`pagehide`,()=>{o(i(e.stores.resolvedLocation.get()??e.stores.location.get())),ii()})),!r.reset&&(r.reset=!0,e.subscribe(`onRendered`,t=>{let n=e.options.scrollRestorationBehavior,o=e.options.scrollToTopSelectors,s=r.next,c=r.hash,l;if(a.clear(),r.next=!0,r.hash=!1,typeof e.options.scrollRestoration==`function`&&!e.options.scrollRestoration({location:e.latestLocation}))return;let u=i(t.toLocation),d=t.fromLocation&&i(t.fromLocation);if(r.restoring&&d&&d!==u){let e=ai[d];if(e){let t=ai[u];for(let n in e){if(n===ui){if(s)continue}else{let e=di(n);if(!e||s&&o&&(l??=fi(o),l.has(e)))continue}t||=ai[u]={},t[n]??=e[n]}}}li=!0;try{let e=t.toLocation.hash,i=t.toLocation.state.__hashScrollIntoViewOptions??!0,a=!1;if(s){!e&&o&&(l??=fi(o));let t=e&&i&&c,s=r.restoring?ai[u]:void 0;if(s)for(let e in s){let{scrollX:r,scrollY:i}=s[e];if(e===ui){if(t)continue;scrollTo({top:i,left:r,behavior:n}),a=!0}else{let t=di(e);t&&(t.scrollLeft=r,t.scrollTop=i,l?.delete(t))}}if(!e){let e={top:0,left:0,behavior:n};if(a||scrollTo(e),l)for(let t of l)t.scrollTo(e)}}!a&&e&&i&&document.getElementById(e)?.scrollIntoView(i)}finally{li=!1}}))}function mi(e,t=String){let n;for(let r in e){let i=e[r];i!==void 0&&(n||=new URLSearchParams).set(r,t(i))}return n?n.toString():``}function hi(e){return e?e===`false`?!1:e===`true`?!0:e*0==0&&+e+``===e?+e:e:``}function gi(e){let t=new URLSearchParams(e),n=Object.create(null);for(let[e,r]of t.entries()){let t=n[e];t==null?n[e]=hi(r):Array.isArray(t)?t.push(hi(r)):n[e]=[t,hi(r)]}return n}var _i=/^(?:\s|["[{\d-]|fa|nu|tr)/,vi=bi(JSON.parse),yi=xi(JSON.stringify,JSON.parse);function bi(e){let t=e===JSON.parse;return n=>{n[0]===`?`&&(n=n.substring(1));let r=gi(n);for(let n in r){let i=r[n];if(typeof i==`string`){if(t&&!_i.test(i))continue;try{r[n]=e(i)}catch{}}}return r}}function xi(e,t){let n=t===JSON.parse;function r(r){if(r&&typeof r==`object`)try{return e(r)}catch{}else if(t&&typeof r==`string`){if(n&&!_i.test(r))return r;try{return t(r),e(r)}catch{}}return r}return e=>{let t=mi(e,r);return t?`?${t}`:``}}function Si(e){let t=new Map,n,r;return{get(e){let n=t.get(e);if(n)return n.visited=!0,n.value},set(i,a){let o=t.get(i);if(o){o.value=a;return}if(t.size>=e){let e=n?.next().value;for(;!e||e.visited;)e?e.visited=!1:n=t.values(),e=n.next().value;e===r&&(n=void 0),t.delete(e.key)}let s={key:i,value:a,visited:!1};r=s,t.set(i,s)},clear(){t.clear(),n=void 0,r=void 0}}}var Ci=4,wi=5;function Ti(e){let t=e.names;if(t)return t;let n=[];for(let t of e)typeof t!=`string`&&n.push(t[1]);return e.names=n}function Ei(e,t,n){let r=e.substring(t,n);if(r.charCodeAt(0)===36)return r.length===1?[2,`_splat`,``,void 0]:[1,r.substring(1),``,``];let i=r.indexOf(`{`);if(i>=0){let a=r.indexOf(`}`,i),o=r.charCodeAt(i+1)===45,s=i+(o?3:2);if(a>=0&&r.charCodeAt(s-1)===36&&(!o||s!e.parse&&e.caseSensitive===_&&e.prefix===h&&e.suffix===g);if(y)c=y;else{let e=ki(r,t,_,h,g);c=e,v.push(e),v.length===2&&i?.push(v)}}r=c}p&&m=0;e--){let n=i.wildcard[e],{prefix:r,suffix:a}=n;if(!r||!_&&(n.caseSensitive?v:y??=v.toLowerCase()).startsWith(r)){if(a){if(_)continue;let e=t.slice(u).join(`/`),i=e.slice(-a.length);if((n.caseSensitive?i:i.toLowerCase())!==a||e.length-a.length=0;t--){let n=i.optional[t];s.push({node:n,index:u,skipped:e,statics:f,dynamics:p,optionals:m,extract:h,rawParams:g})}if(!_)for(let e=i.optional.length-1;e>=0;e--){let t=i.optional[e],{prefix:n,suffix:r}=t;if(n||r){let e=t.caseSensitive?v:y??=v.toLowerCase();if(n&&!e.startsWith(n)||r&&e.indexOf(r,e.length-r.length)=0;e--){let t=i.dynamic[e],{prefix:n,suffix:r}=t;if(n||r){let e=t.caseSensitive?v:y??=v.toLowerCase();if(n&&!e.startsWith(n)||r&&e.indexOf(r,e.length-r.length)=0;e--){let t=i.pathless[e];s.push({node:t,index:u,skipped:d,statics:f,dynamics:p,optionals:m,extract:h,rawParams:g})}}if(l)return l;if(r&&c){let n=c.index;for(let e=0;ee.statics||t.statics===e.statics&&(t.dynamics>e.dynamics||t.dynamics===e.dynamics&&(t.optionals>e.optionals||t.optionals===e.optionals&&((t.node.kind===Ci)>(e.node.kind===Ci)||t.node.kind===Ci==(e.node.kind===Ci)&&t.node.depth>e.node.depth)))}function Wi(e,t,n){let r=pe(e),i=`/${r}`,a=t?i:i.toLowerCase(),o=`${a}/`,s={input:({url:e})=>{let n=t?e.pathname:e.pathname.toLowerCase();return n===a?e.pathname=`/`:n.startsWith(o)&&(e.pathname=e.pathname.slice(i.length)),e},output:({url:e})=>(e.pathname=fe(`/${r}${e.pathname}`),e)};return n?{input:({url:e})=>Gi(n,s.input({url:e})),output:({url:e})=>s.output({url:Ki(n,e)})}:s}function Gi(e,t){let n=e?.input?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function Ki(e,t){let n=e?.output?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function qi(e,t){let{createMutableStore:n,createReadonlyStore:r,batch:i}=t,a=new Map,o=n(`idle`),s=n(e),c=n(void 0),l=n([]),u=r(()=>l.get().map(e=>a.get(e).get())),d=r(()=>({status:o.get(),isLoading:o.get()===`pending`,matches:u.get(),location:s.get(),resolvedLocation:c.get()}));function f(e){let t=a.get(e);return t||(t=n(void 0),a.set(e,t)),t}let p={status:o,location:s,resolvedLocation:c,ids:l,matches:u,byRoute:a,__store:d,getMatchStore:f,setMatches:m};function m(e){let t=l.get(),n=e.map(e=>e.routeId);i(()=>{ge(t,n)||l.set(n);for(let e of t)n.includes(e)||a.get(e).set(()=>void 0);for(let t of e){let e=f(t.routeId);e.get()!==t&&e.set(t)}})}return p}var Ji=`__TSR_index`,Yi=`popstate`,Xi=`beforeunload`,Zi=/^[\x00-\x20]*(?:[\\/][\t\n\r]*){2,}/;function Qi(e){let t=Zi.exec(e);return t?`/`+e.slice(t[0].length):e}function $i(e){return/[\x00-\x1f\x7f]/.test(e)&&(e=e.replace(/[\x00-\x1f\x7f]/g,e=>` -\r`.includes(e)?``:encodeURIComponent(e))),Qi(e)}function ea(e){let t=e.getLocation(),n=new Set,r=r=>{t=e.getLocation(),n.forEach(e=>e({location:t,action:r}))},i=n=>{e.notifyOnIndexChange??!0?r(n):t=e.getLocation()},a=async({task:n,navigateOpts:r,...i})=>{if(r?.ignoreBlocker??!1){n();return}let a=e.getBlockers?.()??[],o=i.type===`PUSH`||i.type===`REPLACE`;if(typeof document<`u`&&a.length&&o)for(let n of a){let r=ra(i.path,i.state);if(await n.blockerFn({currentLocation:t,nextLocation:r,action:i.type})){e.onBlocked?.();return}}n()};return{get location(){return t},get length(){return e.getLength()},subscribers:n,subscribe:e=>(n.add(e),()=>{n.delete(e)}),push:(n,i,o)=>{let s=t.state[Ji];i=ta(s+1,i),a({task:()=>{e.pushState(n,i),r({type:`PUSH`})},navigateOpts:o,type:`PUSH`,path:n,state:i})},replace:(n,i,o)=>{let s=t.state[Ji];i=ta(s,i),a({task:()=>{e.replaceState(n,i),r({type:`REPLACE`})},navigateOpts:o,type:`REPLACE`,path:n,state:i})},go:(t,n)=>{a({task:()=>{e.go(t,n?.ignoreBlocker??!1),i({type:`GO`,index:t})},navigateOpts:n,type:`GO`})},back:t=>{a({task:()=>{e.back(t?.ignoreBlocker??!1),i({type:`BACK`})},navigateOpts:t,type:`BACK`})},forward:t=>{a({task:()=>{e.forward(t?.ignoreBlocker??!1),i({type:`FORWARD`})},navigateOpts:t,type:`FORWARD`})},canGoBack:()=>t.state[Ji]!==0,createHref:t=>e.createHref(t),block:t=>{if(!e.setBlockers)return()=>{};let n=e.getBlockers?.()??[];return e.setBlockers([...n,t]),()=>{let n=e.getBlockers?.()??[];e.setBlockers?.(n.filter(e=>e!==t))}},flush:()=>e.flush?.(),destroy:()=>e.destroy?.(),notify:r,_getBlockers:()=>e.getBlockers?.()??[]}}function ta(e,t){let n=ia();return{...t,key:n,__TSR_key:n,[Ji]:e}}function na(e){let t=e?.window??(typeof document<`u`?window:void 0),n=t.history.pushState,r=t.history.replaceState,i=[],a=()=>i,o=e=>i=e,s=t=>$i(e?.createHref?e.createHref(t):t),c=e?.parseLocation??(()=>ra(`${t.location.pathname}${t.location.search}${t.location.hash}`,t.history.state));if(!t.history.state?.__TSR_key&&!t.history.state?.key){let e=ia();t.history.replaceState({[Ji]:0,key:e,__TSR_key:e},``)}let l=c(),u,d=!1,f=!1,p=!1,m=!1,h=()=>l,g,_=()=>{g&&(S._ignoreSubscribers=!0,(g[2]?t.history.pushState:t.history.replaceState)(g[1],``,g[0]),S._ignoreSubscribers=!1,g=void 0,u=void 0)},v=(t,n,r)=>{let i=e?.createHref?s(n):void 0,a=!!g;a||(u=l),l=ra(n,r),g=[i??l.href,r,g?.[2]||t],a||queueMicrotask(()=>_())},y=e=>{l=c(),S.notify({type:e})},b=async()=>{if(m=!1,f){f=!1;return}let e=c(),n=e.state[Ji]-l.state[Ji],r=n===1,i=n===-1,o=!r&&!i||d;d=!1;let s=o?`GO`:i?`BACK`:`FORWARD`,u=o?{type:`GO`,index:n}:{type:i?`BACK`:`FORWARD`};if(p)p=!1;else{let r=a();if(typeof document<`u`&&r.length){for(let i of r)if(await i.blockerFn({currentLocation:l,nextLocation:e,action:s})){f=!0,t.history.go(-n),S.notify(u);return}}}l=c(),S.notify(u)},x=e=>{if(m){m=!1;return}let t=!1,n=a();if(typeof document<`u`&&n.length)for(let e of n){let n=e.enableBeforeUnload??!0;if(n===!0){t=!0;break}if(typeof n==`function`&&n()===!0){t=!0;break}}if(t)return e.preventDefault(),e.returnValue=``},S=ea({getLocation:h,getLength:()=>t.history.length,pushState:(e,t)=>v(!0,e,t),replaceState:(e,t)=>v(!1,e,t),back:e=>(e&&(p=!0,m=!0),t.history.back()),forward:e=>{e&&(p=!0,m=!0),t.history.forward()},go:(e,n)=>{d=!0,n&&(p=!0,m=!0),t.history.go(e)},createHref:e=>s(e),flush:_,destroy:()=>{t.history.pushState=n,t.history.replaceState=r,t.removeEventListener(Xi,x,{capture:!0}),t.removeEventListener(Yi,b)},onBlocked:()=>{u&&l!==u&&(l=u)},getBlockers:a,setBlockers:o,notifyOnIndexChange:!1});return S._ignoreNextBeforeUnload=e=>{m=!1;try{e=new URL(e,t.document.baseURI).href,m=/^https?:/.test(e)&&(!e.includes(`#`)||e.split(`#`)[0]!==t.location.href.split(`#`)[0])}catch{}},t.addEventListener(Xi,x,{capture:!0}),t.addEventListener(Yi,b),t.history.pushState=function(...e){let r=n.apply(t.history,e);return S._ignoreSubscribers||y(`PUSH`),r},t.history.replaceState=function(...e){let n=r.apply(t.history,e);return S._ignoreSubscribers||y(`REPLACE`),n},S}function ra(e,t){let n=$i(e),r=n.indexOf(`#`),i=n.indexOf(`?`);if(!t){let e=ia();t={[Ji]:0,key:e,__TSR_key:e}}return{href:n,pathname:n.substring(0,r>0?i>0?Math.min(r,i):r:i>0?i:n.length),hash:r>-1?n.substring(r):``,search:i>-1?n.slice(i,r===-1?void 0:r):``,state:t}}function ia(){return(Math.random()+1).toString(36).substring(7)}function aa(e,t){return e.protocol!==`http:`&&e.protocol!==`https:`||e.origin!==t||!!e.username||!!e.password}function R(e){return e.pathname+e.search+e.hash}function oa(e){return e.options.loader||e.options.beforeLoad||e.lazyFn||e.options.component?.preload||e.options.pendingComponent?.preload}function sa(e,t){return{fromLocation:t,toLocation:e,pathChanged:t?.pathname!==e.pathname,hrefChanged:t?.href!==e.href,hashChanged:t?.hash!==e.hash}}function ca({key:e,__TSR_key:t,__TSR_index:n,__hashScrollIntoViewOptions:r,...i}){return i}function la(e){return e.findIndex(e=>e.status===`error`||e.status===`notFound`||e._notFound)+1}function ua(e,t,n,r,i,a){r&&(t=t.slice(0,r)),i&&(n=n.slice(0,i));for(let r of t){if(a&&e._tx!==a)return;n.some(e=>e.routeId===r.routeId)||e.routesById[r.routeId].options.onLeave?.(r)}for(let r of n){if(a&&e._tx!==a)return;e.routesById[r.routeId].options[t.some(e=>e.routeId===r.routeId)?`onStay`:`onEnter`]?.(r)}}var da=class{constructor(e,t){this.tempLocationKey=`${Math.round(Math.random()*1e7)}`,this._scroll={next:!0},this.subscribers=new Set,this._cache=new Map,this._committed=[],this.startTransition=async e=>(e(),!1),this.update=e=>{let t=this.options;this.options={...t,...e},this.isServer=this.options.isServer??!1??typeof document>`u`,this.staticLocations=new WeakMap,this.protocolAllowlist=new Set(this.options.protocolAllowlist),(!this.history||this.options.history&&this.options.history!==this.history)&&(this.history=this.options.history?this.options.history:na()),this.origin=this.options.origin,this.origin||=window?.origin&&window.origin!==`null`?window.origin:`http://localhost`;let n=this.options.basepath??`/`,r=this.options.rewrite,i=this.basepath!==n||t?.rewrite!==r||t?.caseSensitive!==this.options.caseSensitive;if(i&&(this.basepath=n,this.rewrite=n!==`/`&&pe(n)?Wi(n,this.options.caseSensitive,r):r),this.history&&this.updateLatestLocation(),this.options.routeTree!==this.routeTree){this.routeTree=this.options.routeTree;let e;e=this.buildRouteTree(),this.setRoutes(e)}if(this.stores)i&&this.stores.location.set(this.latestLocation);else if(this.latestLocation){let e=this.getStoreConfig(this);this.batch=e.batch,this.stores=qi(this.latestLocation,e),pi(this)}},this.updateLatestLocation=()=>{this.latestLocation=this.parseLocation(this.history.location,this.latestLocation)},this.buildRouteTree=()=>{let e=Pi(this.routeTree,this.options.caseSensitive);return this.options.routeMasks&&Ai(this.options.routeMasks,e.processedTree),{...e,resolvePathCache:Si(1e3)}},this.subscribe=(e,t)=>{let n={eventType:e,fn:t};return this.subscribers.add(n),()=>{this.subscribers.delete(n)}},this.emit=e=>{for(let t of this.subscribers)if(t.eventType===e.type)try{t.fn(e)}catch(e){console.error(e)}},this.parseLocation=(e,t)=>{let n=({pathname:e,search:n,hash:r,href:i},a)=>{if(!this.rewrite&&!/[ \x00-\x1f\x7f\u0080-\uffff]/.test(e)){let i=this.options.parseSearch(n),o=this.options.stringifySearch(i);return{href:e+o+r,publicHref:e+o+r,pathname:oe(e),external:!1,searchStr:o,search:O(t?.search,i),hash:oe(r.slice(1)),state:ee(t?.state,a)}}let o=Gi(this.rewrite,new URL(i,this.origin)),s=this.options.parseSearch(o.search),c=this.options.stringifySearch(s);return o.search=c,{href:o.href.replace(o.origin,``),publicHref:i,pathname:oe(Qi(o.pathname)),external:!!this.rewrite&&aa(o,this.origin),searchStr:c,search:O(t?.search,s),hash:oe(o.hash.slice(1)),state:ee(t?.state,a)}},r=n(e,e.state),{__tempLocation:i,__tempKey:a}=r.state;if(i&&(!a||a===this.tempLocationKey)){let e=n(i,{...i.state,__tempLocation:void 0,key:r.state.key,__TSR_key:r.state.__TSR_key});return e.maskedLocation=r,e}return r},this.matchRoutes=(e,t,n)=>typeof e==`string`?this.matchRoutesInternal({pathname:e,search:t},n):this.matchRoutesInternal(e,t),this.getMatchedRoutes=e=>{let t=Object.create(null),n=Ni(ie(e),this.processedTree,!0);return n&&Object.assign(t,n.rawParams),[n?.branch||[this.routesById.__root__],t,n?.route]},this.buildLocation=e=>{{let t=this.staticLocations.get(e);if(t)return t}let t=!1,n=(n={})=>{if(n.href){let e=ra(n.href,{});n={...n,to:Gi(this.rewrite,new URL(e.pathname,this.origin)).pathname,search:this.options.parseSearch(e.search),hash:e.hash.slice(1)}}let r=n._fromLocation||this._pendingLocation||this.latestLocation,i,a=()=>(t=!0,r),o=()=>(t=!0,i??=this.matchRoutesLightweight(r)),s=n.to?`${n.to}`:`.`,c=ne(s[0]===`/`?``:n.unsafeRelative===`path`?a().pathname:n.from??o()[1],s,this.options.trailingSlash,this.resolvePathCache),l=this.routesByPath[ie(c)],u=c.includes(`$`),d;if(l)d=l._branch??=Li(l);else if(u)d=[];else{let[e,t,n]=this.getMatchedRoutes(c);d=e,this.options.notFoundRoute&&(!n||n.path!==`/`&&t[`**`])&&(d=[...d,this.options.notFoundRoute])}let f=u?l?._interpolation??Di(!1,{fullPath:c},0):void 0,p;for(let e of d){let t=e.options.params?.stringify??e.options.stringifyParams;if(t){let e=o()[3];if(p??=ga(n.params,e),!_e(p))break;p===e&&(p=Object.assign(ae(),p));try{Object.assign(p,t(p))}catch{}}}p??=ga(n.params,_a(n.params,f)?o()[3]:va);let m=e.leaveParams?c:Qi(oe(f?le(c,f,p,this.pathParamsDecoder):c)),h=ya(d,e._includeValidateSearch),g=()=>{let t=o()[2];if(e._includeValidateSearch&&this.options.search?.strict){let e={};d.forEach(n=>{if(n.options.validateSearch)try{Object.assign(e,ha(n.options.validateSearch,{...e,...t}))}catch{}}),t=e}return t},_=h.length?ba(h,g(),n):n.search===!0?g():typeof n.search==`function`?n.search(g()):n.search||va,v=this.options.stringifySearch(_),y=n.hash===!0?a().hash:typeof n.hash==`function`?n.hash(a().hash):n.hash||void 0,b=y?`#${y}`:``,x=n.state?n.state===!0?a().state:typeof n.state==`function`?n.state(a().state):n.state:va,S=`${m}${v}${b}`,C,w,T=!1;if(this.rewrite){let e=new URL(S,this.origin),t=e.origin,n=Ki(this.rewrite,e);C=R(e),aa(n,t)?(w=n.href,T=!0):w=Qi(R(n))}else C=de(S),w=C;return{publicHref:w,href:C,pathname:m,search:_,searchStr:v,state:x,hash:y??``,external:T,unmaskOnReload:n.unmaskOnReload}},r=n(e);if(e.mask)r.maskedLocation=n({from:e.from,...e.mask});else if(this.options.routeMasks){let t=ji(r.pathname,this.processedTree);if(t){let i=Object.assign(ae(),t.rawParams),{from:a,params:o,...s}=t.route,c=ga(o,i);r.maskedLocation=n({from:e.from,...s,params:c})}}return!t&&e._fromLocation&&!r.maskedLocation&&this.staticLocations.set(e,r),r},this.commitLocation=async({viewTransition:e,ignoreBlocker:t,...n})=>{let r=n.maskedLocation??n;if(r.external)return fa(this,r.publicHref,{replace:n.replace,ignoreBlocker:t});let i,a=ie(this.latestLocation.href)===ie(n.href)&&me(ca(n.state),ca(this.latestLocation.state)),o=this._commitPromise,s,c=new Promise(e=>{s=e});if(c.resolve=()=>{s(),o?.resolve()},this._commitPromise=c,a)this.load();else{let{maskedLocation:r,hashScrollIntoView:a,...o}=n;r&&(o={...r,state:{...r.state,__tempKey:void 0,__tempLocation:{...o,search:o.searchStr,state:{...o.state,__tempKey:void 0,__tempLocation:void 0,__TSR_key:void 0,key:void 0}}}},(o.unmaskOnReload??this.options.unmaskOnReload??!1)&&(o.state.__tempKey=this.tempLocationKey)),o.state={...o.state,__hashScrollIntoViewOptions:a??this.options.defaultHashScrollIntoView??!0},this.shouldViewTransition=e,i=n.replace?`REPLACE`:`PUSH`,this.history[i===`REPLACE`?`replace`:`push`](o.publicHref,o.state,{ignoreBlocker:t}),this.history.subscribers.size||this.load({action:{type:i}})}return this._scroll.next=n.resetScroll??!0,this._commitPromise},this.buildAndCommitLocation=({replace:e,resetScroll:t,hashScrollIntoView:n,viewTransition:r,ignoreBlocker:i,...a}={})=>{let o=this.buildLocation({...a,_includeValidateSearch:!0});this._pendingLocation=o;let s=this.commitLocation({...o,viewTransition:r,replace:e,resetScroll:t,hashScrollIntoView:n,ignoreBlocker:i});return queueMicrotask(()=>{this._pendingLocation===o&&(this._pendingLocation=void 0)}),s},this.navigate=async({to:e,reloadDocument:t,href:n,publicHref:r,...i})=>{let a=n?te(n):void 0;if(a||t){if(e!==void 0||!n){let t=this.buildLocation({to:e,...i}),a=t.maskedLocation??t;n??=a.publicHref,r??=a.publicHref}let t=!a&&r?r:n;return fa(this,t,i)}return this.buildAndCommitLocation({...i,href:n,to:e,_isNavigate:!0})},this.load=async e=>{this.updateLatestLocation(),e?.action&&(this._scroll.hash=e.action.type===`PUSH`||e.action.type===`REPLACE`),await mo(this,e)},this.startViewTransition=e=>{let t=this.shouldViewTransition??this.options.defaultViewTransition;if(this.shouldViewTransition=void 0,t&&typeof document.startViewTransition==`function`){let n;if(typeof t==`object`&&window.CSS?.supports?.(`selector(:active-view-transition-type(a))`)){let r=this.latestLocation,i=this.stores.resolvedLocation.get(),a=typeof t.types==`function`?t.types(sa(r,i)):t.types;if(a===!1)return e();n={update:e,types:a}}else n=e;return document.startViewTransition(n).updateCallbackDone}return e()},this.invalidate=e=>{let t=this._committed,n=e?.filter,r=this._preloads,i=new Set,a=e=>{(!n||n(e))&&i.add(e.id)};t.forEach(a),this._cache.forEach(a),r?.forEach(e=>e.forEach(a)),this._tx?.[3].forEach(a);let o=[];for(let[e,t]of r??[])t.some(e=>i.has(e.id))&&(r.delete(e),o.push(e));let s=t=>{if(i.has(t.id)){let n=this.routesById[t.routeId],r={...t,invalid:!0,...(e?.forcePending||t.status===`error`||t.status===`notFound`)&&oa(n)?{status:`pending`,error:void 0}:void 0};return t._flight=void 0,r}return t};this._committed=t.map(s);for(let[t,n]of this._cache)i.has(t)&&(n.invalid=!0,e?.forcePending&&(n.status=`pending`));for(let e of i)this._flights?.delete(e);for(let e of o)e.abort();return this.shouldViewTransition=!1,this.load({sync:e?.sync})},this.resolveRedirect=e=>{let t=e.options,n=e.headers.get(`Location`)||t.href;if(!n){let e=this.buildLocation(t);n=(e.maskedLocation??e).publicHref||`/`}let r;if(j.test(n)||(r=te(n))&&!this.protocolAllowlist.has(r))throw Error(`Redirect blocked: unsafe protocol`);if(r===`http:`||r===`https:`){let e=new URL(n);e.pathname.startsWith(`//`)?n=e.href:aa(e,this.origin)||(n=R(e),r=void 0)}return r&&(t.reloadDocument=!0),t.href=n,e.headers.set(`Location`,n),e},this.clearCache=e=>{let t=this._cache,n=this._preloads,r=e?.filter,i=[],a=[];for(let[e,n]of t)(!r||r(n))&&(a.push(e),i.push(n));let o=[];for(let[e,t]of n??[])(!r||t.some(r))&&(o.push(e),i.push(...t));for(let e of a)t.delete(e);for(let e of o)n.delete(e);for(let e of i){let t=e._flight;e._flight=void 0,t&&!--t[2]&&(this._flights?.get(e.id)===t&&this._flights.delete(e.id),o.push(t[1]))}for(let e of o)e.abort()},this.loadRouteChunk=Ta,this.preloadRoute=e=>ho(this,e),this.matchRoute=(e,t)=>{let n={...e,to:e.to?ne(e.from||``,e.to,this.options.trailingSlash,this.resolvePathCache):void 0,params:e.params||{},leaveParams:!0},r=this.buildLocation(n),i=this.stores.status.get()===`pending`;if(t?.pending&&!i)return!1;let a=t?.pending??!i?this.latestLocation:this.stores.resolvedLocation.get()||this.stores.location.get(),o=Mi(r.pathname,t?.caseSensitive??!1,t?.fuzzy??!1,a.pathname,this.processedTree);return!o||e.params&&!me(o.rawParams,e.params,!0)?!1:t?.includeSearch??!0?me(a.search,r.search,!0)?o.rawParams:!1:o.rawParams},this.getStoreConfig=t,e.pathParamsAllowedCharacters?.length&&(this.pathParamsDecoder=he(e.pathParamsAllowedCharacters)),this.update({defaultPreloadDelay:50,defaultPendingMs:1e3,defaultPendingMinMs:500,context:void 0,...e,caseSensitive:e.caseSensitive??!1,notFoundMode:e.notFoundMode??`fuzzy`,stringifySearch:e.stringifySearch??yi,parseSearch:e.parseSearch??vi,protocolAllowlist:e.protocolAllowlist??ue}),self.__TSR_ROUTER__=this}isShell(){return!!this.options.isShell}get state(){return this.stores.__store.get()}setRoutes(e){Object.assign(this,e),this.lightweightCache=new WeakMap,this.staticLocations=new WeakMap;let t=this.options.notFoundRoute;t&&(t.init(99999999999),this.routesById[t.id]!==t&&(t._interpolation=Di(!1,t,0)),this.routesById[t.id]=t)}matchRoutesInternal(e,t){let[n,r,i]=this.getMatchedRoutes(e.pathname),a=n,o=!1;(i?i.path!==`/`&&r[`**`]:ie(e.pathname))&&(this.options.notFoundRoute?a=[...a,this.options.notFoundRoute]:o=!0);let s=o?xa(this.options.notFoundMode,a):void 0,c=Array(a.length),l=this._committed,u=(e,t)=>{let n=l[t];return n?.routeId===e.id?n:e===this.options.notFoundRoute?l.find(t=>t.routeId===e.id):void 0},d;for(let n=0;ntypeof t!=`string`&&!M.call(e,t[1]))}var va=Object.freeze({});function ya(e,t){let n=[];for(let r=0;r{let n=t(i.preSearchFilters?i.preSearchFilters.reduce((e,t)=>t(e),e):e);return i.postSearchFilters?i.postSearchFilters.reduce((e,t)=>t(e),n):n});let a=i.validateSearch;t&&a&&n.push(({search:e,next:t,meta:n})=>{let r=t(e);try{let e=ha(a,r);if(n&&e)for(let t in e)t in r||(n.defaulted||=new Map).set(t,e[t]);return{...r,...e}}catch{}return r})}return n}function ba(e,t,n){let r=(t,i,a)=>{if(t>=e.length){if(!n.search)return{};if(n.search===!0)return i;let e=se(n.search,i);return a&&(a.explicit=e),e}return e[t]({search:i,next:(e,n)=>{if(n){let n=a||{};return{search:r(t+1,e,n),meta:n}}return r(t+1,e,a)},meta:a})};return r(0,t)}function xa(e,t){if(e!==`root`){let e;for(let n=t.length-1;n>=0;n--){let r=t[n];if(r.options.notFoundComponent)return r.id;e||=r.children&&r.id}if(e)return e}return Te}function Sa(e,t){let n=e.options.params?.parse??e.options.parseParams;n&&Object.assign(t,n(t))}function Ca(e,t){return e.options[t]?.preload?.()}function wa(e,t){let n=Ca(e,`component`),r=Ca(e,`pendingComponent`);return t&&(r?r=r.then(t):t()),n&&r?Promise.all([n,r]).then(()=>{}):n??r}function Ta(e,t,n){let r=()=>t===!1?void 0:t?Ca(e,t):wa(e,n),i=e._lazy;if(i)return i===!0?r():i.then(r);if(!e.lazyFn)return r();let a=e.lazyFn().then(t=>{{let{id:n,...r}=t.options;Object.assign(e.options,r),e._lazy=!0}},t=>{throw e._lazy=void 0,t});return e._lazy=a,a.then(r)}function Ea(e){let t=e.findIndex(e=>e.status!==`success`||e._notFound)+1;return t&&t{let i=()=>r(t);t.addEventListener(`abort`,i,{once:!0}),Promise.resolve(e).then(n,r).then(()=>t.removeEventListener(`abort`,i))})}function Fa(e,t){return e.routesById[t.routeId]}function Ia(e,t,n){return De(e)?[ja,e]:xe(e)?(e.routeId||=n,[Aa,e]):t?(typeof e?.then==`function`&&(e=Error(`A Promise was thrown`,{cause:e})),[ka,e]):[Oa,e]}function La(e,t){let n=Ia(t,!0,e.id);if(n[0]!==ka)return n;try{e.options.onError?.(n[1])}catch(t){n=Ia(t,!0,e.id)}return n}function Ra(e,t,n,r,i){return i[0].signal.aborted?Ma:eo(e,t,n,La(n,r),i)}async function za(e,t,n,r,i,a){let[o,s]=t,c=n[0].signal,l=!!n[3];for(let i=n[6]??0;ie.navigate({...t,_fromLocation:o}),buildLocation:e.buildLocation,cause:l?`preload`:r.cause,abortController:n[0],preload:l,matches:s,routeId:u.id};try{let e=r._ctx||=u.options.context?u.options.context({...f,deps:r.loaderDeps,context:d})||{}:void 0;r.context={...d,...e}}catch(a){return Va(e,r),[i,Ra(e,t,u,a,n)]}if(c.aborted)return[i,Ma];let p=r.paramsError??r.searchError;if(p!==void 0)return Va(e,r),[i,Ra(e,t,u,p,n)];let m=u.options.beforeLoad;if(!m)continue;let h=r.status;i>=a&&(r.status=`pending`,n[7]?.());try{Wa(e,r,`beforeLoad`,n[0]);let a=m({...f,search:r.search,context:r.context,...e.options.additionalContext}),o=await(typeof a?.then==`function`?Pa(a,c):a);if(c.aborted)return[i,Ma];let s=eo(e,t,u,Ia(o,!1,u.id),n);if(s[0]!==Oa)return Va(e,r),[i,s];r.context={...r.context,...o}}catch(a){return Va(e,r),[i,Ra(e,t,u,a,n)]}finally{r.status=h,Wa(e,r,!1,n[0])}}i()}function Ba(e,t,n){if(!(!n||--n[2])){if(e._flights?.get(t.id)===n){let n=e._tx;if(n&&!n[0].signal.aborted&&!n[3].includes(t)&&n[3].some(e=>e.id===t.id)&&n[3].some(e=>e.isFetching===`beforeLoad`))return;e._flights.delete(t.id)}return n[1]}}function Va(e,t){let n=t._flight;t._flight=void 0,Ba(e,t,n)?.abort()}function Ha(e,t,n,r){let i=[];for(let a of t)if(!n?.includes(a)){let t=a._flight;if(a._flight=void 0,r&&t?.[2]===1&&e._flights?.get(a.id)===t&&n?.some(e=>e.id===a.id))t[2]=0;else{let n=Ba(e,a,t);n&&i.push(n)}}for(let e of i)e.abort()}function Ua(e){for(let t of e){let e=t._flight;e&&e[2]++}}function Wa(e,t,n,r){if(t.isFetching=n,r&&e._tx?.[0]!==r)return;let i=e.stores.byRoute.get(t.routeId),a=i?.get();a?.id===t.id&&i.set({...a,isFetching:n})}function Ga(e,t,n,r,i,a,o){let s=t[0];return{params:n.params,location:s,navigate:t=>e.navigate({...t,_fromLocation:s}),cause:o?`preload`:n.cause,abortController:i,preload:o,deps:n.loaderDeps,parentMatchPromise:a,context:n.context,route:r,...e.options.additionalContext}}async function Ka(e,t,n,r,i,a,o){let s=o[0],c=s.signal;if(c.aborted)return Ma;if(!i)return[Oa,void 0];let l=n._flight;Wa(e,n,`loader`,s);try{if(!l){let s=new AbortController;l=[Promise.resolve().then(()=>i(Ga(e,t,n,r,s,a,!!o[3]))).then(e=>Ia(e,!1,r.id),e=>Ia(e,!0,r.id)).then(t=>(t[0]!==Oa&&e._flights?.get(n.id)===l&&(e._flights.delete(n.id),l[2]||s.abort()),t[0]===ka&&l[2]?La(r,t[1]):t)),s,1],(e._flights??=new Map).set(n.id,l)}return n._flight=l,n.abortController=l[1],eo(e,t,r,await Pa(l[0],c),o)}catch(t){if(t!==c||!c.aborted)throw t;return Va(e,n),Ma}finally{Wa(e,n,!1,s)}}function qa(e,t,n){t[0]!==ja&&(e.status=`success`,e.error=void 0,t[0]===Oa?(e.loaderData=t[1],e.invalid=!1,e.updatedAt=Date.now(),e.preload=n):e.invalid=!0)}function Ja(e,t,n){let r=e._cache.get(t.id);if(r!==n||e._committed.some(e=>e.id===t.id&&e._flight===t._flight))return;let i={...t,_notFound:void 0,context:{}};i._flight&&i._flight[2]++,e._cache.set(t.id,i),r&&Va(e,r)}function Ya(e,t){return t[0]===ka||t[0]===Aa?{...e,status:t[0]===ka?`error`:`notFound`,error:t[1],_flight:void 0}:e}function Xa(e,t,n,r,i,a,o){let s=t[1][n],c=Fa(e,s),l=!!a[3],u=e._cache.get(s.id),d,f=!1,p;try{if(s.status===`success`&&(d=c.options.shouldReload,typeof d==`function`&&(d=d(Ga(e,t,s,c,a[0],i,l))),a[0].signal.aborted&&(p=Ma)),!p){if(s.status!==`success`)f=!0;else{let t=l||s.preload?c.options.preloadStaleTime??e.options.defaultPreloadStaleTime??3e4:c.options.staleTime??e.options.defaultStaleTime??0;f=!!(s.invalid||d||d===void 0&&Date.now()-s.updatedAt>=t&&(a[5]||s.cause===`enter`||a[2].some(e=>e.routeId===s.routeId&&e.id!==s.id)))}}}catch(n){s.invalid=!0,Va(e,s),p=Ra(e,t,c,n,a)}let m=c.options.loader,h=typeof m==`function`,g=h?m:m?.handler,_=!l||c.options.preload!==!1,v=_&&m?e._flights?.get(s.id):void 0;v===s._flight||p?v=void 0:v&&!f&&!l&&d===void 0?f=!0:f||(v=void 0);let y=!(!m||!f||s.status!==`success`||l||a[4]||((h?void 0:m.staleReloadMode)??e.options.defaultStaleReloadMode)===`blocking`),b=f&&_,x=b&&!y&&(s.status!==`success`||!!m),S=n>=o?a[7]:void 0,C=c.lazyFn&&c._lazy!==!0?S:void 0;if(b&&!m&&(s.invalid=!1,s.updatedAt=Date.now()),v&&v[2]++,x){let t=s._flight;s._flight=v,Ba(e,s,t)?.abort(),n>=o&&(s.status=`pending`),S?.()}b||(s.isFetching=!1);let w=!p&&x?Ka(e,t,s,c,g,i,a).then(t=>(qa(s,t,l),t[0]===Oa&&(m&&!a[0].signal.aborted&&Ja(e,s,u),n>=o&&(s.status=`pending`)),t)):Promise.resolve(p??[Oa,s.loaderData]),T=(async()=>{try{let e=Ta(c,void 0,C);e&&await Pa(e,a[0].signal)}catch(r){if(!t[1].some((e,t)=>t<=n&&(e.status===`error`||e.status===`notFound`||e._notFound)))return[n,Ra(e,t,c,r,a)]}let r=await w;x&&r[0]===Oa&&s.status===`pending`&&!a[0].signal.aborted&&(s.status=`success`,S?.())})();if(r.push([n,w,T]),!y)return w.then(e=>Ya(s,e));let E={...s,status:`pending`,preload:!1,_flight:v};s.invalid=!1,s.isFetching=`loader`;let D=Ka(e,t,E,c,g,i,a).then(e=>(s.isFetching=!1,qa(E,e,!1),e));return(t[2]??=[]).push([n,D,T,E]),D.then(e=>Ya(E,e))}async function Za(e,t,n,r,i=0){let a=n?.[1][1],o=a?.routeId?t.findIndex(e=>e.routeId===a.routeId):n?.[0]??t.length-1;o<0&&(o=0);for(let n=o;n>=0;n--){let i=Fa(e,t[n]);try{let e=Ta(i,!1);e&&await Pa(e,r)}catch(e){if(e===r&&r.aborted)throw e}if(i.options.notFoundComponent)return n}return a?.routeId?o:i}function Qa(e,t){t[2]&&=(Ha(e,t[2].map(e=>e[3])),void 0)}async function $a(e,t,n,r){let i;try{await Promise.all(e.map(e=>e[1].then(async t=>{let a=e[0];if(!(r&&a>=await r)){if(t[0]>=ja)throw[a,t];!i&&t[0]!==Oa&&(i=[a,t],await Promise.all((n??[]).map(e=>{if(!(e[0]<=a))return e[1].then(t=>{if(t[0]===ja)throw[e[0],t]})})))}})))}catch(e){return e}return t??i}function eo(e,t,n,r,i,a){for(;r[0]===ja;){let o=r[1],s=o.options;try{if((s.href||o.headers.has(`Location`))&&(e.resolveRedirect(o),s.reloadDocument)||(s.reloadDocument?i[3]:i[1]>=20))return r;let n=e.buildLocation({...s,_fromLocation:t[0],_includeValidateSearch:!0}),a=n.maskedLocation??n;if(a.external){let t=o.clone();return t.options={...s},t.headers.set(`Location`,a.publicHref),e.resolveRedirect(t),i[3]?[ja,t]:[ja,t,a]}return[ja,o,n]}catch(e){r=a?[ka,e]:La(n,e),a=!0}}return r}async function to(e,t,n,r,i,a){let o=t[1],s=await i,c=!1,l=o.findIndex(e=>e._notFound),u=t=>t[1][0]===Aa?Za(e,o,t,r.signal):t[0],d=l<0?o.length:l;if((s?.[1][0]??0)>=ja)d=0;else if(s){d=s[2]??=await u(s);for(let e of n){if(e[0]>=d)break;let t=await e[1];if(t[0]!==Oa&&t[0]=d)break;let t=await e[2];if(t){s=t;break}}if((s?.[1][0]??0)>=ja){let n=s[1];if(n[0]!==ja||n[1].options.reloadDocument||n[2])return Qa(e,t),n;c=!0,s=[0,[ka,Error(`Too many redirects`)]]}let f=s?s[2]??await u(s):l;if(f>=0){let i=s?.[1],l=i?.[0],u=o[f],d=i?.[1],p=()=>{i&&(u._notFound=void 0,l===ka?u.status=`error`:(d.routeId=u.routeId,u.routeId===e.routeTree.id?(u.status=`success`,u._notFound=!0):u.status=`notFound`),u.error=d,u.isFetching=!1)};p(),i||a?.();let m=Fa(e,u);try{await Pa(i?Promise.resolve().then(()=>Ta(m,l===ka?`errorComponent`:`notFoundComponent`)):Promise.all([Ta(m),Ta(m,`notFoundComponent`)]),r.signal)}catch(n){if(n===r.signal&&r.signal.aborted)return Qa(e,t),Ma}i?c&&(r.abort(),await Promise.all([...n.map(e=>e[1]),...n.map(e=>e[2]),...(t[2]??[]).map(e=>e[1])]),Qa(e,t),Ha(e,o),p()):u.status=`success`}return t}async function no(e,t,n,r=0,i=t[1].length){let a=t[1];for(let t=r;te._notFound);if(e.options.notFoundMode!==`root`&&s>=0){let t=await Za(e,n,void 0,a,s);n[s]._notFound=void 0,n[t]._notFound=!0,s=t}let c=s<0?n.length:s+1,l=0;for(;l{for(let t=d;t=ja&&(c=0);p()}if(!a.aborted&&!r[3]){let t=[];for(let[n,r]of e._flights??[])r[2]||(e._flights.delete(n),t.push(r[1]));for(let e of t)e.abort()}let h=to(e,i,u,r[0],$a(u,m,i[2]),r[7]);i[2]?.length&&(i[3]=$a(i[2],void 0,void 0,h.then(e=>Na(e)?0:Ea(n).length,()=>0))),o=await h}catch(t){if(Qa(e,i),t===a&&a.aborted)return Ma;throw t}return Na(o)?o:no(e,o,a,r[6]===n.length?r[6]:0)}function io(e,t){if(e._tx!==t)return;let n=t[3],r=e.stores.matches.get(),i=e._pending;for(let a=0;a0){i[3]=setTimeout(()=>io(e,t),n);return}i[2]=0}let m=n.map(e=>({...e,_flight:void 0}));m[a].status=`pending`;let h=i[4]=e.startTransition(()=>e.stores.setMatches(m),m).then(t=>(t&&e._pending===i&&i[4]===h&&!i[2]&&(i[2]=Date.now()+f),t));return}}function ao(e,t){let n=e._pending;(e._tx===t||!e._tx?.[3].some(e=>e.id===n?.[1]))&&(clearTimeout(n?.[3]),e._pending=void 0)}async function oo(e,t){let n=e._pending;if(!n)return;clearTimeout(n[3]);let r=n[2]-Date.now();if(!n[4]||r<=0||!Ea(t[3]).some(e=>e.id===n[1]))return;let i;try{await Pa(new Promise(e=>{i=setTimeout(e,r)}),t[0].signal)}catch{}clearTimeout(i)}function so(e,t){e._committed=t,e.stores.setMatches(t)}function co(e,t,n,r){let i=e._committed,a=e._lifecycleEnd,o=e._cache;for(let e of n)e.preload=!1,r&&(e._assetEnd=void 0);let s=Ea(n).length,c=new Map;{let t=Date.now(),r=new Set;for(let e=0;e=(n.preload?i.options.preloadGcTime??e.options.defaultPreloadGcTime??3e5:i.options.gcTime??e.options.defaultGcTime??3e5)||c.set(n.id,o.get(n.id)===n?n:{...n,_flight:void 0,isFetching:!1,context:{}})}}t[3]=[],e._cache=c;let l=e._lifecycleEnd=la(n);so(e,n),Ha(e,[...o.values(),...i].filter(e=>e._flight&&c.get(e.id)!==e),n),ua(e,i,n,a,l,t)}async function lo(e,t){let n=e._tx;for(;n&&n!==t;)t=n,await n[5],n=e._tx}function uo(e,t,n){let r=n[1].options,i=n[2];if(!i)return e.navigate({...r,replace:!0,ignoreBlocker:!0});if(r.reloadDocument)return e.navigate({href:(i.maskedLocation??i).publicHref,reloadDocument:!0,replace:!0,ignoreBlocker:!0});i._redirects=t[1]+1,e._pendingLocation=i;let a=e.commitLocation({...i,viewTransition:r.viewTransition,replace:!0,resetScroll:r.resetScroll,hashScrollIntoView:r.hashScrollIntoView,ignoreBlocker:!0});return queueMicrotask(()=>{e._pendingLocation===i&&(e._pendingLocation=void 0)}),a}async function fo(e,t,n,r,i){let a=n.map(e=>({...e}));Ua(a);for(let t of r)Va(e,a[t[0]]),a[t[0]]=t[3];let o=[t[2],a],s;try{s=await to(e,o,r,t[0],i)}catch(t){throw Ha(e,a),t}if(Na(s)){Ha(e,a),s[0]===ja&&e._tx===t&&e._committed===n&&await uo(e,t,s);return}if(await no(e,s,t[0].signal),e._tx!==t||e._committed!==n){Ha(e,a);return}for(let t of a){let n=e._cache.get(t.id);n?._flight&&n._flight===t._flight&&(e._cache.delete(t.id),Va(e,n))}so(e,a),Ha(e,n,a)}async function po(e,t,n,r,i,a){let o=await ro(e,t[2],t[3],[t[0],t[1],e._committed,void 0,i,n,a,r]);if(Na(o)){let n=o[0]===ja&&e._tx===t;if((!n||o[1].options.reloadDocument)&&ao(e,t),Ha(e,t[3]),t[3]=[],!n)return;if(e._tx!==t){ao(e,t);return}await uo(e,t,o);return}let s=o[1];if(e._tx===t&&await oo(e,t),e._tx!==t){ao(e,t),Ha(e,s),Qa(e,o);return}let c=t[2],l=sa(c,e.stores.resolvedLocation.get()),u=o[2];await e.startViewTransition(async()=>{if(e._tx===t&&await oo(e,t),e._tx!==t){ao(e,t),Ha(e,s),Qa(e,o);return}let n=await e.startTransition(()=>{ao(e,t),co(e,t,s,a),e._tx===t&&(e.emit({type:`onLoad`,...l}),e._tx===t&&e.emit({type:`onBeforeRouteMount`,...l}))},s);if(e._tx!==t){Qa(e,o);return}u?.length&&fo(e,t,s,u,o[3]).catch(console.error),e.batch(()=>{e.stores.resolvedLocation.set(c),e.stores.status.set(`idle`),e._tx===t&&e.emit({type:`onResolved`,...l}),n&&e._tx===t&&e.emit({type:`onRendered`,...l})}),e._tx===t&&(e._commitPromise?.resolve(),e._commitPromise=void 0)})}async function mo(e,t){let n=e._tx,r=e.stores.resolvedLocation.get(),i=r??e.stores.location.get(),a=e.latestLocation,o=e._pendingLocation,s=o?.href===a.href?o._redirects??0:0,c=e._handoff,l=c?.[0](),u=new AbortController,d=e._preflight;if(e._preflight=u,l||c?.[1](),d?.abort(),!u.signal.aborted){let t=sa(a,r);e.emit({type:`onBeforeNavigate`,...t}),u.signal.aborted||e.emit({type:`onBeforeLoad`,...t})}if(u.signal.aborted){await lo(e,n);return}let f=i.href===a.href,p=u,m=e.matchRoutes(a,{_controller:u});Ua(m);let h=l?c[1](m):void 0;if(h?p=l:l?.abort(),u.signal.aborted){Ha(e,m),await lo(e,n);return}e._preflight=void 0;let g,_=()=>po(e,y,f,()=>io(e,y),t?.sync,h),v=t?.sync?new Promise(e=>g=e):Promise.resolve().then(_),y=[p,s,a,m,Date.now(),v.then(()=>lo(e,y))];if(e._tx=y,n){for(let t of e.stores.matches.get()){if(e._tx!==y)break;t.isFetching&&Wa(e,t,!1)}n[0].abort(),Ha(e,n[3],y[3],!0)}if(e._tx!==y){Ha(e,y[3]),y[3]=[],g?.(),await lo(e,y);return}e.batch(()=>{e.stores.status.set(`pending`),e.stores.location.set(a)}),(h||!e._committed.length&&m[0]?.status!==`success`&&!m.some(e=>e._notFound))&&io(e,y),g?.(_()),await y[5]}async function ho(e,t){let n=e.buildLocation(t);for(let t=0;;t++){let r=e._committed,i=new AbortController,a,o,s;try{try{a=e.matchRoutes(n,{_controller:i}),Ua(a),o=(e._preloads??=new Map).set(i,a),s=await ro(e,n,a,[i,t,r,!0])}finally{o&&(o=o.delete(i),Ha(e,a)),i.abort()}if(!Na(s))return s[1];if(!o||s.length<3)return;n=s[2]}catch(e){xe(e)||console.error(e);return}}}function go(e,t){if(e)return typeof e==`string`?e:e[t]}function _o(e){return e?.scriptFormat??`module`}function vo(e,t,n){let r=yo(t),i=go(n,`script`)??r.crossOrigin;return{..._o(e)===`iife`?{rel:`preload`,as:`script`}:{rel:`modulepreload`},href:r.href,...i?{crossOrigin:i}:{}}}function yo(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}function bo(e,t){if(t.length===0)return;if(t.length===1){e.push(t[0]);return}let n=new Set;for(let r of t){let t=JSON.stringify(r);n.has(t)||(n.add(t),e.push(r))}}function xo(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}function So({update:e,notify:t,unwatched:n}){return{link:r,unlink:i,propagate:a,checkDirty:o,shallowPropagate:s};function r(e,t,n){let r=t.depsTail;if(r!==void 0&&r.dep===e)return;let i=r===void 0?t.deps:r.nextDep;if(i!==void 0&&i.dep===e){i.version=n,t.depsTail=i;return}let a=e.subsTail;if(a!==void 0&&a.version===n&&a.sub===t)return;let o=t.depsTail=e.subsTail={version:n,dep:e,sub:t,prevDep:r,nextDep:i,prevSub:a,nextSub:void 0};i!==void 0&&(i.prevDep=o),r===void 0?t.deps=o:r.nextDep=o,a===void 0?e.subs=o:a.nextSub=o}function i(e,t=e.sub){let r=e.dep,i=e.prevDep,a=e.nextDep,o=e.nextSub,s=e.prevSub;return a===void 0?t.depsTail=i:a.prevDep=i,i===void 0?t.deps=a:i.nextDep=a,o===void 0?r.subsTail=s:o.prevSub=s,s===void 0?(r.subs=o)===void 0&&n(r):s.nextSub=o,a}function a(e){let n=e.nextSub,r;top:do{let i=e.sub,a=i.flags;if(a&60?a&12?a&4?!(a&48)&&c(e,i)?(i.flags=a|40,a&=1):a=0:i.flags=a&-9|32:a=0:i.flags=a|32,a&2&&t(i),a&1){let t=i.subs;if(t!==void 0){let i=(e=t).nextSub;i!==void 0&&(r={value:n,prev:r},n=i);continue}}if((e=n)!==void 0){n=e.nextSub;continue}for(;r!==void 0;)if(e=r.value,r=r.prev,e!==void 0){n=e.nextSub;continue top}break}while(1)}function o(t,n){let r,i=0,a=!1;top:do{let o=t.dep,c=o.flags;if(n.flags&16)a=!0;else if((c&17)==17){if(e(o)){let e=o.subs;e.nextSub!==void 0&&s(e),a=!0}}else if((c&33)==33){(t.nextSub!==void 0||t.prevSub!==void 0)&&(r={value:t,prev:r}),t=o.deps,n=o,++i;continue}if(!a){let e=t.nextDep;if(e!==void 0){t=e;continue}}for(;i--;){let i=n.subs,o=i.nextSub!==void 0;if(o?(t=r.value,r=r.prev):t=i,a){if(e(n)){o&&s(i),n=t.sub;continue}a=!1}else n.flags&=-33;n=t.sub;let c=t.nextDep;if(c!==void 0){t=c;continue top}}return a}while(1)}function s(e){do{let n=e.sub,r=n.flags;(r&48)==32&&(n.flags=r|16,(r&6)==2&&t(n))}while((e=e.nextSub)!==void 0)}function c(e,t){let n=t.depsTail;for(;n!==void 0;){if(n===e)return!0;n=n.prevDep}return!1}}function Co(e,t,n){let r=typeof e==`object`,i=r?e:void 0;return{next:(r?e.next:e)?.bind(i),error:(r?e.error:t)?.bind(i),complete:(r?e.complete:n)?.bind(i)}}var wo=[],To=0,{link:Eo,unlink:Do,propagate:Oo,checkDirty:ko,shallowPropagate:Ao}=So({update(e){return e._update()},notify(e){wo[Mo++]=e,e.flags&=-3},unwatched(e){e.depsTail!==void 0&&(e.depsTail=void 0,e.flags=17,Io(e))}}),jo=0,Mo=0,No,Po=0;function Fo(e){try{++Po,e()}finally{--Po||Lo()}}function Io(e){let t=e.depsTail,n=t===void 0?e.deps:t.nextDep;for(;n!==void 0;)n=Do(n,e)}function Lo(){if(!(Po>0)){for(;jo{i.get(),n.current?t.next?.(i._snapshot):n.current=!0});return{unsubscribe:()=>{r.stop()}}},_update(e){let a=No,o=t?.compare??Object.is;if(n)No=i,++To,i.depsTail=void 0;else if(e===void 0)return!1;n&&(i.flags=5);try{let t=i._snapshot,a=typeof e==`function`?e(t):e===void 0&&n?r(t):e;return t===void 0||!o(t,a)?(i._snapshot=a,!0):!1}finally{No=a,n&&(i.flags&=-5),Io(i)}}};return n?(i.flags=17,i.get=function(){let e=i.flags;if(e&16||e&32&&ko(i.deps,i)){if(i._update()){let e=i.subs;e!==void 0&&Ao(e)}}else e&32&&(i.flags=e&-33);return No!==void 0&&Eo(i,No,To),i._snapshot}):i.set=function(e){if(i._update(e)){let e=i.subs;e!==void 0&&(Oo(e),Ao(e),Lo())}},i}function zo(e){let t=()=>{let t=No;No=n,++To,n.depsTail=void 0,n.flags=6;try{return e()}finally{No=t,n.flags&=-5,Io(n)}},n={deps:void 0,depsTail:void 0,subs:void 0,subsTail:void 0,flags:6,notify(){let e=this.flags;e&16||e&32&&ko(this.deps,this)?t():this.flags=2},stop(){this.flags=0,this.depsTail=void 0,Io(this)}};return t(),n}var z=n(r(),1);function Bo(e,t){let n=e[1];e.length=0,n?.(t)}function Vo({t:e}){let t=ke(),n=t._rendered??=[];return t.startTransition=(r,i)=>new Promise(a=>{Bo(n,!1),n.push(i,a),e(t),z.startTransition(r)}),T(()=>{let e=t.history.subscribe(t.load);t.updateLatestLocation();let r=t.latestLocation,i=t.buildLocation({to:r.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});if(ie(r.publicHref)!==ie(i.publicHref))return t.commitLocation({...i,replace:!0,ignoreBlocker:!0}),e;let a=t.stores.resolvedLocation.get();return a?.href===r.href&&a.state.__TSR_key===r.state.__TSR_key?n.push(t.stores.matches.get(),e=>{e&&t.emit({type:`onRendered`,...sa(a,a)})}):t._tx||t.load({sync:!0}).catch(console.error),e},[t,t.history]),null}var B=t();function V(){let e=ke(),t=e.routesById[Te],n=Se(e,t),r=e.ssr?ye:z.Suspense,i=(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(Vo,{t:z.useState()[1]}),(0,B.jsx)(r,{fallback:n,children:(0,B.jsx)(Ho,{})})]});return e.options.InnerWrap?(0,B.jsx)(e.options.InnerWrap,{children:i}):i}function Ho(){let e=ke(),t=e._rendered,n=je(e.stores.matches,e=>t[0]??e),r=n[0],i=r?.routeId;T(()=>{t[0]===n&&Bo(t,!0)},[t,n]);let a=i?(0,B.jsx)(Ce,{routeId:i}):null;return(0,B.jsx)(we.Provider,{value:i,children:e.options.disableGlobalCatchBoundary?a:(0,B.jsx)(ve,{getResetKey:()=>r,onCatch:void 0,children:a})})}var Uo=e=>({createMutableStore:Ro,createReadonlyStore:Ro,batch:Fo}),Wo=e=>new Go(e),Go=class extends da{constructor(e){super(e,Uo)}};function Ko({router:e,children:t,...n}){_e(n)&&e.update({...e.options,...n,context:{...e.options.context,...n.context}});let r=(0,B.jsx)(Ae.Provider,{value:e,children:t});return e.options.Wrap?(0,B.jsx)(e.options.Wrap,{children:r}):r}function qo({router:e,...t}){return(0,B.jsx)(Ko,{router:e,...t,children:(0,B.jsx)(V,{})})}function Jo(e){let t=ke();return je(t.stores.location,C(e,t))}function Yo(e,t){if(t)for(let[n,r]of Object.entries(t))n!==`suppressHydrationWarning`&&r!==void 0&&r!==!1&&e.setAttribute(n,typeof r==`boolean`?``:String(r))}function Xo(e){let{attrs:t,children:n,nonce:r,preventScriptHoist:i}=e,a=z.useMemo(()=>n===void 0?void 0:{__html:n},[n]);switch(e.tag){case`title`:return(0,B.jsx)(`title`,{...t,suppressHydrationWarning:!0,children:n});case`meta`:return(0,B.jsx)(`meta`,{...t,suppressHydrationWarning:!0});case`link`:return(0,B.jsx)(`link`,{...t,precedence:t?.precedence??(t?.rel===`stylesheet`?`default`:void 0),nonce:r,suppressHydrationWarning:!0});case`style`:return e.inlineCss,(0,B.jsx)(`style`,{...t,dangerouslySetInnerHTML:a,nonce:r});case`script`:return(0,B.jsx)(Zo,{attrs:t,preventScriptHoist:i,children:n});default:return null}}function Zo({attrs:e,children:t,preventScriptHoist:n}){ke();let r=Oe(),i=z.useMemo(()=>t===void 0?void 0:{__html:t},[t]),a=typeof e?.type==`string`&&e.type!==``&&e.type!==`text/javascript`&&e.type!==`module`;if(z.useEffect(()=>{if(!a){if(e?.src){let t=document.createElement(`a`);t.href=e.src;let n=t.href;for(let e of document.scripts)if(e.src===n)return;let r=document.createElement(`script`);return Yo(r,e),document.head.appendChild(r),()=>r.remove()}if(typeof t==`string`){let n=typeof e?.type==`string`?e.type:`text/javascript`,r=typeof e?.nonce==`string`?e.nonce:void 0;for(let e of document.scripts){if(e.hasAttribute(`src`))continue;let i=e.getAttribute(`type`)??`text/javascript`,a=e.getAttribute(`nonce`)??void 0;if(e.textContent===t&&i===n&&a===r)return}let i=document.createElement(`script`);return i.textContent=t,Yo(i,e),document.head.appendChild(i),()=>i.remove()}}},[e,t,a]),a&&typeof t==`string`)return(0,B.jsx)(`script`,{...e,suppressHydrationWarning:!0,dangerouslySetInnerHTML:i});if(!r){if(e?.src)return(0,B.jsx)(`script`,{...e,suppressHydrationWarning:!0});if(typeof t==`string`)return(0,B.jsx)(`script`,{...e,dangerouslySetInnerHTML:i,suppressHydrationWarning:!0})}return null}function Qo(e,t,n,r){n=Da(n);let i=n.map(e=>e.meta).filter(e=>e!==void 0),a=[],o={},s;for(let e=i.length-1;e>=0;e--){let n=i[e];for(let e=n.length-1;e>=0;e--){let r=n[e];if(r){if(r.title)s||={tag:`title`,children:r.title};else if(`script:ld+json`in r)try{let e=JSON.stringify(r[`script:ld+json`]);a.push({tag:`script`,attrs:{type:`application/ld+json`},children:ce(e)})}catch{}else{let e=r.name??r.property;if(e){if(o[e])continue;o[e]=!0}a.push({tag:`meta`,attrs:{...r,nonce:t}})}}}}s&&a.push(s),t&&a.push({tag:`meta`,attrs:{property:`csp-nonce`,content:t}}),a.reverse();let c=n.flatMap(e=>e.links??[]).filter(e=>e!==void 0).map(e=>({tag:`link`,attrs:{...e,nonce:t}})),l=e.ssr?.manifest,u=[];l&&(n.forEach(e=>{(l.routes[e.routeId]?.css)?.forEach(e=>{let n=xo(e);u.push({tag:`link`,attrs:{rel:`stylesheet`,...n,crossOrigin:go(r,`stylesheet`)??n.crossOrigin,suppressHydrationWarning:!0,nonce:t}})})}),l.inlineStyle&&u.push({tag:`style`,attrs:{...l.inlineStyle.attrs,nonce:t},children:l.inlineStyle.children,inlineCss:!0}));let d=[];l&&n.forEach(e=>{l.routes[e.routeId]?.preloads?.forEach(e=>{d.push({tag:`link`,attrs:{...vo(l,e,r),nonce:t}})})});let f=n.flatMap(e=>e.styles??[]).filter(e=>e!==void 0).map(({children:e,...n})=>({tag:`style`,attrs:{...n,nonce:t},children:e})),p=n.flatMap(e=>e.headScripts??[]).filter(e=>e!==void 0).map(({children:e,...n})=>({tag:`script`,attrs:{...n,nonce:t},children:e})),m=[];return bo(m,a),m.push(...d),bo(m,c),m.push(...u),bo(m,f),bo(m,p),m}var $o=e=>{let t=ke(),n=t.options.ssr?.nonce,r=z.useCallback(r=>Qo(t,n,r,e),[e,n,t]);return je(t.stores.matches,r,{compare:me})};function es(e){let t=$o(e.assetCrossOrigin),n=ke().options.ssr?.nonce;return(0,B.jsx)(B.Fragment,{children:t.map(e=>(0,z.createElement)(Xo,{...e,key:`tsr-meta-${JSON.stringify(e)}`,nonce:n}))})}var ts=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m){if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&te(x,t.startTime-e)}}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&te(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,ee=k.port2;k.port1.onmessage=D,O=function(){ee.postMessage(null)}}else O=function(){_(D,0)};function te(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,te(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),ns=e(((e,t)=>{t.exports=ts()})),rs=e((e=>{var t=ns(),n=r(),i=Pe();function a(e){var t=`https://react.dev/errors/`+e;if(1be||(e.current=ye[be],ye[be]=null,be--)}function N(e,t){be++,ye[be]=e.current,e.current=t}var Ce=xe(null),we=xe(null),Te=xe(null),Ee=xe(null);function De(e,t){switch(N(Te,t),N(we,e),N(Ce,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?up(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=up(t),e=dp(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}Se(Ce),N(Ce,e)}function Oe(){Se(Ce),Se(we),Se(Te)}function ke(e){var t=e.memoizedState;t!==null&&(sh._currentValue=t.memoizedState,N(Ee,e)),t=Ce.current;var n=dp(t,e.type);t!==n&&(N(we,e),N(Ce,n))}function Ae(e){we.current===e&&(Se(Ce),Se(we)),Ee.current===e&&(Se(Ee),sh._currentValue=ve)}var je,Me;function Ne(e){if(je===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);je=t&&t[1]||``,Me=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Fe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ne(n):``}function Le(e,t){switch(e.tag){case 26:case 27:case 5:return Ne(e.type);case 16:return Ne(`Lazy`);case 13:return e.child!==t&&t!==null?Ne(`Suspense Fallback`):Ne(`Suspense`);case 19:return Ne(`SuspenseList`);case 0:case 15:return Ie(e.type,!1);case 11:return Ie(e.type.render,!1);case 1:return Ie(e.type,!0);case 31:return Ne(`Activity`);case 30:return Ne(`ViewTransition`);default:return``}}function Re(e){try{var t=``,n=null;do t+=Le(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var ze=Object.prototype.hasOwnProperty,Be=t.unstable_scheduleCallback,Ve=t.unstable_cancelCallback,He=t.unstable_shouldYield,Ue=t.unstable_requestPaint,We=t.unstable_now,Ge=t.unstable_getCurrentPriorityLevel,Ke=t.unstable_ImmediatePriority,P=t.unstable_UserBlockingPriority,qe=t.unstable_NormalPriority,Je=t.unstable_LowPriority,Ye=t.unstable_IdlePriority,Xe=t.log,F=t.unstable_setDisableYieldValue,Ze=null,Qe=null;function $e(e){if(typeof Xe==`function`&&F(e),Qe&&typeof Qe.setStrictMode==`function`)try{Qe.setStrictMode(Ze,e)}catch{}}var et=Math.clz32?Math.clz32:rt,tt=Math.log,nt=Math.LN2;function rt(e){return e>>>=0,e===0?32:31-(tt(e)/nt|0)|0}var it=256,at=262144,ot=4194304;function st(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&-e;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function ct(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=st(n))):i=st(o):i=st(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=st(n))):i=st(o)):i=st(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function lt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function ut(e,t){t&8&&(t|=t&32);var n=e.entangledLanes;if(n!==0)for(e=e.entanglements,n&=t;0n;n++)t.push(e);return t}function mt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ht(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),kn=!1;if(On)try{var An={};Object.defineProperty(An,"passive",{get:function(){kn=!0}}),window.addEventListener(`test`,An,An),window.removeEventListener(`test`,An,An)}catch{kn=!1}var jn=null,Mn=null,Nn=null;function Pn(){if(Nn)return Nn;var e,t=Mn,n=t.length,r,i=`value`in jn?jn.value:jn.textContent,a=i.length;for(e=0;e=pr),gr=` `,_r=!1;function vr(e,t){switch(e){case`keyup`:return dr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function yr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var br=!1;function xr(e,t){switch(e){case`compositionend`:return yr(t);case`keypress`:return t.which===32?(_r=!0,gr):null;case`textInput`:return e=t.data,e===gr&&_r?null:e;default:return null}}function Sr(e,t){if(br)return e===`compositionend`||!fr&&vr(e,t)?(e=Pn(),Nn=Mn=jn=null,br=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Gr(n)}}function qr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?qr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Jr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Wr(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Wr(e.document)}return t}function Yr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Xr=On&&`documentMode`in document&&11>=document.documentMode,Zr=null,Qr=null,$r=null,ei=!1;function ti(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;ei||Zr==null||Zr!==Wr(r)||(r=Zr,`selectionStart`in r&&Yr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),$r&&Ur($r,r)||($r=r,r=Jf(Qr,`onSelect`),0>=o,i-=o,Zi=1<<32-et(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),R&&$i(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),R&&$i(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return R&&$i(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&(_=v.alternate,_!==null&&h.delete(_.key===null?g:_.key)),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),R&&$i(i,g),u}function _(e,r,o,c){if(typeof o==`object`&&o&&o.type===k&&o.key===null&&o.props.ref===void 0&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case D:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===k){if(r.tag===7){n(e,r.sibling),c=i(r,o.props.children),lo(c,o),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ce&&no(l)===r.type){n(e,r.sibling),c=i(r,o.props),lo(c,o),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}o.type===k?(c=Ri(o.props.children,e.mode,c,o.key),lo(c,o),c.return=e,e=c):(c=Li(o.type,o.key,o.props,null,e.mode,c),lo(c,o),c.return=e,e=c)}return s(e);case O:a:{for(l=o.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=i(r,o.children||[]),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}c=Vi(o,e.mode,c),c.return=e,e=c}return s(e);case ce:return o=no(o),_(e,r,o,c)}if(_e(o))return h(e,r,o,c);if(he(o)){if(l=he(o),typeof l!=`function`)throw Error(a(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return _(e,r,co(o),c);if(o.$$typeof===re)return _(e,r,Da(e,o),c);uo(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,o),c.return=e,e=c):(n(e,r),c=zi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{so=0;var i=_(e,t,n,r);return oo=null,i}catch(t){if(t===Xa||t===Qa)throw t;var a=Ni(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var po=fo(!0),mo=fo(!1),ho=!1;function go(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function _o(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function vo(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function yo(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,K&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Ai(e),ki(e,null,n),t}return Ei(e,r,t,n),Ai(e)}function bo(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,_t(e,n)}}function xo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var So=!1;function Co(){if(So){var e=Va;if(e!==null)throw e}}function wo(e,t,n,r){So=!1;var i=e.updateQueue;ho=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(J&f)===f:(r&f)===f){f!==0&&f===Ba&&(So=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,h=s;f=t;var g=n;switch(h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(g,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(g,d,f):m,f==null)break a;d=T({},d,f);break a;case 2:ho=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),sd|=o,e.lanes=o,e.memoizedState=d}}function To(e,t){if(typeof e!=`function`)throw Error(a(191,e));e.call(t)}function Eo(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=j.T,s={};s.types=o===null?null:o.types,j.T=s,dc(e,!1,t,n);try{var c=i(),l=j.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?uc(e,t,Wa(c,r),Ad(e)):uc(e,t,r,Ad(e))}catch(n){uc(e,t,{then:function(){},status:`rejected`,reason:n},Ad())}finally{M.p=a,o!==null&&s.types!==null&&(o.types=s.types),j.T=o}}function ec(){}function tc(e,t,n,r){if(e.tag!==5)throw Error(a(476));var i=nc(e).queue;$s(e,i,t,ve,n===null?ec:function(){return rc(e),n(r)})}function nc(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ve,baseState:ve,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:fs,lastRenderedState:ve},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:fs,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function rc(e){var t=nc(e);t.next===null&&(t=e.alternate.memoizedState),uc(e,t.next.queue,{},Ad())}function ic(){return Ea(sh)}function ac(){return ss().memoizedState}function oc(){return ss().memoizedState}function sc(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Ad();e=vo(n);var r=yo(t,e,n);r!==null&&(Nd(r,t,n),bo(r,t,n)),t={cache:Na()},e.payload=t;return}t=t.return}}function cc(e,t,n){var r=Ad();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},fc(e)?pc(t,n):(n=Di(e,t,n,r),n!==null&&(Nd(n,e,r),mc(n,t,r)))}function lc(e,t,n){uc(e,t,n,Ad())}function uc(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(fc(e))pc(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Hr(s,o))return Ei(e,t,i,0),ed===null&&Ti(),!1}catch{}if(n=Di(e,t,i,r),n!==null)return Nd(n,e,r),mc(n,t,r),!0}return!1}function dc(e,t,n,r){if(r={lane:2,revertLane:Pf(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},fc(e)){if(t)throw Error(a(479))}else t=Di(e,n,r,2),t!==null&&Nd(t,e,2)}function fc(e){var t=e.alternate;return e===V||t!==null&&t===V}function pc(e,t){Go=Wo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function mc(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,_t(e,n)}}var hc={readContext:Ea,use:us,useCallback:Zo,useContext:Zo,useEffect:Zo,useImperativeHandle:Zo,useLayoutEffect:Zo,useInsertionEffect:Zo,useMemo:Zo,useReducer:Zo,useRef:Zo,useState:Zo,useDebugValue:Zo,useDeferredValue:Zo,useTransition:Zo,useSyncExternalStore:Zo,useId:Zo,useHostTransitionStatus:Zo,useFormState:Zo,useActionState:Zo,useOptimistic:Zo,useMemoCache:Zo,useCacheRefresh:Zo,useEffectEvent:Zo},gc={readContext:Ea,use:us,useCallback:function(e,t){return os().memoizedState=[e,t===void 0?null:t],e},useContext:Ea,useEffect:Bs,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),Rs(4194308,4,Ks.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Rs(4194308,4,e,t)},useInsertionEffect:function(e,t){Rs(4,2,e,t)},useMemo:function(e,t){var n=os();t=t===void 0?null:t;var r=e();if(Ko){$e(!0);try{e()}finally{$e(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=os();if(n!==void 0){var i=n(t);if(Ko){$e(!0);try{n(t)}finally{$e(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=cc.bind(null,V,e),[r.memoizedState,e]},useRef:function(e){var t=os();return e={current:e},t.memoizedState=e},useState:function(e){e=Ss(e);var t=e.queue,n=lc.bind(null,V,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Js,useDeferredValue:function(e,t){return Zs(os(),e,t)},useTransition:function(){var e=Ss(!1);return e=$s.bind(null,V,e.queue,!0,!1),os().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=V,i=os();if(R){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),ed===null)throw Error(a(349));J&127||_s(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,Bs(ys.bind(null,r,o,e),[e]),r.flags|=2048,Is(9,{destroy:void 0},vs.bind(null,r,o,n,t),null),n},useId:function(){var e=os(),t=ed.identifierPrefix;if(R){var n=Qi,r=Zi;n=(r&~(1<<32-et(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=qo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(i,{is:r.is}):s.createElement(i)}}o[wt]=t,o[I]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(np(o,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&ml(t)}}return yl(t),t.subtreeFlags&=-33554433,hl(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&ml(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(a(166));if(e=Te.current,fa(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=ia,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[wt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||ep(e.nodeValue,n)),e||la(t,!0)}else e=lp(e).createTextNode(r),e[wt]=t,t.stateNode=e}return yl(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=fa(t),n!==null){if(e===null){if(!r)throw Error(a(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(a(557));e[wt]=t}else pa(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;yl(t),e=!1}else n=ma(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Ro(t),t):(Ro(t),null);if(t.flags&128)throw Error(a(558))}return yl(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=fa(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(a(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(a(317));i[wt]=t}else pa(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;yl(t),i=!1}else i=ma(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(Ro(t),t):(Ro(t),null)}return Ro(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),_l(t,t.updateQueue),yl(t),null);case 4:return Oe(),e===null&&Wf(t.stateNode.containerInfo),t.flags|=67108864,yl(t),null;case 10:return ba(t.type),yl(t),null;case 19:if(Bo(t),r=t.memoizedState,r===null)return yl(t),null;if(i=!!(t.flags&128),o=r.rendering,o===null){if(i)vl(r,!1);else{if(od!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=Vo(e),o!==null){for(t.flags|=128,vl(r,!1),e=o.updateQueue,t.updateQueue=e,_l(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)Ii(n,e),n=n.sibling;return z(t,zo.current&1|2),R&&$i(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&We()>X&&(t.flags|=128,i=!0,vl(r,!1),t.lanes=4194304)}}else{if(!i){if(e=Vo(o),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,_l(t,e),vl(r,!0),r.tail===null&&r.tailMode!==`collapsed`&&r.tailMode!==`visible`&&!o.alternate&&!R)return yl(t),null}else 2*We()-r.renderingStartTime>X&&n!==536870912&&(t.flags|=128,i=!0,vl(r,!1),t.lanes=4194304)}r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}if(r.tail!==null){e=r.tail;a:{for(n=e;n!==null;){if(n.alternate!==null){n=!1;break a}n=n.sibling}n=!0}return r.rendering=e,r.tail=e.sibling,r.renderingStartTime=We(),e.sibling=null,o=zo.current,o=i?o&1|2:o&1,r.tailMode===`visible`||r.tailMode===`collapsed`||!n||R?z(t,o):(n=o,N(Mo,t),N(zo,n),No===null&&(No=t)),R&&$i(t,r.treeForkCount),e}return yl(t),null;case 22:case 23:return Ro(t),jo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(yl(t),t.subtreeFlags&6&&(t.flags|=8192)):yl(t),n=t.updateQueue,n!==null&&_l(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&Se(Ka),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ba(Ma),yl(t),null;case 25:return null;case 30:return t.flags|=33554432,yl(t),null}throw Error(a(156,t.tag))}function xl(e,t){switch(na(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ba(Ma),Oe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Ae(t),null;case 31:if(t.memoizedState!==null){if(Ro(t),t.alternate===null)throw Error(a(340));pa()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ro(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));pa()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Bo(t),e=t.flags,e&65536?(t.flags=e&-65537|128,e=t.memoizedState,e!==null&&(e.rendering=null,e.tail=null),t.flags|=4,t):null;case 4:return Oe(),null;case 10:return ba(t.type),null;case 22:case 23:return Ro(t),jo(),e!==null&&Se(Ka),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ba(Ma),null;case 25:return null;default:return null}}function Sl(e,t){switch(na(t),t.tag){case 3:ba(Ma),Oe();break;case 26:case 27:case 5:Ae(t);break;case 4:Oe();break;case 31:t.memoizedState!==null&&Ro(t);break;case 13:Ro(t);break;case 19:Bo(t);break;case 10:ba(t.type);break;case 22:case 23:Ro(t),jo(),e!==null&&Se(Ka);break;case 24:ba(Ma)}}function Cl(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){pf(t,t.return,e)}}function wl(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){pf(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){pf(t,t.return,e)}}function Tl(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Eo(t,n)}catch(t){pf(e,e.return,t)}}}function El(e,t,n){n.props=Cc(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){pf(e,t,n)}}function Dl(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:var i=e.stateNode,a=vi(e.memoizedProps,i);(i.ref===null||i.ref.name!==a)&&(i.ref=Pp(a)),r=i.ref;break;case 7:if(e.stateNode===null){var o=new Fp(e);p(e.child,!1,Qp,o,void 0,void 0),e.stateNode=o}r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){pf(e,t,n)}}function Ol(e,t){var n=e.ref,r=e.refCleanup;if(n!==null){if(typeof r==`function`)try{r()}catch(n){pf(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){pf(e,t,n)}else n.current=null}}function kl(e,t){if((e.tag===5||e.tag===27||e.tag===6)&&e.alternate===null&&t!==null)for(var n=0;n title`))),np(r,t,n),r[wt]=e,Lt(r),t=r;break a;case`link`:if(o=Gm(`link`,`href`,i).get(t+(n.href||``))){for(s=0;sg&&(o=g,g=h,h=o);var _=Kr(s,h),v=Kr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,j.T=null,n=Cd,Cd=null;var o=yd,s=xd;if(Z=0,bd=yd=null,xd=0,K&6)throw Error(a(331));var c=K;if(K|=4,Yu(o.current),Vu(o,o.current,s,n),K=c,Df(0,!1),Qe&&typeof Qe.onPostCommitFiberRoot==`function`)try{Qe.onPostCommitFiberRoot(Ze,o)}catch{}return!0}finally{M.p=i,j.T=r,lf(e,t)}}function ff(e,t,n){t=Ui(n,t),t=kc(e.stateNode,t,2),e=yo(e,t,2),e!==null&&(mt(e,2),Ef(e))}function pf(e,t,n){if(e.tag===3)ff(e,e,n);else for(;t!==null;){if(t.tag===3){ff(t,e,n);break}if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(vd===null||!vd.has(r))){e=Ui(n,e),n=Ac(2),r=yo(t,n,2),r!==null&&(jc(n,r,t,e),mt(r,2),Ef(r));break}}t=t.return}}function mf(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new $u;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(id=!0,i.add(n),e=hf.bind(null,e,t,n),t.then(e,e))}function hf(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,ed===e&&(J&n)===n&&(od===4||od===3&&(J&62914560)===J&&300>We()-hd?K&2?ld|=n:Bd(e,0):ld|=n,dd===J&&(dd=0)),Ef(e)}function gf(e,t){t===0&&(t=ft()),e=Oi(e,t),e!==null&&(mt(e,t),Ef(e))}function _f(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),gf(e,n)}function vf(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(a(314))}r!==null&&r.delete(t),gf(e,n)}function yf(e,t){return Be(e,t)}var bf=null,xf=null,Sf=!1,Cf=!1,wf=!1,Tf=0;function Ef(e){e!==xf&&e.next===null&&(xf===null?bf=xf=e:xf=xf.next=e),Cf=!0,Sf||(Sf=!0,Nf())}function Df(e,t){if(!wf&&Cf){wf=!0;do for(var n=!1,r=bf;r!==null;){if(!t){if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-et(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,Mf(r,a))}else a=J,a=ct(r,r===ed?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||lt(r,a)||(n=!0,Mf(r,a))}r=r.next}while(n);wf=!1}}function Of(){kf()}function kf(){Cf=Sf=!1;var e=0;Tf!==0&&hp()&&(e=Tf);for(var t=We(),n=null,r=bf;r!==null;){var i=r.next,a=Af(r,t);a===0?(r.next=null,n===null?bf=i:n.next=i,i===null&&(xf=n)):(n=r,(e!==0||a&3)&&(Cf=!0)),r=i}Z!==0&&Z!==5||Df(e,!1),Tf!==0&&(Tf=0)}function Af(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&ap(d)&&(c=c.responseEnd,o+=u*(c$m){s.length=o;break}f=new Promise(jp.bind(f)),s.push(f)}}}if(0`u`?null:document;function Tm(e,t,n){var r=wm;if(r&&typeof t==`string`&&t){var i=rn(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),ym.has(i)||(ym.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),np(t,`link`,e),Lt(t),r.head.appendChild(t)))}}function Em(e){xm.D(e),Tm(`dns-prefetch`,e,null)}function Dm(e,t){xm.C(e,t),Tm(`preconnect`,e,t)}function Om(e,t,n){xm.L(e,t,n);var r=wm;if(r&&e&&t){var i=`link[rel="preload"][as="`+rn(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+rn(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+rn(n.imageSizes)+`"]`)):i+=`[href="`+rn(e)+`"]`;var a=i;switch(t){case`style`:a=Pm(e);break;case`script`:a=Rm(e)}if(!(vm.has(a)||(e=T({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),vm.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Fm(a))||t===`script`&&r.querySelector(zm(a))))){var o=r.createElement(`link`);np(o,`link`,e),t===`style`&&(o[jt]=!0,o.onload=o.onerror=function(){Rt(o)}),Lt(o),r.head.appendChild(o)}}}function km(e,t){xm.m(e,t);var n=wm;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+rn(r)+`"][href="`+rn(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Rm(e)}if(!vm.has(a)&&(e=T({rel:`modulepreload`,href:e},t),vm.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(zm(a)))return}r=n.createElement(`link`),np(r,`link`,e),Lt(r),n.head.appendChild(r)}}}function Am(e,t,n){xm.S(e,t,n);var r=wm;if(r&&e){var i=It(r).hoistableStyles,a=Pm(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Fm(a)))s.loading=5;else{e=T({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=vm.get(a))&&Hm(e,n);var c=o=r.createElement(`link`);Lt(c),np(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Vm(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function jm(e,t){xm.X(e,t);var n=wm;if(n&&e){var r=It(n).hoistableScripts,i=Rm(e),a=r.get(i);a||(a=n.querySelector(zm(i)),a||(e=T({src:e,async:!0},t),(t=vm.get(i))&&Um(e,t),a=n.createElement(`script`),Lt(a),np(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Mm(e,t){xm.M(e,t);var n=wm;if(n&&e){var r=It(n).hoistableScripts,i=Rm(e),a=r.get(i);a||(a=n.querySelector(zm(i)),a||(e=T({src:e,async:!0,type:`module`},t),(t=vm.get(i))&&Um(e,t),a=n.createElement(`script`),Lt(a),np(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Nm(e,t,n,r){var i=(i=Te.current)?bm(i):null;if(!i)throw Error(a(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(n=Pm(n.href),t=It(i).hoistableStyles,r=t.get(n),r||(r={type:`style`,instance:null,count:0,state:null},t.set(n,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Pm(n.href);var o=It(i).hoistableStyles,s=o.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=i.querySelector(Fm(e)))?o._p||(s.instance=o,s.state.loading=5):(o=vm.get(e),o||(o={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vm.set(e,o)),Lm(i,e,o,s.state))),t&&r===null)throw Error(a(528,``));return s}if(t&&r!==null)throw Error(a(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(n=Rm(n),t=It(i).hoistableScripts,r=t.get(n),r||(r={type:`script`,instance:null,count:0,state:null},t.set(n,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(a(444,e))}}function Pm(e){return`href="`+rn(e)+`"`}function Fm(e){return`link[rel="stylesheet"][`+e+`]`}function Im(e){return T({},e,{"data-precedence":e.precedence,precedence:null})}function Lm(e,t,n,r){if(t=e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)){if(!0!==t[jt]){r.loading=1;return}}else t=e.createElement(`link`),t[jt]=!0,t.onload=t.onerror=Rt.bind(null,t),np(t,`link`,n),Lt(t),e.head.appendChild(t);r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2})}function Rm(e){return`[src="`+rn(e)+`"]`}function zm(e){return`script[async]`+e}function Bm(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+rn(n.href)+`"]`);if(r)return t.instance=r,Lt(r),r;var i=T({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Lt(r),np(r,`style`,i),Vm(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Pm(n.href);var o=e.querySelector(Fm(i));if(o)return t.state.loading|=4,t.instance=o,Lt(o),o;r=Im(n),(i=vm.get(i))&&Hm(r,i),o=(e.ownerDocument||e).createElement(`link`),Lt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),np(o,`link`,r),t.state.loading|=4,Vm(o,n.precedence,e),t.instance=o;case`script`:return o=Rm(n.src),(i=e.querySelector(zm(o)))?(t.instance=i,Lt(i),i):(r=n,(i=vm.get(o))&&(r=T({},n),Um(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),Lt(i),np(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(a(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Vm(r,n.precedence,e));return t.instance}function Vm(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function qm(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Jm(e,t){return e===`img`&&t.src!=null&&t.src!==``&&t.onLoad==null&&t.loading!==`lazy`}function Ym(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Xm(e){return(e.width||100)*(e.height||100)*(typeof devicePixelRatio==`number`?devicePixelRatio:1)*.25}function Zm(e,t){typeof t.decode==`function`&&(e.imgCount++,t.complete||(e.imgBytes+=Xm(t),e.suspenseyImages.push(t)),e=rh.bind(e),t.decode().then(e,e))}function Qm(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Pm(r.href),a=t.querySelector(Fm(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=nh.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Lt(a);return}a=t.ownerDocument||t,r=Im(r),(i=vm.get(i))&&Hm(r,i),a=a.createElement(`link`),Lt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),np(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=nh.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var $m=0;function eh(e,t){return e.stylesheets&&e.count===0&&ah(e,e.stylesheets),0$m?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function th(e){if(e.count===0&&(e.imgCount===0||!e.waitingForImages)){if(e.stylesheets)ah(e,e.stylesheets);else if(e.unsuspend){var t=e.unsuspend;e.unsuspend=null,t()}}}function nh(){this.count--,th(this)}function rh(){this.imgCount--,th(this)}var ih=null;function ah(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,ih=new Map,t.forEach(oh,e),ih=null,nh.call(e))}function oh(e,t){if(!(t.state.loading&4)){var n=ih.get(e);if(n)var r=n.get(null);else{n=new Map,ih.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=rs()}))(),1),H=i(),as=(0,z.createContext)(void 0);function os(e,t){try{let t=localStorage.getItem(e);if(t===`light`||t===`dark`||t===`system`)return t}catch{}return t}function ss(e){let t=(0,H.c)(14),{children:n,defaultTheme:r,storageKey:i}=e,a=r===void 0?`dark`:r,o=i===void 0?`ui-theme`:i,s;t[0]!==a||t[1]!==o?(s=()=>os(o,a),t[0]=a,t[1]=o,t[2]=s):s=t[2];let[c,l]=(0,z.useState)(s),u,d;t[3]===c?(u=t[4],d=t[5]):(u=()=>{let e=window.document.documentElement;if(e.classList.remove(`light`,`dark`),c===`system`){let t=window.matchMedia(`(prefers-color-scheme: dark)`).matches?`dark`:`light`;e.classList.add(t);return}e.classList.add(c)},d=[c],t[3]=c,t[4]=u,t[5]=d),(0,z.useEffect)(u,d);let f;t[6]===o?f=t[7]:(f=e=>{try{localStorage.setItem(o,e)}catch{}l(e)},t[6]=o,t[7]=f);let p;t[8]!==f||t[9]!==c?(p={theme:c,setTheme:f},t[8]=f,t[9]=c,t[10]=p):p=t[10];let m=p,h;return t[11]!==n||t[12]!==m?(h=(0,B.jsx)(as.Provider,{value:m,children:n}),t[11]=n,t[12]=m,t[13]=h):h=t[13],h}function cs(){let e=(0,z.useContext)(as);if(e===void 0)throw Error(`useTheme must be used within a ThemeProvider`);return e}var ls={name:`activity`,size:24,node:[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]};ls.node;var us=F(ls),ds={name:`book-open`,size:24,node:[[`path`,{d:`M12 5v16`,key:`1f6ucr`}],[`path`,{d:`M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z`,key:`1fyvmf`}]]};ds.node;var fs=F(ds),ps={name:`boxes`,size:24,node:[[`path`,{d:`M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z`,key:`lc1i9w`}],[`path`,{d:`m7 16.5-4.74-2.85`,key:`1o9zyk`}],[`path`,{d:`m7 16.5 5-3`,key:`va8pkn`}],[`path`,{d:`M7 16.5v5.17`,key:`jnp8gn`}],[`path`,{d:`M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z`,key:`8zsnat`}],[`path`,{d:`m17 16.5-5-3`,key:`8arw3v`}],[`path`,{d:`m17 16.5 4.74-2.85`,key:`8rfmw`}],[`path`,{d:`M17 16.5v5.17`,key:`k6z78m`}],[`path`,{d:`M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z`,key:`1xygjf`}],[`path`,{d:`M12 8 7.26 5.15`,key:`1vbdud`}],[`path`,{d:`m12 8 4.74-2.85`,key:`3rx089`}],[`path`,{d:`M12 13.5V8`,key:`1io7kd`}]]};ps.node;var ms=F(ps),hs={name:`brain`,size:24,node:[[`path`,{d:`M12 18V5`,key:`adv99a`}],[`path`,{d:`M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4`,key:`1e3is1`}],[`path`,{d:`M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5`,key:`1gqd8o`}],[`path`,{d:`M17.997 5.125a4 4 0 0 1 2.526 5.77`,key:`iwvgf7`}],[`path`,{d:`M18 18a4 4 0 0 0 2-7.464`,key:`efp6ie`}],[`path`,{d:`M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517`,key:`1gq6am`}],[`path`,{d:`M6 18a4 4 0 0 1-2-7.464`,key:`k1g0md`}],[`path`,{d:`M6.003 5.125a4 4 0 0 0-2.526 5.77`,key:`q97ue3`}]]};hs.node;var gs=F(hs),_s={name:`circle-alert`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]],aliases:[`alert-circle`]};_s.node;var vs=F(_s),ys={name:`container`,size:24,node:[[`path`,{d:`M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z`,key:`1t2lqe`}],[`path`,{d:`M10 21.9V14L2.1 9.1`,key:`o7czzq`}],[`path`,{d:`m10 14 11.9-6.9`,key:`zm5e20`}],[`path`,{d:`M14 19.8v-8.1`,key:`159ecu`}],[`path`,{d:`M18 17.5V9.4`,key:`11uown`}]]};ys.node;var bs=F(ys),xs={name:`crosshair`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`22`,x2:`18`,y1:`12`,y2:`12`,key:`l9bcsi`}],[`line`,{x1:`6`,x2:`2`,y1:`12`,y2:`12`,key:`13hhkx`}],[`line`,{x1:`12`,x2:`12`,y1:`6`,y2:`2`,key:`10w3f3`}],[`line`,{x1:`12`,x2:`12`,y1:`22`,y2:`18`,key:`15g9kq`}]]};xs.node;var Ss=F(xs),Cs={name:`database`,size:24,node:[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]};Cs.node;var ws=F(Cs),Ts={name:`file-stack`,size:24,node:[[`path`,{d:`M11 21a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1`,key:`likhh7`}],[`path`,{d:`M16 16a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1`,key:`17ky3x`}],[`path`,{d:`M21 6a2 2 0 0 0-.586-1.414l-2-2A2 2 0 0 0 17 2h-3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1z`,key:`1hyeo0`}]]};Ts.node;var Es=F(Ts),Ds={name:`house`,size:24,node:[[`path`,{d:`M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8`,key:`5wwlr5`}],[`path`,{d:`M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`r6nss1`}]],aliases:[`home`]};Ds.node;var Os=F(Ds),ks={name:`id-card`,size:24,node:[[`path`,{d:`M13 19a4 4 0 00-8 0`,key:`cugzd5`}],[`path`,{d:`M16 10h2`,key:`8sgtl7`}],[`path`,{d:`M16 14h2`,key:`epxaof`}],[`circle`,{cx:`9`,cy:`12`,r:`3`,key:`u3jwor`}],[`rect`,{x:`2`,y:`5`,width:`20`,height:`14`,rx:`2`,key:`qneu4z`}]]};ks.node;var As=F(ks),js={name:`key-round`,size:24,node:[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]};js.node;var Ms=F(js),Ns={name:`layers`,size:24,node:[[`path`,{d:`M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z`,key:`zw3jo`}],[`path`,{d:`M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12`,key:`1wduqc`}],[`path`,{d:`M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17`,key:`kqbvx6`}]],aliases:[`layers-3`]};Ns.node;var Ps=F(Ns),Fs={name:`layout-grid`,size:24,node:[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`3`,rx:`1`,key:`6d4xhi`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`14`,rx:`1`,key:`nxv5o0`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}]]};Fs.node;var Is=F(Fs),Ls={name:`layout-list`,size:24,node:[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}],[`path`,{d:`M14 4h7`,key:`3xa0d5`}],[`path`,{d:`M14 9h7`,key:`1icrd9`}],[`path`,{d:`M14 15h7`,key:`1mj8o2`}],[`path`,{d:`M14 20h7`,key:`11slyb`}]]};Ls.node;var Rs=F(Ls),zs={name:`log-out`,size:24,node:[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]};zs.node;var Bs=F(zs),Vs={name:`map-pin`,size:24,node:[[`path`,{d:`M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0`,key:`1r0f0z`}],[`circle`,{cx:`12`,cy:`10`,r:`3`,key:`ilqhr7`}]]};Vs.node;var Hs=F(Vs),Us={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};Us.node;var Ws=F(Us),Gs={name:`moon`,size:24,node:[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]};Gs.node;var Ks=F(Gs),qs={name:`package`,size:24,node:[[`path`,{d:`M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z`,key:`1a0edw`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}],[`polyline`,{points:`3.29 7 12 12 20.71 7`,key:`ousv84`}],[`path`,{d:`m7.5 4.27 9 5.15`,key:`1c824w`}]]};qs.node;var Js=F(qs),Ys={name:`panel-left`,size:24,node:[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M9 3v18`,key:`fh3hqa`}]],aliases:[`sidebar`]};Ys.node;var Xs=F(Ys),Zs={name:`route`,size:24,node:[[`circle`,{cx:`6`,cy:`19`,r:`3`,key:`1kj8tv`}],[`path`,{d:`M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15`,key:`1d8sl`}],[`circle`,{cx:`18`,cy:`5`,r:`3`,key:`gq8acd`}]]};Zs.node;var Qs=F(Zs),$s={name:`shield-check`,size:24,node:[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]};$s.node;var ec=F($s),tc={name:`sun`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`m4.93 4.93 1.41 1.41`,key:`149t6j`}],[`path`,{d:`m17.66 17.66 1.41 1.41`,key:`ptbguv`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`m6.34 17.66-1.41 1.41`,key:`1m8zz5`}],[`path`,{d:`m19.07 4.93-1.41 1.41`,key:`1shlcs`}]]};tc.node;var nc=F(tc),rc={name:`user-cog`,size:24,node:[[`path`,{d:`M10 15H6a4 4 0 0 0-4 4v2`,key:`1nfge6`}],[`path`,{d:`m14.305 16.53.923-.382`,key:`1itpsq`}],[`path`,{d:`m15.228 13.852-.923-.383`,key:`eplpkm`}],[`path`,{d:`m16.852 12.228-.383-.923`,key:`13v3q0`}],[`path`,{d:`m16.852 17.772-.383.924`,key:`1i8mnm`}],[`path`,{d:`m19.148 12.228.383-.923`,key:`1q8j1v`}],[`path`,{d:`m19.53 18.696-.382-.924`,key:`vk1qj3`}],[`path`,{d:`m20.772 13.852.924-.383`,key:`n880s0`}],[`path`,{d:`m20.772 16.148.924.383`,key:`1g6xey`}],[`circle`,{cx:`18`,cy:`15`,r:`3`,key:`gjjjvw`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]};rc.node;var ic=F(rc),ac={name:`users-round`,size:24,node:[[`path`,{d:`M18 21a8 8 0 0 0-16 0`,key:`3ypg7q`}],[`circle`,{cx:`10`,cy:`8`,r:`5`,key:`o932ke`}],[`path`,{d:`M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3`,key:`10s06x`}]],aliases:[`users-2`]};ac.node;var oc=F(ac),sc={name:`users`,size:24,node:[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]};sc.node;var cc=F(sc);function lc(e,t){return t!=null&&!Ut(t)?0:typeof e==`function`?e():e}function uc(e,t,n){let r=lc(e,n);return typeof r==`number`?r:r?.[t]}function dc(e){return typeof e==`function`?e():e}function fc(e,t){return t||e===`click`||e===`mousedown`}function pc(e){return e?.includes(`mouse`)&&e!==`mousedown`}var mc=z.createContext({hasProvider:!1,timeoutMs:0,delayRef:{current:0},initialDelayRef:{current:0},timeout:new kt,currentIdRef:{current:null},currentContextRef:{current:null}});function hc(e,t){e.current=t.current}function gc(e){let{children:t,delay:n,timeoutMs:r=0}=e,i=z.useRef(n),a=z.useRef(n),o=z.useRef(null),s=z.useRef(null),c=Mt();return ht(()=>{if(a.current=n,!o.current){i.current=n;return}i.current={open:uc(i.current,`open`),close:uc(n,`close`)}},[n,o,i,a]),(0,B.jsx)(mc.Provider,{value:z.useMemo(()=>({hasProvider:!0,delayRef:i,initialDelayRef:a,currentIdRef:o,timeoutMs:r,currentContextRef:s,timeout:c}),[r,c]),children:t})}function _c(e,t={open:!1}){let{open:n}=t,r=`rootStore`in e?e.rootStore:e,i=r.useState(`floatingId`),{currentIdRef:a,delayRef:o,timeoutMs:s,initialDelayRef:c,currentContextRef:l,hasProvider:u,timeout:d}=z.useContext(mc),[f,p]=z.useState(!1),m=z.useRef(n);return ht(()=>{m.current=n},[n]),ht(()=>{function e(){l.current?.setIsInstantPhase(!1),a.current=null,l.current=null,o.current=c.current,d.clear()}if(a.current&&!n&&a.current===i){if(p(!1),s){let t=i;return d.start(s,()=>{r.select(`open`)||a.current&&a.current!==t||e()}),()=>{(m.current||a.current!==t)&&d.clear()}}e()}},[n,i,a,o,s,c,l,d,r]),ht(()=>{if(!n)return;let e=l.current,t=a.current;d.clear(),l.current={onOpenChange:r.setOpen,setIsInstantPhase:p},a.current=i,o.current={open:0,close:uc(c.current,`close`)},t!==null&&t!==i?(p(!0),e?.setIsInstantPhase(!0),e?.onOpenChange(!1,Ot(Ct))):(p(!1),e?.setIsInstantPhase(!1))},[n,i,r,a,o,c,l,d]),ht(()=>()=>{if(a.current===i){if(l.current=null,!m.current)return;a.current=null,hc(o,c),d.clear()}},[l,a,o,i,c,d]),z.useMemo(()=>({activeIdRef:a,hasProvider:u,delayRef:o,isInstantPhase:f}),[a,u,o,f])}function vc(e,t){let n=null,r=null,i=!1;return{contextElement:e||void 0,getBoundingClientRect(){let a=e?.getBoundingClientRect()||{width:0,height:0,x:0,y:0},o=t.axis===`x`||t.axis===`both`,s=t.axis===`y`||t.axis===`both`,c=[`mouseenter`,`mousemove`].includes(t.dataRef.current.openEvent?.type||``)&&t.pointerType!==`touch`,l=a.width,u=a.height,d=a.x,f=a.y;return n==null&&t.x&&o&&(n=a.x-t.x),r==null&&t.y&&s&&(r=a.y-t.y),d-=n||0,f-=r||0,l=0,u=0,!i||c?(l=t.axis===`y`?a.width:0,u=t.axis===`x`?a.height:0,d=o&&t.x!=null?t.x:d,f=s&&t.y!=null?t.y:f):i&&!c&&(u=t.axis===`x`?a.height:u,l=t.axis===`y`?a.width:l),i=!0,{width:l,height:u,x:d,y:f,top:f,right:d+l,bottom:f+u,left:d}}}}function yc(e){return e!=null&&e.clientX!=null}function bc(e,t={}){let{enabled:n=!0,axis:r=`both`}=t,i=`rootStore`in e?e.rootStore:e,a=i.useState(`open`),o=i.useState(`floatingElement`),s=i.useState(`domReferenceElement`),c=i.context.dataRef,l=z.useRef(!1),u=z.useRef(null),[d,f]=z.useState(),[p,m]=z.useState([]),h=gt(e=>{i.set(`positionReference`,e)}),g=gt((e,t,n)=>{l.current||(!c.current.openEvent||yc(c.current.openEvent))&&i.set(`positionReference`,vc(n??s,{x:e,y:t,axis:r,dataRef:c,pointerType:d}))}),_=gt(e=>{a?u.current||(g(e.clientX,e.clientY,e.currentTarget),m([])):g(e.clientX,e.clientY,e.currentTarget)}),v=Ut(d)?o:a;z.useEffect(()=>{if(!n){h(s);return}if(!v)return;function e(){u.current?.(),u.current=null}let t=ft(o);function r(t){let n=yt(t);xt(o,n)?e():g(t.clientX,t.clientY)}return!c.current.openEvent||yc(c.current.openEvent)?u.current=Ie(t,`mousemove`,r):h(s),e},[v,n,o,c,s,i,g,h,p]),z.useEffect(()=>()=>{i.set(`positionReference`,null)},[i]),z.useEffect(()=>{n&&!o&&(l.current=!1)},[n,o]),z.useEffect(()=>{!n&&a&&(l.current=!0)},[n,a]);let y=z.useMemo(()=>{function e(e){f(e.pointerType)}return{onPointerDown:e,onPointerEnter:e,onMouseMove:_,onMouseEnter:_}},[_]);return z.useMemo(()=>n?{reference:y,trigger:y}:{},[n,y])}var xc=Me();function Sc(e){let t=z.useCallback(t=>e===void 0?He:e.subscribeStore(t),[e]),n=z.useCallback(()=>e===void 0?void 0:e.store,[e]);return(0,xc.useSyncExternalStore)(t,n,()=>e?.serverStore)}var Cc=on&&Ht;function wc(e,t={}){let{enabled:n=!0,delay:r}=t,i=`rootStore`in e?e.rootStore:e,{events:a,dataRef:o}=i.context,s=z.useRef(!1),c=z.useRef(null),l=z.useRef(!0),u=Mt();z.useEffect(()=>{let e=i.select(`domReferenceElement`);if(!n)return;let t=ft(e);function r(){let e=i.select(`domReferenceElement`);!i.select(`open`)&&pt(e)&&e===jt(bt(e))&&(s.current=!0,c.current=e)}function a(){l.current=!0}function o(){l.current=!1}return It(Ie(t,`blur`,r),Cc&&Ie(t,`keydown`,a,!0),Cc&&Ie(t,`pointerdown`,o,!0))},[i,n]),z.useEffect(()=>{if(!n)return;function e(e){if(e.reason===`trigger-press`||e.reason===`escape-key`){let e=i.select(`domReferenceElement`);vt(e)&&(c.current=e,s.current=!0)}}return a.on(`openchange`,e),()=>{a.off(`openchange`,e)}},[a,n,i]);let d=z.useMemo(()=>{function e(){s.current=!1,c.current=null}return{onMouseLeave(){e()},onFocus(t){let n=t.currentTarget;if(s.current){if(c.current===n)return;e()}let a=yt(t.nativeEvent);if(vt(a)){if(Cc&&!t.relatedTarget){if(!l.current&&!Jt(a))return}else if(!Wt(a))return}let o=Qt(t.relatedTarget,i.context.triggerElements),{nativeEvent:d,currentTarget:f}=t,p=typeof r==`function`?r():r;if(i.select(`open`)&&o||p===0||p===void 0){i.setOpen(!0,Ot(Et,d,f));return}u.start(p,()=>{s.current||i.setOpen(!0,Ot(Et,d,f))})},onBlur(t){e();let n=t.relatedTarget,r=t.nativeEvent,a=vt(n)&&n.hasAttribute(ln(`focus-guard`))&&n.getAttribute(`data-type`)===`outside`;u.start(0,()=>{let e=i.select(`domReferenceElement`),t=jt(bt(e));(n||t!==e)&&(xt(o.current.floatingContext?.refs.floating.current,t)||xt(e,t)||a||Qt(n??t,i.context.triggerElements)||i.setOpen(!1,Ot(Et,r)))})}}},[o,r,i,u]);return z.useMemo(()=>n?{reference:d,trigger:d}:{},[n,d])}var Tc=class e{constructor(){this.pointerType=void 0,this.interactedInside=!1,this.handler=void 0,this.blockMouseMove=!0,this.performedPointerEventsMutation=!1,this.pointerEventsScopeElement=null,this.pointerEventsReferenceElement=null,this.pointerEventsFloatingElement=null,this.restTimeoutPending=!1,this.openChangeTimeout=new kt,this.restTimeout=new kt,this.handleCloseOptions=void 0}static create(){return new e}dispose=()=>{this.openChangeTimeout.clear(),this.restTimeout.clear()};disposeEffect=()=>this.dispose},Ec=new WeakMap;function Dc(e){if(!e.performedPointerEventsMutation)return;let t=e.pointerEventsScopeElement;t&&Ec.get(t)===e&&(e.pointerEventsScopeElement?.style.removeProperty(`pointer-events`),e.pointerEventsReferenceElement?.style.removeProperty(`pointer-events`),e.pointerEventsFloatingElement?.style.removeProperty(`pointer-events`),Ec.delete(t)),e.performedPointerEventsMutation=!1,e.pointerEventsScopeElement=null,e.pointerEventsReferenceElement=null,e.pointerEventsFloatingElement=null}function Oc(e,t){let{scopeElement:n,referenceElement:r,floatingElement:i}=t,a=Ec.get(n);a&&a!==e&&Dc(a),Dc(e),e.performedPointerEventsMutation=!0,e.pointerEventsScopeElement=n,e.pointerEventsReferenceElement=r,e.pointerEventsFloatingElement=i,Ec.set(n,e),n.style.pointerEvents=`none`,r.style.pointerEvents=`auto`,i.style.pointerEvents=`auto`}function kc(e){let t=e.context.dataRef.current,n=Be(()=>t.hoverInteractionState??Tc.create()).current;return t.hoverInteractionState||=n,St(t.hoverInteractionState.disposeEffect),t.hoverInteractionState}function Ac(e,t={}){let{enabled:n=!0,closeDelay:r=0,nodeId:i}=t,a=`rootStore`in e?e.rootStore:e,o=a.useState(`open`),s=a.useState(`floatingElement`),c=a.useState(`domReferenceElement`),{dataRef:l}=a.context,u=Rt(),d=Yt(),f=kc(a),p=Mt(),m=gt(()=>fc(l.current.openEvent?.type,f.interactedInside)),h=gt(()=>pc(l.current.openEvent?.type)),g=gt(()=>{Dc(f)});ht(()=>{o||(f.pointerType=void 0,f.restTimeoutPending=!1,f.interactedInside=!1,g())},[o,f,g]),z.useEffect(()=>g,[g]),ht(()=>{if(n&&o&&f.handleCloseOptions?.blockPointerEvents&&h()&&vt(c)&&s){let e=c,t=s,n=bt(s),r=u?.nodesRef.current.find(e=>e.id===d)?.context?.elements.floating;r&&(r.style.pointerEvents=``);let i=f.pointerEventsScopeElement===t?null:f.pointerEventsScopeElement,a=r===t?null:r,o=f.handleCloseOptions?.getScope?.()??i??a??e.closest(`[data-rootownerid]`)??n.body;return Oc(f,{scopeElement:o,referenceElement:e,floatingElement:t}),()=>{g()}}},[n,o,c,s,f,h,u,d,g]),z.useEffect(()=>{if(!n)return;function e(){return!!(u&&d&&Nt(u.nodesRef.current,d).length>0)}function t(e){let t=uc(r,`close`,f.pointerType),n=()=>{a.setOpen(!1,Ot(I,e)),u?.events.emit(`floating.closed`,e)};t?f.openChangeTimeout.start(t,n):(f.openChangeTimeout.clear(),n())}function o(e){let t=yt(e);if(!nn(t)){f.interactedInside=!1;return}f.interactedInside=t?.closest(`[aria-haspopup]`)!=null}function c(){f.openChangeTimeout.clear(),p.clear(),u?.events.off(`floating.closed`,v),g()}function _(n){if(e()&&u){u.events.on(`floating.closed`,v);return}if(Qt(n.relatedTarget,a.context.triggerElements))return;let r=l.current.floatingContext?.nodeId??i,o=n.relatedTarget;if(!(u&&r&&vt(o)&&Nt(u.nodesRef.current,r,!1).some(e=>xt(e.context?.elements.floating,o)))){if(f.handler){f.handler(n);return}g(),h()&&!m()&&t(n)}}function v(t){u&&d&&!e()&&p.start(0,()=>{u.events.off(`floating.closed`,v),a.setOpen(!1,Ot(I,t)),u.events.emit(`floating.closed`,t)})}let y=s;return It(y&&Ie(y,`mouseenter`,c),y&&Ie(y,`mouseleave`,_),y&&Ie(y,`pointerdown`,o,!0),()=>{u?.events.off(`floating.closed`,v)})},[n,s,a,l,r,i,h,m,g,f,u,d,p])}var jc=n(Pe(),1),Mc={current:null};function Nc(e,t={}){let{enabled:n=!0,delay:r=0,handleClose:i=null,mouseOnly:a=!1,restMs:o=0,move:s=!0,triggerElementRef:c=Mc,externalTree:l,isActiveTrigger:u=!0,getHandleCloseContext:d,isClosing:f,shouldOpen:p,guardStaleOpen:m=!1}=t,h=`rootStore`in e?e.rootStore:e,{dataRef:g,events:_}=h.context,v=Rt(l),y=kc(h),b=z.useRef(!1),x=Le(i),S=Le(r),C=Le(o),w=Le(n),T=Le(p),E=Le(f),D=gt(()=>fc(g.current.openEvent?.type,y.interactedInside)),O=gt(()=>T.current?.()!==!1),k=gt((e,t,n)=>{let r=h.context.triggerElements;if(r.hasElement(t))return!e||!xt(e,t);if(!vt(n))return!1;let i=n;return r.hasMatchingElement(e=>xt(e,i))&&(!e||!xt(e,i))}),ee=gt(()=>{y.handler&&=(bt(h.select(`domReferenceElement`)).removeEventListener(`mousemove`,y.handler),void 0)}),te=gt(()=>{Dc(y)});return u&&(y.handleCloseOptions=i?.__options),z.useEffect(()=>ee,[ee]),z.useEffect(()=>{if(!n)return;function e(e){e.open?b.current=!1:(b.current=e.reason===I,ee(),y.openChangeTimeout.clear(),y.restTimeout.clear(),y.blockMouseMove=!0,y.restTimeoutPending=!1)}return _.on(`openchange`,e),()=>{_.off(`openchange`,e)}},[n,_,y,ee]),z.useEffect(()=>{if(!n)return;function e(e,t=!0){let n=uc(S.current,`close`,y.pointerType);n?y.openChangeTimeout.start(n,()=>{h.setOpen(!1,Ot(I,e)),v?.events.emit(`floating.closed`,e)}):t&&(y.openChangeTimeout.clear(),h.setOpen(!1,Ot(I,e)),v?.events.emit(`floating.closed`,e))}let t=c.current??(u?h.select(`domReferenceElement`):null);if(!vt(t))return;function r(e){if(y.openChangeTimeout.clear(),y.blockMouseMove=!1,a&&!Ut(y.pointerType))return;let t=dc(C.current),n=uc(S.current,`open`,y.pointerType),r=yt(e),i=e.currentTarget??null,o=h.select(`domReferenceElement`),s=i;if(vt(r)&&!h.context.triggerElements.hasElement(r)){for(let e of h.context.triggerElements.elements())if(xt(e,r)){s=e;break}}vt(i)&&vt(o)&&!h.context.triggerElements.hasElement(i)&&xt(i,o)&&(s=o);let c=s!=null&&k(o,s,r),l=h.select(`open`),u=E.current?.()??h.select(`transitionStatus`)===`ending`,d=!l&&u&&b.current,f=!c&&vt(s)&&vt(o)&&xt(o,s)&&d,p=t>0&&!n,m=c&&(l||d)||f,g=!l||c;if(m){O()&&h.setOpen(!0,Ot(I,e,s));return}p||(n?y.openChangeTimeout.start(n,()=>{g&&O()&&h.setOpen(!0,Ot(I,e,s))}):g&&O()&&h.setOpen(!0,Ot(I,e,s)))}function i(t){if(D()){te();return}ee();let n=h.select(`domReferenceElement`),r=bt(n);y.restTimeout.clear(),y.restTimeoutPending=!1;let i=g.current.floatingContext??d?.();if(!Qt(t.relatedTarget,h.context.triggerElements)){if(x.current&&i){h.select(`open`)||y.openChangeTimeout.clear();let n=c.current;y.handler=x.current({...i,tree:v,x:t.clientX,y:t.clientY,onClose(){te(),ee(),w.current&&!D()&&n===h.select(`domReferenceElement`)&&e(t,!0)}}),r.addEventListener(`mousemove`,y.handler),y.handler(t);return}(y.pointerType!==`touch`||!xt(h.select(`floatingElement`),t.relatedTarget))&&e(t)}}function o(e){xt(t,e.relatedTarget)||(y.openChangeTimeout.clear(),y.restTimeout.clear(),y.restTimeoutPending=!1)}let l=m?Ie(t,`mouseout`,o):void 0;return s?It(Ie(t,`mousemove`,r,{once:!0}),Ie(t,`mouseenter`,r),Ie(t,`mouseleave`,i),l):It(Ie(t,`mouseenter`,r),Ie(t,`mouseleave`,i),l)},[ee,te,g,S,h,n,x,y,u,k,D,a,s,C,c,v,w,d,E,O,m]),z.useMemo(()=>{if(!n)return;function e(e){y.pointerType=e.pointerType}return{onPointerDown:e,onPointerEnter:e,onMouseMove(e){let{nativeEvent:t}=e,n=e.currentTarget,r=h.select(`domReferenceElement`),i=h.select(`open`),o=k(r,n,e.target);if(a&&!Ut(y.pointerType))return;if(i&&o&&y.handleCloseOptions?.blockPointerEvents){let e=h.select(`floatingElement`);if(e){let t=y.handleCloseOptions?.getScope?.()??n.ownerDocument.body;Oc(y,{scopeElement:t,referenceElement:n,floatingElement:e})}}let s=dc(C.current);if(i&&!o||s===0||!o&&y.restTimeoutPending&&e.movementX**2+e.movementY**2<2)return;y.restTimeout.clear();function c(){if(y.restTimeoutPending=!1,D())return;let e=h.select(`open`);!y.blockMouseMove&&(!e||o)&&O()&&h.setOpen(!0,Ot(I,t,n))}y.pointerType===`touch`?jc.flushSync(()=>{c()}):o&&i?c():(y.restTimeoutPending=!0,y.restTimeout.start(s,c))}}},[n,y,D,k,a,h,C,O])}var Pc=.1,Fc=Pc*Pc,Ic=.5;function Lc(e,t,n,r,i,a){return r>=t!=a>=t&&e<=(i-n)*(t-r)/(a-r)+n}function Rc(e,t,n,r,i,a,o,s,c,l){let u=!1;return Lc(e,t,n,r,i,a)&&(u=!u),Lc(e,t,i,a,o,s)&&(u=!u),Lc(e,t,o,s,c,l)&&(u=!u),Lc(e,t,c,l,n,r)&&(u=!u),u}function zc(e,t,n){return e>=n.x&&e<=n.x+n.width&&t>=n.y&&t<=n.y+n.height}function Bc(e,t,n,r,i,a){return e>=Math.min(n,i)&&e<=Math.max(n,i)&&t>=Math.min(r,a)&&t<=Math.max(r,a)}function Vc(e={}){let{blockPointerEvents:t=!1}=e,n=new kt,r=({x:e,y:t,placement:r,elements:i,onClose:a,nodeId:o,tree:s})=>{let c=r?.split(`-`)[0],l=!1,u=null,d=null,f=typeof performance<`u`?performance.now():0;function p(e,t){let n=performance.now(),r=n-f;if(u===null||d===null||r===0)return u=e,d=t,f=n,!1;let i=e-u,a=t-d,o=i*i+a*a,s=r*r*Fc;return u=e,d=t,f=n,o0)}function b(){y()||m()}if(y())return;let x=a.getBoundingClientRect(),S=u.getBoundingClientRect(),C=e>S.right-S.width/2,w=t>S.bottom-S.height/2,T=S.width>x.width,E=S.height>x.height,D=(T?x:S).left,O=(T?x:S).right,k=(E?x:S).top,ee=(E?x:S).bottom;if(c===`top`&&t>=x.bottom-1||c===`bottom`&&t<=x.top+1||c===`left`&&e>=x.right-1||c===`right`&&e<=x.left+1){b();return}let te=!1;switch(c){case`top`:te=Bc(d,f,D,x.top+1,O,S.bottom-1);break;case`bottom`:te=Bc(d,f,D,S.top+1,O,x.bottom-1);break;case`left`:te=Bc(d,f,S.right-1,ee,x.left+1,k);break;case`right`:te=Bc(d,f,x.right-1,ee,S.left+1,k)}if(te)return;if(l&&!zc(d,f,x)){b();return}if(!g&&p(d,f)){b();return}let ne=!1;switch(c){case`top`:{let n=T?Ic/2:Ic*4,r=T||C?e+n:e-n,i=T?e-n:C?e+n:e-n,a=t+Ic+1,o=C||T?S.bottom-Ic:S.top,s=C?T?S.bottom-Ic:S.top:S.bottom-Ic;ne=Rc(d,f,r,a,i,a,S.left,o,S.right,s);break}case`bottom`:{let n=T?Ic/2:Ic*4,r=T||C?e+n:e-n,i=T?e-n:C?e+n:e-n,a=t-Ic,o=C||T?S.top+Ic:S.bottom,s=C?T?S.top+Ic:S.bottom:S.top+Ic;ne=Rc(d,f,r,a,i,a,S.left,o,S.right,s);break}case`left`:{let n=E?Ic/2:Ic*4,r=E||w?t+n:t-n,i=E?t-n:w?t+n:t-n,a=e+Ic+1,o=w||E?S.right-Ic:S.left,s=w?E?S.right-Ic:S.left:S.right-Ic;ne=Rc(d,f,o,S.top,s,S.bottom,a,r,a,i);break}case`right`:{let n=E?Ic/2:Ic*4,r=E||w?t+n:t-n,i=E?t-n:w?t+n:t-n,a=e-Ic,o=w||E?S.left+Ic:S.right,s=w?E?S.left+Ic:S.right:S.left+Ic;ne=Rc(d,f,a,r,a,i,o,S.top,s,S.bottom);break}}ne?l||n.start(40,b):b()}};return r.__options={...e,blockPointerEvents:t},r}var Hc=Pt(function(e){return vn(`dialog`,e)});function Uc(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(Hc,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function Wc(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(bn,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function Gc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 z-50 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(_n,{className:i,"data-slot":`sheet-overlay`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Kc(e){let t=(0,H.c)(17),n,r,i,a,o;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5]):({className:r,children:n,side:a,showCloseButton:o,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o);let s=a===void 0?`right`:a,c=o===void 0||o,l;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsx)(Gc,{}),t[6]=l):l=t[6];let u;t[7]===r?u=t[8]:(u=Xe(`data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-closed:animate-out data-open:animate-in fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm`,r),t[7]=r,t[8]=u);let d;t[9]===c?d=t[10]:(d=c&&(0,B.jsxs)(hn,{"data-slot":`sheet-close`,render:(0,B.jsx)(_t,{className:`absolute top-4 right-4`,size:`icon-sm`,variant:`ghost`}),children:[(0,B.jsx)(ut,{}),(0,B.jsx)(`span`,{className:`sr-only`,children:`Close`})]}),t[9]=c,t[10]=d);let f;return t[11]!==n||t[12]!==i||t[13]!==s||t[14]!==u||t[15]!==d?(f=(0,B.jsxs)(Wc,{children:[l,(0,B.jsxs)(gn,{className:u,"data-side":s,"data-slot":`sheet-content`,...i,children:[n,d]})]}),t[11]=n,t[12]=i,t[13]=s,t[14]=u,t[15]=d,t[16]=f):f=t[16],f}function qc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex flex-col gap-1.5 p-6`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-slot":`sheet-header`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Jc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`text-sm font-medium text-foreground`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(Sn,{className:i,"data-slot":`sheet-title`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Yc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`text-xs/relaxed text-muted-foreground`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(xn,{className:i,"data-slot":`sheet-description`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}var Xc=z.createContext(void 0);function Zc(e){let t=z.useContext(Xc);if(t===void 0&&!e)throw Error(We(72));return t}var Qc={...yn,disabled:e=>e.disabled,instantType:e=>e.instantType,isInstantPhase:e=>e.isInstantPhase,trackCursorAxis:e=>e.trackCursorAxis,disableHoverablePopup:e=>e.disableHoverablePopup,lastOpenChangeReason:e=>e.openChangeReason,closeOnClick:e=>e.closeOnClick,closeDelay:e=>e.closeDelay,adaptiveOrigin:e=>e.adaptiveOrigin},$c=class extends Ft{constructor(e,t,n){let r=new en;super(el(e,r,t,n),tl(r),Qc)}setOpen=(e,t)=>{L(this,e,t,{extraState:{openChangeReason:t.reason}})};cancelPendingOpen(e){this.state.floatingRootContext.dispatchOpenChange(!1,Ot(wt,e))}};function el(e,t,n,r=!1){return{...mn(t,n,r),disabled:!1,instantType:void 0,isInstantPhase:!1,trackCursorAxis:`none`,disableHoverablePopup:!1,openChangeReason:null,closeOnClick:!0,closeDelay:0,adaptiveOrigin:void 0,...e}}function tl(e){return{popupRef:z.createRef(),onOpenChange:void 0,onOpenChangeComplete:void 0,triggerElements:e}}var nl=Pt(function(e){let{disabled:t=!1,defaultOpen:n=!1,open:r,disableHoverablePopup:i=!1,trackCursorAxis:a=`none`,actionsRef:o,onOpenChange:s,onOpenChangeComplete:c,handle:l,triggerId:u,defaultTriggerId:d=null,children:f}=e,p=Vt((e,t)=>new $c({open:n,openProp:r,activeTriggerId:d,triggerIdProp:u},e,t));p.useControlledProp(`openProp`,r),p.useControlledProp(`triggerIdProp`,u),p.useContextCallback(`onOpenChange`,s),p.useContextCallback(`onOpenChangeComplete`,c);let m=p.useState(`open`),h=!t&&m,g=p.useState(`activeTriggerId`),_=p.useState(`mounted`),v=p.useState(`payload`);p.useSyncedValues({trackCursorAxis:a,disableHoverablePopup:i,disabled:t}),cn(p,{closeOnActiveTriggerUnmount:!0});let{forceUnmount:y,transitionStatus:b}=Gt(h,p),x=p.useState(`isInstantPhase`),S=p.useState(`instantType`),C=p.useState(`lastOpenChangeReason`),w=z.useRef(null);ht(()=>{m&&t&&p.setOpen(!1,Ot(Tt))},[m,t,p]),ht(()=>{b===`ending`&&C===`none`||b!==`ending`&&x?(S!==`delay`&&(w.current=S),p.set(`instantType`,`delay`)):w.current!==null&&(p.set(`instantType`,w.current),w.current=null)},[b,x,C,S,p]),ht(()=>{h&&(g??p.set(`payload`,void 0))},[p,g,h]),z.useImperativeHandle(o,()=>({unmount:y,close:()=>p.setOpen(!1,Ot(Dt))}),[y,p]);let T=h||_||!t&&a!==`none`;return(0,B.jsxs)(Xc.Provider,{value:p,children:[l&&(0,B.jsx)(qt,{handle:l,store:p}),T&&(0,B.jsx)(rl,{store:p,disabled:t,trackCursorAxis:a}),typeof f==`function`?f({payload:v}):f]})});function rl({store:e,disabled:t,trackCursorAxis:n}){let r=e.useState(`floatingRootContext`),i=Xt(r,{enabled:!t,referencePress:()=>e.select(`closeOnClick`)}),a=bc(r,{enabled:!t&&n!==`none`,axis:n===`none`?void 0:n}),o=z.useMemo(()=>ze(a.reference,i.reference),[a.reference,i.reference]);return sn(e,{activeTriggerProps:o,inactiveTriggerProps:o,popupProps:i.floating??Re}),null}var il=z.createContext(void 0);function al(){return z.useContext(il)}var ol=`data-base-ui-tooltip-trigger`;function sl(e){if(`composedPath`in e){let t=e.composedPath();for(let e=0;em.select(`transitionStatus`)===`ending`,shouldOpen(){return!ie.current}}),de=wc(v,{enabled:!ee}).reference,fe=e=>{let t=ie.current,n=sl(e),r=le(n),i=y.current,a=i&&n&&xt(i,n);if(r&&m.select(`open`)&&m.select(`lastOpenChangeReason`)===`trigger-hover`){m.setOpen(!1,Ot(I,e));return}if(t&&!r&&a&&!te.current&&!m.select(`open`)&&i&&Ut(oe.current)){let t=()=>{!ie.current&&!te.current&&!m.select(`open`)&&m.setOpen(!0,Ot(I,e,i))},n=se();n===0?(ae.clear(),t()):ae.start(n,t)}},pe=m.useState(`triggerProps`,S);return Ve(`button`,e,{state:{open:_},ref:[t,x,y],props:[ue,de,S||ne!==`none`?pe:void 0,{onMouseOver(e){fe(e.nativeEvent)},onFocus(e){ce(sl(e.nativeEvent))&&e.preventBaseUIHandler()},onMouseLeave(){ie.current=!1,ae.clear(),oe.current=void 0},onPointerEnter(e){oe.current=e.pointerType},onPointerDown(e){oe.current=e.pointerType,m.set(`closeOnClick`,l),l&&!m.select(`open`)&&m.cancelPendingOpen(e.nativeEvent)},onClick(e){l&&!m.select(`open`)&&m.cancelPendingOpen(e.nativeEvent)},id:h,[$t]:ee?``:void 0,[ol]:ee?void 0:``},f],stateAttributesMapping:rn})}),ul=z.createContext(void 0);function dl(){let e=z.useContext(ul);if(e===void 0)throw Error(We(70));return e}var fl=z.forwardRef(function(e,t){let{children:n,container:r,className:i,render:a,style:o,...s}=e,{node:c,subtree:l}=Bt({container:r,ref:t,componentProps:e,elementProps:s});return!l&&!c?null:(0,B.jsxs)(z.Fragment,{children:[l,c&&jc.createPortal(n,c)]})}),pl=z.forwardRef(function(e,t){let{keepMounted:n=!1,...r}=e;return Zc().useState(`mounted`)||n?(0,B.jsx)(ul.Provider,{value:n,children:(0,B.jsx)(fl,{ref:t,...r})}):null}),ml=z.createContext(void 0);function hl(){let e=z.useContext(ml);if(e===void 0)throw Error(We(71));return e}var gl=z.forwardRef(function(e,t){let{render:n,className:r,anchor:i,positionMethod:a=`absolute`,side:o=`top`,align:s=`center`,sideOffset:c=0,alignOffset:l=0,collisionBoundary:u=`clipping-ancestors`,collisionPadding:d=5,arrowPadding:f=5,sticky:p=!1,disableAnchorTracking:m=!1,collisionAvoidance:h=Lt,style:g,..._}=e,v=Zc(),y=dl(),b=v.useState(`open`),x=v.useState(`mounted`),S=v.useState(`trackCursorAxis`),C=v.useState(`disableHoverablePopup`),w=v.useState(`floatingRootContext`),T=v.useState(`instantType`),E=v.useState(`transitionStatus`),D=v.useState(`adaptiveOrigin`),O=dn({anchor:i,positionMethod:a,floatingRootContext:w,mounted:x,side:o,sideOffset:c,align:s,alignOffset:l,collisionBoundary:u,collisionPadding:d,sticky:p,arrowPadding:f,disableAnchorTracking:m,keepMounted:y,collisionAvoidance:h,adaptiveOrigin:D}),k=z.useMemo(()=>({open:b,side:O.side,align:O.align,anchorHidden:O.anchorHidden,instant:S===`none`?T:`tracking-cursor`}),[b,O.side,O.align,O.anchorHidden,S,T]),ee=fn(e,k,{styles:O.positionerStyles,transitionStatus:E,props:_,refs:[t,v.useStateSetter(`positionerElement`)],hidden:!x,inert:!b||S===`both`||C});return(0,B.jsx)(ml.Provider,{value:O,children:ee})}),_l=z.forwardRef(function(e,t){let{render:n,className:r,style:i,...a}=e,o=Zc(),{side:s,align:c}=hl(),l=o.useState(`open`),u=o.useState(`instantType`),d=o.useState(`transitionStatus`),f=o.useState(`popupProps`),p=o.useState(`floatingRootContext`),m=o.useState(`disabled`),h=o.useState(`closeDelay`);Fe({open:l,ref:o.context.popupRef,onComplete(){l&&o.context.onOpenChangeComplete?.(!0)}}),Ac(p,{enabled:!m,closeDelay:h});let g=o.useStateSetter(`popupElement`);return Ve(`div`,e,{state:{open:l,side:s,align:c,instant:u,transitionStatus:d},ref:[t,o.context.popupRef,g],props:[Zt,f,un(d),a],stateAttributesMapping:Kt})}),vl=z.forwardRef(function(e,t){let{render:n,className:r,style:i,...a}=e,o=Zc(),{arrowRef:s,side:c,align:l,arrowUncentered:u,arrowStyles:d}=hl(),f={open:o.useState(`open`),side:c,align:l,uncentered:u,instant:o.useState(`instantType`)};return Ve(`div`,e,{state:f,ref:[t,s],props:[{style:d,"aria-hidden":!0},a],stateAttributesMapping:tn})}),yl=function(e){let{delay:t,closeDelay:n,timeout:r=400}=e,i=z.useMemo(()=>({open:t,close:n}),[t,n]);return(0,B.jsx)(il.Provider,{value:t,children:(0,B.jsx)(gc,{delay:i,timeoutMs:r,children:e.children})})};function bl(e){let t=(0,H.c)(6),n,r;t[0]===e?(n=t[1],r=t[2]):({delay:r,...n}=e,t[0]=e,t[1]=n,t[2]=r);let i=r===void 0?0:r,a;return t[3]!==i||t[4]!==n?(a=(0,B.jsx)(yl,{"data-slot":`tooltip-provider`,delay:i,...n}),t[3]=i,t[4]=n,t[5]=a):a=t[5],a}function xl(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(bl,{children:(0,B.jsx)(nl,{"data-slot":`tooltip`,...n})}),t[2]=n,t[3]=r),r}function Sl(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(ll,{"data-slot":`tooltip-trigger`,...n}),t[2]=n,t[3]=r),r}function Cl(e){let t=(0,H.c)(21),n,r,i,a,o,s,c;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5],s=t[6],c=t[7]):({className:r,side:a,sideOffset:o,align:s,alignOffset:c,children:n,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o,t[6]=s,t[7]=c);let l=a===void 0?`top`:a,u=o===void 0?4:o,d=s===void 0?`center`:s,f=c===void 0?0:c,p;t[8]===r?p=t[9]:(p=Xe(`data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-closed:animate-out data-open:animate-in z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background **:data-[slot=kbd]:rounded-md`,r),t[8]=r,t[9]=p);let m;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,B.jsx)(vl,{className:`z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5`}),t[10]=m):m=t[10];let h;t[11]!==n||t[12]!==i||t[13]!==p?(h=(0,B.jsxs)(_l,{className:p,"data-slot":`tooltip-content`,...i,children:[n,m]}),t[11]=n,t[12]=i,t[13]=p,t[14]=h):h=t[14];let g;return t[15]!==d||t[16]!==f||t[17]!==l||t[18]!==u||t[19]!==h?(g=(0,B.jsx)(pl,{children:(0,B.jsx)(gl,{align:d,alignOffset:f,className:`isolate z-50`,side:l,sideOffset:u,children:h})}),t[15]=d,t[16]=f,t[17]=l,t[18]=u,t[19]=h,t[20]=g):g=t[20],g}var wl=window.matchMedia(`(max-width: 767px)`);function Tl(e){return wl.addEventListener(`change`,e),()=>{wl.removeEventListener(`change`,e)}}function El(){return wl.matches}function Dl(){return(0,z.useSyncExternalStore)(Tl,El)}var Ol=`sidebar_state`,kl=604800,Al=`12rem`,jl=`16rem`,Ml=`3rem`,Nl=`b`,Pl=z.createContext(null);function Fl(){let e=z.useContext(Pl);if(!e)throw Error(`useSidebar must be used within a SidebarProvider.`);return e}function Il(e){let t=(0,H.c)(36),n,r,i,a,o,s,c;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5],s=t[6],c=t[7]):({defaultOpen:c,open:i,onOpenChange:o,className:r,style:s,children:n,...a}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o,t[6]=s,t[7]=c);let l=c===void 0||c,u=Dl(),[d,f]=z.useState(!1),[p,m]=z.useState(l),h=i??p,g;t[8]!==h||t[9]!==o?(g=e=>{let t=typeof e==`function`?e(h):e;o?o(t):m(t),document.cookie=`${Ol}=${t}; path=/; max-age=${kl}`},t[8]=h,t[9]=o,t[10]=g):g=t[10];let _=g,v;t[11]!==u||t[12]!==_?(v=()=>{u?f(Rl):_(Ll)},t[11]=u,t[12]=_,t[13]=v):v=t[13];let y=v,b,x;t[14]===y?(b=t[15],x=t[16]):(b=()=>{let e=e=>{e.key===Nl&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),y())};return window.addEventListener(`keydown`,e),()=>{window.removeEventListener(`keydown`,e)}},x=[y],t[14]=y,t[15]=b,t[16]=x),z.useEffect(b,x);let S=h?`expanded`:`collapsed`,C;t[17]!==u||t[18]!==h||t[19]!==d||t[20]!==_||t[21]!==S||t[22]!==y?(C={state:S,open:h,setOpen:_,isMobile:u,openMobile:d,setOpenMobile:f,toggleSidebar:y},t[17]=u,t[18]=h,t[19]=d,t[20]=_,t[21]=S,t[22]=y,t[23]=C):C=t[23];let w=C,T;t[24]===s?T=t[25]:(T={"--sidebar-width":Al,"--sidebar-width-icon":Ml,...s},t[24]=s,t[25]=T);let E=T,D;t[26]===r?D=t[27]:(D=Xe(`group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar`,r),t[26]=r,t[27]=D);let O;t[28]!==n||t[29]!==a||t[30]!==D||t[31]!==E?(O=(0,B.jsx)(`div`,{className:D,"data-slot":`sidebar-wrapper`,style:E,...a,children:n}),t[28]=n,t[29]=a,t[30]=D,t[31]=E,t[32]=O):O=t[32];let k;return t[33]!==w||t[34]!==O?(k=(0,B.jsx)(Pl.Provider,{value:w,children:O}),t[33]=w,t[34]=O,t[35]=k):k=t[35],k}function Ll(e){return!e}function Rl(e){return!e}function zl(e){let t=(0,H.c)(46),n,r,i,a,o,s;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5],s=t[6]):({side:a,variant:o,collapsible:s,className:r,children:n,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o,t[6]=s);let c=a===void 0?`left`:a,l=o===void 0?`sidebar`:o,u=s===void 0?`offExamples`:s,{isMobile:d,state:f,openMobile:p,setOpenMobile:m}=Fl();if(u===`none`){let e;t[7]===r?e=t[8]:(e=Xe(`flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground`,r),t[7]=r,t[8]=e);let a;return t[9]!==n||t[10]!==i||t[11]!==e?(a=(0,B.jsx)(`div`,{className:e,"data-slot":`sidebar`,...i,children:n}),t[9]=n,t[10]=i,t[11]=e,t[12]=a):a=t[12],a}if(d){let e;t[13]===Symbol.for(`react.memo_cache_sentinel`)?(e={"--sidebar-width":jl},t[13]=e):e=t[13];let r=e,a;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,B.jsxs)(qc,{className:`sr-only`,children:[(0,B.jsx)(Jc,{children:`Sidebar`}),(0,B.jsx)(Yc,{children:`Displays the mobile sidebar.`})]}),t[14]=a):a=t[14];let o;t[15]===n?o=t[16]:(o=(0,B.jsx)(`div`,{className:`flex h-full w-full flex-col`,children:n}),t[15]=n,t[16]=o);let s;t[17]!==c||t[18]!==o?(s=(0,B.jsxs)(Kc,{className:`w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden`,"data-mobile":`true`,"data-sidebar":`sidebar`,"data-slot":`sidebar`,side:c,style:r,children:[a,o]}),t[17]=c,t[18]=o,t[19]=s):s=t[19];let l;return t[20]!==p||t[21]!==i||t[22]!==m||t[23]!==s?(l=(0,B.jsx)(Uc,{onOpenChange:m,open:p,...i,children:s}),t[20]=p,t[21]=i,t[22]=m,t[23]=s,t[24]=l):l=t[24],l}let h=f===`collapsed`?u:``,g=l===`floating`||l===`inset`?`group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]`:`group-data-[collapsible=icon]:w-(--sidebar-width-icon)`,_;t[25]===g?_=t[26]:(_=Xe(`relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear`,`group-data-[collapsible=offExamples]:w-0`,`group-data-[side=right]:rotate-180`,g),t[25]=g,t[26]=_);let v;t[27]===_?v=t[28]:(v=(0,B.jsx)(`div`,{className:_,"data-slot":`sidebar-gap`}),t[27]=_,t[28]=v);let y=c===`left`?`left-0 group-data-[collapsible=offExamples]:left-[calc(var(--sidebar-width)*-1)]`:`right-0 group-data-[collapsible=offExamples]:right-[calc(var(--sidebar-width)*-1)]`,b=l===`floating`||l===`inset`?`p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]`:`group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l`,x;t[29]!==r||t[30]!==y||t[31]!==b?(x=Xe(`fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex`,y,b,r),t[29]=r,t[30]=y,t[31]=b,t[32]=x):x=t[32];let S;t[33]===n?S=t[34]:(S=(0,B.jsx)(`div`,{className:`flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border`,"data-sidebar":`sidebar`,"data-slot":`sidebar-inner`,children:n}),t[33]=n,t[34]=S);let C;t[35]!==i||t[36]!==x||t[37]!==S?(C=(0,B.jsx)(`div`,{className:x,"data-slot":`sidebar-container`,...i,children:S}),t[35]=i,t[36]=x,t[37]=S,t[38]=C):C=t[38];let w;return t[39]!==c||t[40]!==f||t[41]!==C||t[42]!==h||t[43]!==v||t[44]!==l?(w=(0,B.jsxs)(`div`,{className:`group peer hidden text-sidebar-foreground md:block`,"data-collapsible":h,"data-side":c,"data-slot":`sidebar`,"data-state":f,"data-variant":l,children:[v,C]}),t[39]=c,t[40]=f,t[41]=C,t[42]=h,t[43]=v,t[44]=l,t[45]=w):w=t[45],w}function Bl(e){let t=(0,H.c)(15),n,r,i;t[0]===e?(n=t[1],r=t[2],i=t[3]):({className:n,onClick:r,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i);let{toggleSidebar:a}=Fl(),o;t[4]===n?o=t[5]:(o=Xe(n),t[4]=n,t[5]=o);let s;t[6]!==r||t[7]!==a?(s=e=>{r?.(e),a()},t[6]=r,t[7]=a,t[8]=s):s=t[8];let c,l;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,B.jsx)(Xs,{}),l=(0,B.jsx)(`span`,{className:`sr-only`,children:`Toggle Sidebar`}),t[9]=c,t[10]=l):(c=t[9],l=t[10]);let u;return t[11]!==i||t[12]!==o||t[13]!==s?(u=(0,B.jsxs)(_t,{className:o,"data-sidebar":`trigger`,"data-slot":`sidebar-trigger`,onClick:s,size:`icon-lg`,variant:`ghost`,...i,children:[c,l]}),t[11]=i,t[12]=o,t[13]=s,t[14]=u):u=t[14],u}function Vl(e){let t=(0,H.c)(9),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let{toggleSidebar:i}=Fl(),a;t[3]===n?a=t[4]:(a=Xe(`absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex`,`in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize`,`[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize`,`group-data-[collapsible=offExamples]:translate-x-0 group-data-[collapsible=offExamples]:after:left-full hover:group-data-[collapsible=offExamples]:bg-sidebar`,`[[data-side=left][data-collapsible=offExamples]_&]:-right-2`,`[[data-side=right][data-collapsible=offExamples]_&]:-left-2`,n),t[3]=n,t[4]=a);let o;return t[5]!==r||t[6]!==a||t[7]!==i?(o=(0,B.jsx)(`button`,{"aria-label":`Toggle Sidebar`,className:a,"data-sidebar":`rail`,"data-slot":`sidebar-rail`,onClick:i,tabIndex:-1,title:`Toggle Sidebar`,type:`button`,...r}),t[5]=r,t[6]=a,t[7]=i,t[8]=o):o=t[8],o}function Hl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex flex-col gap-2 p-2`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`header`,"data-slot":`sidebar-header`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Ul(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex flex-col gap-2 p-2`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`footer`,"data-slot":`sidebar-footer`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Wl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`mx-2 w-auto bg-sidebar-border`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(pn,{className:i,"data-sidebar":`separator`,"data-slot":`sidebar-separator`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Gl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-x-hidden overflow-y-auto`,`group-data-[collapsible=icon]:[scrollbar-width:none] group-data-[collapsible=icon]:[&::-webkit-scrollbar]:hidden`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`content`,"data-slot":`sidebar-content`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Kl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`relative flex w-full min-w-0 flex-col px-2 py-1`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`group`,"data-slot":`sidebar-group`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function ql(e){let t=(0,H.c)(10),n,r,i,a;if(t[0]!==e){let{className:o,render:s,...c}=e;n=s,a=Ge,r=`div`,i=ze({className:Xe(`flex h-8 shrink-0 items-center rounded-md px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0`,o)},c),t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a}else n=t[1],r=t[2],i=t[3],a=t[4];let o;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(o={slot:`sidebar-group-label`,sidebar:`group-label`},t[5]=o):o=t[5];let s;return t[6]!==n||t[7]!==r||t[8]!==i?(s={defaultTagName:r,props:i,render:n,state:o},t[6]=n,t[7]=r,t[8]=i,t[9]=s):s=t[9],a(s)}function Jl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex w-full min-w-0 flex-col gap-px`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`ul`,{className:i,"data-sidebar":`menu`,"data-slot":`sidebar-menu`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function U(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`group/menu-item relative`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`li`,{className:i,"data-sidebar":`menu-item`,"data-slot":`sidebar-menu-item`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}var Yl=Ue(`peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate`,{variants:{variant:{default:`hover:bg-sidebar-accent hover:text-sidebar-accent-foreground`,outline:`bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]`},size:{default:`h-8 text-xs`,sm:`h-7 text-xs`,lg:`h-12 text-xs group-data-[collapsible=icon]:p-0!`}},defaultVariants:{variant:`default`,size:`default`}});function W(e){let t=(0,H.c)(18),{render:n,isActive:r,variant:i,size:a,tooltip:o,className:s,...c}=e,l=r!==void 0&&r,u=i===void 0?`default`:i,d=a===void 0?`default`:a,{isMobile:f,state:p}=Fl(),m=ze({className:Xe(Yl({variant:u,size:d}),s)},c),h;t[0]!==n||t[1]!==o?(h=o?(0,B.jsx)(Sl,{}):n,t[0]=n,t[1]=o,t[2]=h):h=t[2];let g;t[3]!==l||t[4]!==d?(g={slot:`sidebar-menu-button`,sidebar:`menu-button`,size:d,active:l},t[3]=l,t[4]=d,t[5]=g):g=t[5];let _;t[6]!==m||t[7]!==h||t[8]!==g?(_={defaultTagName:`button`,props:m,render:h,state:g},t[6]=m,t[7]=h,t[8]=g,t[9]=_):_=t[9];let v=Ge(_);if(!o)return v;let y;t[10]===o?y=t[11]:(y=typeof o==`string`?{children:o}:o,t[10]=o,t[11]=y);let b=y,x=p!==`collapsed`||f,S;t[12]!==x||t[13]!==b?(S=(0,B.jsx)(Cl,{align:`center`,hidden:x,side:`right`,...b}),t[12]=x,t[13]=b,t[14]=S):S=t[14];let C;return t[15]!==v||t[16]!==S?(C=(0,B.jsxs)(xl,{children:[v,S]}),t[15]=v,t[16]=S,t[17]=C):C=t[17],C}var Xl=new Set([`InvalidClientTokenId`,`SignatureDoesNotMatch`,`AuthFailure`,`MissingAuthenticationToken`,`IncompleteSignature`,`UnrecognizedClientException`,`ExpiredToken`,`ExpiredTokenException`,`InvalidAccessKeyId`]);function Zl(e){return typeof e==`object`&&!!e&&`name`in e&&typeof e.name==`string`}function Ql(e){return Zl(e)&&Xl.has(e.name)}var $l=!1;function eu(e,t){!$l&&Ql(e)&&($l=!0,Ke(),Cn(),t.clear(),window.location.pathname!==`/login`&&(window.location.href=`/login?reason=expired`))}function tu(){let e=new $r({queryCache:new Qr({onError:t=>{eu(t,e)}}),mutationCache:new Xr({onError:t=>{eu(t,e)}}),defaultOptions:{queries:{staleTime:5e3,refetchIntervalInBackground:!1}}});return e}function nu(){let e=(0,H.c)(8),{theme:t,setTheme:n}=cs(),r;e[0]!==n||e[1]!==t?(r=()=>{n(t===`light`?`dark`:t===`dark`?`light`:`dark`)},e[0]=n,e[1]=t,e[2]=r):r=e[2];let i=r,a,o,s;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,B.jsx)(nc,{className:`size-[1.2rem] scale-100 rotate-0 transition-transform dark:scale-0 dark:-rotate-90`}),o=(0,B.jsx)(Ks,{className:`absolute size-[1.2rem] scale-0 rotate-90 transition-transform dark:scale-100 dark:rotate-0`}),s=(0,B.jsx)(`span`,{className:`sr-only`,children:`Toggle theme`}),e[3]=a,e[4]=o,e[5]=s):(a=e[3],o=e[4],s=e[5]);let c;return e[6]===i?c=e[7]:(c=(0,B.jsxs)(_t,{className:`rounded-full transition-none hover:bg-transparent dark:hover:bg-transparent`,onClick:i,size:`icon-sm`,variant:`ghost`,children:[a,o,s]}),e[6]=i,e[7]=c),c}function ru(){let e=(0,H.c)(7),{isAdmin:t,license:n}=wn(),r=t&&n===`open-source`,i,a;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(i=Xe(`z-50 flex h-10 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-4 text-sidebar-foreground`,`sticky top-0`),a=(0,B.jsx)(Bl,{className:`-ml-1`}),e[0]=i,e[1]=a):(i=e[0],a=e[1]);let o;e[2]===r?o=e[3]:(o=r&&(0,B.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:Xe(mt({variant:`outline`,size:`sm`})),children:`Upgrade`}),e[2]=r,e[3]=o);let s;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,B.jsx)(nu,{}),e[4]=s):s=e[4];let c;return e[5]===o?c=e[6]:(c=(0,B.jsxs)(`header`,{className:i,children:[a,(0,B.jsxs)(`div`,{className:`flex w-full items-center justify-end gap-2`,children:[o,s]})]}),e[5]=o,e[6]=c),c}function iu(){let e=(0,H.c)(3),t,n;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,B.jsx)(`h1`,{className:`text-4xl font-bold`,children:`404`}),n=(0,B.jsx)(`p`,{className:`text-muted-foreground`,children:`Page not found`}),e[0]=t,e[1]=n):(t=e[0],n=e[1]);let r;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,B.jsxs)(`div`,{className:`flex flex-1 flex-col items-center justify-center gap-4`,children:[t,n,(0,B.jsx)(A,{to:`/`,children:(0,B.jsx)(_t,{variant:`outline`,children:`Go back home`})})]}),e[2]=r):r=e[2],r}function G(){let e=(0,H.c)(259),t;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t={select:au},e[0]=t):t=e[0];let n=Jo(t),r=Ne(),i=a(),{isAdmin:o}=wn(),s;e[1]!==r||e[2]!==i?(s=function(){Ke(),Cn(),i.clear(),r({to:`/login`})},e[1]=r,e[2]=i,e[3]=s):s=e[3];let c=s,l;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsxs)(Hl,{className:`flex flex-row items-center gap-2 px-4 py-3`,children:[(0,B.jsx)(`img`,{src:`/mulga-logo.svg`,alt:`Mulga`,className:`size-6 shrink-0 dark:invert`}),(0,B.jsx)(`span`,{className:`truncate text-sm font-semibold group-data-[collapsible=icon]:hidden`,children:`Spinifex`})]}),e[4]=l):l=e[4];let u;e[5]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,B.jsx)(ql,{children:`General`}),e[5]=u):u=e[5];let d=n===`/`,f,p;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,B.jsx)(Os,{className:`size-4`}),p=(0,B.jsx)(`span`,{children:`Dashboard`}),e[6]=f,e[7]=p):(f=e[6],p=e[7]);let m;e[8]===d?m=e[9]:(m=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/`,children:(0,B.jsxs)(W,{isActive:d,tooltip:`Dashboard`,children:[f,p]})})}),e[8]=d,e[9]=m);let h;e[10]!==o||e[11]!==n?(h=o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/nodes`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/nodes`),tooltip:`Nodes`,children:[(0,B.jsx)(rt,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Nodes`})]})})}),e[10]=o,e[11]=n,e[12]=h):h=e[12];let g;e[13]!==m||e[14]!==h?(g=(0,B.jsxs)(Kl,{children:[u,(0,B.jsxs)(Jl,{children:[m,h]})]}),e[13]=m,e[14]=h,e[15]=g):g=e[15];let _;e[16]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,B.jsx)(ql,{children:`EC2`}),e[16]=_):_=e[16];let v;e[17]===n?v=e[18]:(v=n.startsWith(`/ec2/describe-instances`)||n.startsWith(`/ec2/run-instances`),e[17]=n,e[18]=v);let y,b;e[19]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,B.jsx)(rt,{className:`size-4`}),b=(0,B.jsx)(`span`,{children:`Instances`}),e[19]=y,e[20]=b):(y=e[19],b=e[20]);let x;e[21]===v?x=e[22]:(x=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-instances`,children:(0,B.jsxs)(W,{isActive:v,tooltip:`Instances`,children:[y,b]})})}),e[21]=v,e[22]=x);let S;e[23]===n?S=e[24]:(S=n.startsWith(`/ec2/describe-launch-templates`)||n.startsWith(`/ec2/create-launch-template`),e[23]=n,e[24]=S);let C,w;e[25]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,B.jsx)(Es,{className:`size-4`}),w=(0,B.jsx)(`span`,{children:`Launch Templates`}),e[25]=C,e[26]=w):(C=e[25],w=e[26]);let T;e[27]===S?T=e[28]:(T=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-launch-templates`,children:(0,B.jsxs)(W,{isActive:S,tooltip:`Launch Templates`,children:[C,w]})})}),e[27]=S,e[28]=T);let E;e[29]===n?E=e[30]:(E=n.startsWith(`/ec2/describe-images`),e[29]=n,e[30]=E);let D,O;e[31]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,B.jsx)(ot,{className:`size-4`}),O=(0,B.jsx)(`span`,{children:`Images`}),e[31]=D,e[32]=O):(D=e[31],O=e[32]);let k;e[33]===E?k=e[34]:(k=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-images`,children:(0,B.jsxs)(W,{isActive:E,tooltip:`Images`,children:[D,O]})})}),e[33]=E,e[34]=k);let ee;e[35]===n?ee=e[36]:(ee=n.startsWith(`/ec2/describe-key-pairs`),e[35]=n,e[36]=ee);let te,ne;e[37]===Symbol.for(`react.memo_cache_sentinel`)?(te=(0,B.jsx)(nt,{className:`size-4`}),ne=(0,B.jsx)(`span`,{children:`Key Pairs`}),e[37]=te,e[38]=ne):(te=e[37],ne=e[38]);let re;e[39]===ee?re=e[40]:(re=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-key-pairs`,children:(0,B.jsxs)(W,{isActive:ee,tooltip:`Key Pairs`,children:[te,ne]})})}),e[39]=ee,e[40]=re);let ie;e[41]===n?ie=e[42]:(ie=n.startsWith(`/ec2/describe-addresses`)||n.startsWith(`/ec2/allocate-address`),e[41]=n,e[42]=ie);let ae,oe;e[43]===Symbol.for(`react.memo_cache_sentinel`)?(ae=(0,B.jsx)(Hs,{className:`size-4`}),oe=(0,B.jsx)(`span`,{children:`Elastic IPs`}),e[43]=ae,e[44]=oe):(ae=e[43],oe=e[44]);let se;e[45]===ie?se=e[46]:(se=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-addresses`,children:(0,B.jsxs)(W,{isActive:ie,tooltip:`Elastic IPs`,children:[ae,oe]})})}),e[45]=ie,e[46]=se);let ce;e[47]===n?ce=e[48]:(ce=n.startsWith(`/ec2/describe-volumes`)||n.startsWith(`/ec2/create-volume`)||n.startsWith(`/ec2/modify-volume`),e[47]=n,e[48]=ce);let le,ue;e[49]===Symbol.for(`react.memo_cache_sentinel`)?(le=(0,B.jsx)(it,{className:`size-4`}),ue=(0,B.jsx)(`span`,{children:`Volumes`}),e[49]=le,e[50]=ue):(le=e[49],ue=e[50]);let de;e[51]===ce?de=e[52]:(de=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-volumes`,children:(0,B.jsxs)(W,{isActive:ce,tooltip:`Volumes`,children:[le,ue]})})}),e[51]=ce,e[52]=de);let fe;e[53]===n?fe=e[54]:(fe=n.startsWith(`/ec2/describe-snapshots`)||n.startsWith(`/ec2/create-snapshot`),e[53]=n,e[54]=fe);let pe,me;e[55]===Symbol.for(`react.memo_cache_sentinel`)?(pe=(0,B.jsx)(Ze,{className:`size-4`}),me=(0,B.jsx)(`span`,{children:`Snapshots`}),e[55]=pe,e[56]=me):(pe=e[55],me=e[56]);let he;e[57]===fe?he=e[58]:(he=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-snapshots`,children:(0,B.jsxs)(W,{isActive:fe,tooltip:`Snapshots`,children:[pe,me]})})}),e[57]=fe,e[58]=he);let ge;e[59]===n?ge=e[60]:(ge=n.startsWith(`/ec2/describe-placement-groups`)||n.startsWith(`/ec2/create-placement-group`),e[59]=n,e[60]=ge);let _e,j;e[61]===Symbol.for(`react.memo_cache_sentinel`)?(_e=(0,B.jsx)(Ps,{className:`size-4`}),j=(0,B.jsx)(`span`,{children:`Placement Groups`}),e[61]=_e,e[62]=j):(_e=e[61],j=e[62]);let M;e[63]===ge?M=e[64]:(M=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-placement-groups`,children:(0,B.jsxs)(W,{isActive:ge,tooltip:`Placement Groups`,children:[_e,j]})})}),e[63]=ge,e[64]=M);let ve;e[65]===n?ve=e[66]:(ve=n.startsWith(`/ec2/describe-load-balancers`)||n.startsWith(`/ec2/create-load-balancer`),e[65]=n,e[66]=ve);let ye,be;e[67]===Symbol.for(`react.memo_cache_sentinel`)?(ye=(0,B.jsx)(at,{className:`size-4`}),be=(0,B.jsx)(`span`,{children:`Load Balancers`}),e[67]=ye,e[68]=be):(ye=e[67],be=e[68]);let xe;e[69]===ve?xe=e[70]:(xe=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-load-balancers`,children:(0,B.jsxs)(W,{isActive:ve,tooltip:`Load Balancers`,children:[ye,be]})})}),e[69]=ve,e[70]=xe);let Se;e[71]===n?Se=e[72]:(Se=n.startsWith(`/ec2/describe-target-groups`)||n.startsWith(`/ec2/create-target-group`),e[71]=n,e[72]=Se);let N,Ce;e[73]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,B.jsx)(Ss,{className:`size-4`}),Ce=(0,B.jsx)(`span`,{children:`Target Groups`}),e[73]=N,e[74]=Ce):(N=e[73],Ce=e[74]);let we;e[75]===Se?we=e[76]:(we=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-target-groups`,children:(0,B.jsxs)(W,{isActive:Se,tooltip:`Target Groups`,children:[N,Ce]})})}),e[75]=Se,e[76]=we);let Te;e[77]!==x||e[78]!==T||e[79]!==k||e[80]!==re||e[81]!==se||e[82]!==de||e[83]!==he||e[84]!==M||e[85]!==xe||e[86]!==we?(Te=(0,B.jsxs)(Kl,{children:[_,(0,B.jsxs)(Jl,{children:[x,T,k,re,se,de,he,M,xe,we]})]}),e[77]=x,e[78]=T,e[79]=k,e[80]=re,e[81]=se,e[82]=de,e[83]=he,e[84]=M,e[85]=xe,e[86]=we,e[87]=Te):Te=e[87];let Ee;e[88]===Symbol.for(`react.memo_cache_sentinel`)?(Ee=(0,B.jsx)(ql,{children:`Networking`}),e[88]=Ee):Ee=e[88];let De;e[89]===n?De=e[90]:(De=n.startsWith(`/ec2/describe-vpcs`)||n.startsWith(`/ec2/create-vpc`),e[89]=n,e[90]=De);let Oe,ke;e[91]===Symbol.for(`react.memo_cache_sentinel`)?(Oe=(0,B.jsx)(Qe,{className:`size-4`}),ke=(0,B.jsx)(`span`,{children:`VPCs`}),e[91]=Oe,e[92]=ke):(Oe=e[91],ke=e[92]);let Ae;e[93]===De?Ae=e[94]:(Ae=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-vpcs`,children:(0,B.jsxs)(W,{isActive:De,tooltip:`VPCs`,children:[Oe,ke]})})}),e[93]=De,e[94]=Ae);let je;e[95]===n?je=e[96]:(je=n.startsWith(`/ec2/describe-subnets`)||n.startsWith(`/ec2/create-subnet`),e[95]=n,e[96]=je);let Me,Pe;e[97]===Symbol.for(`react.memo_cache_sentinel`)?(Me=(0,B.jsx)(Is,{className:`size-4`}),Pe=(0,B.jsx)(`span`,{children:`Subnets`}),e[97]=Me,e[98]=Pe):(Me=e[97],Pe=e[98]);let Fe;e[99]===je?Fe=e[100]:(Fe=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-subnets`,children:(0,B.jsxs)(W,{isActive:je,tooltip:`Subnets`,children:[Me,Pe]})})}),e[99]=je,e[100]=Fe);let Ie;e[101]===n?Ie=e[102]:(Ie=n.startsWith(`/ec2/describe-security-groups`)||n.startsWith(`/ec2/create-security-group`),e[101]=n,e[102]=Ie);let Le,Re;e[103]===Symbol.for(`react.memo_cache_sentinel`)?(Le=(0,B.jsx)(ec,{className:`size-4`}),Re=(0,B.jsx)(`span`,{children:`Security Groups`}),e[103]=Le,e[104]=Re):(Le=e[103],Re=e[104]);let ze;e[105]===Ie?ze=e[106]:(ze=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-security-groups`,children:(0,B.jsxs)(W,{isActive:Ie,tooltip:`Security Groups`,children:[Le,Re]})})}),e[105]=Ie,e[106]=ze);let Be;e[107]===n?Be=e[108]:(Be=n.startsWith(`/ec2/describe-route-tables`)||n.startsWith(`/ec2/create-route-table`),e[107]=n,e[108]=Be);let Ve,He;e[109]===Symbol.for(`react.memo_cache_sentinel`)?(Ve=(0,B.jsx)(Qs,{className:`size-4`}),He=(0,B.jsx)(`span`,{children:`Route Tables`}),e[109]=Ve,e[110]=He):(Ve=e[109],He=e[110]);let Ue;e[111]===Be?Ue=e[112]:(Ue=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-route-tables`,children:(0,B.jsxs)(W,{isActive:Be,tooltip:`Route Tables`,children:[Ve,He]})})}),e[111]=Be,e[112]=Ue);let We;e[113]===n?We=e[114]:(We=n.startsWith(`/ec2/describe-internet-gateways`),e[113]=n,e[114]=We);let Ge,P;e[115]===Symbol.for(`react.memo_cache_sentinel`)?(Ge=(0,B.jsx)($e,{className:`size-4`}),P=(0,B.jsx)(`span`,{children:`Internet Gateways`}),e[115]=Ge,e[116]=P):(Ge=e[115],P=e[116]);let Je;e[117]===We?Je=e[118]:(Je=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-internet-gateways`,children:(0,B.jsxs)(W,{isActive:We,tooltip:`Internet Gateways`,children:[Ge,P]})})}),e[117]=We,e[118]=Je);let Ye;e[119]===n?Ye=e[120]:(Ye=n.startsWith(`/ec2/describe-nat-gateways`)||n.startsWith(`/ec2/create-nat-gateway`),e[119]=n,e[120]=Ye);let Xe,F;e[121]===Symbol.for(`react.memo_cache_sentinel`)?(Xe=(0,B.jsx)(et,{className:`size-4`}),F=(0,B.jsx)(`span`,{children:`NAT Gateways`}),e[121]=Xe,e[122]=F):(Xe=e[121],F=e[122]);let st;e[123]===Ye?st=e[124]:(st=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-nat-gateways`,children:(0,B.jsxs)(W,{isActive:Ye,tooltip:`NAT Gateways`,children:[Xe,F]})})}),e[123]=Ye,e[124]=st);let lt;e[125]!==Ae||e[126]!==Fe||e[127]!==ze||e[128]!==Ue||e[129]!==Je||e[130]!==st?(lt=(0,B.jsxs)(Kl,{children:[Ee,(0,B.jsxs)(Jl,{children:[Ae,Fe,ze,Ue,Je,st]})]}),e[125]=Ae,e[126]=Fe,e[127]=ze,e[128]=Ue,e[129]=Je,e[130]=st,e[131]=lt):lt=e[131];let ut;e[132]===Symbol.for(`react.memo_cache_sentinel`)?(ut=(0,B.jsx)(ql,{children:`Containers`}),e[132]=ut):ut=e[132];let dt;e[133]===n?dt=e[134]:(dt=n.startsWith(`/eks/list-clusters`)||n.startsWith(`/eks/create-cluster`),e[133]=n,e[134]=dt);let ft,pt;e[135]===Symbol.for(`react.memo_cache_sentinel`)?(ft=(0,B.jsx)(ms,{className:`size-4`}),pt=(0,B.jsx)(`span`,{children:`EKS`}),e[135]=ft,e[136]=pt):(ft=e[135],pt=e[136]);let mt;e[137]===dt?mt=e[138]:(mt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/eks/list-clusters`,children:(0,B.jsxs)(W,{isActive:dt,tooltip:`EKS clusters`,children:[ft,pt]})})}),e[137]=dt,e[138]=mt);let ht;e[139]===n?ht=e[140]:(ht=n.startsWith(`/ecr/list-repositories`),e[139]=n,e[140]=ht);let gt,_t;e[141]===Symbol.for(`react.memo_cache_sentinel`)?(gt=(0,B.jsx)(Js,{className:`size-4`}),_t=(0,B.jsx)(`span`,{children:`ECR`}),e[141]=gt,e[142]=_t):(gt=e[141],_t=e[142]);let vt;e[143]===ht?vt=e[144]:(vt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ecr/list-repositories`,children:(0,B.jsxs)(W,{isActive:ht,tooltip:`ECR repositories`,children:[gt,_t]})})}),e[143]=ht,e[144]=vt);let yt;e[145]===n?yt=e[146]:(yt=n.startsWith(`/ecs/`),e[145]=n,e[146]=yt);let bt,xt;e[147]===Symbol.for(`react.memo_cache_sentinel`)?(bt=(0,B.jsx)(bs,{className:`size-4`}),xt=(0,B.jsx)(`span`,{children:`ECS`}),e[147]=bt,e[148]=xt):(bt=e[147],xt=e[148]);let St;e[149]===yt?St=e[150]:(St=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ecs/list-clusters`,children:(0,B.jsxs)(W,{isActive:yt,tooltip:`ECS clusters`,children:[bt,xt]})})}),e[149]=yt,e[150]=St);let Ct;e[151]!==mt||e[152]!==vt||e[153]!==St?(Ct=(0,B.jsxs)(Kl,{children:[ut,(0,B.jsxs)(Jl,{children:[mt,vt,St]})]}),e[151]=mt,e[152]=vt,e[153]=St,e[154]=Ct):Ct=e[154];let wt;e[155]===Symbol.for(`react.memo_cache_sentinel`)?(wt=(0,B.jsx)(ql,{children:`RDS`}),e[155]=wt):wt=e[155];let I;e[156]===n?I=e[157]:(I=n.startsWith(`/rds/describe-db-instances`)||n.startsWith(`/rds/create-db-instance`),e[156]=n,e[157]=I);let Tt,Et;e[158]===Symbol.for(`react.memo_cache_sentinel`)?(Tt=(0,B.jsx)(ws,{className:`size-4`}),Et=(0,B.jsx)(`span`,{children:`Databases`}),e[158]=Tt,e[159]=Et):(Tt=e[158],Et=e[159]);let Dt;e[160]===I?Dt=e[161]:(Dt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-instances`,children:(0,B.jsxs)(W,{isActive:I,tooltip:`Databases`,children:[Tt,Et]})})}),e[160]=I,e[161]=Dt);let Ot;e[162]===n?Ot=e[163]:(Ot=n.startsWith(`/rds/describe-db-snapshots`)||n.startsWith(`/rds/restore-db-instance-from-db-snapshot`),e[162]=n,e[163]=Ot);let kt,At;e[164]===Symbol.for(`react.memo_cache_sentinel`)?(kt=(0,B.jsx)(Ze,{className:`size-4`}),At=(0,B.jsx)(`span`,{children:`Snapshots`}),e[164]=kt,e[165]=At):(kt=e[164],At=e[165]);let jt;e[166]===Ot?jt=e[167]:(jt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-snapshots`,children:(0,B.jsxs)(W,{isActive:Ot,tooltip:`DB Snapshots`,children:[kt,At]})})}),e[166]=Ot,e[167]=jt);let Mt;e[168]===n?Mt=e[169]:(Mt=n.startsWith(`/rds/describe-db-subnet-groups`)||n.startsWith(`/rds/create-db-subnet-group`),e[168]=n,e[169]=Mt);let Nt,Pt;e[170]===Symbol.for(`react.memo_cache_sentinel`)?(Nt=(0,B.jsx)(Is,{className:`size-4`}),Pt=(0,B.jsx)(`span`,{children:`Subnet Groups`}),e[170]=Nt,e[171]=Pt):(Nt=e[170],Pt=e[171]);let Ft;e[172]===Mt?Ft=e[173]:(Ft=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-subnet-groups`,children:(0,B.jsxs)(W,{isActive:Mt,tooltip:`DB Subnet Groups`,children:[Nt,Pt]})})}),e[172]=Mt,e[173]=Ft);let It;e[174]===n?It=e[175]:(It=n.startsWith(`/rds/describe-db-parameter-groups`)||n.startsWith(`/rds/create-db-parameter-group`),e[174]=n,e[175]=It);let Lt,Rt;e[176]===Symbol.for(`react.memo_cache_sentinel`)?(Lt=(0,B.jsx)(ct,{className:`size-4`}),Rt=(0,B.jsx)(`span`,{children:`Parameter Groups`}),e[176]=Lt,e[177]=Rt):(Lt=e[176],Rt=e[177]);let zt;e[178]===It?zt=e[179]:(zt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-parameter-groups`,children:(0,B.jsxs)(W,{isActive:It,tooltip:`DB Parameter Groups`,children:[Lt,Rt]})})}),e[178]=It,e[179]=zt);let Bt;e[180]!==jt||e[181]!==Ft||e[182]!==zt||e[183]!==Dt?(Bt=(0,B.jsxs)(Kl,{children:[wt,(0,B.jsxs)(Jl,{children:[Dt,jt,Ft,zt]})]}),e[180]=jt,e[181]=Ft,e[182]=zt,e[183]=Dt,e[184]=Bt):Bt=e[184];let Vt;e[185]===Symbol.for(`react.memo_cache_sentinel`)?(Vt=(0,B.jsx)(ql,{children:`IAM`}),e[185]=Vt):Vt=e[185];let Ht;e[186]===n?Ht=e[187]:(Ht=n.startsWith(`/iam/list-users`)||n.startsWith(`/iam/create-user`),e[186]=n,e[187]=Ht);let Ut,Wt;e[188]===Symbol.for(`react.memo_cache_sentinel`)?(Ut=(0,B.jsx)(cc,{className:`size-4`}),Wt=(0,B.jsx)(`span`,{children:`Users`}),e[188]=Ut,e[189]=Wt):(Ut=e[188],Wt=e[189]);let Gt;e[190]===Ht?Gt=e[191]:(Gt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-users`,children:(0,B.jsxs)(W,{isActive:Ht,tooltip:`Users`,children:[Ut,Wt]})})}),e[190]=Ht,e[191]=Gt);let Kt;e[192]===n?Kt=e[193]:(Kt=n.startsWith(`/iam/list-groups`)||n.startsWith(`/iam/create-group`),e[192]=n,e[193]=Kt);let L,qt;e[194]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,B.jsx)(oc,{className:`size-4`}),qt=(0,B.jsx)(`span`,{children:`Groups`}),e[194]=L,e[195]=qt):(L=e[194],qt=e[195]);let Jt;e[196]===Kt?Jt=e[197]:(Jt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-groups`,children:(0,B.jsxs)(W,{isActive:Kt,tooltip:`Groups`,children:[L,qt]})})}),e[196]=Kt,e[197]=Jt);let Yt;e[198]===n?Yt=e[199]:(Yt=n.startsWith(`/iam/list-policies`)||n.startsWith(`/iam/create-policy`),e[198]=n,e[199]=Yt);let Xt,Zt;e[200]===Symbol.for(`react.memo_cache_sentinel`)?(Xt=(0,B.jsx)(tt,{className:`size-4`}),Zt=(0,B.jsx)(`span`,{children:`Policies`}),e[200]=Xt,e[201]=Zt):(Xt=e[200],Zt=e[201]);let Qt;e[202]===Yt?Qt=e[203]:(Qt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-policies`,children:(0,B.jsxs)(W,{isActive:Yt,tooltip:`Policies`,children:[Xt,Zt]})})}),e[202]=Yt,e[203]=Qt);let $t;e[204]===n?$t=e[205]:($t=n.startsWith(`/iam/list-roles`)||n.startsWith(`/iam/create-role`),e[204]=n,e[205]=$t);let en,tn;e[206]===Symbol.for(`react.memo_cache_sentinel`)?(en=(0,B.jsx)(ic,{className:`size-4`}),tn=(0,B.jsx)(`span`,{children:`Roles`}),e[206]=en,e[207]=tn):(en=e[206],tn=e[207]);let nn;e[208]===$t?nn=e[209]:(nn=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-roles`,children:(0,B.jsxs)(W,{isActive:$t,tooltip:`Roles`,children:[en,tn]})})}),e[208]=$t,e[209]=nn);let rn;e[210]===n?rn=e[211]:(rn=n.startsWith(`/iam/list-instance-profiles`)||n.startsWith(`/iam/create-instance-profile`),e[210]=n,e[211]=rn);let an,on;e[212]===Symbol.for(`react.memo_cache_sentinel`)?(an=(0,B.jsx)(As,{className:`size-4`}),on=(0,B.jsx)(`span`,{children:`Instance Profiles`}),e[212]=an,e[213]=on):(an=e[212],on=e[213]);let sn;e[214]===rn?sn=e[215]:(sn=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-instance-profiles`,children:(0,B.jsxs)(W,{isActive:rn,tooltip:`Instance Profiles`,children:[an,on]})})}),e[214]=rn,e[215]=sn);let cn;e[216]!==Gt||e[217]!==Jt||e[218]!==Qt||e[219]!==nn||e[220]!==sn?(cn=(0,B.jsxs)(Kl,{children:[Vt,(0,B.jsxs)(Jl,{children:[Gt,Jt,Qt,nn,sn]})]}),e[216]=Gt,e[217]=Jt,e[218]=Qt,e[219]=nn,e[220]=sn,e[221]=cn):cn=e[221];let ln;e[222]===Symbol.for(`react.memo_cache_sentinel`)?(ln=(0,B.jsx)(ql,{children:`S3`}),e[222]=ln):ln=e[222];let un;e[223]===n?un=e[224]:(un=n.startsWith(`/s3/ls`),e[223]=n,e[224]=un);let dn,fn;e[225]===Symbol.for(`react.memo_cache_sentinel`)?(dn=(0,B.jsx)(rt,{className:`size-4`}),fn=(0,B.jsx)(`span`,{children:`Buckets`}),e[225]=dn,e[226]=fn):(dn=e[225],fn=e[226]);let pn;e[227]===un?pn=e[228]:(pn=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/s3/ls`,children:(0,B.jsxs)(W,{isActive:un,tooltip:`Buckets`,children:[dn,fn]})})}),e[227]=un,e[228]=pn);let mn;e[229]!==o||e[230]!==n?(mn=o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/s3/service-metrics`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/s3/service-metrics`),tooltip:`Service Metrics`,children:[(0,B.jsx)(us,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Service Metrics`})]})})}),e[229]=o,e[230]=n,e[231]=mn):mn=e[231];let hn;e[232]!==pn||e[233]!==mn?(hn=(0,B.jsxs)(Kl,{children:[ln,(0,B.jsxs)(Jl,{children:[pn,mn]})]}),e[232]=pn,e[233]=mn,e[234]=hn):hn=e[234];let gn;e[235]!==o||e[236]!==n?(gn=qe()&&(0,B.jsxs)(Kl,{children:[(0,B.jsx)(ql,{children:`Ochre`}),(0,B.jsxs)(Jl,{children:[(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-knowledge-bases`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-knowledge-bases`),tooltip:`Knowledge Bases`,children:[(0,B.jsx)(gs,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Knowledge Bases`})]})})}),(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-guardrails`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-guardrails`),tooltip:`Guardrails`,children:[(0,B.jsx)(tt,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Guardrails`})]})})}),(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/playground`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/playground`),tooltip:`Playground`,children:[(0,B.jsx)(Ws,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Playground`})]})})}),o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-model-catalog`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-model-catalog`),tooltip:`Model Catalog`,children:[(0,B.jsx)(Rs,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Model Catalog`})]})})}),o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-model-access`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-model-access`),tooltip:`Model Access`,children:[(0,B.jsx)(Ms,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Model Access`})]})})})]})]}),e[235]=o,e[236]=n,e[237]=gn):gn=e[237];let _n;e[238]===o?_n=e[239]:(_n=o&&(0,B.jsxs)(Kl,{children:[(0,B.jsx)(ql,{children:`Documentation`}),(0,B.jsx)(Jl,{children:(0,B.jsx)(U,{children:(0,B.jsx)(`a`,{href:`https://docs.mulgadc.com`,target:`_blank`,rel:`noopener noreferrer`,children:(0,B.jsxs)(W,{tooltip:`Documentation`,children:[(0,B.jsx)(fs,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Docs`})]})})})})]}),e[238]=o,e[239]=_n);let vn;e[240]!==Bt||e[241]!==cn||e[242]!==hn||e[243]!==gn||e[244]!==_n||e[245]!==Te||e[246]!==lt||e[247]!==g||e[248]!==Ct?(vn=(0,B.jsxs)(Gl,{children:[g,Te,lt,Ct,Bt,cn,hn,gn,_n]}),e[240]=Bt,e[241]=cn,e[242]=hn,e[243]=gn,e[244]=_n,e[245]=Te,e[246]=lt,e[247]=g,e[248]=Ct,e[249]=vn):vn=e[249];let yn;e[250]===Symbol.for(`react.memo_cache_sentinel`)?(yn=(0,B.jsx)(Wl,{className:`mx-0 w-full`}),e[250]=yn):yn=e[250];let bn,xn;e[251]===Symbol.for(`react.memo_cache_sentinel`)?(bn=(0,B.jsx)(Bs,{className:`size-4`}),xn=(0,B.jsx)(`span`,{children:`Sign out`}),e[251]=bn,e[252]=xn):(bn=e[251],xn=e[252]);let Sn;e[253]===c?Sn=e[254]:(Sn=(0,B.jsxs)(Ul,{children:[yn,(0,B.jsx)(Jl,{children:(0,B.jsx)(U,{children:(0,B.jsxs)(W,{onClick:c,tooltip:`Sign out`,children:[bn,xn]})})})]}),e[253]=c,e[254]=Sn);let Tn;e[255]===Symbol.for(`react.memo_cache_sentinel`)?(Tn=(0,B.jsx)(Vl,{}),e[255]=Tn):Tn=e[255];let En;return e[256]!==vn||e[257]!==Sn?(En=(0,B.jsxs)(zl,{collapsible:`icon`,children:[l,vn,Sn,Tn]}),e[256]=vn,e[257]=Sn,e[258]=En):En=e[258],En}function au(e){return e.pathname}function ou(e){let t=(0,H.c)(18),{error:n,reset:r}=e,i=ke(),o=a(),s;t[0]!==o||t[1]!==r||t[2]!==i?(s=function(){o.invalidateQueries(),r(),i.invalidate()},t[0]=o,t[1]=r,t[2]=i,t[3]=s):s=t[3];let c=s,l;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsx)(G,{}),t[4]=l):l=t[4];let u;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,B.jsx)(ru,{}),t[5]=u):u=t[5];let d,f;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,B.jsx)(`div`,{className:`flex justify-center`,children:(0,B.jsx)(vs,{className:`size-12 text-destructive`})}),f=(0,B.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Something went wrong`}),t[6]=d,t[7]=f):(d=t[6],f=t[7]);let p=(n instanceof Error?n.message:String(n))||`An unexpected error occurred`,m;t[8]===p?m=t[9]:(m=(0,B.jsx)(`p`,{className:`text-muted-foreground`,children:p}),t[8]=p,t[9]=m);let h;t[10]===c?h=t[11]:(h=(0,B.jsx)(_t,{onClick:c,variant:`default`,children:`Try Again`}),t[10]=c,t[11]=h);let g;t[12]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,B.jsx)(A,{to:`/`,children:(0,B.jsx)(_t,{variant:`outline`,children:`Go Home`})}),t[12]=g):g=t[12];let _;t[13]===h?_=t[14]:(_=(0,B.jsxs)(`div`,{className:`flex justify-center gap-2`,children:[h,g]}),t[13]=h,t[14]=_);let v;return t[15]!==_||t[16]!==m?(v=(0,B.jsxs)(B.Fragment,{children:[l,(0,B.jsxs)(`main`,{className:`flex flex-1 flex-col`,children:[u,(0,B.jsx)(`div`,{className:`flex flex-1 items-center justify-center p-6 pt-0`,children:(0,B.jsxs)(`div`,{className:`max-w-md space-y-4 text-center`,children:[d,f,m,_]})})]})]}),t[15]=_,t[16]=m,t[17]=v):v=t[17],v}var su=E()({head:()=>({meta:[{title:`Mulga`}]}),component:()=>(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(es,{}),(0,B.jsx)(G,{}),(0,B.jsxs)(`main`,{className:`flex flex-1 flex-col`,children:[(0,B.jsx)(ru,{}),(0,B.jsx)(`div`,{className:`flex flex-1 flex-col p-8`,children:(0,B.jsx)(be,{})})]})]}),errorComponent:ou,notFoundComponent:iu}),cu=w(`/_auth`)({beforeLoad:()=>{if(!Je())throw Ee({to:`/login`})},component:D(()=>P(()=>import(`./route-DPFKZkGs.js`),__vite__mapDeps([0,1,2,3,4,5])),`component`)}),lu=w(`/_auth/`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...zn,staleTime:`static`}),e.queryClient.query({...An,staleTime:`static`}),e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Hn,staleTime:`static`}),e.queryClient.query({...lr,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`}),e.queryClient.query({...Fn,staleTime:`static`}),e.queryClient.query({...Bn,staleTime:`static`}),e.queryClient.query({...Ln,staleTime:`static`})])},head:()=>({meta:[{title:`Dashboard | Mulga`}]}),component:D(()=>P(()=>import(`./_auth-DsyoN1M9.js`),__vite__mapDeps([6,1,7,8,9,10,11,4,5,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38])),`component`)}),uu=w(`/_auth/bedrock`)({beforeLoad:()=>{qe()||N({throw:!0})},component:D(()=>P(()=>import(`./route-n5jLl8qw.js`),__vite__mapDeps([39,1,2,3,4,5])),`component`)}),du=w(`/_auth/nodes`)({head:()=>({meta:[{title:`Nodes | Mulga`}]}),component:D(()=>P(()=>import(`./nodes-DExo87Po.js`),__vite__mapDeps([40,1,7,8,9,10,11,4,5,41,12,13,15,14,16,18,19,20,26,27,28,29,30,31,22])),`component`)}),fu=w(`/_auth/s3/service-metrics`)({head:()=>({meta:[{title:`Service Metrics | S3 | Mulga`}]}),component:D(()=>P(()=>import(`./service-metrics-C6gBIZci.js`),__vite__mapDeps([42,1,7,8,9,10,41,18,12,13,19,20,26,27,28,29,30,31,22])),`component`)}),pu=w(`/_auth/ec2/(elastic-ips)/allocate-address`)({head:()=>({meta:[{title:`Allocate Elastic IP | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./allocate-address-CDvylWBR.js`),__vite__mapDeps([43,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,62])),`component`)}),mu=w(`/_auth/ec2/(internet-gateways)/create-internet-gateway`)({head:()=>({meta:[{title:`Create Internet Gateway | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-internet-gateway-CGStzV_5.js`),__vite__mapDeps([63,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,62])),`component`)}),hu=w(`/_auth/ec2/(key)/create-key-pair`)({head:()=>({meta:[{title:`Create Key Pair | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-key-pair-CKL_t5ui.js`),__vite__mapDeps([64,1,44,4,5,45,11,14,47,65,35,28,12,13,33,36,15,51,37,66,67,52,68,69,46,48,49,70,50,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,32,34,62])),`component`)}),gu=w(`/_auth/ec2/(key)/import-key-pair`)({head:()=>({meta:[{title:`Import Key Pair | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./import-key-pair-kETKjfrj.js`),__vite__mapDeps([71,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,72,62])),`component`)}),_u=w(`/_auth/ec2/(launch-templates)/create-launch-template`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Vn,staleTime:`static`}),e.queryClient.query({...In,staleTime:`static`}),e.queryClient.query({...Hn,staleTime:`static`}),e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Launch Template | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-launch-template-vwPKoZ8a.js`),__vite__mapDeps([73,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,62,74,75,8,10,65,35,37,66,67,68,72,76])),`component`)}),vu=w(`/_auth/ec2/(load-balancers)/create-load-balancer`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`}),e.queryClient.query({...Qn,staleTime:`static`}),e.queryClient.query({...En,staleTime:`static`}),e.queryClient.query({...Zn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Load Balancer | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-load-balancer-BBKiIxj3.js`),__vite__mapDeps([77,1,75,8,9,10,44,4,5,12,13,45,11,14,65,35,28,33,36,47,15,51,37,66,67,52,68,46,48,49,50,78,22,23,24,79,54,55,58,60,61,72,32,34,21,80,81,82,26,57,59,83,84,85,86,76,87])),`component`)}),yu=w(`/_auth/ec2/(nat-gateways)/create-nat-gateway`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Bn,staleTime:`static`})])},head:()=>({meta:[{title:`Create NAT Gateway | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-nat-gateway-C0zhuUio.js`),__vite__mapDeps([88,1,75,8,9,10,11,4,5,44,12,13,45,14,65,35,28,33,36,47,15,51,37,66,67,52,68,46,48,49,50,21,22,23,24,53,54,55,56,26,57,58,59,60,61,62])),`component`)}),bu=w(`/_auth/ec2/(placement-groups)/create-placement-group`)({head:()=>({meta:[{title:`Create Placement Group | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-placement-group-D_3YKZZQ.js`),__vite__mapDeps([89,1,44,4,5,45,11,14,65,35,28,12,13,33,36,47,15,51,37,66,67,52,68,46,48,49,50,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,62])),`component`)}),xu=w(`/_auth/ec2/(route-tables)/create-route-table`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`Create Route Table | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-route-table-q3_xIq0T.js`),__vite__mapDeps([90,1,75,8,9,10,44,4,5,12,13,45,11,14,65,35,28,33,36,47,15,51,37,66,67,52,68,50,21,22,23,24,53,54,55,56,26,57,58,59,60,61,62])),`component`)}),Su=w(`/_auth/ec2/(security-groups)/create-security-group`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`Create Security Group | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-security-group-BD7bwg9p.js`),__vite__mapDeps([91,1,75,8,9,10,44,4,5,12,13,45,11,14,65,35,28,33,36,47,15,51,37,66,67,52,68,46,48,49,50,21,22,23,24,53,54,55,56,26,57,58,59,60,61,62])),`component`)}),Cu=w(`/_auth/ec2/(subnet)/create-subnet`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Un,staleTime:`static`})])},head:()=>({meta:[{title:`Create Subnet | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-subnet-BXdYKpy-.js`),__vite__mapDeps([92,1,75,8,9,10,44,4,5,12,13,45,11,14,65,35,28,33,36,47,15,51,37,66,67,52,68,46,48,49,50,21,22,23,24,53,54,55,56,26,57,58,59,60,61,62])),`component`)}),wu=w(`/_auth/ec2/(target-groups)/create-target-group`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`Create Target Group | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-target-group-CSrYTK6r.js`),__vite__mapDeps([93,1,75,8,9,10,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,21,22,23,24,80,54,55,81,26,57,58,59,86,65,35,36,51,37,66,67,52,68,50,60,61,83,84,85,76,87])),`component`)}),Tu=w(`/_auth/ec2/(volumes)/create-volume`)({loader:async({context:e})=>{await e.queryClient.query({...Un,staleTime:`static`})},head:()=>({meta:[{title:`Create Volume | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-volume-BamGr8Qm.js`),__vite__mapDeps([94,1,75,8,9,10,44,4,5,45,11,14,65,35,28,12,13,33,36,47,15,51,37,66,67,52,68,46,48,49,50,21,22,23,24,53,54,55,56,26,57,58,59,60,61,62])),`component`)}),Eu=w(`/_auth/ec2/(vpc)/create-vpc`)({head:()=>({meta:[{title:`Create VPC | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-vpc-CyDH_vpB.js`),__vite__mapDeps([95,1,44,4,5,45,11,14,15,16,65,35,28,12,13,33,36,47,51,37,66,67,52,68,46,48,49,50,53,9,54,55,23,24,21,22,56,26,57,58,59,60,61,62])),`component`)}),Du=w(`/_auth/ecs/(clusters)/task-definitions`)({loader:async({context:e})=>{await e.queryClient.query({...Jn,staleTime:`static`})},head:()=>({meta:[{title:`Task Definitions | ECS | Mulga`}]}),component:D(()=>P(()=>import(`./task-definitions-qkfdMMhk.js`),__vite__mapDeps([96,1,75,8,9,10,11,4,5,33,28,12,13,97,22,23,24,98,54,55,20,26,58,99,32,34,35,36,37,100])),`component`)}),Ou=w(`/_auth/eks/(clusters)/create-cluster`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...rr,staleTime:`static`}),e.queryClient.query({...Vn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Cluster | EKS | Mulga`}]}),component:D(()=>P(()=>import(`./create-cluster-eQiUzhUk.js`),__vite__mapDeps([101,1,9,75,8,10,44,4,5,12,13,45,11,14,65,35,28,33,36,47,15,51,37,66,67,52,68,50,21,22,23,24,102,54,55,103,104,56,105,106,107,26,57,58,59,60,61,32,34,108,109,48,110,70,49,111])),`component`)}),ku=w(`/_auth/iam/(groups)/create-group`)({head:()=>({meta:[{title:`Create Group | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-group-6mVlm4h5.js`),__vite__mapDeps([112,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,105,9,54,55,23,24,106,26,57,58,59,60,61,113,104])),`component`)}),Au=w(`/_auth/iam/(instance-profiles)/create-instance-profile`)({head:()=>({meta:[{title:`Create Instance Profile | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-instance-profile-CgetPfkP.js`),__vite__mapDeps([114,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,105,9,54,55,23,24,106,26,57,58,59,60,61,113,104])),`component`)}),ju=w(`/_auth/iam/(policies)/create-policy`)({head:()=>({meta:[{title:`Create Policy | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-policy-BJFldcK3.js`),__vite__mapDeps([115,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,105,9,54,55,23,24,106,26,57,58,59,60,61,116,104,72,113])),`component`)}),Mu=w(`/_auth/iam/(roles)/create-role`)({head:()=>({meta:[{title:`Create Role | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-role-DvSx0q-L.js`),__vite__mapDeps([117,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,105,9,54,55,23,24,106,26,57,58,59,60,61,116,104,72,113])),`component`)}),Nu=w(`/_auth/iam/(users)/create-user`)({head:()=>({meta:[{title:`Create User | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-user-Bv8sj586.js`),__vite__mapDeps([118,1,44,4,5,45,11,14,46,12,13,47,15,48,33,28,49,50,36,51,52,105,9,54,55,23,24,106,26,57,58,59,60,61,113,104])),`component`)}),Pu=w(`/_auth/rds/(db-instances)/create-db-instance`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...sr,staleTime:`static`}),e.queryClient.query({...or,staleTime:`static`}),e.queryClient.query({...ar,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`}),e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Vn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Database | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./create-db-instance-CY_ivPtb.js`),__vite__mapDeps([119,1,9,7,8,10,75,44,4,5,45,11,14,65,35,28,12,13,33,36,47,15,51,37,66,67,52,68,46,48,49,50,21,22,23,24,120,121,54,55,122,26,57,58,59,60,61,83,84,85,108,109,110,70,123,124])),`component`)}),Fu=w(`/_auth/rds/(parameter-groups)/create-db-parameter-group`)({loader:async({context:e})=>{await e.queryClient.query({...sr,staleTime:`static`})},head:()=>({meta:[{title:`Create Parameter Group | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./create-db-parameter-group-CkK-hDRM.js`),__vite__mapDeps([125,1,75,8,9,10,44,4,5,45,11,14,65,35,28,12,13,33,36,47,15,51,37,66,67,52,68,46,48,49,50,120,23,24,121,54,55,122,22,26,57,58,59,60,61,83,84,85])),`component`)}),Iu=w(`/_auth/rds/(subnet-groups)/create-db-subnet-group`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Mn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Subnet Group | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./create-db-subnet-group-BV65rf5O.js`),__vite__mapDeps([126,1,75,8,9,10,44,4,5,12,13,45,11,14,46,47,15,48,33,28,49,50,36,51,52,21,22,23,24,120,121,54,55,26,57,58,59,60,61,83,84,85])),`component`)}),Lu=w(`/_auth/s3/(buckets)/create-bucket`)({head:()=>({meta:[{title:`Create Bucket | S3 | Mulga`}]}),component:D(()=>P(()=>import(`./create-bucket-B5kEkFpz.js`),__vite__mapDeps([127,1,44,4,5,12,13,45,11,14,46,47,15,48,33,28,49,50,36,51,52,128,9,54,55,23,24,26,57,58,59,60,61])),`component`)}),Ru=w(`/_auth/s3/ls/`)({loader:async({context:e})=>{await e.queryClient.query({...lr,staleTime:`static`})},head:()=>({meta:[{title:`Buckets | S3 | Mulga`}]}),component:D(()=>P(()=>import(`./ls-XgxEPnDq.js`),__vite__mapDeps([129,1,75,8,9,10,11,4,5,12,13,33,28,25,22,23,24,26,130])),`component`)}),zu=w(`/_auth/s3/ls/$bucket`)({component:D(()=>P(()=>import(`./route-DtXjPAiX.js`),__vite__mapDeps([131,1,2,3,4,5])),`component`)}),Bu=w(`/_auth/bedrock/(catalog)/list-model-catalog/`)({head:()=>({meta:[{title:`Model Catalog | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-model-catalog-CPZ8xUbd.js`),__vite__mapDeps([132,1,7,8,9,10,41,12,13,18,19,20,26,27,28,29,30,31,22])),`component`)}),Vu=w(`/_auth/bedrock/(guardrails)/list-guardrails/`)({loader:async({context:e})=>{await e.queryClient.query({...Dn,staleTime:`static`})},head:()=>({meta:[{title:`Guardrails | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-guardrails-DYYlNj8n.js`),__vite__mapDeps([133,1,75,8,9,10,11,4,5,33,28,12,13,134,22,23,24,135,26,38,130])),`component`)}),Hu=w(`/_auth/bedrock/(guardrails)/list-guardrails/create`)({head:()=>({meta:[{title:`Create Guardrail | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./create-CoZioQAG.js`),__vite__mapDeps([136,1,137,9,54,55,12,13,23,24,135,138,44,4,5,45,11,14,65,35,28,33,36,47,15,51,37,66,67,52,68,84,85,50,26,57,58,59,60,61,83,72])),`component`)}),Uu=w(`/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/`)({loader:async({context:e})=>{await e.queryClient.query({...kn,staleTime:`static`})},head:()=>({meta:[{title:`Knowledge Bases | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-knowledge-bases-CH_zlnnr.js`),__vite__mapDeps([139,1,75,8,9,10,11,4,5,33,28,12,13,140,22,23,24,141,26,38,130])),`component`)}),Wu=w(`/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/create`)({loader:async({context:e})=>{await e.queryClient.query({...On,staleTime:`static`})},head:()=>({meta:[{title:`Create Knowledge Base | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./create-uNWIyxX8.js`),__vite__mapDeps([142,1,75,8,9,10,44,4,5,45,11,14,65,35,28,12,13,33,36,47,15,51,37,66,67,52,68,50,134,22,23,24,135,143,54,55,141,26,57,58,59,60,61,72])),`component`)}),Gu=w(`/_auth/bedrock/(model-access)/list-model-access/`)({head:()=>({meta:[{title:`Model Access | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-model-access-Ds5MpzNH.js`),__vite__mapDeps([144,1,9,54,55,7,8,10,41,65,35,28,12,13,33,36,14,47,15,51,37,5,66,67,52,68,50,18,19,20,26,27,29,30,31,22,60,61])),`component`)}),Ku=w(`/_auth/bedrock/(playground)/playground/`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...On,staleTime:`static`}),e.queryClient.query({...Dn,staleTime:`static`})])},head:()=>({meta:[{title:`Playground | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./playground-zdI5o66q.js`),__vite__mapDeps([145,1,54,9,55,75,8,10,12,13,15,14,65,35,28,33,36,47,51,37,5,66,67,52,68,46,48,49,50,23,24,134,22,135,146,26,27,29,60,61,72])),`component`)}),qu=w(`/_auth/ec2/(elastic-ips)/describe-addresses/`)({loader:async({context:e})=>{await e.queryClient.query({...Bn,staleTime:`static`})},head:()=>({meta:[{title:`Elastic IPs | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-addresses-DKlK9Anw.js`),__vite__mapDeps([147,1,75,8,9,10,11,4,5,12,13,33,28,21,22,23,24,26,130])),`component`)}),Ju=w(`/_auth/ec2/(images)/describe-images/`)({loader:async({context:e})=>{await e.queryClient.query({...Vn,staleTime:`static`})},head:()=>({meta:[{title:`Images | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-images-Zp7giKqa.js`),__vite__mapDeps([148,1,75,8,9,10,21,22,12,13,23,24,26,38,130,11,4,5,108])),`component`)}),Yu=w(`/_auth/ec2/(instances)/describe-instances/`)({loader:async({context:e})=>{await e.queryClient.query({...zn,staleTime:`static`})},head:()=>({meta:[{title:`Instances | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-instances-BK0FW0LB.js`),__vite__mapDeps([149,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,38,130])),`component`)}),Xu=w(`/_auth/ec2/(internet-gateways)/describe-internet-gateways/`)({loader:async({context:e})=>{await e.queryClient.query({...Ln,staleTime:`static`})},head:()=>({meta:[{title:`Internet Gateways | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-internet-gateways-CiiBScpk.js`),__vite__mapDeps([150,1,75,8,9,10,11,4,5,12,13,33,28,21,22,23,24,26,130])),`component`)}),Zu=w(`/_auth/ec2/(key)/describe-key-pairs/`)({loader:async({context:e})=>{await e.queryClient.query({...Hn,staleTime:`static`})},head:()=>({meta:[{title:`Key Pairs | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-key-pairs-BRKxsJWn.js`),__vite__mapDeps([151,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,130])),`component`)}),Qu=w(`/_auth/ec2/(launch-templates)/describe-launch-templates/`)({loader:async({context:e})=>{await e.queryClient.query({...Rn,staleTime:`static`})},head:()=>({meta:[{title:`Launch Templates | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-launch-templates-BuaCkV_y.js`),__vite__mapDeps([152,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,27,29,130])),`component`)}),$u=w(`/_auth/ec2/(load-balancers)/describe-load-balancers/`)({loader:async({context:e})=>{await e.queryClient.query({...Xn,staleTime:`static`})},head:()=>({meta:[{title:`Load Balancers | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-load-balancers-ayJnDdyt.js`),__vite__mapDeps([153,1,75,8,9,10,11,4,5,44,12,13,33,28,82,22,23,24,81,26,38])),`component`)}),K=w(`/_auth/ec2/(nat-gateways)/describe-nat-gateways/`)({loader:async({context:e})=>{await e.queryClient.query({...Fn,staleTime:`static`})},head:()=>({meta:[{title:`NAT Gateways | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-nat-gateways-B8p3GKN1.js`),__vite__mapDeps([154,1,75,8,9,10,11,4,5,12,13,33,28,21,22,23,24,26,38,130])),`component`)}),ed=w(`/_auth/ec2/(placement-groups)/describe-placement-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...Gn,staleTime:`static`})},head:()=>({meta:[{title:`Placement Groups | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-placement-groups-XiQbPRZ5.js`),__vite__mapDeps([155,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,38,130])),`component`)}),q=w(`/_auth/ec2/(route-tables)/describe-route-tables/`)({loader:async({context:e})=>{await e.queryClient.query({...Pn,staleTime:`static`})},head:()=>({meta:[{title:`Route Tables | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-route-tables-Cz9uMOpx.js`),__vite__mapDeps([156,1,75,8,9,10,11,4,5,12,13,33,28,21,22,23,24,26,27,29,130])),`component`)}),J=w(`/_auth/ec2/(security-groups)/describe-security-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...Wn,staleTime:`static`})},head:()=>({meta:[{title:`Security Groups | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-security-groups-uryLUVIn.js`),__vite__mapDeps([157,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,130])),`component`)}),Y=w(`/_auth/ec2/(snapshots)/describe-snapshots/`)({loader:async({context:e})=>{await e.queryClient.query({...jn,staleTime:`static`})},head:()=>({meta:[{title:`Snapshots | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-snapshots-63g9NU9g.js`),__vite__mapDeps([158,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,38,130])),`component`)}),td=w(`/_auth/ec2/(subnet)/describe-subnets/`)({loader:async({context:e})=>{await e.queryClient.query({...Nn,staleTime:`static`})},head:()=>({meta:[{title:`Subnets | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-subnets-lWLraP1d.js`),__vite__mapDeps([159,1,75,8,9,10,11,4,5,12,13,33,28,21,22,23,24,26,38,130])),`component`)}),nd=w(`/_auth/ec2/(target-groups)/describe-target-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...Qn,staleTime:`static`})},head:()=>({meta:[{title:`Target Groups | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-target-groups-5RCbjKmg.js`),__vite__mapDeps([160,1,7,8,9,10,75,11,4,5,44,33,28,12,13,82,22,23,24,81,26])),`component`)}),rd=w(`/_auth/ec2/(volumes)/describe-volumes/`)({loader:async({context:e})=>{await e.queryClient.query({...An,staleTime:`static`})},head:()=>({meta:[{title:`Volumes | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-volumes-Dj4fTW0o.js`),__vite__mapDeps([161,1,75,8,9,10,11,4,5,33,28,12,13,21,22,23,24,26,38,130])),`component`)}),id=w(`/_auth/ec2/(vpc)/describe-vpcs/`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`VPCs | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-vpcs-cdkXn9V9.js`),__vite__mapDeps([162,1,75,8,9,10,11,4,5,12,13,33,28,21,22,23,24,26,38,130])),`component`)}),ad=w(`/_auth/ecr/(repositories)/list-repositories/`)({loader:async({context:e})=>{await e.queryClient.query({...Kn,staleTime:`static`})},head:()=>({meta:[{title:`Repositories | ECR | Mulga`}]}),component:D(()=>P(()=>import(`./list-repositories-B-9oI4MN.js`),__vite__mapDeps([163,1,75,8,9,10,11,4,5,44,12,13,33,28,50,36,51,52,164,22,23,24,165,54,55,26,60,61,32,34,35,37])),`component`)}),od=w(`/_auth/ecs/(clusters)/list-clusters/`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...qn,staleTime:`static`}),e.queryClient.query({...Vn,staleTime:`static`})])},head:()=>({meta:[{title:`Clusters | ECS | Mulga`}]}),component:D(()=>P(()=>import(`./list-clusters-UynY0efZ.js`),__vite__mapDeps([166,1,9,75,8,10,11,4,5,44,33,28,12,13,50,36,51,52,21,22,23,24,97,98,54,55,20,26,58,60,61,32,34,35,37,99,38,108,109,47,14,48,110,70,49,100])),`component`)}),sd=w(`/_auth/eks/(clusters)/list-clusters/`)({loader:async({context:e})=>{await e.queryClient.query({...Yn,staleTime:`static`})},head:()=>({meta:[{title:`Clusters | EKS | Mulga`}]}),component:D(()=>P(()=>import(`./list-clusters-CIHqWLhW.js`),__vite__mapDeps([167,1,75,8,9,10,11,4,5,33,28,12,13,168,22,23,24,103,26,130])),`component`)}),cd=w(`/_auth/iam/(groups)/list-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...tr,staleTime:`static`})},head:()=>({meta:[{title:`Groups | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-groups-B-aCbhJM.js`),__vite__mapDeps([169,1,75,8,9,10,11,4,5,33,28,12,13,107,22,23,24,106,104,26,130])),`component`)}),ld=w(`/_auth/iam/(instance-profiles)/list-instance-profiles/`)({loader:async({context:e})=>{await e.queryClient.query({...er,staleTime:`static`})},head:()=>({meta:[{title:`Instance Profiles | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-instance-profiles-BSt7PimM.js`),__vite__mapDeps([170,1,75,8,9,10,11,4,5,33,28,12,13,107,22,23,24,106,104,26,130])),`component`)}),ud=w(`/_auth/iam/(policies)/list-policies/`)({loader:async({context:e})=>{await e.queryClient.query({...nr,staleTime:`static`})},head:()=>({meta:[{title:`Policies | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-policies-DeMAXGlh.js`),__vite__mapDeps([171,1,75,8,9,10,11,4,5,33,28,12,13,107,22,23,24,106,104,26,130])),`component`)}),dd=w(`/_auth/iam/(roles)/list-roles/`)({loader:async({context:e})=>{await e.queryClient.query({...rr,staleTime:`static`})},head:()=>({meta:[{title:`Roles | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-roles-DKJKr-De.js`),__vite__mapDeps([172,1,75,8,9,10,11,4,5,33,28,12,13,107,22,23,24,106,104,26,130])),`component`)}),fd=w(`/_auth/iam/(users)/list-users/`)({loader:async({context:e})=>{await e.queryClient.query({...$n,staleTime:`static`})},head:()=>({meta:[{title:`Users | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-users-B87cjyLt.js`),__vite__mapDeps([173,1,75,8,9,10,11,4,5,33,28,12,13,107,22,23,24,106,104,26,130])),`component`)}),pd=w(`/_auth/rds/(db-instances)/describe-db-instances/`)({loader:async({context:e})=>{await e.queryClient.query({...cr,staleTime:`static`})},head:()=>({meta:[{title:`Databases | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-instances-ririu0gg.js`),__vite__mapDeps([174,1,75,8,9,10,11,4,5,44,33,28,12,13,120,23,24,121,54,55,122,22,26,58,38,175,50,36,51,52,60,61,32,34,35,37])),`component`)}),md=w(`/_auth/rds/(parameter-groups)/describe-db-parameter-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...ar,staleTime:`static`})},head:()=>({meta:[{title:`Parameter Groups | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-parameter-groups-Vj5U5CsP.js`),__vite__mapDeps([176,1,75,8,9,10,11,4,5,44,33,28,12,13,120,23,24,122,22,26,177,121,54,55,178,99,32,34,35,36,37])),`component`)}),hd=w(`/_auth/rds/(snapshots)/describe-db-snapshots/`)({loader:async({context:e})=>{await e.queryClient.query({...ir,staleTime:`static`})},head:()=>({meta:[{title:`Snapshots | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-snapshots-DXVrzU4j.js`),__vite__mapDeps([179,1,75,8,9,10,11,4,5,44,12,13,33,28,120,23,24,122,22,26,38,180,7,65,35,36,14,47,15,51,37,66,67,52,68,50,121,54,55,57,60,61,83,84,85,32,34,123,181,178,99])),`component`)}),gd=w(`/_auth/rds/(subnet-groups)/describe-db-subnet-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...or,staleTime:`static`})},head:()=>({meta:[{title:`Subnet Groups | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-subnet-groups-B9VO6RnW.js`),__vite__mapDeps([182,1,75,8,9,10,11,4,5,44,33,28,12,13,122,22,23,24,120,26,38,183,121,54,55,178,99,32,34,35,36,37])),`component`)}),X=cu.update({id:`/_auth`,getParentRoute:()=>su}),_d=ur.update({id:`/login`,path:`/login`,getParentRoute:()=>su}),vd=lu.update({id:`/`,path:`/`,getParentRoute:()=>X}),Z=uu.update({id:`/bedrock`,path:`/bedrock`,getParentRoute:()=>X}),yd=du.update({id:`/nodes`,path:`/nodes`,getParentRoute:()=>X}),bd=fu.update({id:`/s3/service-metrics`,path:`/s3/service-metrics`,getParentRoute:()=>X}),xd=pu.update({id:`/ec2/(elastic-ips)/allocate-address`,path:`/ec2/allocate-address`,getParentRoute:()=>X}),Sd=dr.update({id:`/ec2/(instances)/run-instances`,path:`/ec2/run-instances`,getParentRoute:()=>X}),Cd=mu.update({id:`/ec2/(internet-gateways)/create-internet-gateway`,path:`/ec2/create-internet-gateway`,getParentRoute:()=>X}),wd=hu.update({id:`/ec2/(key)/create-key-pair`,path:`/ec2/create-key-pair`,getParentRoute:()=>X}),Td=gu.update({id:`/ec2/(key)/import-key-pair`,path:`/ec2/import-key-pair`,getParentRoute:()=>X}),Ed=_u.update({id:`/ec2/(launch-templates)/create-launch-template`,path:`/ec2/create-launch-template`,getParentRoute:()=>X}),Dd=vu.update({id:`/ec2/(load-balancers)/create-load-balancer`,path:`/ec2/create-load-balancer`,getParentRoute:()=>X}),Od=yu.update({id:`/ec2/(nat-gateways)/create-nat-gateway`,path:`/ec2/create-nat-gateway`,getParentRoute:()=>X}),kd=bu.update({id:`/ec2/(placement-groups)/create-placement-group`,path:`/ec2/create-placement-group`,getParentRoute:()=>X}),Ad=xu.update({id:`/ec2/(route-tables)/create-route-table`,path:`/ec2/create-route-table`,getParentRoute:()=>X}),jd=Su.update({id:`/ec2/(security-groups)/create-security-group`,path:`/ec2/create-security-group`,getParentRoute:()=>X}),Md=fr.update({id:`/ec2/(snapshots)/create-snapshot`,path:`/ec2/create-snapshot`,getParentRoute:()=>X}),Nd=Cu.update({id:`/ec2/(subnet)/create-subnet`,path:`/ec2/create-subnet`,getParentRoute:()=>X}),Pd=wu.update({id:`/ec2/(target-groups)/create-target-group`,path:`/ec2/create-target-group`,getParentRoute:()=>X}),Fd=Tu.update({id:`/ec2/(volumes)/create-volume`,path:`/ec2/create-volume`,getParentRoute:()=>X}),Id=Eu.update({id:`/ec2/(vpc)/create-vpc`,path:`/ec2/create-vpc`,getParentRoute:()=>X}),Ld=pr.update({id:`/ecs/(clusters)/create-service`,path:`/ecs/create-service`,getParentRoute:()=>X}),Rd=mr.update({id:`/ecs/(clusters)/register-task-definition`,path:`/ecs/register-task-definition`,getParentRoute:()=>X}),zd=hr.update({id:`/ecs/(clusters)/run-task`,path:`/ecs/run-task`,getParentRoute:()=>X}),Bd=Du.update({id:`/ecs/(clusters)/task-definitions`,path:`/ecs/task-definitions`,getParentRoute:()=>X}),Vd=Ou.update({id:`/eks/(clusters)/create-cluster`,path:`/eks/create-cluster`,getParentRoute:()=>X}),Hd=ku.update({id:`/iam/(groups)/create-group`,path:`/iam/create-group`,getParentRoute:()=>X}),Ud=Au.update({id:`/iam/(instance-profiles)/create-instance-profile`,path:`/iam/create-instance-profile`,getParentRoute:()=>X}),Wd=ju.update({id:`/iam/(policies)/create-policy`,path:`/iam/create-policy`,getParentRoute:()=>X}),Gd=Mu.update({id:`/iam/(roles)/create-role`,path:`/iam/create-role`,getParentRoute:()=>X}),Kd=Nu.update({id:`/iam/(users)/create-user`,path:`/iam/create-user`,getParentRoute:()=>X}),qd=Pu.update({id:`/rds/(db-instances)/create-db-instance`,path:`/rds/create-db-instance`,getParentRoute:()=>X}),Jd=Fu.update({id:`/rds/(parameter-groups)/create-db-parameter-group`,path:`/rds/create-db-parameter-group`,getParentRoute:()=>X}),Yd=Iu.update({id:`/rds/(subnet-groups)/create-db-subnet-group`,path:`/rds/create-db-subnet-group`,getParentRoute:()=>X}),Xd=Lu.update({id:`/s3/(buckets)/create-bucket`,path:`/s3/create-bucket`,getParentRoute:()=>X}),Zd=Ru.update({id:`/s3/ls/`,path:`/s3/ls/`,getParentRoute:()=>X}),Qd=zu.update({id:`/s3/ls/$bucket`,path:`/s3/ls/$bucket`,getParentRoute:()=>X}),$d=Bu.update({id:`/(catalog)/list-model-catalog/`,path:`/list-model-catalog/`,getParentRoute:()=>Z}),ef=Vu.update({id:`/(guardrails)/list-guardrails/`,path:`/list-guardrails/`,getParentRoute:()=>Z}),tf=Hu.update({id:`/(guardrails)/list-guardrails/create`,path:`/list-guardrails/create`,getParentRoute:()=>Z}),nf=Uu.update({id:`/(knowledge-bases)/list-knowledge-bases/`,path:`/list-knowledge-bases/`,getParentRoute:()=>Z}),rf=gr.update({id:`/(knowledge-bases)/list-knowledge-bases/$knowledgeBaseId`,path:`/list-knowledge-bases/$knowledgeBaseId`,getParentRoute:()=>Z}),af=Wu.update({id:`/(knowledge-bases)/list-knowledge-bases/create`,path:`/list-knowledge-bases/create`,getParentRoute:()=>Z}),of=Gu.update({id:`/(model-access)/list-model-access/`,path:`/list-model-access/`,getParentRoute:()=>Z}),sf=Ku.update({id:`/(playground)/playground/`,path:`/playground/`,getParentRoute:()=>Z}),cf=qu.update({id:`/ec2/(elastic-ips)/describe-addresses/`,path:`/ec2/describe-addresses/`,getParentRoute:()=>X}),lf=_r.update({id:`/ec2/(elastic-ips)/describe-addresses/$id`,path:`/ec2/describe-addresses/$id`,getParentRoute:()=>X}),uf=Ju.update({id:`/ec2/(images)/describe-images/`,path:`/ec2/describe-images/`,getParentRoute:()=>X}),df=vr.update({id:`/ec2/(images)/describe-images/$id`,path:`/ec2/describe-images/$id`,getParentRoute:()=>X}),ff=Yu.update({id:`/ec2/(instances)/describe-instances/`,path:`/ec2/describe-instances/`,getParentRoute:()=>X}),pf=yr.update({id:`/ec2/(instances)/describe-instances/$id`,path:`/ec2/describe-instances/$id`,getParentRoute:()=>X}),mf=Xu.update({id:`/ec2/(internet-gateways)/describe-internet-gateways/`,path:`/ec2/describe-internet-gateways/`,getParentRoute:()=>X}),hf=br.update({id:`/ec2/(internet-gateways)/describe-internet-gateways/$id`,path:`/ec2/describe-internet-gateways/$id`,getParentRoute:()=>X}),gf=Zu.update({id:`/ec2/(key)/describe-key-pairs/`,path:`/ec2/describe-key-pairs/`,getParentRoute:()=>X}),_f=xr.update({id:`/ec2/(key)/describe-key-pairs/$id`,path:`/ec2/describe-key-pairs/$id`,getParentRoute:()=>X}),vf=Qu.update({id:`/ec2/(launch-templates)/describe-launch-templates/`,path:`/ec2/describe-launch-templates/`,getParentRoute:()=>X}),yf=Sr.update({id:`/ec2/(launch-templates)/describe-launch-templates/$id`,path:`/ec2/describe-launch-templates/$id`,getParentRoute:()=>X}),bf=$u.update({id:`/ec2/(load-balancers)/describe-load-balancers/`,path:`/ec2/describe-load-balancers/`,getParentRoute:()=>X}),xf=Cr.update({id:`/ec2/(load-balancers)/describe-load-balancers/$id`,path:`/ec2/describe-load-balancers/$id`,getParentRoute:()=>X}),Sf=K.update({id:`/ec2/(nat-gateways)/describe-nat-gateways/`,path:`/ec2/describe-nat-gateways/`,getParentRoute:()=>X}),Cf=wr.update({id:`/ec2/(nat-gateways)/describe-nat-gateways/$id`,path:`/ec2/describe-nat-gateways/$id`,getParentRoute:()=>X}),wf=ed.update({id:`/ec2/(placement-groups)/describe-placement-groups/`,path:`/ec2/describe-placement-groups/`,getParentRoute:()=>X}),Tf=Tr.update({id:`/ec2/(placement-groups)/describe-placement-groups/$id`,path:`/ec2/describe-placement-groups/$id`,getParentRoute:()=>X}),Ef=q.update({id:`/ec2/(route-tables)/describe-route-tables/`,path:`/ec2/describe-route-tables/`,getParentRoute:()=>X}),Df=Er.update({id:`/ec2/(route-tables)/describe-route-tables/$id`,path:`/ec2/describe-route-tables/$id`,getParentRoute:()=>X}),Of=J.update({id:`/ec2/(security-groups)/describe-security-groups/`,path:`/ec2/describe-security-groups/`,getParentRoute:()=>X}),kf=dt.update({id:`/ec2/(security-groups)/describe-security-groups/$id`,path:`/ec2/describe-security-groups/$id`,getParentRoute:()=>X}),Af=Y.update({id:`/ec2/(snapshots)/describe-snapshots/`,path:`/ec2/describe-snapshots/`,getParentRoute:()=>X}),jf=st.update({id:`/ec2/(snapshots)/describe-snapshots/$id`,path:`/ec2/describe-snapshots/$id`,getParentRoute:()=>X}),Mf=td.update({id:`/ec2/(subnet)/describe-subnets/`,path:`/ec2/describe-subnets/`,getParentRoute:()=>X}),Nf=Dr.update({id:`/ec2/(subnet)/describe-subnets/$id`,path:`/ec2/describe-subnets/$id`,getParentRoute:()=>X}),Pf=nd.update({id:`/ec2/(target-groups)/describe-target-groups/`,path:`/ec2/describe-target-groups/`,getParentRoute:()=>X}),Ff=Or.update({id:`/ec2/(target-groups)/describe-target-groups/$id`,path:`/ec2/describe-target-groups/$id`,getParentRoute:()=>X}),If=rd.update({id:`/ec2/(volumes)/describe-volumes/`,path:`/ec2/describe-volumes/`,getParentRoute:()=>X}),Lf=kr.update({id:`/ec2/(volumes)/describe-volumes/$id`,path:`/ec2/describe-volumes/$id`,getParentRoute:()=>X}),Rf=Ar.update({id:`/ec2/(volumes)/modify-volume/$id`,path:`/ec2/modify-volume/$id`,getParentRoute:()=>X}),zf=id.update({id:`/ec2/(vpc)/describe-vpcs/`,path:`/ec2/describe-vpcs/`,getParentRoute:()=>X}),Bf=jr.update({id:`/ec2/(vpc)/describe-vpcs/$id`,path:`/ec2/describe-vpcs/$id`,getParentRoute:()=>X}),Vf=ad.update({id:`/ecr/(repositories)/list-repositories/`,path:`/ecr/list-repositories/`,getParentRoute:()=>X}),Q=Mr.update({id:`/ecr/(repositories)/list-repositories/$id`,path:`/ecr/list-repositories/$id`,getParentRoute:()=>X}),Hf=od.update({id:`/ecs/(clusters)/list-clusters/`,path:`/ecs/list-clusters/`,getParentRoute:()=>X}),Uf=Nr.update({id:`/ecs/(clusters)/list-clusters/$clusterName`,path:`/ecs/list-clusters/$clusterName`,getParentRoute:()=>X}),Wf=sd.update({id:`/eks/(clusters)/list-clusters/`,path:`/eks/list-clusters/`,getParentRoute:()=>X}),Gf=lt.update({id:`/eks/(clusters)/list-clusters/$clusterName`,path:`/eks/list-clusters/$clusterName`,getParentRoute:()=>X}),Kf=cd.update({id:`/iam/(groups)/list-groups/`,path:`/iam/list-groups/`,getParentRoute:()=>X}),qf=Pr.update({id:`/iam/(groups)/list-groups/$groupName`,path:`/iam/list-groups/$groupName`,getParentRoute:()=>X}),Jf=ld.update({id:`/iam/(instance-profiles)/list-instance-profiles/`,path:`/iam/list-instance-profiles/`,getParentRoute:()=>X}),Yf=Fr.update({id:`/iam/(instance-profiles)/list-instance-profiles/$instanceProfileName`,path:`/iam/list-instance-profiles/$instanceProfileName`,getParentRoute:()=>X}),Xf=ud.update({id:`/iam/(policies)/list-policies/`,path:`/iam/list-policies/`,getParentRoute:()=>X}),Zf=Ir.update({id:`/iam/(policies)/list-policies/$policyArn`,path:`/iam/list-policies/$policyArn`,getParentRoute:()=>X}),Qf=dd.update({id:`/iam/(roles)/list-roles/`,path:`/iam/list-roles/`,getParentRoute:()=>X}),$f=Lr.update({id:`/iam/(roles)/list-roles/$roleName`,path:`/iam/list-roles/$roleName`,getParentRoute:()=>X}),ep=fd.update({id:`/iam/(users)/list-users/`,path:`/iam/list-users/`,getParentRoute:()=>X}),$=Rr.update({id:`/iam/(users)/list-users/$userName`,path:`/iam/list-users/$userName`,getParentRoute:()=>X}),tp=pd.update({id:`/rds/(db-instances)/describe-db-instances/`,path:`/rds/describe-db-instances/`,getParentRoute:()=>X}),np=zr.update({id:`/rds/(db-instances)/describe-db-instances/$id`,path:`/rds/describe-db-instances/$id`,getParentRoute:()=>X}),rp=md.update({id:`/rds/(parameter-groups)/describe-db-parameter-groups/`,path:`/rds/describe-db-parameter-groups/`,getParentRoute:()=>X}),ip=Br.update({id:`/rds/(parameter-groups)/describe-db-parameter-groups/$name`,path:`/rds/describe-db-parameter-groups/$name`,getParentRoute:()=>X}),ap=hd.update({id:`/rds/(snapshots)/describe-db-snapshots/`,path:`/rds/describe-db-snapshots/`,getParentRoute:()=>X}),op=Vr.update({id:`/rds/(snapshots)/describe-db-snapshots/$id`,path:`/rds/describe-db-snapshots/$id`,getParentRoute:()=>X}),sp=Hr.update({id:`/rds/(snapshots)/restore-db-instance-from-db-snapshot/$id`,path:`/rds/restore-db-instance-from-db-snapshot/$id`,getParentRoute:()=>X}),cp=gd.update({id:`/rds/(subnet-groups)/describe-db-subnet-groups/`,path:`/rds/describe-db-subnet-groups/`,getParentRoute:()=>X}),lp=Ur.update({id:`/rds/(subnet-groups)/describe-db-subnet-groups/$name`,path:`/rds/describe-db-subnet-groups/$name`,getParentRoute:()=>X}),up=Wr.update({id:`/`,path:`/`,getParentRoute:()=>Qd}),dp=Gr.update({id:`/$`,path:`/$`,getParentRoute:()=>Qd}),fp=Kr.update({id:`/(guardrails)/list-guardrails/$guardrailId/`,path:`/list-guardrails/$guardrailId/`,getParentRoute:()=>Z}),pp=qr.update({id:`/(guardrails)/list-guardrails/$guardrailId/edit`,path:`/list-guardrails/$guardrailId/edit`,getParentRoute:()=>Z}),mp=Jr.update({id:`/ecs/(clusters)/list-clusters/$clusterName_/services/$serviceName`,path:`/ecs/list-clusters/$clusterName/services/$serviceName`,getParentRoute:()=>X}),hp=Yr.update({id:`/ecs/(clusters)/list-clusters/$clusterName_/tasks/$taskId`,path:`/ecs/list-clusters/$clusterName/tasks/$taskId`,getParentRoute:()=>X}),gp={AuthBedrockguardrailsListGuardrailsCreateRoute:tf,AuthBedrockknowledgeBasesListKnowledgeBasesKnowledgeBaseIdRoute:rf,AuthBedrockknowledgeBasesListKnowledgeBasesCreateRoute:af,AuthBedrockcatalogListModelCatalogIndexRoute:$d,AuthBedrockguardrailsListGuardrailsIndexRoute:ef,AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute:nf,AuthBedrockmodelAccessListModelAccessIndexRoute:of,AuthBedrockplaygroundPlaygroundIndexRoute:sf,AuthBedrockguardrailsListGuardrailsGuardrailIdEditRoute:pp,AuthBedrockguardrailsListGuardrailsGuardrailIdIndexRoute:fp},_p=Z._addFileChildren(gp),vp={AuthS3LsBucketSplatRoute:dp,AuthS3LsBucketIndexRoute:up},yp={AuthBedrockRouteRoute:_p,AuthNodesRoute:yd,AuthIndexRoute:vd,AuthS3ServiceMetricsRoute:bd,AuthS3LsBucketRouteRoute:Qd._addFileChildren(vp),AuthEc2elasticIpsAllocateAddressRoute:xd,AuthEc2instancesRunInstancesRoute:Sd,AuthEc2internetGatewaysCreateInternetGatewayRoute:Cd,AuthEc2keyCreateKeyPairRoute:wd,AuthEc2keyImportKeyPairRoute:Td,AuthEc2launchTemplatesCreateLaunchTemplateRoute:Ed,AuthEc2loadBalancersCreateLoadBalancerRoute:Dd,AuthEc2natGatewaysCreateNatGatewayRoute:Od,AuthEc2placementGroupsCreatePlacementGroupRoute:kd,AuthEc2routeTablesCreateRouteTableRoute:Ad,AuthEc2securityGroupsCreateSecurityGroupRoute:jd,AuthEc2snapshotsCreateSnapshotRoute:Md,AuthEc2subnetCreateSubnetRoute:Nd,AuthEc2targetGroupsCreateTargetGroupRoute:Pd,AuthEc2volumesCreateVolumeRoute:Fd,AuthEc2vpcCreateVpcRoute:Id,AuthEcsclustersCreateServiceRoute:Ld,AuthEcsclustersRegisterTaskDefinitionRoute:Rd,AuthEcsclustersRunTaskRoute:zd,AuthEcsclustersTaskDefinitionsRoute:Bd,AuthEksclustersCreateClusterRoute:Vd,AuthIamgroupsCreateGroupRoute:Hd,AuthIaminstanceProfilesCreateInstanceProfileRoute:Ud,AuthIampoliciesCreatePolicyRoute:Wd,AuthIamrolesCreateRoleRoute:Gd,AuthIamusersCreateUserRoute:Kd,AuthRdsdbInstancesCreateDbInstanceRoute:qd,AuthRdsparameterGroupsCreateDbParameterGroupRoute:Jd,AuthRdssubnetGroupsCreateDbSubnetGroupRoute:Yd,AuthS3bucketsCreateBucketRoute:Xd,AuthS3LsIndexRoute:Zd,AuthEc2elasticIpsDescribeAddressesIdRoute:lf,AuthEc2imagesDescribeImagesIdRoute:df,AuthEc2instancesDescribeInstancesIdRoute:pf,AuthEc2internetGatewaysDescribeInternetGatewaysIdRoute:hf,AuthEc2keyDescribeKeyPairsIdRoute:_f,AuthEc2launchTemplatesDescribeLaunchTemplatesIdRoute:yf,AuthEc2loadBalancersDescribeLoadBalancersIdRoute:xf,AuthEc2natGatewaysDescribeNatGatewaysIdRoute:Cf,AuthEc2placementGroupsDescribePlacementGroupsIdRoute:Tf,AuthEc2routeTablesDescribeRouteTablesIdRoute:Df,AuthEc2securityGroupsDescribeSecurityGroupsIdRoute:kf,AuthEc2snapshotsDescribeSnapshotsIdRoute:jf,AuthEc2subnetDescribeSubnetsIdRoute:Nf,AuthEc2targetGroupsDescribeTargetGroupsIdRoute:Ff,AuthEc2volumesDescribeVolumesIdRoute:Lf,AuthEc2volumesModifyVolumeIdRoute:Rf,AuthEc2vpcDescribeVpcsIdRoute:Bf,AuthEcrrepositoriesListRepositoriesIdRoute:Q,AuthEcsclustersListClustersClusterNameRoute:Uf,AuthEksclustersListClustersClusterNameRoute:Gf,AuthIamgroupsListGroupsGroupNameRoute:qf,AuthIaminstanceProfilesListInstanceProfilesInstanceProfileNameRoute:Yf,AuthIampoliciesListPoliciesPolicyArnRoute:Zf,AuthIamrolesListRolesRoleNameRoute:$f,AuthIamusersListUsersUserNameRoute:$,AuthRdsdbInstancesDescribeDbInstancesIdRoute:np,AuthRdsparameterGroupsDescribeDbParameterGroupsNameRoute:ip,AuthRdssnapshotsDescribeDbSnapshotsIdRoute:op,AuthRdssnapshotsRestoreDbInstanceFromDbSnapshotIdRoute:sp,AuthRdssubnetGroupsDescribeDbSubnetGroupsNameRoute:lp,AuthEc2elasticIpsDescribeAddressesIndexRoute:cf,AuthEc2imagesDescribeImagesIndexRoute:uf,AuthEc2instancesDescribeInstancesIndexRoute:ff,AuthEc2internetGatewaysDescribeInternetGatewaysIndexRoute:mf,AuthEc2keyDescribeKeyPairsIndexRoute:gf,AuthEc2launchTemplatesDescribeLaunchTemplatesIndexRoute:vf,AuthEc2loadBalancersDescribeLoadBalancersIndexRoute:bf,AuthEc2natGatewaysDescribeNatGatewaysIndexRoute:Sf,AuthEc2placementGroupsDescribePlacementGroupsIndexRoute:wf,AuthEc2routeTablesDescribeRouteTablesIndexRoute:Ef,AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute:Of,AuthEc2snapshotsDescribeSnapshotsIndexRoute:Af,AuthEc2subnetDescribeSubnetsIndexRoute:Mf,AuthEc2targetGroupsDescribeTargetGroupsIndexRoute:Pf,AuthEc2volumesDescribeVolumesIndexRoute:If,AuthEc2vpcDescribeVpcsIndexRoute:zf,AuthEcrrepositoriesListRepositoriesIndexRoute:Vf,AuthEcsclustersListClustersIndexRoute:Hf,AuthEksclustersListClustersIndexRoute:Wf,AuthIamgroupsListGroupsIndexRoute:Kf,AuthIaminstanceProfilesListInstanceProfilesIndexRoute:Jf,AuthIampoliciesListPoliciesIndexRoute:Xf,AuthIamrolesListRolesIndexRoute:Qf,AuthIamusersListUsersIndexRoute:ep,AuthRdsdbInstancesDescribeDbInstancesIndexRoute:tp,AuthRdsparameterGroupsDescribeDbParameterGroupsIndexRoute:rp,AuthRdssnapshotsDescribeDbSnapshotsIndexRoute:ap,AuthRdssubnetGroupsDescribeDbSubnetGroupsIndexRoute:cp,AuthEcsclustersListClustersClusterNameServicesServiceNameRoute:mp,AuthEcsclustersListClustersClusterNameTasksTaskIdRoute:hp},bp={AuthRouteRoute:X._addFileChildren(yp),LoginRoute:_d},xp=su._addFileChildren(bp)._addFileTypes(),Sp=tu(),Cp=Wo({routeTree:xp,context:{queryClient:Sp},defaultPreload:`intent`,scrollRestoration:!0,defaultStructuralSharing:!0,defaultPreloadStaleTime:0});async function wp(e){try{await Ye()}catch(t){e.render((0,B.jsxs)(`div`,{style:{fontFamily:`sans-serif`,margin:`4rem auto`,maxWidth:`40rem`},children:[(0,B.jsx)(`h1`,{children:`Console unavailable`}),(0,B.jsx)(`p`,{children:`This cluster did not report its region, so the console cannot sign requests. Check that the serving node has a region set in its configuration.`}),(0,B.jsx)(`pre`,{children:t instanceof Error?t.message:String(t)})]}));return}e.render((0,B.jsx)(z.StrictMode,{children:(0,B.jsx)(ss,{defaultTheme:`dark`,storageKey:`spinifex-ui-theme`,children:(0,B.jsx)(p,{client:Sp,children:(0,B.jsx)(Tn,{children:(0,B.jsx)(Il,{children:(0,B.jsx)(qo,{router:Cp})})})})})}))}var Tp=document.querySelector(`#app`);Tp&&!Tp.innerHTML&&await wp(is.createRoot(Tp)); \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index-CV30i9r0.css b/spinifex/services/spinifexui/frontend/dist/assets/index-CV30i9r0.css new file mode 100644 index 000000000..e489a49b6 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/index-CV30i9r0.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:#ffe2e2;--color-red-800:#9f0712;--color-red-900:#82181a;--color-amber-400:#fcbb00;--color-amber-500:#f99c00;--color-amber-700:#b75000;--color-yellow-100:#fef9c2;--color-yellow-800:#874b00;--color-yellow-900:#733e0a;--color-green-100:#dcfce7;--color-green-400:#05df72;--color-green-600:#00a544;--color-green-800:#016630;--color-green-900:#0d542b;--color-emerald-400:#00d294;--color-emerald-500:#00bb7f;--color-emerald-700:#007956;--color-blue-500:#3080ff;--color-gray-100:#f3f4f6;--color-gray-800:#1e2939;--color-zinc-100:#f4f4f5;--color-zinc-400:#9f9fa9;--color-zinc-500:#71717b;--color-zinc-800:#27272a;--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 2px);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-foreground:var(--foreground)}@supports (color:lab(0% 0 0)){:root,:host{--color-red-100:lab(92.243% 10.2865 3.83865);--color-red-800:lab(33.7174% 55.8993 41.0293);--color-red-900:lab(28.5139% 44.5539 29.0463);--color-amber-400:lab(80.1641% 16.6016 99.2089);--color-amber-500:lab(72.7183% 31.8672 97.9407);--color-amber-700:lab(47.2709% 42.9082 69.2966);--color-yellow-100:lab(97.3564% -4.51407 27.344);--color-yellow-800:lab(38.7484% 23.5833 51.4916);--color-yellow-900:lab(32.3865% 21.1273 38.5959);--color-green-100:lab(96.1861% -13.8464 6.52365);--color-green-400:lab(78.503% -64.9265 39.7492);--color-green-600:lab(59.0978% -58.6621 41.2579);--color-green-800:lab(37.4616% -36.7971 22.9692);--color-green-900:lab(30.797% -29.6927 17.382);--color-emerald-400:lab(75.0771% -60.7313 19.4147);--color-emerald-500:lab(66.9756% -58.27 19.5419);--color-emerald-700:lab(44.4871% -41.0396 11.0361);--color-blue-500:lab(54.1736% 13.3369 -74.6839);--color-gray-100:lab(96.1596% -.0823438 -1.13575);--color-gray-800:lab(16.1051% -1.18239 -11.7533);--color-zinc-100:lab(96.1634% .0993311 -.364041);--color-zinc-400:lab(65.6464% 1.53497 -5.42429);--color-zinc-500:lab(47.8878% 1.65477 -5.77283);--color-zinc-800:lab(15.7305% .613764 -2.16959)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}*{scrollbar-width:thin}html,body{overscroll-behavior:none}body{background-color:var(--background);width:100%;min-height:100svh;color:var(--foreground);font-family:var(--font-sans), sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.font-mono{font-family:var(--font-mono), monospace}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container\/field-group{container:field-group/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.left-0{left:0}.left-1\/2{left:50%}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-first{order:-9999}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0{margin-inline:0}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3\.5{margin-inline:calc(var(--spacing) * 3.5)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-1{margin-left:var(--spacing)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-\[1\.2rem\]{width:1.2rem;height:1.2rem}.size-full{width:100%;height:100%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-\[calc\(--spacing\(4\.75\)\)\]{height:calc(calc(var(--spacing) * 4.75))}.h-\[calc\(100dvh-15rem\)\]{height:calc(100dvh - 15rem)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-svh{height:100svh}.max-h-\(--available-height\){max-height:var(--available-height)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[min\(calc\(--spacing\(72\)---spacing\(9\)\)\,calc\(var\(--available-height\)---spacing\(9\)\)\)\]{max-height:min(calc(calc(var(--spacing) * 72) - calc(var(--spacing) * 9)), calc(var(--available-height) - calc(var(--spacing) * 9)))}.min-h-0{min-height:0}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-\[24rem\]{min-height:24rem}.min-h-svh{min-height:100svh}.w-\(--anchor-width\){width:var(--anchor-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--available-width\){max-width:var(--available-width)}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\]{min-width:calc(var(--anchor-width) + calc(var(--spacing) * 7))}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.origin-\(--transform-origin\){transform-origin:var(--transform-origin)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[calc\(-50\%-2px\)\]{--tw-translate-y:calc(-50% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-0{rotate:0deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:var(--spacing)}.scroll-py-1{scroll-padding-block:var(--spacing)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-\[calc\(var\(--radius-sm\)\+2px\)\]{border-radius:calc(var(--radius-sm) + 2px)}.rounded-\[calc\(var\(--radius-sm\)-2px\)\]{border-radius:calc(var(--radius-sm) - 2px)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-t-lg{border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}.rounded-b-lg{border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-primary{border-color:var(--primary)}.border-sidebar-border{border-color:var(--sidebar-border)}.border-tactical-amber\/40{border-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.border-tactical-amber\/40{border-color:color-mix(in oklab, var(--tactical-amber) 40%, transparent)}}.border-transparent{border-color:#0000}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background{background-color:var(--background)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.bg-border\/50{background-color:color-mix(in oklab, var(--border) 50%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-foreground{background-color:var(--foreground)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-100{background-color:var(--color-green-100)}.bg-input\/20{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.bg-input\/20{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/20{background-color:color-mix(in oklab, var(--muted) 20%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/40{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/40{background-color:color-mix(in oklab, var(--muted) 40%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-tactical-amber\/5{background-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.bg-tactical-amber\/5{background-color:color-mix(in oklab, var(--tactical-amber) 5%, transparent)}}.bg-transparent{background-color:#0000}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-clip-padding{background-clip:padding-box}.fill-foreground{fill:var(--foreground)}.object-cover{object-fit:cover}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-0{padding-top:0}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.625rem\]{font-size:.625rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-700{color:var(--color-emerald-700)}.text-foreground{color:var(--foreground)}.text-gray-800{color:var(--color-gray-800)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.text-tactical-amber{color:var(--tactical-amber)}.text-tactical-cyan{color:var(--tactical-cyan)}.text-tactical-green{color:var(--tactical-green)}.text-yellow-800{color:var(--color-yellow-800)}.text-zinc-500{color:var(--color-zinc-500)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.underline-offset-3{text-underline-offset:3px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.bg-blend-color{background-blend-mode:color}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-background{--tw-ring-color:var(--background)}.ring-foreground\/10{--tw-ring-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ring-foreground\/10{--tw-ring-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-focus\/dropdown-menu-item\:text-accent-foreground:is(:where(.group\/dropdown-menu-item):focus *){color:var(--accent-foreground)}.group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing) * 8)}.group-has-data-\[size\=lg\]\/avatar-group\:size-10:is(:where(.group\/avatar-group):has([data-size=lg]) *){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.group-has-data-\[size\=sm\]\/avatar-group\:size-6:is(:where(.group\/avatar-group):has([data-size=sm]) *){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.group-has-data-\[slot\=combobox-clear\]\/input-group\:hidden:is(:where(.group\/input-group):has([data-slot=combobox-clear]) *){display:none}.group-has-\[\>input\]\/input-group\:pt-2:is(:where(.group\/input-group):has(>input) *){padding-top:calc(var(--spacing) * 2)}.group-has-\[\>input\]\/input-group\:pb-2:is(:where(.group\/input-group):has(>input) *){padding-bottom:calc(var(--spacing) * 2)}.group-data-empty\/combobox-content\:flex:is(:where(.group\/combobox-content)[data-empty] *){display:flex}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing) * -8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing) * 8)!important;height:calc(var(--spacing) * 8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)) + 2px)}.group-data-\[collapsible\=icon\]\:\[scrollbar-width\:none\]:is(:where(.group)[data-collapsible=icon] *){scrollbar-width:none}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:0!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offExamples\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){right:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){left:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:w-0:is(:where(.group)[data-collapsible=offExamples] *){width:0}.group-data-\[collapsible\=offExamples\]\:translate-x-0:is(:where(.group)[data-collapsible=offExamples] *){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *),.group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.group\/field)[data-disabled=true] *),.group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.group\/input-group)[data-disabled=true] *){opacity:.5}.group-data-\[panel-open\]\:rotate-180:is(:where(.group)[data-panel-open] *){rotate:180deg}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing) * -4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:0}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[size\=default\]\/avatar\:size-2\.5:is(:where(.group\/avatar)[data-size=default] *){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.group-data-\[size\=lg\]\/avatar\:size-3:is(:where(.group\/avatar)[data-size=lg] *){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=sm\]\/alert-dialog-content\:grid:is(:where(.group\/alert-dialog-content)[data-size=sm] *){display:grid}.group-data-\[size\=sm\]\/alert-dialog-content\:grid-cols-2:is(:where(.group\/alert-dialog-content)[data-size=sm] *){grid-template-columns:repeat(2,minmax(0,1fr))}.group-data-\[size\=sm\]\/avatar\:size-2:is(:where(.group\/avatar)[data-size=sm] *){width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:text-xs:is(:where(.group\/avatar)[data-size=sm] *){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.group-data-\[size\=sm\]\/card\:px-3:is(:where(.group\/card)[data-size=sm] *){padding-inline:calc(var(--spacing) * 3)}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:calc(var(--radius) + 2px)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-1:is(:where(.group)[data-variant=floating] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-sidebar-border:is(:where(.group)[data-variant=floating] *){--tw-ring-color:var(--sidebar-border)}.group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--spacing) * -2)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-active\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing) * 1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing) * 2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:var(--spacing)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-6::file-selector-button{height:calc(var(--spacing) * 6)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-xs\/relaxed::file-selector-button{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.after\:inset-0:after{content:var(--tw-content);inset:0}.after\:inset-y-0:after{content:var(--tw-content);inset-block:0}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.after\:rounded-full:after{content:var(--tw-content);border-radius:2147483647px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-border:after{content:var(--tw-content);border-color:var(--border)}.after\:mix-blend-darken:after{content:var(--tw-content);mix-blend-mode:darken}.group-data-\[collapsible\=offExamples\]\:after\:left-full:is(:where(.group)[data-collapsible=offExamples] *):after{content:var(--tw-content);left:100%}.last\:mt-0:last-child{margin-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.autofill\:\[-webkit-text-fill-color\:var\(--color-foreground\)\]:autofill{-webkit-text-fill-color:var(--color-foreground)}.autofill\:\[transition\:background-color_9999s_ease-in-out_0s\]:autofill{transition:background-color 9999s ease-in-out}.focus-within\:border-ring:focus-within{border-color:var(--ring)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.hover\:bg-input\/40:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/40:hover{background-color:color-mix(in oklab, var(--input) 40%, transparent)}}.hover\:bg-input\/50:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/50:hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--muted)}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:bg-transparent:hover{background-color:#0000}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:group-data-\[collapsible\=offExamples\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offExamples] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus,:is(.focus\:\*\*\:text-accent-foreground:focus *),:is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant=destructive]):focus *){color:var(--accent-foreground)}.focus-visible\:border-destructive\/40:focus-visible{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:border-destructive\/40:focus-visible{border-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible,.focus-visible\:ring-\[2px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:border-inherit:focus-within{border-color:inherit}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-disabled\:pointer-events-none:has(:disabled){pointer-events:none}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-50:has(:disabled){opacity:.5}.has-aria-invalid\:border-destructive:has([aria-invalid=true]){border-color:var(--destructive)}.has-aria-invalid\:ring-2:has([aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.has-data-\[align\=block-end\]\:rounded-md:has([data-align=block-end]),.has-data-\[align\=block-start\]\:rounded-md:has([data-align=block-start]){border-radius:var(--radius)}.has-data-\[icon\=inline-end\]\:pr-1\.5:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-end\]\:pr-2:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.has-data-\[icon\=inline-start\]\:pl-1\.5:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-start\]\:pl-2:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_auto_1fr\]:has([data-slot=alert-dialog-media]){grid-template-rows:auto auto 1fr}.has-data-\[slot\=alert-dialog-media\]\:gap-x-4:has([data-slot=alert-dialog-media]){column-gap:calc(var(--spacing) * 4)}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[slot\=card-description\]\:grid-rows-\[auto_auto\]:has([data-slot=card-description]){grid-template-rows:auto auto}.has-data-\[slot\=combobox-chip\]\:px-1:has([data-slot=combobox-chip]){padding-inline:var(--spacing)}.has-data-\[slot\=combobox-chip-remove\]\:pr-0:has([data-slot=combobox-chip-remove]){padding-right:0}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-2:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-2:has([data-slot][aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-\[textarea\]\:rounded-md:has(:is(textarea)){border-radius:var(--radius)}.has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--spacing) * 3)}.has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:var(--radius)}.has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.has-\[\>button\]\:mr-\[-0\.275rem\]:has(>button){margin-right:-.275rem}.has-\[\>button\]\:ml-\[-0\.275rem\]:has(>button){margin-left:-.275rem}.has-\[\>img\:first-child\]\:pt-0:has(>img:first-child){padding-top:0}.has-\[\>kbd\]\:mr-\[-0\.275rem\]:has(>kbd){margin-right:-.275rem}.has-\[\>kbd\]\:ml-\[-0\.275rem\]:has(>kbd){margin-left:-.275rem}.has-\[\>svg\]\:p-0:has(>svg){padding:0}.has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-expanded\:bg-muted[aria-expanded=true]{background-color:var(--muted)}.aria-expanded\:bg-secondary[aria-expanded=true]{background-color:var(--secondary)}.aria-expanded\:text-foreground[aria-expanded=true]{color:var(--foreground)}.aria-expanded\:text-secondary-foreground[aria-expanded=true]{color:var(--secondary-foreground)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-0[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-2[aria-invalid=true],.aria-invalid\:ring-\[2px\][aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-active\:bg-sidebar-accent[data-active]{background-color:var(--sidebar-accent)}.data-active\:font-medium[data-active]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-active\:text-sidebar-accent-foreground[data-active]{color:var(--sidebar-accent-foreground)}.data-closed\:overflow-hidden[data-closed]{overflow:hidden}.data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-empty\:p-0[data-empty]{padding:0}.data-ending-style\:opacity-0[data-ending-style]{opacity:0}.data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.data-highlighted\:text-accent-foreground[data-highlighted],:is(.not-data-\[variant\=destructive\]\:data-highlighted\:\*\*\:text-accent-foreground:not([data-variant=destructive])[data-highlighted] *){color:var(--accent-foreground)}.data-inset\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-open\:bg-accent[data-open]{background-color:var(--accent)}.data-open\:text-accent-foreground[data-open]{color:var(--accent-foreground)}.data-open\:opacity-100[data-open]{opacity:1}@media (hover:hover){.data-open\:hover\:bg-sidebar-accent[data-open]:hover{background-color:var(--sidebar-accent)}.data-open\:hover\:text-sidebar-accent-foreground[data-open]:hover{color:var(--sidebar-accent-foreground)}}.data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-pressed\:bg-transparent[data-pressed]{background-color:#0000}.data-selected\:border-primary[data-selected]{border-color:var(--primary)}.data-selected\:text-foreground[data-selected]{color:var(--foreground)}.data-starting-style\:opacity-0[data-starting-style]{opacity:0}.data-\[chips\=true\]\:min-w-\(--anchor-width\)[data-chips=true]{min-width:var(--anchor-width)}.data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[orientation\=vertical\]\:self-stretch[data-orientation=vertical]{align-self:stretch}.data-\[side\=bottom\]\:inset-x-0[data-side=bottom]{inset-inline:0}.data-\[side\=bottom\]\:top-1[data-side=bottom]{top:var(--spacing)}.data-\[side\=bottom\]\:bottom-0[data-side=bottom]{bottom:0}.data-\[side\=bottom\]\:h-auto[data-side=bottom]{height:auto}.data-\[side\=bottom\]\:border-t[data-side=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.data-\[side\=left\]\:inset-y-0[data-side=left]{inset-block:0}.data-\[side\=left\]\:top-1\/2\![data-side=left]{top:50%!important}.data-\[side\=left\]\:-right-1[data-side=left]{right:calc(var(--spacing) * -1)}.data-\[side\=left\]\:left-0[data-side=left]{left:0}.data-\[side\=left\]\:h-full[data-side=left]{height:100%}.data-\[side\=left\]\:w-3\/4[data-side=left]{width:75%}.data-\[side\=left\]\:-translate-y-1\/2[data-side=left]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:border-r[data-side=left]{border-right-style:var(--tw-border-style);border-right-width:1px}.data-\[side\=right\]\:inset-y-0[data-side=right]{inset-block:0}.data-\[side\=right\]\:top-1\/2\![data-side=right]{top:50%!important}.data-\[side\=right\]\:right-0[data-side=right]{right:0}.data-\[side\=right\]\:-left-1[data-side=right]{left:calc(var(--spacing) * -1)}.data-\[side\=right\]\:h-full[data-side=right]{height:100%}.data-\[side\=right\]\:w-3\/4[data-side=right]{width:75%}.data-\[side\=right\]\:-translate-y-1\/2[data-side=right]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:border-l[data-side=right]{border-left-style:var(--tw-border-style);border-left-width:1px}.data-\[side\=top\]\:inset-x-0[data-side=top]{inset-inline:0}.data-\[side\=top\]\:top-0[data-side=top]{top:0}.data-\[side\=top\]\:-bottom-2\.5[data-side=top]{bottom:calc(var(--spacing) * -2.5)}.data-\[side\=top\]\:h-auto[data-side=top]{height:auto}.data-\[side\=top\]\:border-b[data-side=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.data-\[size\=default\]\:h-7[data-size=default]{height:calc(var(--spacing) * 7)}.data-\[size\=default\]\:max-w-xs[data-size=default]{max-width:var(--container-xs)}.data-\[size\=lg\]\:size-10[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.data-\[size\=md\]\:text-xs[data-size=md]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.data-\[size\=sm\]\:size-6[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:h-6[data-size=sm]{height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:max-w-64[data-size=sm]{max-width:calc(var(--spacing) * 64)}.data-\[size\=sm\]\:gap-3[data-size=sm]{gap:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:py-3[data-size=sm]{padding-block:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:text-xs[data-size=sm]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}:is(.\*\:data-\[slot\=avatar\]\:ring-2>*)[data-slot=avatar]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\:data-\[slot\=avatar\]\:ring-background>*)[data-slot=avatar]{--tw-ring-color:var(--background)}.data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--spacing) * 3)}:is(.\*\:data-\[slot\=field\]\:p-2>*)[data-slot=field]{padding:calc(var(--spacing) * 2)}:is(.\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--spacing) * 4)}:is(.\*\:data-\[slot\=input-group\]\:m-1>*)[data-slot=input-group]{margin:var(--spacing)}:is(.\*\:data-\[slot\=input-group\]\:mb-0>*)[data-slot=input-group]{margin-bottom:0}:is(.\*\:data-\[slot\=input-group\]\:h-7>*)[data-slot=input-group]{height:calc(var(--spacing) * 7)}:is(.\*\:data-\[slot\=input-group\]\:border-none>*)[data-slot=input-group]{--tw-border-style:none;border-style:none}:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}:is(.\*\:data-\[slot\=input-group\]\:shadow-none>*)[data-slot=input-group]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-\[calc\(var\(--radius-sm\)-2px\)\] *)[data-slot=kbd]{border-radius:calc(var(--radius-sm) - 2px)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-md *)[data-slot=kbd]{border-radius:var(--radius)}:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}:is(.\*\*\:data-\[slot\=kbd\]\:px-1 *)[data-slot=kbd]{padding-inline:var(--spacing)}:is(.\*\*\:data-\[slot\=kbd\]\:text-\[0\.625rem\] *)[data-slot=kbd]{font-size:.625rem}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-1\.5>*)[data-slot=select-value]{gap:calc(var(--spacing) * 1.5)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.data-\[variant\=label\]\:text-xs\/relaxed[data-variant=label]{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.data-\[variant\=legend\]\:text-sm[data-variant=legend]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--spacing) * -1)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-backdrop-filter\:backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}}@media (width>=40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:mr-auto{margin-right:auto}.sm\:flex{display:flex}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1fr_1fr_auto\]{grid-template-columns:1fr 1fr 1fr auto}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:self-center{align-self:center}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:row-span-2:is(:where(.group\/alert-dialog-content)[data-size=default] *){grid-row:span 2/span 2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:place-items-start:is(:where(.group\/alert-dialog-content)[data-size=default] *){place-items:start}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:text-left:is(:where(.group\/alert-dialog-content)[data-size=default] *){text-align:left}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:group-has-data-\[slot\=alert-dialog-media\]\/alert-dialog-content\:col-start-2:is(:where(.group\/alert-dialog-content)[data-size=default] *):is(:where(.group\/alert-dialog-content):has([data-slot=alert-dialog-media]) *){grid-column-start:2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_1fr\]:is(:where(.group\/alert-dialog-content)[data-size=default] *):has([data-slot=alert-dialog-media]){grid-template-rows:auto 1fr}.data-\[side\=left\]\:sm\:max-w-sm[data-side=left],.data-\[side\=right\]\:sm\:max-w-sm[data-side=right],.data-\[size\=default\]\:sm\:max-w-sm[data-size=default]{max-width:var(--container-sm)}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.md\:text-pretty{text-wrap:pretty}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing) * 2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:0}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing) * 2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (width>=64rem){.lg\:block{display:block}.lg\:grid-cols-\[minmax\(0\,1fr\)_20rem\]{grid-template-columns:minmax(0,1fr) 20rem}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@container field-group (width>=28rem){.\@md\/field-group\:flex-row{flex-direction:row}.\@md\/field-group\:items-center{align-items:center}.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}}.dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.dark\:rotate-0:where(.dark,.dark *){rotate:0deg}.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-muted\/30:where(.dark,.dark *){background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-muted\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.dark\:bg-popover:where(.dark,.dark *){background-color:var(--popover)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:invert:where(.dark,.dark *){--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.dark\:after\:mix-blend-lighten:where(.dark,.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}@media (hover:hover){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.dark\:hover\:bg-transparent:where(.dark,.dark *):hover{background-color:#0000}}.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-2\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.group-data-\[collapsible\=icon\]\:\[\&\:\:-webkit-scrollbar\]\:hidden:is(:where(.group)[data-collapsible=icon] *)::-webkit-scrollbar{display:none}.\[\.border-b\]\:pb-2.border-b{padding-bottom:calc(var(--spacing) * 2)}.\[\.border-b\]\:pb-4.border-b{padding-bottom:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-b\]\:pb-3:is(:where(.group\/card)[data-size=sm] *).border-b{padding-bottom:calc(var(--spacing) * 3)}.\[\.border-t\]\:pt-2.border-t{padding-top:calc(var(--spacing) * 2)}.\[\.border-t\]\:pt-4.border-t{padding-top:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-t\]\:pt-3:is(:where(.group\/card)[data-size=sm] *).border-t{padding-top:calc(var(--spacing) * 3)}:is(.\*\:\[a\]\:underline>*):is(a){text-decoration-line:underline}:is(.\*\:\[a\]\:underline-offset-3>*):is(a){text-underline-offset:3px}@media (hover:hover){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[a\]\:hover\:bg-muted:is(a):hover{background-color:var(--muted)}.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.\[a\]\:hover\:text-muted-foreground:is(a):hover{color:var(--muted-foreground)}:is(.\*\:\[a\]\:hover\:text-foreground>*):is(a):hover{color:var(--foreground)}}:is(.\*\:\[img\:first-child\]\:rounded-t-lg>*):is(img:first-child){border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}:is(.\*\:\[img\:last-child\]\:rounded-b-lg>*):is(img:last-child){border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)}:is(.\*\:\[svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>*):is(svg:not([class*=size-])){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>\*\]\:w-full>*{width:100%}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\*\]\:w-auto>*{width:auto}}.\[\&\>\.sr-only\]\:w-auto>.sr-only{width:auto}.\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}}.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}@container field-group (width>=28rem){.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.\[\&\>button\]\:hidden>button{display:none}.\[\&\>code\]\:bg-transparent>code{background-color:#0000}.\[\&\>code\]\:p-0>code{padding:0}.has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pr-1\.5:has(>[data-align=inline-end])>input{padding-right:calc(var(--spacing) * 1.5)}.has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:pl-1\.5:has(>[data-align=inline-start])>input{padding-left:calc(var(--spacing) * 1.5)}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-2\.5\!>svg{width:calc(var(--spacing) * 2.5)!important;height:calc(var(--spacing) * 2.5)!important}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.group-has-data-\[size\=lg\]\/avatar-group\:\[\&\>svg\]\:size-5:is(:where(.group\/avatar-group):has([data-size=lg]) *)>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.group-has-data-\[size\=sm\]\/avatar-group\:\[\&\>svg\]\:size-3:is(:where(.group\/avatar-group):has([data-size=sm]) *)>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=default\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=default] *)>svg,.group-data-\[size\=lg\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=lg] *)>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:\[\&\>svg\]\:hidden:is(:where(.group\/avatar)[data-size=sm] *)>svg{display:none}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3>svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[data-side=left][data-collapsible=offExamples] .\[\[data-side\=left\]\[data-collapsible\=offExamples\]_\&\]\:-right-2{right:calc(var(--spacing) * -2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offExamples] .\[\[data-side\=right\]\[data-collapsible\=offExamples\]_\&\]\:-left-2{left:calc(var(--spacing) * -2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--spacing) * -1.5)}}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-wght-normal-BgDaEnEv.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,d09GMgABAAAAAAfsABQAAAAAEAwAAAeCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhwbHhwoP0hWQVJbBmA/U1RBVIFiJyYAdC9qEQgKhGSEAAsgADCGCAE2AiQDOgQgBYlMB4EUDAcbLQ4onoexrSC/2ZyLAa8p8VHB8/x3Vue+V0hVJalMJg2nx/TCrQXxBeqLjQG7FyM1WEa/X1tEXN7cFz9EJEMmMUz3RihWSSKeQCbcIou0izz/C8v+fq3VfajEa9gDD11CImXS7qL/RJFVzC1qiB6KmKeD6TZdQ6IRGv78dL6uSVVCfgni5mzu7kcgQBgAEAQTQRCoL++STTYybkJxNfQxAAIAGu8OdEB9teW2jh4BpgDqFjAeSEByW3zFP0CBBgNMsMCGEDjgggdhiEAUAeIIED7ABTDUEnkIE9Q9ahFgKttcVhApo4ACB4qobHaccgDfEjFO6aaWUhjMLt2SyIvHKoDqoA4CSUwEIYQCEjhAO9R1G6keDeDZGjNo+AhxOjCEGTr1WeIF3kYBiLAOKvkJSMiKX0VdAyQt3SDJClCkxJCHkCzfqyVTriJZLcolS32JZHUekq2TYNkYtCtjYHMQXSxGjXDz2t/yLWXzDzxz+o3zFwDEaN23F+13pyMdQAEaSKAR9vcGq4A4MTSKCElGW+M7UcY7xqkggITb28ZJhlqc9q2twYKTt0NjixBgYvO9BIihEBLYuOFXQzfIQ7dXGUEEEgFDooBfAzqiQbpJrhiWSuKJCRFKYbHCyJKI2G5GiZbNAvgAu5pc3vwx4G+g3aDkhklABiSz0BICXrYghtYhx/cdJ+44rY2oZ0aMNRFz3VZjb6W33F3gzltqtOCV8tTHSpOeXuItfvr5lCdfzFpqtEitvqdcdGGFd28ZqqC0tPbeChGXgrIlnhSWu/eUso4uKWFLugyDzQJhflY4659+WjQ++6x72WUMv9G8mw6QJl7BVxX5fe/kpUsOvnZwee9uQ0cGXYd0o89XB2748sDSnt8d2VphdOTTgceDVvOds0v9P/s7HPq15aGun/6Vllb56f1dl0t1LejqrNkpdRZsG8TOnM5vkBG5oiVyVGnS8LHps5cfNWJs6qKPfaNSxiQNBUm3cKNWROr0GSur7Za31k1vieq7LH11VF+jXdRIasRKflc7jkobm1Z9te1IyZA0pDkhLR98+H37Zf1c/8at+dB7x+7GfVyTfJMPiYztsnl59Y5l4j+0n1RXlpHnF3Tq7HecmNF/CJodEMAikruxiyJaGLvHOdAfoA+oDvpjBm2b91cHGRZMU9n25xEU0A8fgEEAdKI3Q1iDtc034sug5YVMkE2jsE+BIkwSoQ3gxXMqz9tELp48bd0cFKOKS7xYjEuXBnZP5ia7DyiO/X/YI+PQSbt2uSdqAkWL9nQbV1XB94/+uPfdZz8dnXYFBYrcTl2SIR/ybxJNJPz/Gupb0JaZeens2ekC7EKr8t+Ls/P5VJPYJdHKyqfg2nqU6bhlidzcddQV/7MmecTzJ5VPcKXkNKSEogHjYFx6QZ7rQ+FSe8njaiNuOnXS8H2ScQ619c2mC3VTtauL0rRbXd/CkSOP37FY9Zkjz8+GibYUMOEWF+RdrFS8Ecv1SHOpPUPZGEIpjPvFyU5cXKjd6OXqorTqy9GwRd++HVufPGnVsW+aO3vggKZ18jR9sXaTC1PWTEsVUaK0FkNySbTQDqlm2PfDjZcu4aalnSLKjnOoYQ0nUlqqXcGpPu/4VgV/xU2pAqW4BW3qzhQ8/hFKhV2qE3+BKAtDqBXjfgnVdH4y0wg5tbVNRenNdTWOrenWLcupQdmsbq5b+18piTe/xRdp1xbILxNPJGInm2z6hoB21Lal0i+ePTtd7B45+3XhFJ329evskXm7qurUVREotqSluSo/L29d3qDhI4YOQqWhI4YNvBNfsMHeXKemXrxQfKeuPOGRVayA3JtkJKEgbPp+dXUDluddutRYLFoXGXWX6N3WFaGLbQtRSitVYNacTNSdy7AaG/HSaUEANcBoGXNdcZvZsOqQ1icBDv21/gzAoYPHH/WDW0qNR3QTYKEAEHig6o13NXbND06CQPlRtYjGNnSktRc09k1mAMDvAlDKfQjgy6fssInlfzmNAjKkDxoxHOBLdVRAIVt9j4qo+hA1w9T1aNBNTUOTTNUHLbqokE+UAfJXCIGw/IxCSL5GRUJeR40rL/UxTm4Q08H6MbCs70ObuNyIIXrINHQYInF06UUlevTjbQzTh5upiDMzMMogUtEnjPs/Y7jAHCJeB0GBHh04tC6FiB6ZFB1oArUSIoFoqhzCeAN6lHwm0T4C3VVPWvjpSMXReuWesMEcoqrmgtNBGd2noWeV0hNAz9rFeShNJxHGsPa3HXeKTk8b55hahySYHaYKKFFLpCfN8rsoaJn01CR04Gkc+5k7KVTCmClX8Q10HCrUEkVlSX+XO33oQR9609tJ516H497WSobWs5Up6TLaS10/dessIskgJSLiDlWvHVUywpkQ7hdPZqGyiEF0uVQerVcPamT1A3eKXdyI1vG9OoflrSXihZ1qqGE3nhmAgiIbRCQgPLEPtOM3UQwTLYaYYomNlpA44opnjV6jkD6id80OOrzf6BzmMD6eEa1zKyeYG1fzfEf16V6jw9XYOaar1/b2kP/IYX8oR2mcFvv2GtBV3JXgd437AQAA)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans:"Geist Variable", sans-serif;--font-mono:"JetBrains Mono Variable", monospace;--background:#f4f1ec;--foreground:#1d2930;--card:#f9f8f6;--card-foreground:#1d2930;--popover:#fbfaf9;--popover-foreground:#1d2930;--primary:#305030;--primary-foreground:#f5f3ef;--secondary:#eae5d7;--secondary-foreground:#263740;--muted:#e9e7e2;--muted-foreground:#576b75;--accent:#e7e2da;--accent-foreground:#1d2930;--destructive:#c32222;--destructive-foreground:#fff;--border:#b6c9b6;--input:#dedbd3;--ring:#3e743e;--chart-1:#2e6b2e;--chart-2:#cfa117;--chart-3:#297aa3;--chart-4:#cc8c33;--chart-5:#b82e2e;--radius:.25rem;--sidebar:#f5f3f0;--sidebar-background:#f5f3f0;--sidebar-foreground:#263740;--sidebar-primary:#305030;--sidebar-primary-foreground:#f5f3ef;--sidebar-accent:#e7e2da;--sidebar-accent-foreground:#1d2930;--sidebar-border:#c4d4c4;--sidebar-ring:#3e743e;--tactical-amber:#c2940a;--tactical-amber-dim:#d6b85c;--tactical-green:#267326;--tactical-green-dim:#7ab87a;--tactical-cyan:#24868f;--tactical-cyan-dim:#5eb2ba;--tactical-red:#bd2828;--tactical-olive:#64783a;--scanline-opacity:.015}.dark{--background:#0d1012;--foreground:#e0ddd1;--card:#14181a;--card-foreground:#e0ddd1;--popover:#111517;--popover-foreground:#e0ddd1;--primary:#fa0;--primary-foreground:#0d1012;--secondary:#323922;--secondary-foreground:#c6d2ac;--muted:#202527;--muted-foreground:#819098;--accent:#292f32;--accent-foreground:#ebe8e0;--destructive:#c32222;--destructive-foreground:#fff;--border:#203c32;--input:#292f32;--ring:#ffbf00;--chart-1:#ffbf00;--chart-2:#22c38e;--chart-3:#1aa1e6;--chart-4:#86ac39;--chart-5:#d92626;--sidebar:#111517;--sidebar-background:#111517;--sidebar-foreground:#e0ddd1;--sidebar-primary:#ffbf00;--sidebar-primary-foreground:#0d1012;--sidebar-accent:#292f32;--sidebar-accent-foreground:#ebe8e0;--sidebar-border:#203c32;--sidebar-ring:#ffbf00;--tactical-amber:#ffbf00;--tactical-amber-dim:#a17d12;--tactical-green:#22c38e;--tactical-green-dim:#20604a;--tactical-cyan:#17bfcf;--tactical-cyan-dim:#196066;--tactical-red:#d92626;--tactical-olive:#576b2e;--scanline-opacity:.03}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index-D7dQJPHv.js b/spinifex/services/spinifexui/frontend/dist/assets/index-D7dQJPHv.js new file mode 100644 index 000000000..c3c01c17e --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/index-D7dQJPHv.js @@ -0,0 +1,11 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/route-DPFKZkGs.js","assets/compiler-runtime-D_vZfn79.js","assets/Match-Q4m9gh_H.js","assets/matchContext-DjfNTEDZ.js","assets/useSelector-CRh8wCTv.js","assets/with-selector-DrM041uv.js","assets/_auth-Cg86DRhs.js","assets/useQuery-2EKf7AyV.js","assets/useBaseQuery-Dqg0ViLa.js","assets/removable-Dwr9otKB.js","assets/query-BPr_oSEg.js","assets/link-Dhy4xOXd.js","assets/cluster-config-xw_Q7X1e.js","assets/hex-encoding-B19xAT_7.js","assets/createLucideIcon-BQgzNCBR.js","assets/chevron-down-Dt4q6d24.js","assets/chevron-right-c57whaks.js","assets/waypoints-DE7eIn9V.js","assets/network-RAx0KOlf.js","assets/admin-context-CXmiyIcc.js","assets/sts-B5ZEON3v.js","assets/signed-fetch-B-FFW93q.js","assets/ec2-CWEplLq4.js","assets/queryOptions-Dfvzj6n2.js","assets/awsClient-b0hcP1Iz.js","assets/index.browser-C3WbWNjZ.js","assets/s3-DFfxBBj-.js","assets/page-heading-vksh_67w.js","assets/badge-B_ExXcw2.js","assets/dist-BKgR2qk6.js","assets/useRender-2pqPQOAE.js","assets/card-BXl0Qff8.js","assets/admin-BUkrFIlI.js","assets/alert-dialog-kCQ1W4Rz.js","assets/button-BarTr35m.js","assets/DialogTitle-DiInIGjk.js","assets/useValueAsRef-F56m8WbP.js","assets/createBaseUIEventDetails-DL-8egB9.js","assets/useScrollLock-NX6x9Pv8.js","assets/state-badge-D1Z6q_OA.js","assets/route-n5jLl8qw.js","assets/nodes-DExo87Po.js","assets/redirect-B4OxYbs2.js","assets/service-metrics-C6gBIZci.js","assets/allocate-address-CDvylWBR.js","assets/useNavigate-BH3pQKLF.js","assets/back-link-CjKy1JE5.js","assets/cli-command-panel-CLiFy1rG.js","assets/check-BO7W8OSs.js","assets/copy-CamWo-pg.js","assets/use-copy-to-clipboard-DjiD3qo6.js","assets/input-6kdUb_DH.js","assets/useValueChanged-B2TUO8IO.js","assets/useControlled-DwxdYHYK.js","assets/ec2-Ctto2xjU.js","assets/useMutation-6rdxVQpi.js","assets/mutation-BziiWz5u.js","assets/subnet-calculator-BlCTyGtY.js","assets/zod-DF_n2ejy.js","assets/error-banner-DeOeVBKS.js","assets/form-actions-BUM37An0.js","assets/field-c7GHI5st.js","assets/separator-B3CHPrlt.js","assets/ec2-DhWukC79.js","assets/create-internet-gateway-CGStzV_5.js","assets/create-key-pair-CKL_t5ui.js","assets/select-B01jpDaX.js","assets/usePositioner-DUsvphCt.js","assets/DirectionContext-C9rdm8xn.js","assets/useCompositeListItem-CEsaEeXb.js","assets/download-B-6MA128.js","assets/triangle-alert-DcjzeKLg.js","assets/import-key-pair-kETKjfrj.js","assets/textarea-n04EavUm.js","assets/create-launch-template-vwPKoZ8a.js","assets/launch-template-data-fields-BXmKXe0L.js","assets/useSuspenseQuery-Bc7E1P-f.js","assets/collapsible-k5LqEmTn.js","assets/create-load-balancer-BBKiIxj3.js","assets/acm-BYWQapOv.js","assets/certificate-import-dialog-UyecRuny.js","assets/elbv2-K4aD09h6.js","assets/commandBuilder-DzFUl2wS.js","assets/elbv2-DSjTzB2q.js","assets/tags-field-array-Dk9nPDzt.js","assets/plus-BodhJT2v.js","assets/trash-DVab4pEz.js","assets/target-group-form-DvZOw-26.js","assets/elbv2-CtsfBUSG.js","assets/create-nat-gateway-C0zhuUio.js","assets/create-placement-group-D_3YKZZQ.js","assets/create-route-table-q3_xIq0T.js","assets/create-security-group-BD7bwg9p.js","assets/create-subnet-BXdYKpy-.js","assets/create-target-group-CSrYTK6r.js","assets/create-volume-BamGr8Qm.js","assets/create-vpc-CyDH_vpB.js","assets/task-definitions-qkfdMMhk.js","assets/ecs-gqt7DXoF.js","assets/ecs-8RzYyUs4.js","assets/delete-confirmation-dialog-SdDN4LiF.js","assets/ecs-section-nav-CU0acTSl.js","assets/create-cluster-eQiUzhUk.js","assets/eks-C1P60riG.js","assets/commandBuilder-DOzu_YCE.js","assets/json-DsgWHaZY.js","assets/iam-CKOkzDco.js","assets/commandBuilder-CoLHZwsC.js","assets/iam-CVg27y22.js","assets/system-managed-Dk7ySh_E.js","assets/system-image-required-PXuPdEKF.js","assets/rotate-cw-FTUcK_f-.js","assets/policy-document-viewer-DAhLJAGU.js","assets/create-group-6mVlm4h5.js","assets/iam-Cgrr-Iit.js","assets/create-instance-profile-CgetPfkP.js","assets/create-policy-BJFldcK3.js","assets/json-editor-Bwbz8mfn.js","assets/create-role-DvSx0q-L.js","assets/create-user-Bv8sj586.js","assets/create-db-instance-CY_ivPtb.js","assets/rds-CE3126Ga.js","assets/rds-BkKLEWPo.js","assets/rds-CyiqRExv.js","assets/picker-notice-tK9XfUSR.js","assets/security-group-checkboxes-BkxIRro9.js","assets/create-db-parameter-group-CkK-hDRM.js","assets/create-db-subnet-group-BV65rf5O.js","assets/create-bucket-B5kEkFpz.js","assets/s3-DaPUyf7s.js","assets/ls-XgxEPnDq.js","assets/list-card-DFj_XTL4.js","assets/route-DtXjPAiX.js","assets/list-model-catalog-CPZ8xUbd.js","assets/list-guardrails-DYYlNj8n.js","assets/bedrock-Ct-f5HCJ.js","assets/commandBuilder-CyJu3wq4.js","assets/create-CoZioQAG.js","assets/bedrock-7XDhMm-7.js","assets/guardrail-form-page-C6v-izo3.js","assets/list-knowledge-bases-CH_zlnnr.js","assets/bedrockAgent-D9Za8qAi.js","assets/commandBuilder-BH-JcvCM.js","assets/create-uNWIyxX8.js","assets/bedrockAgent-BL6fVxPp.js","assets/list-model-access-Ds5MpzNH.js","assets/playground-zdI5o66q.js","assets/commandBuilder-BLgOdGhm.js","assets/describe-addresses-DKlK9Anw.js","assets/describe-images-Zp7giKqa.js","assets/describe-instances-BK0FW0LB.js","assets/describe-internet-gateways-CiiBScpk.js","assets/describe-key-pairs-BRKxsJWn.js","assets/describe-launch-templates-BuaCkV_y.js","assets/describe-load-balancers-ayJnDdyt.js","assets/describe-nat-gateways-B8p3GKN1.js","assets/describe-placement-groups-XiQbPRZ5.js","assets/describe-route-tables-Cz9uMOpx.js","assets/describe-security-groups-uryLUVIn.js","assets/describe-snapshots-63g9NU9g.js","assets/describe-subnets-lWLraP1d.js","assets/describe-target-groups-5RCbjKmg.js","assets/describe-volumes-Dj4fTW0o.js","assets/describe-vpcs-cdkXn9V9.js","assets/list-repositories-B-9oI4MN.js","assets/ecr-BFtKzkrF.js","assets/ecr-F-ANqxrm.js","assets/list-clusters-UynY0efZ.js","assets/list-clusters-CIHqWLhW.js","assets/eks-CC8GhK3R.js","assets/list-groups-B-aCbhJM.js","assets/list-instance-profiles-BSt7PimM.js","assets/list-policies-DeMAXGlh.js","assets/list-roles-DKJKr-De.js","assets/list-users-B87cjyLt.js","assets/describe-db-instances-ririu0gg.js","assets/delete-db-instance-dialog-BdhZGMA5.js","assets/describe-db-parameter-groups-Vj5U5CsP.js","assets/delete-db-parameter-group-dialog-DHJeV95z.js","assets/rds-delete-dialog-BtEPTeBR.js","assets/describe-db-snapshots-DXVrzU4j.js","assets/create-db-snapshot-dialog-CiBeyQ69.js","assets/delete-db-snapshot-dialog-D3b3mehr.js","assets/describe-db-subnet-groups-B9VO6RnW.js","assets/delete-db-subnet-group-dialog-BgemfFMN.js"])))=>i.map(i=>d[i]); +import{i as e,n as t,o as n,r,t as i}from"./compiler-runtime-D_vZfn79.js";import{A as a,C as o,E as s,a as c,b as l,c as u,g as d,h as f,k as p,m,o as h,s as g,v as _,x as v,y}from"./removable-Dwr9otKB.js";import{t as b}from"./query-BPr_oSEg.js";import{t as x}from"./mutation-BziiWz5u.js";import{a as S,i as C,n as w,o as T,r as E,t as D}from"./lazyRouteComponent-CiZDS8hZ.js";import{C as O,S as k,T as ee,_ as te,a as ne,b as re,c as ie,d as ae,f as oe,g as se,h as ce,i as le,l as ue,m as de,n as fe,o as pe,p as me,r as he,t as A,u as ge,v as _e,w as j,y as M}from"./link-Dhy4xOXd.js";import{a as ve,i as ye,n as be,o as xe,r as Se,s as N,t as Ce}from"./Match-Q4m9gh_H.js";import{n as we,r as Te}from"./matchContext-DjfNTEDZ.js";import{n as Ee,t as De}from"./redirect-B4OxYbs2.js";import{a as Oe,n as ke,r as Ae,t as je}from"./useSelector-CRh8wCTv.js";import{n as Me}from"./with-selector-DrM041uv.js";import{t as Ne}from"./useNavigate-BH3pQKLF.js";import{f as Pe,i as Fe,n as Ie,t as Le}from"./useValueAsRef-F56m8WbP.js";import{a as Re,f as ze,l as Be,n as Ve,o as He,t as Ue,u as We}from"./dist-BKgR2qk6.js";import{t as Ge}from"./useRender-2pqPQOAE.js";import{a as Ke,lt as P,n as qe,o as Je,r as Ye,tn as Xe}from"./cluster-config-xw_Q7X1e.js";import{t as F}from"./createLucideIcon-BQgzNCBR.js";import{t as Ze}from"./camera-bmANKZ8M.js";import{a as Qe,i as $e,n as et,o as tt,r as nt,s as rt,t as it}from"./waypoints-DE7eIn9V.js";import{n as at,t as ot}from"./_id-njlqJfO-.js";import{t as st}from"./network-RAx0KOlf.js";import{n as ct,t as lt}from"./_clusterName-CoDfUrWf.js";import{n as ut,t as dt}from"./_id--x_QHBJa.js";import{_ as ft,b as pt,n as mt,o as ht,s as gt,t as _t,y as vt}from"./button-BarTr35m.js";import{C as yt,E as bt,S as xt,b as St,f as Ct,g as wt,h as I,i as Tt,m as Et,s as Dt,t as Ot,v as kt,w as At,x as jt,y as Mt}from"./createBaseUIEventDetails-DL-8egB9.js";import{$ as Nt,D as Pt,E as Ft,H as It,L as Lt,M as Rt,O as zt,P as Bt,S as Vt,St as Ht,Y as Ut,at as Wt,b as Gt,ct as Kt,g as L,h as qt,it as Jt,j as Yt,k as Xt,m as Zt,nt as Qt,ot as $t,p as en,st as tn,tt as nn,ut as rn,w as an,wt as on,x as sn,y as cn,z as ln}from"./useScrollLock-NX6x9Pv8.js";import{n as un,r as dn,t as fn}from"./usePositioner-DUsvphCt.js";import"./input-6kdUb_DH.js";import{t as pn}from"./separator-B3CHPrlt.js";import{a as mn,c as hn,i as gn,l as _n,n as vn,o as yn,r as bn,s as xn,t as Sn}from"./DialogTitle-DiInIGjk.js";import{t as Cn}from"./awsClient-b0hcP1Iz.js";import{n as wn,t as Tn}from"./admin-context-CXmiyIcc.js";import{t as En}from"./acm-BYWQapOv.js";import{i as Dn,t as On}from"./bedrock-Ct-f5HCJ.js";import{i as kn}from"./bedrockAgent-D9Za8qAi.js";import{A as An,E as jn,M as Mn,O as Nn,S as Pn,_ as Fn,c as In,d as Ln,h as Rn,l as zn,n as Bn,o as Vn,p as Hn,r as Un,w as Wn,y as Gn}from"./ec2-CWEplLq4.js";import{n as Kn}from"./ecr-BFtKzkrF.js";import{n as qn,o as Jn}from"./ecs-gqt7DXoF.js";import{c as Yn}from"./eks-CC8GhK3R.js";import{a as Xn,o as Zn,u as Qn}from"./elbv2-DSjTzB2q.js";import{C as $n,f as er,l as tr,p as nr,y as rr}from"./iam-CVg27y22.js";import{a as ir,d as ar,h as or,o as sr,r as cr}from"./rds-CyiqRExv.js";import{n as lr}from"./s3-DFfxBBj-.js";import{t as ur}from"./login-Zz7MnUTC.js";import{t as dr}from"./run-instances-CCY2HdNd.js";import{t as fr}from"./create-snapshot-wXcq0RWi.js";import{t as pr}from"./create-service-DExIeTQv.js";import{t as mr}from"./register-task-definition-9gqzIyeT.js";import{t as hr}from"./run-task-Dkc0UyHr.js";import{t as gr}from"./_knowledgeBaseId-7saNV50A.js";import{t as _r}from"./_id-FFfcJrCW.js";import{t as vr}from"./_id-Df06xbgH.js";import{t as yr}from"./_id-Czat7SAc.js";import{t as br}from"./_id-DpCiHWsF.js";import{t as xr}from"./_id-CXZivkIZ.js";import{t as Sr}from"./_id-UPy9yakk.js";import{t as Cr}from"./_id-jxfo70ry.js";import{t as wr}from"./_id-1XMcJG7p.js";import{t as Tr}from"./_id-BnnKVvOl.js";import{t as Er}from"./_id-CjFrz8tY.js";import{t as Dr}from"./_id-DVMhXCwR.js";import{t as Or}from"./_id-CjZcD-rB.js";import{t as kr}from"./_id-NWQukwhC.js";import{t as Ar}from"./_id-CbghuErm.js";import{t as jr}from"./_id-Cssn1mnV.js";import{t as Mr}from"./_id-v23tv1cK.js";import{t as Nr}from"./_clusterName-Dsur-gaB.js";import{t as Pr}from"./_groupName-ybM6rgP-.js";import{t as Fr}from"./_instanceProfileName-DIUtYtG8.js";import{t as Ir}from"./_policyArn-CxrcnaS5.js";import{t as Lr}from"./_roleName-Cb_y_odC.js";import{t as Rr}from"./_userName-O8ujEE0v.js";import{t as zr}from"./_id-BY8BuAtk.js";import{t as Br}from"./_name-BAZWFAbg.js";import{t as Vr}from"./_id-Oh4JEFTn.js";import{t as Hr}from"./_id-CHu8CrA0.js";import{t as Ur}from"./_name-0Lr5w_9G.js";import{t as Wr}from"./_bucket-CkOBR5mi.js";import{t as Gr}from"./_-DN9VaV2B.js";import{t as Kr}from"./_guardrailId.index-D-j5LmDO.js";import{t as qr}from"./_guardrailId.edit-B0KNJrXP.js";import{t as Jr}from"./_serviceName-BtG8OFfw.js";import{t as Yr}from"./_taskId-sla3KnqA.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var Xr=class extends u{#e;#t;#n;constructor(e={}){super(),this.config=e,this.#e=new Set,this.#t=new Map,this.#n=0}build(e,t,n){let r=new x({client:e,mutationCache:this,mutationId:++this.#n,options:e.defaultMutationOptions(t),state:n});return this.add(r),r}add(e){this.#e.add(e);let t=Zr(e);if(typeof t==`string`){let n=this.#t.get(t);n?n.push(e):this.#t.set(t,[e])}this.notify({type:`added`,mutation:e})}remove(e){if(this.#e.delete(e)){let t=Zr(e);if(typeof t==`string`){let n=this.#t.get(t);if(n){if(n.length>1){let t=n.indexOf(e);t!==-1&&n.splice(t,1)}else n[0]===e&&this.#t.delete(t)}}}this.notify({type:`removed`,mutation:e})}canRun(e){let t=Zr(e);if(typeof t==`string`){let n=this.#t.get(t)?.find(e=>e.state.status===`pending`);return!n||n===e}return!0}runNext(e){let t=Zr(e);return typeof t==`string`?(this.#t.get(t)?.find(t=>t!==e&&t.state.isPaused))?.continue()??Promise.resolve():Promise.resolve()}clear(){h.batch(()=>{this.#e.forEach(e=>{this.notify({type:`removed`,mutation:e})}),this.#e.clear(),this.#t.clear()})}getAll(){return Array.from(this.#e)}find(e){let t={exact:!0,...e};return this.getAll().find(e=>_(t,e))}findAll(e={}){return this.getAll().filter(t=>_(e,t))}notify(e){h.batch(()=>{this.listeners.forEach(t=>{t(e)})})}resumePausedMutations(){let e=this.getAll().filter(e=>e.state.isPaused);return h.batch(()=>Promise.all(e.map(e=>e.continue().catch(l))))}};function Zr(e){return e.options.scope?.id}var Qr=class extends u{#e;constructor(e={}){super(),this.config=e,this.#e=new Map}build(e,t,n){let r=t.queryKey,i=t.queryHash??d(r,t),a=this.get(i);return a||(a=new b({client:e,queryKey:r,queryHash:i,options:e.defaultQueryOptions(t),state:n,defaultOptions:e.getQueryDefaults(r)}),this.add(a)),a}add(e){this.#e.has(e.queryHash)||(this.#e.set(e.queryHash,e),this.notify({type:`added`,query:e}))}remove(e){let t=this.#e.get(e.queryHash);t&&(e.destroy(),t===e&&this.#e.delete(e.queryHash),this.notify({type:`removed`,query:e}))}clear(){h.batch(()=>{this.getAll().forEach(e=>{this.remove(e)})})}get(e){return this.#e.get(e)}getAll(){return[...this.#e.values()]}find(e){let t={exact:!0,...e};return this.getAll().find(e=>y(t,e))}findAll(e={}){let t=this.getAll();return Object.keys(e).length>0?t.filter(t=>y(e,t)):t}notify(e){h.batch(()=>{this.listeners.forEach(t=>{t(e)})})}onFocus(){h.batch(()=>{this.getAll().forEach(e=>{e.onFocus()})})}onOnline(){h.batch(()=>{this.getAll().forEach(e=>{e.onOnline()})})}},$r=class{#e;#t;#n;#r;#i;#a;#o;#s;constructor(e={}){this.#e=e.queryCache||new Qr,this.#t=e.mutationCache||new Xr,this.#n=e.defaultOptions||{},this.#r=new Map,this.#i=new Map,this.#a=0}mount(){this.#a++,this.#a===1&&(this.#o=g.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onFocus())}),this.#s=c.subscribe(async e=>{e&&(await this.resumePausedMutations(),this.#e.onOnline())}))}unmount(){this.#a--,this.#a===0&&(this.#o?.(),this.#o=void 0,this.#s?.(),this.#s=void 0)}isFetching(e){return this.#e.findAll({...e,fetchStatus:`fetching`}).length}isMutating(e){return this.#t.findAll({...e,status:`pending`}).length}getQueryData(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state.data}ensureQueryData(e){let t=this.defaultQueryOptions(e),n=this.#e.build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(o(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return this.#e.findAll(e).map(({queryKey:e,state:t})=>[e,t.data])}setQueryData(e,t,n){let r=this.defaultQueryOptions({queryKey:e}),i=this.#e.get(r.queryHash)?.state.data,a=m(t,i);if(a!==void 0)return this.#e.build(this,r).setData(a,{...n,manual:!0})}setQueriesData(e,t,n){return h.batch(()=>this.#e.findAll(e).map(({queryKey:e})=>[e,this.setQueryData(e,t,n)]))}getQueryState(e){let t=this.defaultQueryOptions({queryKey:e});return this.#e.get(t.queryHash)?.state}removeQueries(e){let t=this.#e;h.batch(()=>{t.findAll(e).forEach(e=>{t.remove(e)})})}resetQueries(e,t){let n=this.#e;return h.batch(()=>{let r=n.findAll(e),i=new Set(r);return r.forEach(e=>{e.reset()}),this.refetchQueries({type:`active`,predicate:e=>i.has(e)},t)})}cancelQueries(e,t={}){let n={revert:!0,...t},r=h.batch(()=>this.#e.findAll(e).map(e=>e.cancel(n)));return Promise.all(r).then(l).catch(l)}invalidateQueries(e,t={}){return h.batch(()=>(this.#e.findAll(e).forEach(e=>{e.invalidate()}),e?.refetchType===`none`?Promise.resolve():this.refetchQueries({...e,type:e?.refetchType??e?.type??`active`},t)))}refetchQueries(e,t={}){let n={...t,cancelRefetch:t.cancelRefetch??!0},r=h.batch(()=>this.#e.findAll(e).filter(e=>!e.isDisabled()&&!e.isStatic()).map(e=>{let t=e.fetch(void 0,n);return n.throwOnError||(t=t.catch(l)),e.state.fetchStatus===`paused`?Promise.resolve():t}));return Promise.all(r).then(l)}async query(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t),r=n.isStaleByTime(o(t.staleTime,n))?await n.fetch(t):n.state.data,i=t.select;return i?i(r):r}fetchQuery(e){let t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);let n=this.#e.build(this,t);return n.isStaleByTime(o(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(l).catch(l)}infiniteQuery(e){return e._type=`infinite`,this.query(e)}fetchInfiniteQuery(e){return e._type=`infinite`,this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(l).catch(l)}ensureInfiniteQueryData(e){return e._type=`infinite`,this.ensureQueryData(e)}resumePausedMutations(){return c.isOnline()?this.#t.resumePausedMutations():Promise.resolve()}getQueryCache(){return this.#e}getMutationCache(){return this.#t}getDefaultOptions(){return this.#n}setDefaultOptions(e){this.#n=e}setQueryDefaults(e,t){this.#r.set(f(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){let t=[...this.#r.values()],n={};return t.forEach(t=>{v(e,t.queryKey)&&Object.assign(n,t.defaultOptions)}),n}setMutationDefaults(e,t){this.#i.set(f(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){let t=[...this.#i.values()],n={};return t.forEach(t=>{v(e,t.mutationKey)&&Object.assign(n,t.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;let t={...this.#n.queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||=d(t.queryKey,t),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!==`always`),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode=`offlineFirst`),t.queryFn===s&&(t.enabled=!1),t}defaultMutationOptions(e){return e?._defaulted?e:{...this.#n.mutations,...e?.mutationKey&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){this.#e.clear(),this.#t.clear()}};function ei(){try{return sessionStorage}catch{return}}var ti=`tsr-scroll-restoration-v1_3`,ni=ei();function ri(){try{return JSON.parse(ni?.getItem(`tsr-scroll-restoration-v1_3`)||`{}`)}catch{return{}}}function ii(){try{ni?.setItem(ti,JSON.stringify(ai))}catch{}}var ai=ri(),oi=`data-scroll-restoration-id`,si=e=>e.state.__TSR_key||e.href;function ci(e){let t=e.getAttribute(oi);if(t)return`[${oi}="${t}"]`;let n=``,r=e,i;for(;i=r.parentNode;){let e=1,t=r;for(;t=t.previousElementSibling;)e++;let a=`${r.localName}:nth-child(${e})`;n=n?`${a} > ${n}`:a,r=i}return n}var li=!1,ui=`window`;function di(e){try{return typeof e==`function`?e():document.querySelector(e)}catch{}}function fi(e){let t=new Set;for(let n of e){if(n===ui)continue;let e=di(n);e&&t.add(e)}return t}function pi(e,t){let n=t??e.options.scrollRestoration,r=e._scroll;n&&(r.restoring=!0);let i=e.options.getScrollRestorationKey||si,a=new Set,o=e=>{let t=ai[e]||={};for(let e of a)e===document?t[ui]={scrollX,scrollY}:e.isConnected&&(t[ci(e)]={scrollX:e.scrollLeft,scrollY:e.scrollTop})};n&&!r.restoration&&(r.restoration=!0,li=!1,history.scrollRestoration=`manual`,document.addEventListener(`scroll`,e=>{li||a.add(e.target)},!0),e.subscribe(`onBeforeLoad`,e=>{e.fromLocation&&o(i(e.fromLocation)),a.clear()}),addEventListener(`pagehide`,()=>{o(i(e.stores.resolvedLocation.get()??e.stores.location.get())),ii()})),!r.reset&&(r.reset=!0,e.subscribe(`onRendered`,t=>{let n=e.options.scrollRestorationBehavior,o=e.options.scrollToTopSelectors,s=r.next,c=r.hash,l;if(a.clear(),r.next=!0,r.hash=!1,typeof e.options.scrollRestoration==`function`&&!e.options.scrollRestoration({location:e.latestLocation}))return;let u=i(t.toLocation),d=t.fromLocation&&i(t.fromLocation);if(r.restoring&&d&&d!==u){let e=ai[d];if(e){let t=ai[u];for(let n in e){if(n===ui){if(s)continue}else{let e=di(n);if(!e||s&&o&&(l??=fi(o),l.has(e)))continue}t||=ai[u]={},t[n]??=e[n]}}}li=!0;try{let e=t.toLocation.hash,i=t.toLocation.state.__hashScrollIntoViewOptions??!0,a=!1;if(s){!e&&o&&(l??=fi(o));let t=e&&i&&c,s=r.restoring?ai[u]:void 0;if(s)for(let e in s){let{scrollX:r,scrollY:i}=s[e];if(e===ui){if(t)continue;scrollTo({top:i,left:r,behavior:n}),a=!0}else{let t=di(e);t&&(t.scrollLeft=r,t.scrollTop=i,l?.delete(t))}}if(!e){let e={top:0,left:0,behavior:n};if(a||scrollTo(e),l)for(let t of l)t.scrollTo(e)}}!a&&e&&i&&document.getElementById(e)?.scrollIntoView(i)}finally{li=!1}}))}function mi(e,t=String){let n;for(let r in e){let i=e[r];i!==void 0&&(n||=new URLSearchParams).set(r,t(i))}return n?n.toString():``}function hi(e){return e?e===`false`?!1:e===`true`?!0:e*0==0&&+e+``===e?+e:e:``}function gi(e){let t=new URLSearchParams(e),n=Object.create(null);for(let[e,r]of t.entries()){let t=n[e];t==null?n[e]=hi(r):Array.isArray(t)?t.push(hi(r)):n[e]=[t,hi(r)]}return n}var _i=/^(?:\s|["[{\d-]|fa|nu|tr)/,vi=bi(JSON.parse),yi=xi(JSON.stringify,JSON.parse);function bi(e){let t=e===JSON.parse;return n=>{n[0]===`?`&&(n=n.substring(1));let r=gi(n);for(let n in r){let i=r[n];if(typeof i==`string`){if(t&&!_i.test(i))continue;try{r[n]=e(i)}catch{}}}return r}}function xi(e,t){let n=t===JSON.parse;function r(r){if(r&&typeof r==`object`)try{return e(r)}catch{}else if(t&&typeof r==`string`){if(n&&!_i.test(r))return r;try{return t(r),e(r)}catch{}}return r}return e=>{let t=mi(e,r);return t?`?${t}`:``}}function Si(e){let t=new Map,n,r;return{get(e){let n=t.get(e);if(n)return n.visited=!0,n.value},set(i,a){let o=t.get(i);if(o){o.value=a;return}if(t.size>=e){let e=n?.next().value;for(;!e||e.visited;)e?e.visited=!1:n=t.values(),e=n.next().value;e===r&&(n=void 0),t.delete(e.key)}let s={key:i,value:a,visited:!1};r=s,t.set(i,s)},clear(){t.clear(),n=void 0,r=void 0}}}var Ci=4,wi=5;function Ti(e){let t=e.names;if(t)return t;let n=[];for(let t of e)typeof t!=`string`&&n.push(t[1]);return e.names=n}function Ei(e,t,n){let r=e.substring(t,n);if(r.charCodeAt(0)===36)return r.length===1?[2,`_splat`,``,void 0]:[1,r.substring(1),``,``];let i=r.indexOf(`{`);if(i>=0){let a=r.indexOf(`}`,i),o=r.charCodeAt(i+1)===45,s=i+(o?3:2);if(a>=0&&r.charCodeAt(s-1)===36&&(!o||s!e.parse&&e.caseSensitive===_&&e.prefix===h&&e.suffix===g);if(y)c=y;else{let e=ki(r,t,_,h,g);c=e,v.push(e),v.length===2&&i?.push(v)}}r=c}p&&m=0;e--){let n=i.wildcard[e],{prefix:r,suffix:a}=n;if(!r||!_&&(n.caseSensitive?v:y??=v.toLowerCase()).startsWith(r)){if(a){if(_)continue;let e=t.slice(u).join(`/`),i=e.slice(-a.length);if((n.caseSensitive?i:i.toLowerCase())!==a||e.length-a.length=0;t--){let n=i.optional[t];s.push({node:n,index:u,skipped:e,statics:f,dynamics:p,optionals:m,extract:h,rawParams:g})}if(!_)for(let e=i.optional.length-1;e>=0;e--){let t=i.optional[e],{prefix:n,suffix:r}=t;if(n||r){let e=t.caseSensitive?v:y??=v.toLowerCase();if(n&&!e.startsWith(n)||r&&e.indexOf(r,e.length-r.length)=0;e--){let t=i.dynamic[e],{prefix:n,suffix:r}=t;if(n||r){let e=t.caseSensitive?v:y??=v.toLowerCase();if(n&&!e.startsWith(n)||r&&e.indexOf(r,e.length-r.length)=0;e--){let t=i.pathless[e];s.push({node:t,index:u,skipped:d,statics:f,dynamics:p,optionals:m,extract:h,rawParams:g})}}if(l)return l;if(r&&c){let n=c.index;for(let e=0;ee.statics||t.statics===e.statics&&(t.dynamics>e.dynamics||t.dynamics===e.dynamics&&(t.optionals>e.optionals||t.optionals===e.optionals&&((t.node.kind===Ci)>(e.node.kind===Ci)||t.node.kind===Ci==(e.node.kind===Ci)&&t.node.depth>e.node.depth)))}function Wi(e,t,n){let r=pe(e),i=`/${r}`,a=t?i:i.toLowerCase(),o=`${a}/`,s={input:({url:e})=>{let n=t?e.pathname:e.pathname.toLowerCase();return n===a?e.pathname=`/`:n.startsWith(o)&&(e.pathname=e.pathname.slice(i.length)),e},output:({url:e})=>(e.pathname=fe(`/${r}${e.pathname}`),e)};return n?{input:({url:e})=>Gi(n,s.input({url:e})),output:({url:e})=>s.output({url:Ki(n,e)})}:s}function Gi(e,t){let n=e?.input?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function Ki(e,t){let n=e?.output?.({url:t});if(n){if(typeof n==`string`)return new URL(n);if(n instanceof URL)return n}return t}function qi(e,t){let{createMutableStore:n,createReadonlyStore:r,batch:i}=t,a=new Map,o=n(`idle`),s=n(e),c=n(void 0),l=n([]),u=r(()=>l.get().map(e=>a.get(e).get())),d=r(()=>({status:o.get(),isLoading:o.get()===`pending`,matches:u.get(),location:s.get(),resolvedLocation:c.get()}));function f(e){let t=a.get(e);return t||(t=n(void 0),a.set(e,t)),t}let p={status:o,location:s,resolvedLocation:c,ids:l,matches:u,byRoute:a,__store:d,getMatchStore:f,setMatches:m};function m(e){let t=l.get(),n=e.map(e=>e.routeId);i(()=>{ge(t,n)||l.set(n);for(let e of t)n.includes(e)||a.get(e).set(()=>void 0);for(let t of e){let e=f(t.routeId);e.get()!==t&&e.set(t)}})}return p}var Ji=`__TSR_index`,Yi=`popstate`,Xi=`beforeunload`,Zi=/^[\x00-\x20]*(?:[\\/][\t\n\r]*){2,}/;function Qi(e){let t=Zi.exec(e);return t?`/`+e.slice(t[0].length):e}function $i(e){return/[\x00-\x1f\x7f]/.test(e)&&(e=e.replace(/[\x00-\x1f\x7f]/g,e=>` +\r`.includes(e)?``:encodeURIComponent(e))),Qi(e)}function ea(e){let t=e.getLocation(),n=new Set,r=r=>{t=e.getLocation(),n.forEach(e=>e({location:t,action:r}))},i=n=>{e.notifyOnIndexChange??!0?r(n):t=e.getLocation()},a=async({task:n,navigateOpts:r,...i})=>{if(r?.ignoreBlocker??!1){n();return}let a=e.getBlockers?.()??[],o=i.type===`PUSH`||i.type===`REPLACE`;if(typeof document<`u`&&a.length&&o)for(let n of a){let r=ra(i.path,i.state);if(await n.blockerFn({currentLocation:t,nextLocation:r,action:i.type})){e.onBlocked?.();return}}n()};return{get location(){return t},get length(){return e.getLength()},subscribers:n,subscribe:e=>(n.add(e),()=>{n.delete(e)}),push:(n,i,o)=>{let s=t.state[Ji];i=ta(s+1,i),a({task:()=>{e.pushState(n,i),r({type:`PUSH`})},navigateOpts:o,type:`PUSH`,path:n,state:i})},replace:(n,i,o)=>{let s=t.state[Ji];i=ta(s,i),a({task:()=>{e.replaceState(n,i),r({type:`REPLACE`})},navigateOpts:o,type:`REPLACE`,path:n,state:i})},go:(t,n)=>{a({task:()=>{e.go(t,n?.ignoreBlocker??!1),i({type:`GO`,index:t})},navigateOpts:n,type:`GO`})},back:t=>{a({task:()=>{e.back(t?.ignoreBlocker??!1),i({type:`BACK`})},navigateOpts:t,type:`BACK`})},forward:t=>{a({task:()=>{e.forward(t?.ignoreBlocker??!1),i({type:`FORWARD`})},navigateOpts:t,type:`FORWARD`})},canGoBack:()=>t.state[Ji]!==0,createHref:t=>e.createHref(t),block:t=>{if(!e.setBlockers)return()=>{};let n=e.getBlockers?.()??[];return e.setBlockers([...n,t]),()=>{let n=e.getBlockers?.()??[];e.setBlockers?.(n.filter(e=>e!==t))}},flush:()=>e.flush?.(),destroy:()=>e.destroy?.(),notify:r,_getBlockers:()=>e.getBlockers?.()??[]}}function ta(e,t){let n=ia();return{...t,key:n,__TSR_key:n,[Ji]:e}}function na(e){let t=e?.window??(typeof document<`u`?window:void 0),n=t.history.pushState,r=t.history.replaceState,i=[],a=()=>i,o=e=>i=e,s=t=>$i(e?.createHref?e.createHref(t):t),c=e?.parseLocation??(()=>ra(`${t.location.pathname}${t.location.search}${t.location.hash}`,t.history.state));if(!t.history.state?.__TSR_key&&!t.history.state?.key){let e=ia();t.history.replaceState({[Ji]:0,key:e,__TSR_key:e},``)}let l=c(),u,d=!1,f=!1,p=!1,m=!1,h=()=>l,g,_=()=>{g&&(S._ignoreSubscribers=!0,(g[2]?t.history.pushState:t.history.replaceState)(g[1],``,g[0]),S._ignoreSubscribers=!1,g=void 0,u=void 0)},v=(t,n,r)=>{let i=e?.createHref?s(n):void 0,a=!!g;a||(u=l),l=ra(n,r),g=[i??l.href,r,g?.[2]||t],a||queueMicrotask(()=>_())},y=e=>{l=c(),S.notify({type:e})},b=async()=>{if(m=!1,f){f=!1;return}let e=c(),n=e.state[Ji]-l.state[Ji],r=n===1,i=n===-1,o=!r&&!i||d;d=!1;let s=o?`GO`:i?`BACK`:`FORWARD`,u=o?{type:`GO`,index:n}:{type:i?`BACK`:`FORWARD`};if(p)p=!1;else{let r=a();if(typeof document<`u`&&r.length){for(let i of r)if(await i.blockerFn({currentLocation:l,nextLocation:e,action:s})){f=!0,t.history.go(-n),S.notify(u);return}}}l=c(),S.notify(u)},x=e=>{if(m){m=!1;return}let t=!1,n=a();if(typeof document<`u`&&n.length)for(let e of n){let n=e.enableBeforeUnload??!0;if(n===!0){t=!0;break}if(typeof n==`function`&&n()===!0){t=!0;break}}if(t)return e.preventDefault(),e.returnValue=``},S=ea({getLocation:h,getLength:()=>t.history.length,pushState:(e,t)=>v(!0,e,t),replaceState:(e,t)=>v(!1,e,t),back:e=>(e&&(p=!0,m=!0),t.history.back()),forward:e=>{e&&(p=!0,m=!0),t.history.forward()},go:(e,n)=>{d=!0,n&&(p=!0,m=!0),t.history.go(e)},createHref:e=>s(e),flush:_,destroy:()=>{t.history.pushState=n,t.history.replaceState=r,t.removeEventListener(Xi,x,{capture:!0}),t.removeEventListener(Yi,b)},onBlocked:()=>{u&&l!==u&&(l=u)},getBlockers:a,setBlockers:o,notifyOnIndexChange:!1});return S._ignoreNextBeforeUnload=e=>{m=!1;try{e=new URL(e,t.document.baseURI).href,m=/^https?:/.test(e)&&(!e.includes(`#`)||e.split(`#`)[0]!==t.location.href.split(`#`)[0])}catch{}},t.addEventListener(Xi,x,{capture:!0}),t.addEventListener(Yi,b),t.history.pushState=function(...e){let r=n.apply(t.history,e);return S._ignoreSubscribers||y(`PUSH`),r},t.history.replaceState=function(...e){let n=r.apply(t.history,e);return S._ignoreSubscribers||y(`REPLACE`),n},S}function ra(e,t){let n=$i(e),r=n.indexOf(`#`),i=n.indexOf(`?`);if(!t){let e=ia();t={[Ji]:0,key:e,__TSR_key:e}}return{href:n,pathname:n.substring(0,r>0?i>0?Math.min(r,i):r:i>0?i:n.length),hash:r>-1?n.substring(r):``,search:i>-1?n.slice(i,r===-1?void 0:r):``,state:t}}function ia(){return(Math.random()+1).toString(36).substring(7)}function aa(e,t){return e.protocol!==`http:`&&e.protocol!==`https:`||e.origin!==t||!!e.username||!!e.password}function R(e){return e.pathname+e.search+e.hash}function oa(e){return e.options.loader||e.options.beforeLoad||e.lazyFn||e.options.component?.preload||e.options.pendingComponent?.preload}function sa(e,t){return{fromLocation:t,toLocation:e,pathChanged:t?.pathname!==e.pathname,hrefChanged:t?.href!==e.href,hashChanged:t?.hash!==e.hash}}function ca({key:e,__TSR_key:t,__TSR_index:n,__hashScrollIntoViewOptions:r,...i}){return i}function la(e){return e.findIndex(e=>e.status===`error`||e.status===`notFound`||e._notFound)+1}function ua(e,t,n,r,i,a){r&&(t=t.slice(0,r)),i&&(n=n.slice(0,i));for(let r of t){if(a&&e._tx!==a)return;n.some(e=>e.routeId===r.routeId)||e.routesById[r.routeId].options.onLeave?.(r)}for(let r of n){if(a&&e._tx!==a)return;e.routesById[r.routeId].options[t.some(e=>e.routeId===r.routeId)?`onStay`:`onEnter`]?.(r)}}var da=class{constructor(e,t){this.tempLocationKey=`${Math.round(Math.random()*1e7)}`,this._scroll={next:!0},this.subscribers=new Set,this._cache=new Map,this._committed=[],this.startTransition=async e=>(e(),!1),this.update=e=>{let t=this.options;this.options={...t,...e},this.isServer=this.options.isServer??!1??typeof document>`u`,this.staticLocations=new WeakMap,this.protocolAllowlist=new Set(this.options.protocolAllowlist),(!this.history||this.options.history&&this.options.history!==this.history)&&(this.history=this.options.history?this.options.history:na()),this.origin=this.options.origin,this.origin||=window?.origin&&window.origin!==`null`?window.origin:`http://localhost`;let n=this.options.basepath??`/`,r=this.options.rewrite,i=this.basepath!==n||t?.rewrite!==r||t?.caseSensitive!==this.options.caseSensitive;if(i&&(this.basepath=n,this.rewrite=n!==`/`&&pe(n)?Wi(n,this.options.caseSensitive,r):r),this.history&&this.updateLatestLocation(),this.options.routeTree!==this.routeTree){this.routeTree=this.options.routeTree;let e;e=this.buildRouteTree(),this.setRoutes(e)}if(this.stores)i&&this.stores.location.set(this.latestLocation);else if(this.latestLocation){let e=this.getStoreConfig(this);this.batch=e.batch,this.stores=qi(this.latestLocation,e),pi(this)}},this.updateLatestLocation=()=>{this.latestLocation=this.parseLocation(this.history.location,this.latestLocation)},this.buildRouteTree=()=>{let e=Pi(this.routeTree,this.options.caseSensitive);return this.options.routeMasks&&Ai(this.options.routeMasks,e.processedTree),{...e,resolvePathCache:Si(1e3)}},this.subscribe=(e,t)=>{let n={eventType:e,fn:t};return this.subscribers.add(n),()=>{this.subscribers.delete(n)}},this.emit=e=>{for(let t of this.subscribers)if(t.eventType===e.type)try{t.fn(e)}catch(e){console.error(e)}},this.parseLocation=(e,t)=>{let n=({pathname:e,search:n,hash:r,href:i},a)=>{if(!this.rewrite&&!/[ \x00-\x1f\x7f\u0080-\uffff]/.test(e)){let i=this.options.parseSearch(n),o=this.options.stringifySearch(i);return{href:e+o+r,publicHref:e+o+r,pathname:oe(e),external:!1,searchStr:o,search:O(t?.search,i),hash:oe(r.slice(1)),state:ee(t?.state,a)}}let o=Gi(this.rewrite,new URL(i,this.origin)),s=this.options.parseSearch(o.search),c=this.options.stringifySearch(s);return o.search=c,{href:o.href.replace(o.origin,``),publicHref:i,pathname:oe(Qi(o.pathname)),external:!!this.rewrite&&aa(o,this.origin),searchStr:c,search:O(t?.search,s),hash:oe(o.hash.slice(1)),state:ee(t?.state,a)}},r=n(e,e.state),{__tempLocation:i,__tempKey:a}=r.state;if(i&&(!a||a===this.tempLocationKey)){let e=n(i,{...i.state,__tempLocation:void 0,key:r.state.key,__TSR_key:r.state.__TSR_key});return e.maskedLocation=r,e}return r},this.matchRoutes=(e,t,n)=>typeof e==`string`?this.matchRoutesInternal({pathname:e,search:t},n):this.matchRoutesInternal(e,t),this.getMatchedRoutes=e=>{let t=Object.create(null),n=Ni(ie(e),this.processedTree,!0);return n&&Object.assign(t,n.rawParams),[n?.branch||[this.routesById.__root__],t,n?.route]},this.buildLocation=e=>{{let t=this.staticLocations.get(e);if(t)return t}let t=!1,n=(n={})=>{if(n.href){let e=ra(n.href,{});n={...n,to:Gi(this.rewrite,new URL(e.pathname,this.origin)).pathname,search:this.options.parseSearch(e.search),hash:e.hash.slice(1)}}let r=n._fromLocation||this._pendingLocation||this.latestLocation,i,a=()=>(t=!0,r),o=()=>(t=!0,i??=this.matchRoutesLightweight(r)),s=n.to?`${n.to}`:`.`,c=ne(s[0]===`/`?``:n.unsafeRelative===`path`?a().pathname:n.from??o()[1],s,this.options.trailingSlash,this.resolvePathCache),l=this.routesByPath[ie(c)],u=c.includes(`$`),d;if(l)d=l._branch??=Li(l);else if(u)d=[];else{let[e,t,n]=this.getMatchedRoutes(c);d=e,this.options.notFoundRoute&&(!n||n.path!==`/`&&t[`**`])&&(d=[...d,this.options.notFoundRoute])}let f=u?l?._interpolation??Di(!1,{fullPath:c},0):void 0,p;for(let e of d){let t=e.options.params?.stringify??e.options.stringifyParams;if(t){let e=o()[3];if(p??=ga(n.params,e),!_e(p))break;p===e&&(p=Object.assign(ae(),p));try{Object.assign(p,t(p))}catch{}}}p??=ga(n.params,_a(n.params,f)?o()[3]:va);let m=e.leaveParams?c:Qi(oe(f?le(c,f,p,this.pathParamsDecoder):c)),h=ya(d,e._includeValidateSearch),g=()=>{let t=o()[2];if(e._includeValidateSearch&&this.options.search?.strict){let e={};d.forEach(n=>{if(n.options.validateSearch)try{Object.assign(e,ha(n.options.validateSearch,{...e,...t}))}catch{}}),t=e}return t},_=h.length?ba(h,g(),n):n.search===!0?g():typeof n.search==`function`?n.search(g()):n.search||va,v=this.options.stringifySearch(_),y=n.hash===!0?a().hash:typeof n.hash==`function`?n.hash(a().hash):n.hash||void 0,b=y?`#${y}`:``,x=n.state?n.state===!0?a().state:typeof n.state==`function`?n.state(a().state):n.state:va,S=`${m}${v}${b}`,C,w,T=!1;if(this.rewrite){let e=new URL(S,this.origin),t=e.origin,n=Ki(this.rewrite,e);C=R(e),aa(n,t)?(w=n.href,T=!0):w=Qi(R(n))}else C=de(S),w=C;return{publicHref:w,href:C,pathname:m,search:_,searchStr:v,state:x,hash:y??``,external:T,unmaskOnReload:n.unmaskOnReload}},r=n(e);if(e.mask)r.maskedLocation=n({from:e.from,...e.mask});else if(this.options.routeMasks){let t=ji(r.pathname,this.processedTree);if(t){let i=Object.assign(ae(),t.rawParams),{from:a,params:o,...s}=t.route,c=ga(o,i);r.maskedLocation=n({from:e.from,...s,params:c})}}return!t&&e._fromLocation&&!r.maskedLocation&&this.staticLocations.set(e,r),r},this.commitLocation=async({viewTransition:e,ignoreBlocker:t,...n})=>{let r=n.maskedLocation??n;if(r.external)return fa(this,r.publicHref,{replace:n.replace,ignoreBlocker:t});let i,a=ie(this.latestLocation.href)===ie(n.href)&&me(ca(n.state),ca(this.latestLocation.state)),o=this._commitPromise,s,c=new Promise(e=>{s=e});if(c.resolve=()=>{s(),o?.resolve()},this._commitPromise=c,a)this.load();else{let{maskedLocation:r,hashScrollIntoView:a,...o}=n;r&&(o={...r,state:{...r.state,__tempKey:void 0,__tempLocation:{...o,search:o.searchStr,state:{...o.state,__tempKey:void 0,__tempLocation:void 0,__TSR_key:void 0,key:void 0}}}},(o.unmaskOnReload??this.options.unmaskOnReload??!1)&&(o.state.__tempKey=this.tempLocationKey)),o.state={...o.state,__hashScrollIntoViewOptions:a??this.options.defaultHashScrollIntoView??!0},this.shouldViewTransition=e,i=n.replace?`REPLACE`:`PUSH`,this.history[i===`REPLACE`?`replace`:`push`](o.publicHref,o.state,{ignoreBlocker:t}),this.history.subscribers.size||this.load({action:{type:i}})}return this._scroll.next=n.resetScroll??!0,this._commitPromise},this.buildAndCommitLocation=({replace:e,resetScroll:t,hashScrollIntoView:n,viewTransition:r,ignoreBlocker:i,...a}={})=>{let o=this.buildLocation({...a,_includeValidateSearch:!0});this._pendingLocation=o;let s=this.commitLocation({...o,viewTransition:r,replace:e,resetScroll:t,hashScrollIntoView:n,ignoreBlocker:i});return queueMicrotask(()=>{this._pendingLocation===o&&(this._pendingLocation=void 0)}),s},this.navigate=async({to:e,reloadDocument:t,href:n,publicHref:r,...i})=>{let a=n?te(n):void 0;if(a||t){if(e!==void 0||!n){let t=this.buildLocation({to:e,...i}),a=t.maskedLocation??t;n??=a.publicHref,r??=a.publicHref}let t=!a&&r?r:n;return fa(this,t,i)}return this.buildAndCommitLocation({...i,href:n,to:e,_isNavigate:!0})},this.load=async e=>{this.updateLatestLocation(),e?.action&&(this._scroll.hash=e.action.type===`PUSH`||e.action.type===`REPLACE`),await mo(this,e)},this.startViewTransition=e=>{let t=this.shouldViewTransition??this.options.defaultViewTransition;if(this.shouldViewTransition=void 0,t&&typeof document.startViewTransition==`function`){let n;if(typeof t==`object`&&window.CSS?.supports?.(`selector(:active-view-transition-type(a))`)){let r=this.latestLocation,i=this.stores.resolvedLocation.get(),a=typeof t.types==`function`?t.types(sa(r,i)):t.types;if(a===!1)return e();n={update:e,types:a}}else n=e;return document.startViewTransition(n).updateCallbackDone}return e()},this.invalidate=e=>{let t=this._committed,n=e?.filter,r=this._preloads,i=new Set,a=e=>{(!n||n(e))&&i.add(e.id)};t.forEach(a),this._cache.forEach(a),r?.forEach(e=>e.forEach(a)),this._tx?.[3].forEach(a);let o=[];for(let[e,t]of r??[])t.some(e=>i.has(e.id))&&(r.delete(e),o.push(e));let s=t=>{if(i.has(t.id)){let n=this.routesById[t.routeId],r={...t,invalid:!0,...(e?.forcePending||t.status===`error`||t.status===`notFound`)&&oa(n)?{status:`pending`,error:void 0}:void 0};return t._flight=void 0,r}return t};this._committed=t.map(s);for(let[t,n]of this._cache)i.has(t)&&(n.invalid=!0,e?.forcePending&&(n.status=`pending`));for(let e of i)this._flights?.delete(e);for(let e of o)e.abort();return this.shouldViewTransition=!1,this.load({sync:e?.sync})},this.resolveRedirect=e=>{let t=e.options,n=e.headers.get(`Location`)||t.href;if(!n){let e=this.buildLocation(t);n=(e.maskedLocation??e).publicHref||`/`}let r;if(j.test(n)||(r=te(n))&&!this.protocolAllowlist.has(r))throw Error(`Redirect blocked: unsafe protocol`);if(r===`http:`||r===`https:`){let e=new URL(n);e.pathname.startsWith(`//`)?n=e.href:aa(e,this.origin)||(n=R(e),r=void 0)}return r&&(t.reloadDocument=!0),t.href=n,e.headers.set(`Location`,n),e},this.clearCache=e=>{let t=this._cache,n=this._preloads,r=e?.filter,i=[],a=[];for(let[e,n]of t)(!r||r(n))&&(a.push(e),i.push(n));let o=[];for(let[e,t]of n??[])(!r||t.some(r))&&(o.push(e),i.push(...t));for(let e of a)t.delete(e);for(let e of o)n.delete(e);for(let e of i){let t=e._flight;e._flight=void 0,t&&!--t[2]&&(this._flights?.get(e.id)===t&&this._flights.delete(e.id),o.push(t[1]))}for(let e of o)e.abort()},this.loadRouteChunk=Ta,this.preloadRoute=e=>ho(this,e),this.matchRoute=(e,t)=>{let n={...e,to:e.to?ne(e.from||``,e.to,this.options.trailingSlash,this.resolvePathCache):void 0,params:e.params||{},leaveParams:!0},r=this.buildLocation(n),i=this.stores.status.get()===`pending`;if(t?.pending&&!i)return!1;let a=t?.pending??!i?this.latestLocation:this.stores.resolvedLocation.get()||this.stores.location.get(),o=Mi(r.pathname,t?.caseSensitive??!1,t?.fuzzy??!1,a.pathname,this.processedTree);return!o||e.params&&!me(o.rawParams,e.params,!0)?!1:t?.includeSearch??!0?me(a.search,r.search,!0)?o.rawParams:!1:o.rawParams},this.getStoreConfig=t,e.pathParamsAllowedCharacters?.length&&(this.pathParamsDecoder=he(e.pathParamsAllowedCharacters)),this.update({defaultPreloadDelay:50,defaultPendingMs:1e3,defaultPendingMinMs:500,context:void 0,...e,caseSensitive:e.caseSensitive??!1,notFoundMode:e.notFoundMode??`fuzzy`,stringifySearch:e.stringifySearch??yi,parseSearch:e.parseSearch??vi,protocolAllowlist:e.protocolAllowlist??ue}),self.__TSR_ROUTER__=this}isShell(){return!!this.options.isShell}get state(){return this.stores.__store.get()}setRoutes(e){Object.assign(this,e),this.lightweightCache=new WeakMap,this.staticLocations=new WeakMap;let t=this.options.notFoundRoute;t&&(t.init(99999999999),this.routesById[t.id]!==t&&(t._interpolation=Di(!1,t,0)),this.routesById[t.id]=t)}matchRoutesInternal(e,t){let[n,r,i]=this.getMatchedRoutes(e.pathname),a=n,o=!1;(i?i.path!==`/`&&r[`**`]:ie(e.pathname))&&(this.options.notFoundRoute?a=[...a,this.options.notFoundRoute]:o=!0);let s=o?xa(this.options.notFoundMode,a):void 0,c=Array(a.length),l=this._committed,u=(e,t)=>{let n=l[t];return n?.routeId===e.id?n:e===this.options.notFoundRoute?l.find(t=>t.routeId===e.id):void 0},d;for(let n=0;ntypeof t!=`string`&&!M.call(e,t[1]))}var va=Object.freeze({});function ya(e,t){let n=[];for(let r=0;r{let n=t(i.preSearchFilters?i.preSearchFilters.reduce((e,t)=>t(e),e):e);return i.postSearchFilters?i.postSearchFilters.reduce((e,t)=>t(e),n):n});let a=i.validateSearch;t&&a&&n.push(({search:e,next:t,meta:n})=>{let r=t(e);try{let e=ha(a,r);if(n&&e)for(let t in e)t in r||(n.defaulted||=new Map).set(t,e[t]);return{...r,...e}}catch{}return r})}return n}function ba(e,t,n){let r=(t,i,a)=>{if(t>=e.length){if(!n.search)return{};if(n.search===!0)return i;let e=se(n.search,i);return a&&(a.explicit=e),e}return e[t]({search:i,next:(e,n)=>{if(n){let n=a||{};return{search:r(t+1,e,n),meta:n}}return r(t+1,e,a)},meta:a})};return r(0,t)}function xa(e,t){if(e!==`root`){let e;for(let n=t.length-1;n>=0;n--){let r=t[n];if(r.options.notFoundComponent)return r.id;e||=r.children&&r.id}if(e)return e}return Te}function Sa(e,t){let n=e.options.params?.parse??e.options.parseParams;n&&Object.assign(t,n(t))}function Ca(e,t){return e.options[t]?.preload?.()}function wa(e,t){let n=Ca(e,`component`),r=Ca(e,`pendingComponent`);return t&&(r?r=r.then(t):t()),n&&r?Promise.all([n,r]).then(()=>{}):n??r}function Ta(e,t,n){let r=()=>t===!1?void 0:t?Ca(e,t):wa(e,n),i=e._lazy;if(i)return i===!0?r():i.then(r);if(!e.lazyFn)return r();let a=e.lazyFn().then(t=>{{let{id:n,...r}=t.options;Object.assign(e.options,r),e._lazy=!0}},t=>{throw e._lazy=void 0,t});return e._lazy=a,a.then(r)}function Ea(e){let t=e.findIndex(e=>e.status!==`success`||e._notFound)+1;return t&&t{let i=()=>r(t);t.addEventListener(`abort`,i,{once:!0}),Promise.resolve(e).then(n,r).then(()=>t.removeEventListener(`abort`,i))})}function Fa(e,t){return e.routesById[t.routeId]}function Ia(e,t,n){return De(e)?[ja,e]:xe(e)?(e.routeId||=n,[Aa,e]):t?(typeof e?.then==`function`&&(e=Error(`A Promise was thrown`,{cause:e})),[ka,e]):[Oa,e]}function La(e,t){let n=Ia(t,!0,e.id);if(n[0]!==ka)return n;try{e.options.onError?.(n[1])}catch(t){n=Ia(t,!0,e.id)}return n}function Ra(e,t,n,r,i){return i[0].signal.aborted?Ma:eo(e,t,n,La(n,r),i)}async function za(e,t,n,r,i,a){let[o,s]=t,c=n[0].signal,l=!!n[3];for(let i=n[6]??0;ie.navigate({...t,_fromLocation:o}),buildLocation:e.buildLocation,cause:l?`preload`:r.cause,abortController:n[0],preload:l,matches:s,routeId:u.id};try{let e=r._ctx||=u.options.context?u.options.context({...f,deps:r.loaderDeps,context:d})||{}:void 0;r.context={...d,...e}}catch(a){return Va(e,r),[i,Ra(e,t,u,a,n)]}if(c.aborted)return[i,Ma];let p=r.paramsError??r.searchError;if(p!==void 0)return Va(e,r),[i,Ra(e,t,u,p,n)];let m=u.options.beforeLoad;if(!m)continue;let h=r.status;i>=a&&(r.status=`pending`,n[7]?.());try{Wa(e,r,`beforeLoad`,n[0]);let a=m({...f,search:r.search,context:r.context,...e.options.additionalContext}),o=await(typeof a?.then==`function`?Pa(a,c):a);if(c.aborted)return[i,Ma];let s=eo(e,t,u,Ia(o,!1,u.id),n);if(s[0]!==Oa)return Va(e,r),[i,s];r.context={...r.context,...o}}catch(a){return Va(e,r),[i,Ra(e,t,u,a,n)]}finally{r.status=h,Wa(e,r,!1,n[0])}}i()}function Ba(e,t,n){if(!(!n||--n[2])){if(e._flights?.get(t.id)===n){let n=e._tx;if(n&&!n[0].signal.aborted&&!n[3].includes(t)&&n[3].some(e=>e.id===t.id)&&n[3].some(e=>e.isFetching===`beforeLoad`))return;e._flights.delete(t.id)}return n[1]}}function Va(e,t){let n=t._flight;t._flight=void 0,Ba(e,t,n)?.abort()}function Ha(e,t,n,r){let i=[];for(let a of t)if(!n?.includes(a)){let t=a._flight;if(a._flight=void 0,r&&t?.[2]===1&&e._flights?.get(a.id)===t&&n?.some(e=>e.id===a.id))t[2]=0;else{let n=Ba(e,a,t);n&&i.push(n)}}for(let e of i)e.abort()}function Ua(e){for(let t of e){let e=t._flight;e&&e[2]++}}function Wa(e,t,n,r){if(t.isFetching=n,r&&e._tx?.[0]!==r)return;let i=e.stores.byRoute.get(t.routeId),a=i?.get();a?.id===t.id&&i.set({...a,isFetching:n})}function Ga(e,t,n,r,i,a,o){let s=t[0];return{params:n.params,location:s,navigate:t=>e.navigate({...t,_fromLocation:s}),cause:o?`preload`:n.cause,abortController:i,preload:o,deps:n.loaderDeps,parentMatchPromise:a,context:n.context,route:r,...e.options.additionalContext}}async function Ka(e,t,n,r,i,a,o){let s=o[0],c=s.signal;if(c.aborted)return Ma;if(!i)return[Oa,void 0];let l=n._flight;Wa(e,n,`loader`,s);try{if(!l){let s=new AbortController;l=[Promise.resolve().then(()=>i(Ga(e,t,n,r,s,a,!!o[3]))).then(e=>Ia(e,!1,r.id),e=>Ia(e,!0,r.id)).then(t=>(t[0]!==Oa&&e._flights?.get(n.id)===l&&(e._flights.delete(n.id),l[2]||s.abort()),t[0]===ka&&l[2]?La(r,t[1]):t)),s,1],(e._flights??=new Map).set(n.id,l)}return n._flight=l,n.abortController=l[1],eo(e,t,r,await Pa(l[0],c),o)}catch(t){if(t!==c||!c.aborted)throw t;return Va(e,n),Ma}finally{Wa(e,n,!1,s)}}function qa(e,t,n){t[0]!==ja&&(e.status=`success`,e.error=void 0,t[0]===Oa?(e.loaderData=t[1],e.invalid=!1,e.updatedAt=Date.now(),e.preload=n):e.invalid=!0)}function Ja(e,t,n){let r=e._cache.get(t.id);if(r!==n||e._committed.some(e=>e.id===t.id&&e._flight===t._flight))return;let i={...t,_notFound:void 0,context:{}};i._flight&&i._flight[2]++,e._cache.set(t.id,i),r&&Va(e,r)}function Ya(e,t){return t[0]===ka||t[0]===Aa?{...e,status:t[0]===ka?`error`:`notFound`,error:t[1],_flight:void 0}:e}function Xa(e,t,n,r,i,a,o){let s=t[1][n],c=Fa(e,s),l=!!a[3],u=e._cache.get(s.id),d,f=!1,p;try{if(s.status===`success`&&(d=c.options.shouldReload,typeof d==`function`&&(d=d(Ga(e,t,s,c,a[0],i,l))),a[0].signal.aborted&&(p=Ma)),!p){if(s.status!==`success`)f=!0;else{let t=l||s.preload?c.options.preloadStaleTime??e.options.defaultPreloadStaleTime??3e4:c.options.staleTime??e.options.defaultStaleTime??0;f=!!(s.invalid||d||d===void 0&&Date.now()-s.updatedAt>=t&&(a[5]||s.cause===`enter`||a[2].some(e=>e.routeId===s.routeId&&e.id!==s.id)))}}}catch(n){s.invalid=!0,Va(e,s),p=Ra(e,t,c,n,a)}let m=c.options.loader,h=typeof m==`function`,g=h?m:m?.handler,_=!l||c.options.preload!==!1,v=_&&m?e._flights?.get(s.id):void 0;v===s._flight||p?v=void 0:v&&!f&&!l&&d===void 0?f=!0:f||(v=void 0);let y=!(!m||!f||s.status!==`success`||l||a[4]||((h?void 0:m.staleReloadMode)??e.options.defaultStaleReloadMode)===`blocking`),b=f&&_,x=b&&!y&&(s.status!==`success`||!!m),S=n>=o?a[7]:void 0,C=c.lazyFn&&c._lazy!==!0?S:void 0;if(b&&!m&&(s.invalid=!1,s.updatedAt=Date.now()),v&&v[2]++,x){let t=s._flight;s._flight=v,Ba(e,s,t)?.abort(),n>=o&&(s.status=`pending`),S?.()}b||(s.isFetching=!1);let w=!p&&x?Ka(e,t,s,c,g,i,a).then(t=>(qa(s,t,l),t[0]===Oa&&(m&&!a[0].signal.aborted&&Ja(e,s,u),n>=o&&(s.status=`pending`)),t)):Promise.resolve(p??[Oa,s.loaderData]),T=(async()=>{try{let e=Ta(c,void 0,C);e&&await Pa(e,a[0].signal)}catch(r){if(!t[1].some((e,t)=>t<=n&&(e.status===`error`||e.status===`notFound`||e._notFound)))return[n,Ra(e,t,c,r,a)]}let r=await w;x&&r[0]===Oa&&s.status===`pending`&&!a[0].signal.aborted&&(s.status=`success`,S?.())})();if(r.push([n,w,T]),!y)return w.then(e=>Ya(s,e));let E={...s,status:`pending`,preload:!1,_flight:v};s.invalid=!1,s.isFetching=`loader`;let D=Ka(e,t,E,c,g,i,a).then(e=>(s.isFetching=!1,qa(E,e,!1),e));return(t[2]??=[]).push([n,D,T,E]),D.then(e=>Ya(E,e))}async function Za(e,t,n,r,i=0){let a=n?.[1][1],o=a?.routeId?t.findIndex(e=>e.routeId===a.routeId):n?.[0]??t.length-1;o<0&&(o=0);for(let n=o;n>=0;n--){let i=Fa(e,t[n]);try{let e=Ta(i,!1);e&&await Pa(e,r)}catch(e){if(e===r&&r.aborted)throw e}if(i.options.notFoundComponent)return n}return a?.routeId?o:i}function Qa(e,t){t[2]&&=(Ha(e,t[2].map(e=>e[3])),void 0)}async function $a(e,t,n,r){let i;try{await Promise.all(e.map(e=>e[1].then(async t=>{let a=e[0];if(!(r&&a>=await r)){if(t[0]>=ja)throw[a,t];!i&&t[0]!==Oa&&(i=[a,t],await Promise.all((n??[]).map(e=>{if(!(e[0]<=a))return e[1].then(t=>{if(t[0]===ja)throw[e[0],t]})})))}})))}catch(e){return e}return t??i}function eo(e,t,n,r,i,a){for(;r[0]===ja;){let o=r[1],s=o.options;try{if((s.href||o.headers.has(`Location`))&&(e.resolveRedirect(o),s.reloadDocument)||(s.reloadDocument?i[3]:i[1]>=20))return r;let n=e.buildLocation({...s,_fromLocation:t[0],_includeValidateSearch:!0}),a=n.maskedLocation??n;if(a.external){let t=o.clone();return t.options={...s},t.headers.set(`Location`,a.publicHref),e.resolveRedirect(t),i[3]?[ja,t]:[ja,t,a]}return[ja,o,n]}catch(e){r=a?[ka,e]:La(n,e),a=!0}}return r}async function to(e,t,n,r,i,a){let o=t[1],s=await i,c=!1,l=o.findIndex(e=>e._notFound),u=t=>t[1][0]===Aa?Za(e,o,t,r.signal):t[0],d=l<0?o.length:l;if((s?.[1][0]??0)>=ja)d=0;else if(s){d=s[2]??=await u(s);for(let e of n){if(e[0]>=d)break;let t=await e[1];if(t[0]!==Oa&&t[0]=d)break;let t=await e[2];if(t){s=t;break}}if((s?.[1][0]??0)>=ja){let n=s[1];if(n[0]!==ja||n[1].options.reloadDocument||n[2])return Qa(e,t),n;c=!0,s=[0,[ka,Error(`Too many redirects`)]]}let f=s?s[2]??await u(s):l;if(f>=0){let i=s?.[1],l=i?.[0],u=o[f],d=i?.[1],p=()=>{i&&(u._notFound=void 0,l===ka?u.status=`error`:(d.routeId=u.routeId,u.routeId===e.routeTree.id?(u.status=`success`,u._notFound=!0):u.status=`notFound`),u.error=d,u.isFetching=!1)};p(),i||a?.();let m=Fa(e,u);try{await Pa(i?Promise.resolve().then(()=>Ta(m,l===ka?`errorComponent`:`notFoundComponent`)):Promise.all([Ta(m),Ta(m,`notFoundComponent`)]),r.signal)}catch(n){if(n===r.signal&&r.signal.aborted)return Qa(e,t),Ma}i?c&&(r.abort(),await Promise.all([...n.map(e=>e[1]),...n.map(e=>e[2]),...(t[2]??[]).map(e=>e[1])]),Qa(e,t),Ha(e,o),p()):u.status=`success`}return t}async function no(e,t,n,r=0,i=t[1].length){let a=t[1];for(let t=r;te._notFound);if(e.options.notFoundMode!==`root`&&s>=0){let t=await Za(e,n,void 0,a,s);n[s]._notFound=void 0,n[t]._notFound=!0,s=t}let c=s<0?n.length:s+1,l=0;for(;l{for(let t=d;t=ja&&(c=0);p()}if(!a.aborted&&!r[3]){let t=[];for(let[n,r]of e._flights??[])r[2]||(e._flights.delete(n),t.push(r[1]));for(let e of t)e.abort()}let h=to(e,i,u,r[0],$a(u,m,i[2]),r[7]);i[2]?.length&&(i[3]=$a(i[2],void 0,void 0,h.then(e=>Na(e)?0:Ea(n).length,()=>0))),o=await h}catch(t){if(Qa(e,i),t===a&&a.aborted)return Ma;throw t}return Na(o)?o:no(e,o,a,r[6]===n.length?r[6]:0)}function io(e,t){if(e._tx!==t)return;let n=t[3],r=e.stores.matches.get(),i=e._pending;for(let a=0;a0){i[3]=setTimeout(()=>io(e,t),n);return}i[2]=0}let m=n.map(e=>({...e,_flight:void 0}));m[a].status=`pending`;let h=i[4]=e.startTransition(()=>e.stores.setMatches(m),m).then(t=>(t&&e._pending===i&&i[4]===h&&!i[2]&&(i[2]=Date.now()+f),t));return}}function ao(e,t){let n=e._pending;(e._tx===t||!e._tx?.[3].some(e=>e.id===n?.[1]))&&(clearTimeout(n?.[3]),e._pending=void 0)}async function oo(e,t){let n=e._pending;if(!n)return;clearTimeout(n[3]);let r=n[2]-Date.now();if(!n[4]||r<=0||!Ea(t[3]).some(e=>e.id===n[1]))return;let i;try{await Pa(new Promise(e=>{i=setTimeout(e,r)}),t[0].signal)}catch{}clearTimeout(i)}function so(e,t){e._committed=t,e.stores.setMatches(t)}function co(e,t,n,r){let i=e._committed,a=e._lifecycleEnd,o=e._cache;for(let e of n)e.preload=!1,r&&(e._assetEnd=void 0);let s=Ea(n).length,c=new Map;{let t=Date.now(),r=new Set;for(let e=0;e=(n.preload?i.options.preloadGcTime??e.options.defaultPreloadGcTime??3e5:i.options.gcTime??e.options.defaultGcTime??3e5)||c.set(n.id,o.get(n.id)===n?n:{...n,_flight:void 0,isFetching:!1,context:{}})}}t[3]=[],e._cache=c;let l=e._lifecycleEnd=la(n);so(e,n),Ha(e,[...o.values(),...i].filter(e=>e._flight&&c.get(e.id)!==e),n),ua(e,i,n,a,l,t)}async function lo(e,t){let n=e._tx;for(;n&&n!==t;)t=n,await n[5],n=e._tx}function uo(e,t,n){let r=n[1].options,i=n[2];if(!i)return e.navigate({...r,replace:!0,ignoreBlocker:!0});if(r.reloadDocument)return e.navigate({href:(i.maskedLocation??i).publicHref,reloadDocument:!0,replace:!0,ignoreBlocker:!0});i._redirects=t[1]+1,e._pendingLocation=i;let a=e.commitLocation({...i,viewTransition:r.viewTransition,replace:!0,resetScroll:r.resetScroll,hashScrollIntoView:r.hashScrollIntoView,ignoreBlocker:!0});return queueMicrotask(()=>{e._pendingLocation===i&&(e._pendingLocation=void 0)}),a}async function fo(e,t,n,r,i){let a=n.map(e=>({...e}));Ua(a);for(let t of r)Va(e,a[t[0]]),a[t[0]]=t[3];let o=[t[2],a],s;try{s=await to(e,o,r,t[0],i)}catch(t){throw Ha(e,a),t}if(Na(s)){Ha(e,a),s[0]===ja&&e._tx===t&&e._committed===n&&await uo(e,t,s);return}if(await no(e,s,t[0].signal),e._tx!==t||e._committed!==n){Ha(e,a);return}for(let t of a){let n=e._cache.get(t.id);n?._flight&&n._flight===t._flight&&(e._cache.delete(t.id),Va(e,n))}so(e,a),Ha(e,n,a)}async function po(e,t,n,r,i,a){let o=await ro(e,t[2],t[3],[t[0],t[1],e._committed,void 0,i,n,a,r]);if(Na(o)){let n=o[0]===ja&&e._tx===t;if((!n||o[1].options.reloadDocument)&&ao(e,t),Ha(e,t[3]),t[3]=[],!n)return;if(e._tx!==t){ao(e,t);return}await uo(e,t,o);return}let s=o[1];if(e._tx===t&&await oo(e,t),e._tx!==t){ao(e,t),Ha(e,s),Qa(e,o);return}let c=t[2],l=sa(c,e.stores.resolvedLocation.get()),u=o[2];await e.startViewTransition(async()=>{if(e._tx===t&&await oo(e,t),e._tx!==t){ao(e,t),Ha(e,s),Qa(e,o);return}let n=await e.startTransition(()=>{ao(e,t),co(e,t,s,a),e._tx===t&&(e.emit({type:`onLoad`,...l}),e._tx===t&&e.emit({type:`onBeforeRouteMount`,...l}))},s);if(e._tx!==t){Qa(e,o);return}u?.length&&fo(e,t,s,u,o[3]).catch(console.error),e.batch(()=>{e.stores.resolvedLocation.set(c),e.stores.status.set(`idle`),e._tx===t&&e.emit({type:`onResolved`,...l}),n&&e._tx===t&&e.emit({type:`onRendered`,...l})}),e._tx===t&&(e._commitPromise?.resolve(),e._commitPromise=void 0)})}async function mo(e,t){let n=e._tx,r=e.stores.resolvedLocation.get(),i=r??e.stores.location.get(),a=e.latestLocation,o=e._pendingLocation,s=o?.href===a.href?o._redirects??0:0,c=e._handoff,l=c?.[0](),u=new AbortController,d=e._preflight;if(e._preflight=u,l||c?.[1](),d?.abort(),!u.signal.aborted){let t=sa(a,r);e.emit({type:`onBeforeNavigate`,...t}),u.signal.aborted||e.emit({type:`onBeforeLoad`,...t})}if(u.signal.aborted){await lo(e,n);return}let f=i.href===a.href,p=u,m=e.matchRoutes(a,{_controller:u});Ua(m);let h=l?c[1](m):void 0;if(h?p=l:l?.abort(),u.signal.aborted){Ha(e,m),await lo(e,n);return}e._preflight=void 0;let g,_=()=>po(e,y,f,()=>io(e,y),t?.sync,h),v=t?.sync?new Promise(e=>g=e):Promise.resolve().then(_),y=[p,s,a,m,Date.now(),v.then(()=>lo(e,y))];if(e._tx=y,n){for(let t of e.stores.matches.get()){if(e._tx!==y)break;t.isFetching&&Wa(e,t,!1)}n[0].abort(),Ha(e,n[3],y[3],!0)}if(e._tx!==y){Ha(e,y[3]),y[3]=[],g?.(),await lo(e,y);return}e.batch(()=>{e.stores.status.set(`pending`),e.stores.location.set(a)}),(h||!e._committed.length&&m[0]?.status!==`success`&&!m.some(e=>e._notFound))&&io(e,y),g?.(_()),await y[5]}async function ho(e,t){let n=e.buildLocation(t);for(let t=0;;t++){let r=e._committed,i=new AbortController,a,o,s;try{try{a=e.matchRoutes(n,{_controller:i}),Ua(a),o=(e._preloads??=new Map).set(i,a),s=await ro(e,n,a,[i,t,r,!0])}finally{o&&(o=o.delete(i),Ha(e,a)),i.abort()}if(!Na(s))return s[1];if(!o||s.length<3)return;n=s[2]}catch(e){xe(e)||console.error(e);return}}}function go(e,t){if(e)return typeof e==`string`?e:e[t]}function _o(e){return e?.scriptFormat??`module`}function vo(e,t,n){let r=yo(t),i=go(n,`script`)??r.crossOrigin;return{..._o(e)===`iife`?{rel:`preload`,as:`script`}:{rel:`modulepreload`},href:r.href,...i?{crossOrigin:i}:{}}}function yo(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}function bo(e,t){if(t.length===0)return;if(t.length===1){e.push(t[0]);return}let n=new Set;for(let r of t){let t=JSON.stringify(r);n.has(t)||(n.add(t),e.push(r))}}function xo(e){return typeof e==`string`?{href:e,crossOrigin:void 0}:e}function So({update:e,notify:t,unwatched:n}){return{link:r,unlink:i,propagate:a,checkDirty:o,shallowPropagate:s};function r(e,t,n){let r=t.depsTail;if(r!==void 0&&r.dep===e)return;let i=r===void 0?t.deps:r.nextDep;if(i!==void 0&&i.dep===e){i.version=n,t.depsTail=i;return}let a=e.subsTail;if(a!==void 0&&a.version===n&&a.sub===t)return;let o=t.depsTail=e.subsTail={version:n,dep:e,sub:t,prevDep:r,nextDep:i,prevSub:a,nextSub:void 0};i!==void 0&&(i.prevDep=o),r===void 0?t.deps=o:r.nextDep=o,a===void 0?e.subs=o:a.nextSub=o}function i(e,t=e.sub){let r=e.dep,i=e.prevDep,a=e.nextDep,o=e.nextSub,s=e.prevSub;return a===void 0?t.depsTail=i:a.prevDep=i,i===void 0?t.deps=a:i.nextDep=a,o===void 0?r.subsTail=s:o.prevSub=s,s===void 0?(r.subs=o)===void 0&&n(r):s.nextSub=o,a}function a(e){let n=e.nextSub,r;top:do{let i=e.sub,a=i.flags;if(a&60?a&12?a&4?!(a&48)&&c(e,i)?(i.flags=a|40,a&=1):a=0:i.flags=a&-9|32:a=0:i.flags=a|32,a&2&&t(i),a&1){let t=i.subs;if(t!==void 0){let i=(e=t).nextSub;i!==void 0&&(r={value:n,prev:r},n=i);continue}}if((e=n)!==void 0){n=e.nextSub;continue}for(;r!==void 0;)if(e=r.value,r=r.prev,e!==void 0){n=e.nextSub;continue top}break}while(1)}function o(t,n){let r,i=0,a=!1;top:do{let o=t.dep,c=o.flags;if(n.flags&16)a=!0;else if((c&17)==17){if(e(o)){let e=o.subs;e.nextSub!==void 0&&s(e),a=!0}}else if((c&33)==33){(t.nextSub!==void 0||t.prevSub!==void 0)&&(r={value:t,prev:r}),t=o.deps,n=o,++i;continue}if(!a){let e=t.nextDep;if(e!==void 0){t=e;continue}}for(;i--;){let i=n.subs,o=i.nextSub!==void 0;if(o?(t=r.value,r=r.prev):t=i,a){if(e(n)){o&&s(i),n=t.sub;continue}a=!1}else n.flags&=-33;n=t.sub;let c=t.nextDep;if(c!==void 0){t=c;continue top}}return a}while(1)}function s(e){do{let n=e.sub,r=n.flags;(r&48)==32&&(n.flags=r|16,(r&6)==2&&t(n))}while((e=e.nextSub)!==void 0)}function c(e,t){let n=t.depsTail;for(;n!==void 0;){if(n===e)return!0;n=n.prevDep}return!1}}function Co(e,t,n){let r=typeof e==`object`,i=r?e:void 0;return{next:(r?e.next:e)?.bind(i),error:(r?e.error:t)?.bind(i),complete:(r?e.complete:n)?.bind(i)}}var wo=[],To=0,{link:Eo,unlink:Do,propagate:Oo,checkDirty:ko,shallowPropagate:Ao}=So({update(e){return e._update()},notify(e){wo[Mo++]=e,e.flags&=-3},unwatched(e){e.depsTail!==void 0&&(e.depsTail=void 0,e.flags=17,Io(e))}}),jo=0,Mo=0,No,Po=0;function Fo(e){try{++Po,e()}finally{--Po||Lo()}}function Io(e){let t=e.depsTail,n=t===void 0?e.deps:t.nextDep;for(;n!==void 0;)n=Do(n,e)}function Lo(){if(!(Po>0)){for(;jo{i.get(),n.current?t.next?.(i._snapshot):n.current=!0});return{unsubscribe:()=>{r.stop()}}},_update(e){let a=No,o=t?.compare??Object.is;if(n)No=i,++To,i.depsTail=void 0;else if(e===void 0)return!1;n&&(i.flags=5);try{let t=i._snapshot,a=typeof e==`function`?e(t):e===void 0&&n?r(t):e;return t===void 0||!o(t,a)?(i._snapshot=a,!0):!1}finally{No=a,n&&(i.flags&=-5),Io(i)}}};return n?(i.flags=17,i.get=function(){let e=i.flags;if(e&16||e&32&&ko(i.deps,i)){if(i._update()){let e=i.subs;e!==void 0&&Ao(e)}}else e&32&&(i.flags=e&-33);return No!==void 0&&Eo(i,No,To),i._snapshot}):i.set=function(e){if(i._update(e)){let e=i.subs;e!==void 0&&(Oo(e),Ao(e),Lo())}},i}function zo(e){let t=()=>{let t=No;No=n,++To,n.depsTail=void 0,n.flags=6;try{return e()}finally{No=t,n.flags&=-5,Io(n)}},n={deps:void 0,depsTail:void 0,subs:void 0,subsTail:void 0,flags:6,notify(){let e=this.flags;e&16||e&32&&ko(this.deps,this)?t():this.flags=2},stop(){this.flags=0,this.depsTail=void 0,Io(this)}};return t(),n}var z=n(r(),1);function Bo(e,t){let n=e[1];e.length=0,n?.(t)}function Vo({t:e}){let t=ke(),n=t._rendered??=[];return t.startTransition=(r,i)=>new Promise(a=>{Bo(n,!1),n.push(i,a),e(t),z.startTransition(r)}),T(()=>{let e=t.history.subscribe(t.load);t.updateLatestLocation();let r=t.latestLocation,i=t.buildLocation({to:r.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});if(ie(r.publicHref)!==ie(i.publicHref))return t.commitLocation({...i,replace:!0,ignoreBlocker:!0}),e;let a=t.stores.resolvedLocation.get();return a?.href===r.href&&a.state.__TSR_key===r.state.__TSR_key?n.push(t.stores.matches.get(),e=>{e&&t.emit({type:`onRendered`,...sa(a,a)})}):t._tx||t.load({sync:!0}).catch(console.error),e},[t,t.history]),null}var B=t();function V(){let e=ke(),t=e.routesById[Te],n=Se(e,t),r=e.ssr?ye:z.Suspense,i=(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(Vo,{t:z.useState()[1]}),(0,B.jsx)(r,{fallback:n,children:(0,B.jsx)(Ho,{})})]});return e.options.InnerWrap?(0,B.jsx)(e.options.InnerWrap,{children:i}):i}function Ho(){let e=ke(),t=e._rendered,n=je(e.stores.matches,e=>t[0]??e),r=n[0],i=r?.routeId;T(()=>{t[0]===n&&Bo(t,!0)},[t,n]);let a=i?(0,B.jsx)(Ce,{routeId:i}):null;return(0,B.jsx)(we.Provider,{value:i,children:e.options.disableGlobalCatchBoundary?a:(0,B.jsx)(ve,{getResetKey:()=>r,onCatch:void 0,children:a})})}var Uo=e=>({createMutableStore:Ro,createReadonlyStore:Ro,batch:Fo}),Wo=e=>new Go(e),Go=class extends da{constructor(e){super(e,Uo)}};function Ko({router:e,children:t,...n}){_e(n)&&e.update({...e.options,...n,context:{...e.options.context,...n.context}});let r=(0,B.jsx)(Ae.Provider,{value:e,children:t});return e.options.Wrap?(0,B.jsx)(e.options.Wrap,{children:r}):r}function qo({router:e,...t}){return(0,B.jsx)(Ko,{router:e,...t,children:(0,B.jsx)(V,{})})}function Jo(e){let t=ke();return je(t.stores.location,C(e,t))}function Yo(e,t){if(t)for(let[n,r]of Object.entries(t))n!==`suppressHydrationWarning`&&r!==void 0&&r!==!1&&e.setAttribute(n,typeof r==`boolean`?``:String(r))}function Xo(e){let{attrs:t,children:n,nonce:r,preventScriptHoist:i}=e,a=z.useMemo(()=>n===void 0?void 0:{__html:n},[n]);switch(e.tag){case`title`:return(0,B.jsx)(`title`,{...t,suppressHydrationWarning:!0,children:n});case`meta`:return(0,B.jsx)(`meta`,{...t,suppressHydrationWarning:!0});case`link`:return(0,B.jsx)(`link`,{...t,precedence:t?.precedence??(t?.rel===`stylesheet`?`default`:void 0),nonce:r,suppressHydrationWarning:!0});case`style`:return e.inlineCss,(0,B.jsx)(`style`,{...t,dangerouslySetInnerHTML:a,nonce:r});case`script`:return(0,B.jsx)(Zo,{attrs:t,preventScriptHoist:i,children:n});default:return null}}function Zo({attrs:e,children:t,preventScriptHoist:n}){ke();let r=Oe(),i=z.useMemo(()=>t===void 0?void 0:{__html:t},[t]),a=typeof e?.type==`string`&&e.type!==``&&e.type!==`text/javascript`&&e.type!==`module`;if(z.useEffect(()=>{if(!a){if(e?.src){let t=document.createElement(`a`);t.href=e.src;let n=t.href;for(let e of document.scripts)if(e.src===n)return;let r=document.createElement(`script`);return Yo(r,e),document.head.appendChild(r),()=>r.remove()}if(typeof t==`string`){let n=typeof e?.type==`string`?e.type:`text/javascript`,r=typeof e?.nonce==`string`?e.nonce:void 0;for(let e of document.scripts){if(e.hasAttribute(`src`))continue;let i=e.getAttribute(`type`)??`text/javascript`,a=e.getAttribute(`nonce`)??void 0;if(e.textContent===t&&i===n&&a===r)return}let i=document.createElement(`script`);return i.textContent=t,Yo(i,e),document.head.appendChild(i),()=>i.remove()}}},[e,t,a]),a&&typeof t==`string`)return(0,B.jsx)(`script`,{...e,suppressHydrationWarning:!0,dangerouslySetInnerHTML:i});if(!r){if(e?.src)return(0,B.jsx)(`script`,{...e,suppressHydrationWarning:!0});if(typeof t==`string`)return(0,B.jsx)(`script`,{...e,dangerouslySetInnerHTML:i,suppressHydrationWarning:!0})}return null}function Qo(e,t,n,r){n=Da(n);let i=n.map(e=>e.meta).filter(e=>e!==void 0),a=[],o={},s;for(let e=i.length-1;e>=0;e--){let n=i[e];for(let e=n.length-1;e>=0;e--){let r=n[e];if(r){if(r.title)s||={tag:`title`,children:r.title};else if(`script:ld+json`in r)try{let e=JSON.stringify(r[`script:ld+json`]);a.push({tag:`script`,attrs:{type:`application/ld+json`},children:ce(e)})}catch{}else{let e=r.name??r.property;if(e){if(o[e])continue;o[e]=!0}a.push({tag:`meta`,attrs:{...r,nonce:t}})}}}}s&&a.push(s),t&&a.push({tag:`meta`,attrs:{property:`csp-nonce`,content:t}}),a.reverse();let c=n.flatMap(e=>e.links??[]).filter(e=>e!==void 0).map(e=>({tag:`link`,attrs:{...e,nonce:t}})),l=e.ssr?.manifest,u=[];l&&(n.forEach(e=>{(l.routes[e.routeId]?.css)?.forEach(e=>{let n=xo(e);u.push({tag:`link`,attrs:{rel:`stylesheet`,...n,crossOrigin:go(r,`stylesheet`)??n.crossOrigin,suppressHydrationWarning:!0,nonce:t}})})}),l.inlineStyle&&u.push({tag:`style`,attrs:{...l.inlineStyle.attrs,nonce:t},children:l.inlineStyle.children,inlineCss:!0}));let d=[];l&&n.forEach(e=>{l.routes[e.routeId]?.preloads?.forEach(e=>{d.push({tag:`link`,attrs:{...vo(l,e,r),nonce:t}})})});let f=n.flatMap(e=>e.styles??[]).filter(e=>e!==void 0).map(({children:e,...n})=>({tag:`style`,attrs:{...n,nonce:t},children:e})),p=n.flatMap(e=>e.headScripts??[]).filter(e=>e!==void 0).map(({children:e,...n})=>({tag:`script`,attrs:{...n,nonce:t},children:e})),m=[];return bo(m,a),m.push(...d),bo(m,c),m.push(...u),bo(m,f),bo(m,p),m}var $o=e=>{let t=ke(),n=t.options.ssr?.nonce,r=z.useCallback(r=>Qo(t,n,r,e),[e,n,t]);return je(t.stores.matches,r,{compare:me})};function es(e){let t=$o(e.assetCrossOrigin),n=ke().options.ssr?.nonce;return(0,B.jsx)(B.Fragment,{children:t.map(e=>(0,z.createElement)(Xo,{...e,key:`tsr-meta-${JSON.stringify(e)}`,nonce:n}))})}var ts=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m){if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&te(x,t.startTime-e)}}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&te(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,ee=k.port2;k.port1.onmessage=D,O=function(){ee.postMessage(null)}}else O=function(){_(D,0)};function te(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,te(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),ns=e(((e,t)=>{t.exports=ts()})),rs=e((e=>{var t=ns(),n=r(),i=Pe();function a(e){var t=`https://react.dev/errors/`+e;if(1be||(e.current=ye[be],ye[be]=null,be--)}function N(e,t){be++,ye[be]=e.current,e.current=t}var Ce=xe(null),we=xe(null),Te=xe(null),Ee=xe(null);function De(e,t){switch(N(Te,t),N(we,e),N(Ce,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?up(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=up(t),e=dp(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}Se(Ce),N(Ce,e)}function Oe(){Se(Ce),Se(we),Se(Te)}function ke(e){var t=e.memoizedState;t!==null&&(sh._currentValue=t.memoizedState,N(Ee,e)),t=Ce.current;var n=dp(t,e.type);t!==n&&(N(we,e),N(Ce,n))}function Ae(e){we.current===e&&(Se(Ce),Se(we)),Ee.current===e&&(Se(Ee),sh._currentValue=ve)}var je,Me;function Ne(e){if(je===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);je=t&&t[1]||``,Me=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{Fe=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ne(n):``}function Le(e,t){switch(e.tag){case 26:case 27:case 5:return Ne(e.type);case 16:return Ne(`Lazy`);case 13:return e.child!==t&&t!==null?Ne(`Suspense Fallback`):Ne(`Suspense`);case 19:return Ne(`SuspenseList`);case 0:case 15:return Ie(e.type,!1);case 11:return Ie(e.type.render,!1);case 1:return Ie(e.type,!0);case 31:return Ne(`Activity`);case 30:return Ne(`ViewTransition`);default:return``}}function Re(e){try{var t=``,n=null;do t+=Le(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var ze=Object.prototype.hasOwnProperty,Be=t.unstable_scheduleCallback,Ve=t.unstable_cancelCallback,He=t.unstable_shouldYield,Ue=t.unstable_requestPaint,We=t.unstable_now,Ge=t.unstable_getCurrentPriorityLevel,Ke=t.unstable_ImmediatePriority,P=t.unstable_UserBlockingPriority,qe=t.unstable_NormalPriority,Je=t.unstable_LowPriority,Ye=t.unstable_IdlePriority,Xe=t.log,F=t.unstable_setDisableYieldValue,Ze=null,Qe=null;function $e(e){if(typeof Xe==`function`&&F(e),Qe&&typeof Qe.setStrictMode==`function`)try{Qe.setStrictMode(Ze,e)}catch{}}var et=Math.clz32?Math.clz32:rt,tt=Math.log,nt=Math.LN2;function rt(e){return e>>>=0,e===0?32:31-(tt(e)/nt|0)|0}var it=256,at=262144,ot=4194304;function st(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&-e;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function ct(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=st(n))):i=st(o):i=st(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=st(n))):i=st(o)):i=st(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function lt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function ut(e,t){t&8&&(t|=t&32);var n=e.entangledLanes;if(n!==0)for(e=e.entanglements,n&=t;0n;n++)t.push(e);return t}function mt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ht(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),kn=!1;if(On)try{var An={};Object.defineProperty(An,"passive",{get:function(){kn=!0}}),window.addEventListener(`test`,An,An),window.removeEventListener(`test`,An,An)}catch{kn=!1}var jn=null,Mn=null,Nn=null;function Pn(){if(Nn)return Nn;var e,t=Mn,n=t.length,r,i=`value`in jn?jn.value:jn.textContent,a=i.length;for(e=0;e=pr),gr=` `,_r=!1;function vr(e,t){switch(e){case`keyup`:return dr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function yr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var br=!1;function xr(e,t){switch(e){case`compositionend`:return yr(t);case`keypress`:return t.which===32?(_r=!0,gr):null;case`textInput`:return e=t.data,e===gr&&_r?null:e;default:return null}}function Sr(e,t){if(br)return e===`compositionend`||!fr&&vr(e,t)?(e=Pn(),Nn=Mn=jn=null,br=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Gr(n)}}function qr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?qr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Jr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Wr(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Wr(e.document)}return t}function Yr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Xr=On&&`documentMode`in document&&11>=document.documentMode,Zr=null,Qr=null,$r=null,ei=!1;function ti(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;ei||Zr==null||Zr!==Wr(r)||(r=Zr,`selectionStart`in r&&Yr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),$r&&Ur($r,r)||($r=r,r=qf(Qr,`onSelect`),0>=o,i-=o,Zi=1<<32-et(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),R&&$i(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),R&&$i(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return R&&$i(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&(_=v.alternate,_!==null&&h.delete(_.key===null?g:_.key)),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),R&&$i(i,g),u}function _(e,r,o,c){if(typeof o==`object`&&o&&o.type===k&&o.key===null&&o.props.ref===void 0&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case D:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===k){if(r.tag===7){n(e,r.sibling),c=i(r,o.props.children),lo(c,o),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ce&&no(l)===r.type){n(e,r.sibling),c=i(r,o.props),lo(c,o),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}o.type===k?(c=Ri(o.props.children,e.mode,c,o.key),lo(c,o),c.return=e,e=c):(c=Li(o.type,o.key,o.props,null,e.mode,c),lo(c,o),c.return=e,e=c)}return s(e);case O:a:{for(l=o.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=i(r,o.children||[]),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}c=Vi(o,e.mode,c),c.return=e,e=c}return s(e);case ce:return o=no(o),_(e,r,o,c)}if(_e(o))return h(e,r,o,c);if(he(o)){if(l=he(o),typeof l!=`function`)throw Error(a(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return _(e,r,co(o),c);if(o.$$typeof===re)return _(e,r,Da(e,o),c);uo(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,o),c.return=e,e=c):(n(e,r),c=zi(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{so=0;var i=_(e,t,n,r);return oo=null,i}catch(t){if(t===Xa||t===Qa)throw t;var a=Ni(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var po=fo(!0),mo=fo(!1),ho=!1;function go(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function _o(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function vo(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function yo(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,K&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Ai(e),ki(e,null,n),t}return Ei(e,r,t,n),Ai(e)}function bo(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,_t(e,n)}}function xo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var So=!1;function Co(){if(So){var e=Va;if(e!==null)throw e}}function wo(e,t,n,r){So=!1;var i=e.updateQueue;ho=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(J&f)===f:(r&f)===f){f!==0&&f===Ba&&(So=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,h=s;f=t;var g=n;switch(h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(g,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(g,d,f):m,f==null)break a;d=T({},d,f);break a;case 2:ho=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),sd|=o,e.lanes=o,e.memoizedState=d}}function To(e,t){if(typeof e!=`function`)throw Error(a(191,e));e.call(t)}function Eo(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=j.T,s={};s.types=o===null?null:o.types,j.T=s,dc(e,!1,t,n);try{var c=i(),l=j.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?uc(e,t,Wa(c,r),Ad(e)):uc(e,t,r,Ad(e))}catch(n){uc(e,t,{then:function(){},status:`rejected`,reason:n},Ad())}finally{M.p=a,o!==null&&s.types!==null&&(o.types=s.types),j.T=o}}function ec(){}function tc(e,t,n,r){if(e.tag!==5)throw Error(a(476));var i=nc(e).queue;$s(e,i,t,ve,n===null?ec:function(){return rc(e),n(r)})}function nc(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ve,baseState:ve,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:fs,lastRenderedState:ve},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:fs,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function rc(e){var t=nc(e);t.next===null&&(t=e.alternate.memoizedState),uc(e,t.next.queue,{},Ad())}function ic(){return Ea(sh)}function ac(){return ss().memoizedState}function oc(){return ss().memoizedState}function sc(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Ad();e=vo(n);var r=yo(t,e,n);r!==null&&(Nd(r,t,n),bo(r,t,n)),t={cache:Na()},e.payload=t;return}t=t.return}}function cc(e,t,n){var r=Ad();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},fc(e)?pc(t,n):(n=Di(e,t,n,r),n!==null&&(Nd(n,e,r),mc(n,t,r)))}function lc(e,t,n){uc(e,t,n,Ad())}function uc(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(fc(e))pc(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Hr(s,o))return Ei(e,t,i,0),ed===null&&Ti(),!1}catch{}if(n=Di(e,t,i,r),n!==null)return Nd(n,e,r),mc(n,t,r),!0}return!1}function dc(e,t,n,r){if(r={lane:2,revertLane:Nf(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},fc(e)){if(t)throw Error(a(479))}else t=Di(e,n,r,2),t!==null&&Nd(t,e,2)}function fc(e){var t=e.alternate;return e===V||t!==null&&t===V}function pc(e,t){Go=Wo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function mc(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,_t(e,n)}}var hc={readContext:Ea,use:us,useCallback:Zo,useContext:Zo,useEffect:Zo,useImperativeHandle:Zo,useLayoutEffect:Zo,useInsertionEffect:Zo,useMemo:Zo,useReducer:Zo,useRef:Zo,useState:Zo,useDebugValue:Zo,useDeferredValue:Zo,useTransition:Zo,useSyncExternalStore:Zo,useId:Zo,useHostTransitionStatus:Zo,useFormState:Zo,useActionState:Zo,useOptimistic:Zo,useMemoCache:Zo,useCacheRefresh:Zo,useEffectEvent:Zo},gc={readContext:Ea,use:us,useCallback:function(e,t){return os().memoizedState=[e,t===void 0?null:t],e},useContext:Ea,useEffect:Bs,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),Rs(4194308,4,Ks.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Rs(4194308,4,e,t)},useInsertionEffect:function(e,t){Rs(4,2,e,t)},useMemo:function(e,t){var n=os();t=t===void 0?null:t;var r=e();if(Ko){$e(!0);try{e()}finally{$e(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=os();if(n!==void 0){var i=n(t);if(Ko){$e(!0);try{n(t)}finally{$e(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=cc.bind(null,V,e),[r.memoizedState,e]},useRef:function(e){var t=os();return e={current:e},t.memoizedState=e},useState:function(e){e=Ss(e);var t=e.queue,n=lc.bind(null,V,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Js,useDeferredValue:function(e,t){return Zs(os(),e,t)},useTransition:function(){var e=Ss(!1);return e=$s.bind(null,V,e.queue,!0,!1),os().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=V,i=os();if(R){if(n===void 0)throw Error(a(407));n=n()}else{if(n=t(),ed===null)throw Error(a(349));J&127||_s(r,t,n)}i.memoizedState=n;var o={value:n,getSnapshot:t};return i.queue=o,Bs(ys.bind(null,r,o,e),[e]),r.flags|=2048,Is(9,{destroy:void 0},vs.bind(null,r,o,n,t),null),n},useId:function(){var e=os(),t=ed.identifierPrefix;if(R){var n=Qi,r=Zi;n=(r&~(1<<32-et(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=qo++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(i,{is:r.is}):s.createElement(i)}}o[wt]=t,o[I]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(np(o,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&ml(t)}}return yl(t),t.subtreeFlags&=-33554433,hl(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&ml(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(a(166));if(e=Te.current,fa(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=ia,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[wt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||$f(e.nodeValue,n)),e||la(t,!0)}else e=lp(e).createTextNode(r),e[wt]=t,t.stateNode=e}return yl(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=fa(t),n!==null){if(e===null){if(!r)throw Error(a(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(a(557));e[wt]=t}else pa(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;yl(t),e=!1}else n=ma(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Ro(t),t):(Ro(t),null);if(t.flags&128)throw Error(a(558))}return yl(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=fa(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(a(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(a(317));i[wt]=t}else pa(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;yl(t),i=!1}else i=ma(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(Ro(t),t):(Ro(t),null)}return Ro(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),_l(t,t.updateQueue),yl(t),null);case 4:return Oe(),e===null&&Uf(t.stateNode.containerInfo),t.flags|=67108864,yl(t),null;case 10:return ba(t.type),yl(t),null;case 19:if(Bo(t),r=t.memoizedState,r===null)return yl(t),null;if(i=!!(t.flags&128),o=r.rendering,o===null){if(i)vl(r,!1);else{if(od!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=Vo(e),o!==null){for(t.flags|=128,vl(r,!1),e=o.updateQueue,t.updateQueue=e,_l(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)Ii(n,e),n=n.sibling;return z(t,zo.current&1|2),R&&$i(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&We()>X&&(t.flags|=128,i=!0,vl(r,!1),t.lanes=4194304)}}else{if(!i){if(e=Vo(o),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,_l(t,e),vl(r,!0),r.tail===null&&r.tailMode!==`collapsed`&&r.tailMode!==`visible`&&!o.alternate&&!R)return yl(t),null}else 2*We()-r.renderingStartTime>X&&n!==536870912&&(t.flags|=128,i=!0,vl(r,!1),t.lanes=4194304)}r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}if(r.tail!==null){e=r.tail;a:{for(n=e;n!==null;){if(n.alternate!==null){n=!1;break a}n=n.sibling}n=!0}return r.rendering=e,r.tail=e.sibling,r.renderingStartTime=We(),e.sibling=null,o=zo.current,o=i?o&1|2:o&1,r.tailMode===`visible`||r.tailMode===`collapsed`||!n||R?z(t,o):(n=o,N(Mo,t),N(zo,n),No===null&&(No=t)),R&&$i(t,r.treeForkCount),e}return yl(t),null;case 22:case 23:return Ro(t),jo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(yl(t),t.subtreeFlags&6&&(t.flags|=8192)):yl(t),n=t.updateQueue,n!==null&&_l(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&Se(Ka),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ba(Ma),yl(t),null;case 25:return null;case 30:return t.flags|=33554432,yl(t),null}throw Error(a(156,t.tag))}function xl(e,t){switch(na(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ba(Ma),Oe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Ae(t),null;case 31:if(t.memoizedState!==null){if(Ro(t),t.alternate===null)throw Error(a(340));pa()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ro(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(a(340));pa()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Bo(t),e=t.flags,e&65536?(t.flags=e&-65537|128,e=t.memoizedState,e!==null&&(e.rendering=null,e.tail=null),t.flags|=4,t):null;case 4:return Oe(),null;case 10:return ba(t.type),null;case 22:case 23:return Ro(t),jo(),e!==null&&Se(Ka),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ba(Ma),null;case 25:return null;default:return null}}function Sl(e,t){switch(na(t),t.tag){case 3:ba(Ma),Oe();break;case 26:case 27:case 5:Ae(t);break;case 4:Oe();break;case 31:t.memoizedState!==null&&Ro(t);break;case 13:Ro(t);break;case 19:Bo(t);break;case 10:ba(t.type);break;case 22:case 23:Ro(t),jo(),e!==null&&Se(Ka);break;case 24:ba(Ma)}}function Cl(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Q(t,t.return,e)}}function wl(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Q(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Q(t,t.return,e)}}function Tl(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Eo(t,n)}catch(t){Q(e,e.return,t)}}}function El(e,t,n){n.props=Cc(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Q(e,t,n)}}function Dl(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:var i=e.stateNode,a=vi(e.memoizedProps,i);(i.ref===null||i.ref.name!==a)&&(i.ref=Pp(a)),r=i.ref;break;case 7:if(e.stateNode===null){var o=new Fp(e);p(e.child,!1,Qp,o,void 0,void 0),e.stateNode=o}r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Q(e,t,n)}}function Ol(e,t){var n=e.ref,r=e.refCleanup;if(n!==null){if(typeof r==`function`)try{r()}catch(n){Q(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Q(e,t,n)}else n.current=null}}function kl(e,t){if((e.tag===5||e.tag===27||e.tag===6)&&e.alternate===null&&t!==null)for(var n=0;n title`))),np(r,t,n),r[wt]=e,Lt(r),t=r;break a;case`link`:if(o=Gm(`link`,`href`,i).get(t+(n.href||``))){for(s=0;sg&&(o=g,g=h,h=o);var _=Kr(s,h),v=Kr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,j.T=null,n=Cd,Cd=null;var o=yd,s=xd;if(Z=0,bd=yd=null,xd=0,K&6)throw Error(a(331));var c=K;if(K|=4,Yu(o.current),Vu(o,o.current,s,n),K=c,Ef(0,!1),Qe&&typeof Qe.onPostCommitFiberRoot==`function`)try{Qe.onPostCommitFiberRoot(Ze,o)}catch{}return!0}finally{M.p=i,j.T=r,lf(e,t)}}function ff(e,t,n){t=Ui(n,t),t=kc(e.stateNode,t,2),e=yo(e,t,2),e!==null&&(mt(e,2),Tf(e))}function Q(e,t,n){if(e.tag===3)ff(e,e,n);else for(;t!==null;){if(t.tag===3){ff(t,e,n);break}if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(vd===null||!vd.has(r))){e=Ui(n,e),n=Ac(2),r=yo(t,n,2),r!==null&&(jc(n,r,t,e),mt(r,2),Tf(r));break}}t=t.return}}function pf(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new $u;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(id=!0,i.add(n),e=mf.bind(null,e,t,n),t.then(e,e))}function mf(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,ed===e&&(J&n)===n&&(od===4||od===3&&(J&62914560)===J&&300>We()-hd?K&2?ld|=n:Bd(e,0):ld|=n,dd===J&&(dd=0)),Tf(e)}function hf(e,t){t===0&&(t=ft()),e=Oi(e,t),e!==null&&(mt(e,t),Tf(e))}function gf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),hf(e,n)}function _f(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(a(314))}r!==null&&r.delete(t),hf(e,n)}function vf(e,t){return Be(e,t)}var yf=null,bf=null,xf=!1,Sf=!1,Cf=!1,wf=0;function Tf(e){e!==bf&&e.next===null&&(bf===null?yf=bf=e:bf=bf.next=e),Sf=!0,xf||(xf=!0,Mf())}function Ef(e,t){if(!Cf&&Sf){Cf=!0;do for(var n=!1,r=yf;r!==null;){if(!t){if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-et(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,jf(r,a))}else a=J,a=ct(r,r===ed?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||lt(r,a)||(n=!0,jf(r,a))}r=r.next}while(n);Cf=!1}}function Df(){Of()}function Of(){Sf=xf=!1;var e=0;wf!==0&&hp()&&(e=wf);for(var t=We(),n=null,r=yf;r!==null;){var i=r.next,a=kf(r,t);a===0?(r.next=null,n===null?yf=i:n.next=i,i===null&&(bf=n)):(n=r,(e!==0||a&3)&&(Sf=!0)),r=i}Z!==0&&Z!==5||Ef(e,!1),wf!==0&&(wf=0)}function kf(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&ap(d)&&(c=c.responseEnd,o+=u*(c$m){s.length=o;break}f=new Promise(jp.bind(f)),s.push(f)}}}if(0`u`?null:document;function Tm(e,t,n){var r=wm;if(r&&typeof t==`string`&&t){var i=rn(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),ym.has(i)||(ym.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),np(t,`link`,e),Lt(t),r.head.appendChild(t)))}}function Em(e){xm.D(e),Tm(`dns-prefetch`,e,null)}function Dm(e,t){xm.C(e,t),Tm(`preconnect`,e,t)}function Om(e,t,n){xm.L(e,t,n);var r=wm;if(r&&e&&t){var i=`link[rel="preload"][as="`+rn(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+rn(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+rn(n.imageSizes)+`"]`)):i+=`[href="`+rn(e)+`"]`;var a=i;switch(t){case`style`:a=Pm(e);break;case`script`:a=Rm(e)}if(!(vm.has(a)||(e=T({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),vm.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(Fm(a))||t===`script`&&r.querySelector(zm(a))))){var o=r.createElement(`link`);np(o,`link`,e),t===`style`&&(o[jt]=!0,o.onload=o.onerror=function(){Rt(o)}),Lt(o),r.head.appendChild(o)}}}function km(e,t){xm.m(e,t);var n=wm;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+rn(r)+`"][href="`+rn(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Rm(e)}if(!vm.has(a)&&(e=T({rel:`modulepreload`,href:e},t),vm.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(zm(a)))return}r=n.createElement(`link`),np(r,`link`,e),Lt(r),n.head.appendChild(r)}}}function Am(e,t,n){xm.S(e,t,n);var r=wm;if(r&&e){var i=It(r).hoistableStyles,a=Pm(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(Fm(a)))s.loading=5;else{e=T({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=vm.get(a))&&Hm(e,n);var c=o=r.createElement(`link`);Lt(c),np(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Vm(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function jm(e,t){xm.X(e,t);var n=wm;if(n&&e){var r=It(n).hoistableScripts,i=Rm(e),a=r.get(i);a||(a=n.querySelector(zm(i)),a||(e=T({src:e,async:!0},t),(t=vm.get(i))&&Um(e,t),a=n.createElement(`script`),Lt(a),np(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Mm(e,t){xm.M(e,t);var n=wm;if(n&&e){var r=It(n).hoistableScripts,i=Rm(e),a=r.get(i);a||(a=n.querySelector(zm(i)),a||(e=T({src:e,async:!0,type:`module`},t),(t=vm.get(i))&&Um(e,t),a=n.createElement(`script`),Lt(a),np(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Nm(e,t,n,r){var i=(i=Te.current)?bm(i):null;if(!i)throw Error(a(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(n=Pm(n.href),t=It(i).hoistableStyles,r=t.get(n),r||(r={type:`style`,instance:null,count:0,state:null},t.set(n,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Pm(n.href);var o=It(i).hoistableStyles,s=o.get(e);if(s||(i=i.ownerDocument||i,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=i.querySelector(Fm(e)))?o._p||(s.instance=o,s.state.loading=5):(o=vm.get(e),o||(o={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vm.set(e,o)),Lm(i,e,o,s.state))),t&&r===null)throw Error(a(528,``));return s}if(t&&r!==null)throw Error(a(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(n=Rm(n),t=It(i).hoistableScripts,r=t.get(n),r||(r={type:`script`,instance:null,count:0,state:null},t.set(n,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(a(444,e))}}function Pm(e){return`href="`+rn(e)+`"`}function Fm(e){return`link[rel="stylesheet"][`+e+`]`}function Im(e){return T({},e,{"data-precedence":e.precedence,precedence:null})}function Lm(e,t,n,r){if(t=e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)){if(!0!==t[jt]){r.loading=1;return}}else t=e.createElement(`link`),t[jt]=!0,t.onload=t.onerror=Rt.bind(null,t),np(t,`link`,n),Lt(t),e.head.appendChild(t);r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2})}function Rm(e){return`[src="`+rn(e)+`"]`}function zm(e){return`script[async]`+e}function Bm(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+rn(n.href)+`"]`);if(r)return t.instance=r,Lt(r),r;var i=T({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Lt(r),np(r,`style`,i),Vm(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Pm(n.href);var o=e.querySelector(Fm(i));if(o)return t.state.loading|=4,t.instance=o,Lt(o),o;r=Im(n),(i=vm.get(i))&&Hm(r,i),o=(e.ownerDocument||e).createElement(`link`),Lt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),np(o,`link`,r),t.state.loading|=4,Vm(o,n.precedence,e),t.instance=o;case`script`:return o=Rm(n.src),(i=e.querySelector(zm(o)))?(t.instance=i,Lt(i),i):(r=n,(i=vm.get(o))&&(r=T({},n),Um(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),Lt(i),np(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(a(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Vm(r,n.precedence,e));return t.instance}function Vm(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function qm(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Jm(e,t){return e===`img`&&t.src!=null&&t.src!==``&&t.onLoad==null&&t.loading!==`lazy`}function Ym(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Xm(e){return(e.width||100)*(e.height||100)*(typeof devicePixelRatio==`number`?devicePixelRatio:1)*.25}function Zm(e,t){typeof t.decode==`function`&&(e.imgCount++,t.complete||(e.imgBytes+=Xm(t),e.suspenseyImages.push(t)),e=rh.bind(e),t.decode().then(e,e))}function Qm(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Pm(r.href),a=t.querySelector(Fm(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=nh.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Lt(a);return}a=t.ownerDocument||t,r=Im(r),(i=vm.get(i))&&Hm(r,i),a=a.createElement(`link`),Lt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),np(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=nh.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var $m=0;function eh(e,t){return e.stylesheets&&e.count===0&&ah(e,e.stylesheets),0$m?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function th(e){if(e.count===0&&(e.imgCount===0||!e.waitingForImages)){if(e.stylesheets)ah(e,e.stylesheets);else if(e.unsuspend){var t=e.unsuspend;e.unsuspend=null,t()}}}function nh(){this.count--,th(this)}function rh(){this.imgCount--,th(this)}var ih=null;function ah(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,ih=new Map,t.forEach(oh,e),ih=null,nh.call(e))}function oh(e,t){if(!(t.state.loading&4)){var n=ih.get(e);if(n)var r=n.get(null);else{n=new Map,ih.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=rs()}))(),1),H=i(),as=(0,z.createContext)(void 0);function os(e,t){try{let t=localStorage.getItem(e);if(t===`light`||t===`dark`||t===`system`)return t}catch{}return t}function ss(e){let t=(0,H.c)(14),{children:n,defaultTheme:r,storageKey:i}=e,a=r===void 0?`dark`:r,o=i===void 0?`ui-theme`:i,s;t[0]!==a||t[1]!==o?(s=()=>os(o,a),t[0]=a,t[1]=o,t[2]=s):s=t[2];let[c,l]=(0,z.useState)(s),u,d;t[3]===c?(u=t[4],d=t[5]):(u=()=>{let e=window.document.documentElement;if(e.classList.remove(`light`,`dark`),c===`system`){let t=window.matchMedia(`(prefers-color-scheme: dark)`).matches?`dark`:`light`;e.classList.add(t);return}e.classList.add(c)},d=[c],t[3]=c,t[4]=u,t[5]=d),(0,z.useEffect)(u,d);let f;t[6]===o?f=t[7]:(f=e=>{try{localStorage.setItem(o,e)}catch{}l(e)},t[6]=o,t[7]=f);let p;t[8]!==f||t[9]!==c?(p={theme:c,setTheme:f},t[8]=f,t[9]=c,t[10]=p):p=t[10];let m=p,h;return t[11]!==n||t[12]!==m?(h=(0,B.jsx)(as.Provider,{value:m,children:n}),t[11]=n,t[12]=m,t[13]=h):h=t[13],h}function cs(){let e=(0,z.useContext)(as);if(e===void 0)throw Error(`useTheme must be used within a ThemeProvider`);return e}var ls={name:`activity`,size:24,node:[[`path`,{d:`M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2`,key:`169zse`}]]};ls.node;var us=F(ls),ds={name:`book-open`,size:24,node:[[`path`,{d:`M12 5v16`,key:`1f6ucr`}],[`path`,{d:`M20.001 19A2 2 0 0022 17V5a2 2 0 00-1.999-2L16 3.002A5 5 0 0012 5a5 5 0 00-4-2H4a2 2 0 00-2 2v12a2 2 0 001.999 2H8a5 5 0 014 2 5 5 0 014-2z`,key:`1fyvmf`}]]};ds.node;var fs=F(ds),ps={name:`boxes`,size:24,node:[[`path`,{d:`M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z`,key:`lc1i9w`}],[`path`,{d:`m7 16.5-4.74-2.85`,key:`1o9zyk`}],[`path`,{d:`m7 16.5 5-3`,key:`va8pkn`}],[`path`,{d:`M7 16.5v5.17`,key:`jnp8gn`}],[`path`,{d:`M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z`,key:`8zsnat`}],[`path`,{d:`m17 16.5-5-3`,key:`8arw3v`}],[`path`,{d:`m17 16.5 4.74-2.85`,key:`8rfmw`}],[`path`,{d:`M17 16.5v5.17`,key:`k6z78m`}],[`path`,{d:`M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z`,key:`1xygjf`}],[`path`,{d:`M12 8 7.26 5.15`,key:`1vbdud`}],[`path`,{d:`m12 8 4.74-2.85`,key:`3rx089`}],[`path`,{d:`M12 13.5V8`,key:`1io7kd`}]]};ps.node;var ms=F(ps),hs={name:`brain`,size:24,node:[[`path`,{d:`M12 18V5`,key:`adv99a`}],[`path`,{d:`M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4`,key:`1e3is1`}],[`path`,{d:`M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5`,key:`1gqd8o`}],[`path`,{d:`M17.997 5.125a4 4 0 0 1 2.526 5.77`,key:`iwvgf7`}],[`path`,{d:`M18 18a4 4 0 0 0 2-7.464`,key:`efp6ie`}],[`path`,{d:`M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517`,key:`1gq6am`}],[`path`,{d:`M6 18a4 4 0 0 1-2-7.464`,key:`k1g0md`}],[`path`,{d:`M6.003 5.125a4 4 0 0 0-2.526 5.77`,key:`q97ue3`}]]};hs.node;var gs=F(hs),_s={name:`circle-alert`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]],aliases:[`alert-circle`]};_s.node;var vs=F(_s),ys={name:`container`,size:24,node:[[`path`,{d:`M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z`,key:`1t2lqe`}],[`path`,{d:`M10 21.9V14L2.1 9.1`,key:`o7czzq`}],[`path`,{d:`m10 14 11.9-6.9`,key:`zm5e20`}],[`path`,{d:`M14 19.8v-8.1`,key:`159ecu`}],[`path`,{d:`M18 17.5V9.4`,key:`11uown`}]]};ys.node;var bs=F(ys),xs={name:`crosshair`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`22`,x2:`18`,y1:`12`,y2:`12`,key:`l9bcsi`}],[`line`,{x1:`6`,x2:`2`,y1:`12`,y2:`12`,key:`13hhkx`}],[`line`,{x1:`12`,x2:`12`,y1:`6`,y2:`2`,key:`10w3f3`}],[`line`,{x1:`12`,x2:`12`,y1:`22`,y2:`18`,key:`15g9kq`}]]};xs.node;var Ss=F(xs),Cs={name:`database`,size:24,node:[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]};Cs.node;var ws=F(Cs),Ts={name:`file-stack`,size:24,node:[[`path`,{d:`M11 21a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1`,key:`likhh7`}],[`path`,{d:`M16 16a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1`,key:`17ky3x`}],[`path`,{d:`M21 6a2 2 0 0 0-.586-1.414l-2-2A2 2 0 0 0 17 2h-3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1z`,key:`1hyeo0`}]]};Ts.node;var Es=F(Ts),Ds={name:`house`,size:24,node:[[`path`,{d:`M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8`,key:`5wwlr5`}],[`path`,{d:`M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`r6nss1`}]],aliases:[`home`]};Ds.node;var Os=F(Ds),ks={name:`id-card`,size:24,node:[[`path`,{d:`M13 19a4 4 0 00-8 0`,key:`cugzd5`}],[`path`,{d:`M16 10h2`,key:`8sgtl7`}],[`path`,{d:`M16 14h2`,key:`epxaof`}],[`circle`,{cx:`9`,cy:`12`,r:`3`,key:`u3jwor`}],[`rect`,{x:`2`,y:`5`,width:`20`,height:`14`,rx:`2`,key:`qneu4z`}]]};ks.node;var As=F(ks),js={name:`key-round`,size:24,node:[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]};js.node;var Ms=F(js),Ns={name:`layers`,size:24,node:[[`path`,{d:`M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z`,key:`zw3jo`}],[`path`,{d:`M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12`,key:`1wduqc`}],[`path`,{d:`M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17`,key:`kqbvx6`}]],aliases:[`layers-3`]};Ns.node;var Ps=F(Ns),Fs={name:`layout-grid`,size:24,node:[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`3`,rx:`1`,key:`6d4xhi`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`14`,rx:`1`,key:`nxv5o0`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}]]};Fs.node;var Is=F(Fs),Ls={name:`layout-list`,size:24,node:[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}],[`path`,{d:`M14 4h7`,key:`3xa0d5`}],[`path`,{d:`M14 9h7`,key:`1icrd9`}],[`path`,{d:`M14 15h7`,key:`1mj8o2`}],[`path`,{d:`M14 20h7`,key:`11slyb`}]]};Ls.node;var Rs=F(Ls),zs={name:`log-out`,size:24,node:[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]};zs.node;var Bs=F(zs),Vs={name:`map-pin`,size:24,node:[[`path`,{d:`M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0`,key:`1r0f0z`}],[`circle`,{cx:`12`,cy:`10`,r:`3`,key:`ilqhr7`}]]};Vs.node;var Hs=F(Vs),Us={name:`message-square`,size:24,node:[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]};Us.node;var Ws=F(Us),Gs={name:`moon`,size:24,node:[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]};Gs.node;var Ks=F(Gs),qs={name:`package`,size:24,node:[[`path`,{d:`M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z`,key:`1a0edw`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}],[`polyline`,{points:`3.29 7 12 12 20.71 7`,key:`ousv84`}],[`path`,{d:`m7.5 4.27 9 5.15`,key:`1c824w`}]]};qs.node;var Js=F(qs),Ys={name:`panel-left`,size:24,node:[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`M9 3v18`,key:`fh3hqa`}]],aliases:[`sidebar`]};Ys.node;var Xs=F(Ys),Zs={name:`route`,size:24,node:[[`circle`,{cx:`6`,cy:`19`,r:`3`,key:`1kj8tv`}],[`path`,{d:`M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15`,key:`1d8sl`}],[`circle`,{cx:`18`,cy:`5`,r:`3`,key:`gq8acd`}]]};Zs.node;var Qs=F(Zs),$s={name:`shield-check`,size:24,node:[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]};$s.node;var ec=F($s),tc={name:`sun`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`4`,key:`4exip2`}],[`path`,{d:`M12 2v2`,key:`tus03m`}],[`path`,{d:`M12 20v2`,key:`1lh1kg`}],[`path`,{d:`m4.93 4.93 1.41 1.41`,key:`149t6j`}],[`path`,{d:`m17.66 17.66 1.41 1.41`,key:`ptbguv`}],[`path`,{d:`M2 12h2`,key:`1t8f8n`}],[`path`,{d:`M20 12h2`,key:`1q8mjw`}],[`path`,{d:`m6.34 17.66-1.41 1.41`,key:`1m8zz5`}],[`path`,{d:`m19.07 4.93-1.41 1.41`,key:`1shlcs`}]]};tc.node;var nc=F(tc),rc={name:`user-cog`,size:24,node:[[`path`,{d:`M10 15H6a4 4 0 0 0-4 4v2`,key:`1nfge6`}],[`path`,{d:`m14.305 16.53.923-.382`,key:`1itpsq`}],[`path`,{d:`m15.228 13.852-.923-.383`,key:`eplpkm`}],[`path`,{d:`m16.852 12.228-.383-.923`,key:`13v3q0`}],[`path`,{d:`m16.852 17.772-.383.924`,key:`1i8mnm`}],[`path`,{d:`m19.148 12.228.383-.923`,key:`1q8j1v`}],[`path`,{d:`m19.53 18.696-.382-.924`,key:`vk1qj3`}],[`path`,{d:`m20.772 13.852.924-.383`,key:`n880s0`}],[`path`,{d:`m20.772 16.148.924.383`,key:`1g6xey`}],[`circle`,{cx:`18`,cy:`15`,r:`3`,key:`gjjjvw`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]};rc.node;var ic=F(rc),ac={name:`users-round`,size:24,node:[[`path`,{d:`M18 21a8 8 0 0 0-16 0`,key:`3ypg7q`}],[`circle`,{cx:`10`,cy:`8`,r:`5`,key:`o932ke`}],[`path`,{d:`M22 20c0-3.37-2-6.5-4-8a5 5 0 0 0-.45-8.3`,key:`10s06x`}]],aliases:[`users-2`]};ac.node;var oc=F(ac),sc={name:`users`,size:24,node:[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]};sc.node;var cc=F(sc);function lc(e,t){return t!=null&&!Ut(t)?0:typeof e==`function`?e():e}function uc(e,t,n){let r=lc(e,n);return typeof r==`number`?r:r?.[t]}function dc(e){return typeof e==`function`?e():e}function fc(e,t){return t||e===`click`||e===`mousedown`}function pc(e){return e?.includes(`mouse`)&&e!==`mousedown`}var mc=z.createContext({hasProvider:!1,timeoutMs:0,delayRef:{current:0},initialDelayRef:{current:0},timeout:new kt,currentIdRef:{current:null},currentContextRef:{current:null}});function hc(e,t){e.current=t.current}function gc(e){let{children:t,delay:n,timeoutMs:r=0}=e,i=z.useRef(n),a=z.useRef(n),o=z.useRef(null),s=z.useRef(null),c=Mt();return ht(()=>{if(a.current=n,!o.current){i.current=n;return}i.current={open:uc(i.current,`open`),close:uc(n,`close`)}},[n,o,i,a]),(0,B.jsx)(mc.Provider,{value:z.useMemo(()=>({hasProvider:!0,delayRef:i,initialDelayRef:a,currentIdRef:o,timeoutMs:r,currentContextRef:s,timeout:c}),[r,c]),children:t})}function _c(e,t={open:!1}){let{open:n}=t,r=`rootStore`in e?e.rootStore:e,i=r.useState(`floatingId`),{currentIdRef:a,delayRef:o,timeoutMs:s,initialDelayRef:c,currentContextRef:l,hasProvider:u,timeout:d}=z.useContext(mc),[f,p]=z.useState(!1),m=z.useRef(n);return ht(()=>{m.current=n},[n]),ht(()=>{function e(){l.current?.setIsInstantPhase(!1),a.current=null,l.current=null,o.current=c.current,d.clear()}if(a.current&&!n&&a.current===i){if(p(!1),s){let t=i;return d.start(s,()=>{r.select(`open`)||a.current&&a.current!==t||e()}),()=>{(m.current||a.current!==t)&&d.clear()}}e()}},[n,i,a,o,s,c,l,d,r]),ht(()=>{if(!n)return;let e=l.current,t=a.current;d.clear(),l.current={onOpenChange:r.setOpen,setIsInstantPhase:p},a.current=i,o.current={open:0,close:uc(c.current,`close`)},t!==null&&t!==i?(p(!0),e?.setIsInstantPhase(!0),e?.onOpenChange(!1,Ot(Ct))):(p(!1),e?.setIsInstantPhase(!1))},[n,i,r,a,o,c,l,d]),ht(()=>()=>{if(a.current===i){if(l.current=null,!m.current)return;a.current=null,hc(o,c),d.clear()}},[l,a,o,i,c,d]),z.useMemo(()=>({activeIdRef:a,hasProvider:u,delayRef:o,isInstantPhase:f}),[a,u,o,f])}function vc(e,t){let n=null,r=null,i=!1;return{contextElement:e||void 0,getBoundingClientRect(){let a=e?.getBoundingClientRect()||{width:0,height:0,x:0,y:0},o=t.axis===`x`||t.axis===`both`,s=t.axis===`y`||t.axis===`both`,c=[`mouseenter`,`mousemove`].includes(t.dataRef.current.openEvent?.type||``)&&t.pointerType!==`touch`,l=a.width,u=a.height,d=a.x,f=a.y;return n==null&&t.x&&o&&(n=a.x-t.x),r==null&&t.y&&s&&(r=a.y-t.y),d-=n||0,f-=r||0,l=0,u=0,!i||c?(l=t.axis===`y`?a.width:0,u=t.axis===`x`?a.height:0,d=o&&t.x!=null?t.x:d,f=s&&t.y!=null?t.y:f):i&&!c&&(u=t.axis===`x`?a.height:u,l=t.axis===`y`?a.width:l),i=!0,{width:l,height:u,x:d,y:f,top:f,right:d+l,bottom:f+u,left:d}}}}function yc(e){return e!=null&&e.clientX!=null}function bc(e,t={}){let{enabled:n=!0,axis:r=`both`}=t,i=`rootStore`in e?e.rootStore:e,a=i.useState(`open`),o=i.useState(`floatingElement`),s=i.useState(`domReferenceElement`),c=i.context.dataRef,l=z.useRef(!1),u=z.useRef(null),[d,f]=z.useState(),[p,m]=z.useState([]),h=gt(e=>{i.set(`positionReference`,e)}),g=gt((e,t,n)=>{l.current||(!c.current.openEvent||yc(c.current.openEvent))&&i.set(`positionReference`,vc(n??s,{x:e,y:t,axis:r,dataRef:c,pointerType:d}))}),_=gt(e=>{a?u.current||(g(e.clientX,e.clientY,e.currentTarget),m([])):g(e.clientX,e.clientY,e.currentTarget)}),v=Ut(d)?o:a;z.useEffect(()=>{if(!n){h(s);return}if(!v)return;function e(){u.current?.(),u.current=null}let t=ft(o);function r(t){let n=yt(t);xt(o,n)?e():g(t.clientX,t.clientY)}return!c.current.openEvent||yc(c.current.openEvent)?u.current=Ie(t,`mousemove`,r):h(s),e},[v,n,o,c,s,i,g,h,p]),z.useEffect(()=>()=>{i.set(`positionReference`,null)},[i]),z.useEffect(()=>{n&&!o&&(l.current=!1)},[n,o]),z.useEffect(()=>{!n&&a&&(l.current=!0)},[n,a]);let y=z.useMemo(()=>{function e(e){f(e.pointerType)}return{onPointerDown:e,onPointerEnter:e,onMouseMove:_,onMouseEnter:_}},[_]);return z.useMemo(()=>n?{reference:y,trigger:y}:{},[n,y])}var xc=Me();function Sc(e){let t=z.useCallback(t=>e===void 0?He:e.subscribeStore(t),[e]),n=z.useCallback(()=>e===void 0?void 0:e.store,[e]);return(0,xc.useSyncExternalStore)(t,n,()=>e?.serverStore)}var Cc=on&&Ht;function wc(e,t={}){let{enabled:n=!0,delay:r}=t,i=`rootStore`in e?e.rootStore:e,{events:a,dataRef:o}=i.context,s=z.useRef(!1),c=z.useRef(null),l=z.useRef(!0),u=Mt();z.useEffect(()=>{let e=i.select(`domReferenceElement`);if(!n)return;let t=ft(e);function r(){let e=i.select(`domReferenceElement`);!i.select(`open`)&&pt(e)&&e===jt(bt(e))&&(s.current=!0,c.current=e)}function a(){l.current=!0}function o(){l.current=!1}return It(Ie(t,`blur`,r),Cc&&Ie(t,`keydown`,a,!0),Cc&&Ie(t,`pointerdown`,o,!0))},[i,n]),z.useEffect(()=>{if(!n)return;function e(e){if(e.reason===`trigger-press`||e.reason===`escape-key`){let e=i.select(`domReferenceElement`);vt(e)&&(c.current=e,s.current=!0)}}return a.on(`openchange`,e),()=>{a.off(`openchange`,e)}},[a,n,i]);let d=z.useMemo(()=>{function e(){s.current=!1,c.current=null}return{onMouseLeave(){e()},onFocus(t){let n=t.currentTarget;if(s.current){if(c.current===n)return;e()}let a=yt(t.nativeEvent);if(vt(a)){if(Cc&&!t.relatedTarget){if(!l.current&&!Jt(a))return}else if(!Wt(a))return}let o=Qt(t.relatedTarget,i.context.triggerElements),{nativeEvent:d,currentTarget:f}=t,p=typeof r==`function`?r():r;if(i.select(`open`)&&o||p===0||p===void 0){i.setOpen(!0,Ot(Et,d,f));return}u.start(p,()=>{s.current||i.setOpen(!0,Ot(Et,d,f))})},onBlur(t){e();let n=t.relatedTarget,r=t.nativeEvent,a=vt(n)&&n.hasAttribute(ln(`focus-guard`))&&n.getAttribute(`data-type`)===`outside`;u.start(0,()=>{let e=i.select(`domReferenceElement`),t=jt(bt(e));(n||t!==e)&&(xt(o.current.floatingContext?.refs.floating.current,t)||xt(e,t)||a||Qt(n??t,i.context.triggerElements)||i.setOpen(!1,Ot(Et,r)))})}}},[o,r,i,u]);return z.useMemo(()=>n?{reference:d,trigger:d}:{},[n,d])}var Tc=class e{constructor(){this.pointerType=void 0,this.interactedInside=!1,this.handler=void 0,this.blockMouseMove=!0,this.performedPointerEventsMutation=!1,this.pointerEventsScopeElement=null,this.pointerEventsReferenceElement=null,this.pointerEventsFloatingElement=null,this.restTimeoutPending=!1,this.openChangeTimeout=new kt,this.restTimeout=new kt,this.handleCloseOptions=void 0}static create(){return new e}dispose=()=>{this.openChangeTimeout.clear(),this.restTimeout.clear()};disposeEffect=()=>this.dispose},Ec=new WeakMap;function Dc(e){if(!e.performedPointerEventsMutation)return;let t=e.pointerEventsScopeElement;t&&Ec.get(t)===e&&(e.pointerEventsScopeElement?.style.removeProperty(`pointer-events`),e.pointerEventsReferenceElement?.style.removeProperty(`pointer-events`),e.pointerEventsFloatingElement?.style.removeProperty(`pointer-events`),Ec.delete(t)),e.performedPointerEventsMutation=!1,e.pointerEventsScopeElement=null,e.pointerEventsReferenceElement=null,e.pointerEventsFloatingElement=null}function Oc(e,t){let{scopeElement:n,referenceElement:r,floatingElement:i}=t,a=Ec.get(n);a&&a!==e&&Dc(a),Dc(e),e.performedPointerEventsMutation=!0,e.pointerEventsScopeElement=n,e.pointerEventsReferenceElement=r,e.pointerEventsFloatingElement=i,Ec.set(n,e),n.style.pointerEvents=`none`,r.style.pointerEvents=`auto`,i.style.pointerEvents=`auto`}function kc(e){let t=e.context.dataRef.current,n=Be(()=>t.hoverInteractionState??Tc.create()).current;return t.hoverInteractionState||=n,St(t.hoverInteractionState.disposeEffect),t.hoverInteractionState}function Ac(e,t={}){let{enabled:n=!0,closeDelay:r=0,nodeId:i}=t,a=`rootStore`in e?e.rootStore:e,o=a.useState(`open`),s=a.useState(`floatingElement`),c=a.useState(`domReferenceElement`),{dataRef:l}=a.context,u=Rt(),d=Yt(),f=kc(a),p=Mt(),m=gt(()=>fc(l.current.openEvent?.type,f.interactedInside)),h=gt(()=>pc(l.current.openEvent?.type)),g=gt(()=>{Dc(f)});ht(()=>{o||(f.pointerType=void 0,f.restTimeoutPending=!1,f.interactedInside=!1,g())},[o,f,g]),z.useEffect(()=>g,[g]),ht(()=>{if(n&&o&&f.handleCloseOptions?.blockPointerEvents&&h()&&vt(c)&&s){let e=c,t=s,n=bt(s),r=u?.nodesRef.current.find(e=>e.id===d)?.context?.elements.floating;r&&(r.style.pointerEvents=``);let i=f.pointerEventsScopeElement===t?null:f.pointerEventsScopeElement,a=r===t?null:r,o=f.handleCloseOptions?.getScope?.()??i??a??e.closest(`[data-rootownerid]`)??n.body;return Oc(f,{scopeElement:o,referenceElement:e,floatingElement:t}),()=>{g()}}},[n,o,c,s,f,h,u,d,g]),z.useEffect(()=>{if(!n)return;function e(){return!!(u&&d&&Nt(u.nodesRef.current,d).length>0)}function t(e){let t=uc(r,`close`,f.pointerType),n=()=>{a.setOpen(!1,Ot(I,e)),u?.events.emit(`floating.closed`,e)};t?f.openChangeTimeout.start(t,n):(f.openChangeTimeout.clear(),n())}function o(e){let t=yt(e);if(!nn(t)){f.interactedInside=!1;return}f.interactedInside=t?.closest(`[aria-haspopup]`)!=null}function c(){f.openChangeTimeout.clear(),p.clear(),u?.events.off(`floating.closed`,v),g()}function _(n){if(e()&&u){u.events.on(`floating.closed`,v);return}if(Qt(n.relatedTarget,a.context.triggerElements))return;let r=l.current.floatingContext?.nodeId??i,o=n.relatedTarget;if(!(u&&r&&vt(o)&&Nt(u.nodesRef.current,r,!1).some(e=>xt(e.context?.elements.floating,o)))){if(f.handler){f.handler(n);return}g(),h()&&!m()&&t(n)}}function v(t){u&&d&&!e()&&p.start(0,()=>{u.events.off(`floating.closed`,v),a.setOpen(!1,Ot(I,t)),u.events.emit(`floating.closed`,t)})}let y=s;return It(y&&Ie(y,`mouseenter`,c),y&&Ie(y,`mouseleave`,_),y&&Ie(y,`pointerdown`,o,!0),()=>{u?.events.off(`floating.closed`,v)})},[n,s,a,l,r,i,h,m,g,f,u,d,p])}var jc=n(Pe(),1),Mc={current:null};function Nc(e,t={}){let{enabled:n=!0,delay:r=0,handleClose:i=null,mouseOnly:a=!1,restMs:o=0,move:s=!0,triggerElementRef:c=Mc,externalTree:l,isActiveTrigger:u=!0,getHandleCloseContext:d,isClosing:f,shouldOpen:p,guardStaleOpen:m=!1}=t,h=`rootStore`in e?e.rootStore:e,{dataRef:g,events:_}=h.context,v=Rt(l),y=kc(h),b=z.useRef(!1),x=Le(i),S=Le(r),C=Le(o),w=Le(n),T=Le(p),E=Le(f),D=gt(()=>fc(g.current.openEvent?.type,y.interactedInside)),O=gt(()=>T.current?.()!==!1),k=gt((e,t,n)=>{let r=h.context.triggerElements;if(r.hasElement(t))return!e||!xt(e,t);if(!vt(n))return!1;let i=n;return r.hasMatchingElement(e=>xt(e,i))&&(!e||!xt(e,i))}),ee=gt(()=>{y.handler&&=(bt(h.select(`domReferenceElement`)).removeEventListener(`mousemove`,y.handler),void 0)}),te=gt(()=>{Dc(y)});return u&&(y.handleCloseOptions=i?.__options),z.useEffect(()=>ee,[ee]),z.useEffect(()=>{if(!n)return;function e(e){e.open?b.current=!1:(b.current=e.reason===I,ee(),y.openChangeTimeout.clear(),y.restTimeout.clear(),y.blockMouseMove=!0,y.restTimeoutPending=!1)}return _.on(`openchange`,e),()=>{_.off(`openchange`,e)}},[n,_,y,ee]),z.useEffect(()=>{if(!n)return;function e(e,t=!0){let n=uc(S.current,`close`,y.pointerType);n?y.openChangeTimeout.start(n,()=>{h.setOpen(!1,Ot(I,e)),v?.events.emit(`floating.closed`,e)}):t&&(y.openChangeTimeout.clear(),h.setOpen(!1,Ot(I,e)),v?.events.emit(`floating.closed`,e))}let t=c.current??(u?h.select(`domReferenceElement`):null);if(!vt(t))return;function r(e){if(y.openChangeTimeout.clear(),y.blockMouseMove=!1,a&&!Ut(y.pointerType))return;let t=dc(C.current),n=uc(S.current,`open`,y.pointerType),r=yt(e),i=e.currentTarget??null,o=h.select(`domReferenceElement`),s=i;if(vt(r)&&!h.context.triggerElements.hasElement(r)){for(let e of h.context.triggerElements.elements())if(xt(e,r)){s=e;break}}vt(i)&&vt(o)&&!h.context.triggerElements.hasElement(i)&&xt(i,o)&&(s=o);let c=s!=null&&k(o,s,r),l=h.select(`open`),u=E.current?.()??h.select(`transitionStatus`)===`ending`,d=!l&&u&&b.current,f=!c&&vt(s)&&vt(o)&&xt(o,s)&&d,p=t>0&&!n,m=c&&(l||d)||f,g=!l||c;if(m){O()&&h.setOpen(!0,Ot(I,e,s));return}p||(n?y.openChangeTimeout.start(n,()=>{g&&O()&&h.setOpen(!0,Ot(I,e,s))}):g&&O()&&h.setOpen(!0,Ot(I,e,s)))}function i(t){if(D()){te();return}ee();let n=h.select(`domReferenceElement`),r=bt(n);y.restTimeout.clear(),y.restTimeoutPending=!1;let i=g.current.floatingContext??d?.();if(!Qt(t.relatedTarget,h.context.triggerElements)){if(x.current&&i){h.select(`open`)||y.openChangeTimeout.clear();let n=c.current;y.handler=x.current({...i,tree:v,x:t.clientX,y:t.clientY,onClose(){te(),ee(),w.current&&!D()&&n===h.select(`domReferenceElement`)&&e(t,!0)}}),r.addEventListener(`mousemove`,y.handler),y.handler(t);return}(y.pointerType!==`touch`||!xt(h.select(`floatingElement`),t.relatedTarget))&&e(t)}}function o(e){xt(t,e.relatedTarget)||(y.openChangeTimeout.clear(),y.restTimeout.clear(),y.restTimeoutPending=!1)}let l=m?Ie(t,`mouseout`,o):void 0;return s?It(Ie(t,`mousemove`,r,{once:!0}),Ie(t,`mouseenter`,r),Ie(t,`mouseleave`,i),l):It(Ie(t,`mouseenter`,r),Ie(t,`mouseleave`,i),l)},[ee,te,g,S,h,n,x,y,u,k,D,a,s,C,c,v,w,d,E,O,m]),z.useMemo(()=>{if(!n)return;function e(e){y.pointerType=e.pointerType}return{onPointerDown:e,onPointerEnter:e,onMouseMove(e){let{nativeEvent:t}=e,n=e.currentTarget,r=h.select(`domReferenceElement`),i=h.select(`open`),o=k(r,n,e.target);if(a&&!Ut(y.pointerType))return;if(i&&o&&y.handleCloseOptions?.blockPointerEvents){let e=h.select(`floatingElement`);if(e){let t=y.handleCloseOptions?.getScope?.()??n.ownerDocument.body;Oc(y,{scopeElement:t,referenceElement:n,floatingElement:e})}}let s=dc(C.current);if(i&&!o||s===0||!o&&y.restTimeoutPending&&e.movementX**2+e.movementY**2<2)return;y.restTimeout.clear();function c(){if(y.restTimeoutPending=!1,D())return;let e=h.select(`open`);!y.blockMouseMove&&(!e||o)&&O()&&h.setOpen(!0,Ot(I,t,n))}y.pointerType===`touch`?jc.flushSync(()=>{c()}):o&&i?c():(y.restTimeoutPending=!0,y.restTimeout.start(s,c))}}},[n,y,D,k,a,h,C,O])}var Pc=.1,Fc=Pc*Pc,Ic=.5;function Lc(e,t,n,r,i,a){return r>=t!=a>=t&&e<=(i-n)*(t-r)/(a-r)+n}function Rc(e,t,n,r,i,a,o,s,c,l){let u=!1;return Lc(e,t,n,r,i,a)&&(u=!u),Lc(e,t,i,a,o,s)&&(u=!u),Lc(e,t,o,s,c,l)&&(u=!u),Lc(e,t,c,l,n,r)&&(u=!u),u}function zc(e,t,n){return e>=n.x&&e<=n.x+n.width&&t>=n.y&&t<=n.y+n.height}function Bc(e,t,n,r,i,a){return e>=Math.min(n,i)&&e<=Math.max(n,i)&&t>=Math.min(r,a)&&t<=Math.max(r,a)}function Vc(e={}){let{blockPointerEvents:t=!1}=e,n=new kt,r=({x:e,y:t,placement:r,elements:i,onClose:a,nodeId:o,tree:s})=>{let c=r?.split(`-`)[0],l=!1,u=null,d=null,f=typeof performance<`u`?performance.now():0;function p(e,t){let n=performance.now(),r=n-f;if(u===null||d===null||r===0)return u=e,d=t,f=n,!1;let i=e-u,a=t-d,o=i*i+a*a,s=r*r*Fc;return u=e,d=t,f=n,o0)}function b(){y()||m()}if(y())return;let x=a.getBoundingClientRect(),S=u.getBoundingClientRect(),C=e>S.right-S.width/2,w=t>S.bottom-S.height/2,T=S.width>x.width,E=S.height>x.height,D=(T?x:S).left,O=(T?x:S).right,k=(E?x:S).top,ee=(E?x:S).bottom;if(c===`top`&&t>=x.bottom-1||c===`bottom`&&t<=x.top+1||c===`left`&&e>=x.right-1||c===`right`&&e<=x.left+1){b();return}let te=!1;switch(c){case`top`:te=Bc(d,f,D,x.top+1,O,S.bottom-1);break;case`bottom`:te=Bc(d,f,D,S.top+1,O,x.bottom-1);break;case`left`:te=Bc(d,f,S.right-1,ee,x.left+1,k);break;case`right`:te=Bc(d,f,x.right-1,ee,S.left+1,k)}if(te)return;if(l&&!zc(d,f,x)){b();return}if(!g&&p(d,f)){b();return}let ne=!1;switch(c){case`top`:{let n=T?Ic/2:Ic*4,r=T||C?e+n:e-n,i=T?e-n:C?e+n:e-n,a=t+Ic+1,o=C||T?S.bottom-Ic:S.top,s=C?T?S.bottom-Ic:S.top:S.bottom-Ic;ne=Rc(d,f,r,a,i,a,S.left,o,S.right,s);break}case`bottom`:{let n=T?Ic/2:Ic*4,r=T||C?e+n:e-n,i=T?e-n:C?e+n:e-n,a=t-Ic,o=C||T?S.top+Ic:S.bottom,s=C?T?S.top+Ic:S.bottom:S.top+Ic;ne=Rc(d,f,r,a,i,a,S.left,o,S.right,s);break}case`left`:{let n=E?Ic/2:Ic*4,r=E||w?t+n:t-n,i=E?t-n:w?t+n:t-n,a=e+Ic+1,o=w||E?S.right-Ic:S.left,s=w?E?S.right-Ic:S.left:S.right-Ic;ne=Rc(d,f,o,S.top,s,S.bottom,a,r,a,i);break}case`right`:{let n=E?Ic/2:Ic*4,r=E||w?t+n:t-n,i=E?t-n:w?t+n:t-n,a=e-Ic,o=w||E?S.left+Ic:S.right,s=w?E?S.left+Ic:S.right:S.left+Ic;ne=Rc(d,f,a,r,a,i,o,S.top,s,S.bottom);break}}ne?l||n.start(40,b):b()}};return r.__options={...e,blockPointerEvents:t},r}var Hc=Pt(function(e){return vn(`dialog`,e)});function Uc(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(Hc,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function Wc(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(bn,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function Gc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`data-closed:fade-out-0 data-open:fade-in-0 data-closed:animate-out data-open:animate-in fixed inset-0 z-50 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(_n,{className:i,"data-slot":`sheet-overlay`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Kc(e){let t=(0,H.c)(17),n,r,i,a,o;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5]):({className:r,children:n,side:a,showCloseButton:o,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o);let s=a===void 0?`right`:a,c=o===void 0||o,l;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsx)(Gc,{}),t[6]=l):l=t[6];let u;t[7]===r?u=t[8]:(u=Xe(`data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-closed:animate-out data-open:animate-in fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm`,r),t[7]=r,t[8]=u);let d;t[9]===c?d=t[10]:(d=c&&(0,B.jsxs)(hn,{"data-slot":`sheet-close`,render:(0,B.jsx)(_t,{className:`absolute top-4 right-4`,size:`icon-sm`,variant:`ghost`}),children:[(0,B.jsx)(ut,{}),(0,B.jsx)(`span`,{className:`sr-only`,children:`Close`})]}),t[9]=c,t[10]=d);let f;return t[11]!==n||t[12]!==i||t[13]!==s||t[14]!==u||t[15]!==d?(f=(0,B.jsxs)(Wc,{children:[l,(0,B.jsxs)(gn,{className:u,"data-side":s,"data-slot":`sheet-content`,...i,children:[n,d]})]}),t[11]=n,t[12]=i,t[13]=s,t[14]=u,t[15]=d,t[16]=f):f=t[16],f}function qc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex flex-col gap-1.5 p-6`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-slot":`sheet-header`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Jc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`text-sm font-medium text-foreground`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(Sn,{className:i,"data-slot":`sheet-title`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Yc(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`text-xs/relaxed text-muted-foreground`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(xn,{className:i,"data-slot":`sheet-description`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}var Xc=z.createContext(void 0);function Zc(e){let t=z.useContext(Xc);if(t===void 0&&!e)throw Error(We(72));return t}var Qc={...yn,disabled:e=>e.disabled,instantType:e=>e.instantType,isInstantPhase:e=>e.isInstantPhase,trackCursorAxis:e=>e.trackCursorAxis,disableHoverablePopup:e=>e.disableHoverablePopup,lastOpenChangeReason:e=>e.openChangeReason,closeOnClick:e=>e.closeOnClick,closeDelay:e=>e.closeDelay,adaptiveOrigin:e=>e.adaptiveOrigin},$c=class extends Ft{constructor(e,t,n){let r=new en;super(el(e,r,t,n),tl(r),Qc)}setOpen=(e,t)=>{L(this,e,t,{extraState:{openChangeReason:t.reason}})};cancelPendingOpen(e){this.state.floatingRootContext.dispatchOpenChange(!1,Ot(wt,e))}};function el(e,t,n,r=!1){return{...mn(t,n,r),disabled:!1,instantType:void 0,isInstantPhase:!1,trackCursorAxis:`none`,disableHoverablePopup:!1,openChangeReason:null,closeOnClick:!0,closeDelay:0,adaptiveOrigin:void 0,...e}}function tl(e){return{popupRef:z.createRef(),onOpenChange:void 0,onOpenChangeComplete:void 0,triggerElements:e}}var nl=Pt(function(e){let{disabled:t=!1,defaultOpen:n=!1,open:r,disableHoverablePopup:i=!1,trackCursorAxis:a=`none`,actionsRef:o,onOpenChange:s,onOpenChangeComplete:c,handle:l,triggerId:u,defaultTriggerId:d=null,children:f}=e,p=Vt((e,t)=>new $c({open:n,openProp:r,activeTriggerId:d,triggerIdProp:u},e,t));p.useControlledProp(`openProp`,r),p.useControlledProp(`triggerIdProp`,u),p.useContextCallback(`onOpenChange`,s),p.useContextCallback(`onOpenChangeComplete`,c);let m=p.useState(`open`),h=!t&&m,g=p.useState(`activeTriggerId`),_=p.useState(`mounted`),v=p.useState(`payload`);p.useSyncedValues({trackCursorAxis:a,disableHoverablePopup:i,disabled:t}),cn(p,{closeOnActiveTriggerUnmount:!0});let{forceUnmount:y,transitionStatus:b}=Gt(h,p),x=p.useState(`isInstantPhase`),S=p.useState(`instantType`),C=p.useState(`lastOpenChangeReason`),w=z.useRef(null);ht(()=>{m&&t&&p.setOpen(!1,Ot(Tt))},[m,t,p]),ht(()=>{b===`ending`&&C===`none`||b!==`ending`&&x?(S!==`delay`&&(w.current=S),p.set(`instantType`,`delay`)):w.current!==null&&(p.set(`instantType`,w.current),w.current=null)},[b,x,C,S,p]),ht(()=>{h&&(g??p.set(`payload`,void 0))},[p,g,h]),z.useImperativeHandle(o,()=>({unmount:y,close:()=>p.setOpen(!1,Ot(Dt))}),[y,p]);let T=h||_||!t&&a!==`none`;return(0,B.jsxs)(Xc.Provider,{value:p,children:[l&&(0,B.jsx)(qt,{handle:l,store:p}),T&&(0,B.jsx)(rl,{store:p,disabled:t,trackCursorAxis:a}),typeof f==`function`?f({payload:v}):f]})});function rl({store:e,disabled:t,trackCursorAxis:n}){let r=e.useState(`floatingRootContext`),i=Xt(r,{enabled:!t,referencePress:()=>e.select(`closeOnClick`)}),a=bc(r,{enabled:!t&&n!==`none`,axis:n===`none`?void 0:n}),o=z.useMemo(()=>ze(a.reference,i.reference),[a.reference,i.reference]);return sn(e,{activeTriggerProps:o,inactiveTriggerProps:o,popupProps:i.floating??Re}),null}var il=z.createContext(void 0);function al(){return z.useContext(il)}var ol=`data-base-ui-tooltip-trigger`;function sl(e){if(`composedPath`in e){let t=e.composedPath();for(let e=0;em.select(`transitionStatus`)===`ending`,shouldOpen(){return!ie.current}}),de=wc(v,{enabled:!ee}).reference,fe=e=>{let t=ie.current,n=sl(e),r=le(n),i=y.current,a=i&&n&&xt(i,n);if(r&&m.select(`open`)&&m.select(`lastOpenChangeReason`)===`trigger-hover`){m.setOpen(!1,Ot(I,e));return}if(t&&!r&&a&&!te.current&&!m.select(`open`)&&i&&Ut(oe.current)){let t=()=>{!ie.current&&!te.current&&!m.select(`open`)&&m.setOpen(!0,Ot(I,e,i))},n=se();n===0?(ae.clear(),t()):ae.start(n,t)}},pe=m.useState(`triggerProps`,S);return Ve(`button`,e,{state:{open:_},ref:[t,x,y],props:[ue,de,S||ne!==`none`?pe:void 0,{onMouseOver(e){fe(e.nativeEvent)},onFocus(e){ce(sl(e.nativeEvent))&&e.preventBaseUIHandler()},onMouseLeave(){ie.current=!1,ae.clear(),oe.current=void 0},onPointerEnter(e){oe.current=e.pointerType},onPointerDown(e){oe.current=e.pointerType,m.set(`closeOnClick`,l),l&&!m.select(`open`)&&m.cancelPendingOpen(e.nativeEvent)},onClick(e){l&&!m.select(`open`)&&m.cancelPendingOpen(e.nativeEvent)},id:h,[$t]:ee?``:void 0,[ol]:ee?void 0:``},f],stateAttributesMapping:rn})}),ul=z.createContext(void 0);function dl(){let e=z.useContext(ul);if(e===void 0)throw Error(We(70));return e}var fl=z.forwardRef(function(e,t){let{children:n,container:r,className:i,render:a,style:o,...s}=e,{node:c,subtree:l}=Bt({container:r,ref:t,componentProps:e,elementProps:s});return!l&&!c?null:(0,B.jsxs)(z.Fragment,{children:[l,c&&jc.createPortal(n,c)]})}),pl=z.forwardRef(function(e,t){let{keepMounted:n=!1,...r}=e;return Zc().useState(`mounted`)||n?(0,B.jsx)(ul.Provider,{value:n,children:(0,B.jsx)(fl,{ref:t,...r})}):null}),ml=z.createContext(void 0);function hl(){let e=z.useContext(ml);if(e===void 0)throw Error(We(71));return e}var gl=z.forwardRef(function(e,t){let{render:n,className:r,anchor:i,positionMethod:a=`absolute`,side:o=`top`,align:s=`center`,sideOffset:c=0,alignOffset:l=0,collisionBoundary:u=`clipping-ancestors`,collisionPadding:d=5,arrowPadding:f=5,sticky:p=!1,disableAnchorTracking:m=!1,collisionAvoidance:h=Lt,style:g,..._}=e,v=Zc(),y=dl(),b=v.useState(`open`),x=v.useState(`mounted`),S=v.useState(`trackCursorAxis`),C=v.useState(`disableHoverablePopup`),w=v.useState(`floatingRootContext`),T=v.useState(`instantType`),E=v.useState(`transitionStatus`),D=v.useState(`adaptiveOrigin`),O=dn({anchor:i,positionMethod:a,floatingRootContext:w,mounted:x,side:o,sideOffset:c,align:s,alignOffset:l,collisionBoundary:u,collisionPadding:d,sticky:p,arrowPadding:f,disableAnchorTracking:m,keepMounted:y,collisionAvoidance:h,adaptiveOrigin:D}),k=z.useMemo(()=>({open:b,side:O.side,align:O.align,anchorHidden:O.anchorHidden,instant:S===`none`?T:`tracking-cursor`}),[b,O.side,O.align,O.anchorHidden,S,T]),ee=fn(e,k,{styles:O.positionerStyles,transitionStatus:E,props:_,refs:[t,v.useStateSetter(`positionerElement`)],hidden:!x,inert:!b||S===`both`||C});return(0,B.jsx)(ml.Provider,{value:O,children:ee})}),_l=z.forwardRef(function(e,t){let{render:n,className:r,style:i,...a}=e,o=Zc(),{side:s,align:c}=hl(),l=o.useState(`open`),u=o.useState(`instantType`),d=o.useState(`transitionStatus`),f=o.useState(`popupProps`),p=o.useState(`floatingRootContext`),m=o.useState(`disabled`),h=o.useState(`closeDelay`);Fe({open:l,ref:o.context.popupRef,onComplete(){l&&o.context.onOpenChangeComplete?.(!0)}}),Ac(p,{enabled:!m,closeDelay:h});let g=o.useStateSetter(`popupElement`);return Ve(`div`,e,{state:{open:l,side:s,align:c,instant:u,transitionStatus:d},ref:[t,o.context.popupRef,g],props:[Zt,f,un(d),a],stateAttributesMapping:Kt})}),vl=z.forwardRef(function(e,t){let{render:n,className:r,style:i,...a}=e,o=Zc(),{arrowRef:s,side:c,align:l,arrowUncentered:u,arrowStyles:d}=hl(),f={open:o.useState(`open`),side:c,align:l,uncentered:u,instant:o.useState(`instantType`)};return Ve(`div`,e,{state:f,ref:[t,s],props:[{style:d,"aria-hidden":!0},a],stateAttributesMapping:tn})}),yl=function(e){let{delay:t,closeDelay:n,timeout:r=400}=e,i=z.useMemo(()=>({open:t,close:n}),[t,n]);return(0,B.jsx)(il.Provider,{value:t,children:(0,B.jsx)(gc,{delay:i,timeoutMs:r,children:e.children})})};function bl(e){let t=(0,H.c)(6),n,r;t[0]===e?(n=t[1],r=t[2]):({delay:r,...n}=e,t[0]=e,t[1]=n,t[2]=r);let i=r===void 0?0:r,a;return t[3]!==i||t[4]!==n?(a=(0,B.jsx)(yl,{"data-slot":`tooltip-provider`,delay:i,...n}),t[3]=i,t[4]=n,t[5]=a):a=t[5],a}function xl(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(bl,{children:(0,B.jsx)(nl,{"data-slot":`tooltip`,...n})}),t[2]=n,t[3]=r),r}function Sl(e){let t=(0,H.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,B.jsx)(ll,{"data-slot":`tooltip-trigger`,...n}),t[2]=n,t[3]=r),r}function Cl(e){let t=(0,H.c)(21),n,r,i,a,o,s,c;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5],s=t[6],c=t[7]):({className:r,side:a,sideOffset:o,align:s,alignOffset:c,children:n,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o,t[6]=s,t[7]=c);let l=a===void 0?`top`:a,u=o===void 0?4:o,d=s===void 0?`center`:s,f=c===void 0?0:c,p;t[8]===r?p=t[9]:(p=Xe(`data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-closed:animate-out data-open:animate-in z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background **:data-[slot=kbd]:rounded-md`,r),t[8]=r,t[9]=p);let m;t[10]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,B.jsx)(vl,{className:`z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5`}),t[10]=m):m=t[10];let h;t[11]!==n||t[12]!==i||t[13]!==p?(h=(0,B.jsxs)(_l,{className:p,"data-slot":`tooltip-content`,...i,children:[n,m]}),t[11]=n,t[12]=i,t[13]=p,t[14]=h):h=t[14];let g;return t[15]!==d||t[16]!==f||t[17]!==l||t[18]!==u||t[19]!==h?(g=(0,B.jsx)(pl,{children:(0,B.jsx)(gl,{align:d,alignOffset:f,className:`isolate z-50`,side:l,sideOffset:u,children:h})}),t[15]=d,t[16]=f,t[17]=l,t[18]=u,t[19]=h,t[20]=g):g=t[20],g}var wl=window.matchMedia(`(max-width: 767px)`);function Tl(e){return wl.addEventListener(`change`,e),()=>{wl.removeEventListener(`change`,e)}}function El(){return wl.matches}function Dl(){return(0,z.useSyncExternalStore)(Tl,El)}var Ol=`sidebar_state`,kl=604800,Al=`12rem`,jl=`16rem`,Ml=`3rem`,Nl=`b`,Pl=z.createContext(null);function Fl(){let e=z.useContext(Pl);if(!e)throw Error(`useSidebar must be used within a SidebarProvider.`);return e}function Il(e){let t=(0,H.c)(36),n,r,i,a,o,s,c;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5],s=t[6],c=t[7]):({defaultOpen:c,open:i,onOpenChange:o,className:r,style:s,children:n,...a}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o,t[6]=s,t[7]=c);let l=c===void 0||c,u=Dl(),[d,f]=z.useState(!1),[p,m]=z.useState(l),h=i??p,g;t[8]!==h||t[9]!==o?(g=e=>{let t=typeof e==`function`?e(h):e;o?o(t):m(t),document.cookie=`${Ol}=${t}; path=/; max-age=${kl}`},t[8]=h,t[9]=o,t[10]=g):g=t[10];let _=g,v;t[11]!==u||t[12]!==_?(v=()=>{u?f(Rl):_(Ll)},t[11]=u,t[12]=_,t[13]=v):v=t[13];let y=v,b,x;t[14]===y?(b=t[15],x=t[16]):(b=()=>{let e=e=>{e.key===Nl&&(e.metaKey||e.ctrlKey)&&(e.preventDefault(),y())};return window.addEventListener(`keydown`,e),()=>{window.removeEventListener(`keydown`,e)}},x=[y],t[14]=y,t[15]=b,t[16]=x),z.useEffect(b,x);let S=h?`expanded`:`collapsed`,C;t[17]!==u||t[18]!==h||t[19]!==d||t[20]!==_||t[21]!==S||t[22]!==y?(C={state:S,open:h,setOpen:_,isMobile:u,openMobile:d,setOpenMobile:f,toggleSidebar:y},t[17]=u,t[18]=h,t[19]=d,t[20]=_,t[21]=S,t[22]=y,t[23]=C):C=t[23];let w=C,T;t[24]===s?T=t[25]:(T={"--sidebar-width":Al,"--sidebar-width-icon":Ml,...s},t[24]=s,t[25]=T);let E=T,D;t[26]===r?D=t[27]:(D=Xe(`group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar`,r),t[26]=r,t[27]=D);let O;t[28]!==n||t[29]!==a||t[30]!==D||t[31]!==E?(O=(0,B.jsx)(`div`,{className:D,"data-slot":`sidebar-wrapper`,style:E,...a,children:n}),t[28]=n,t[29]=a,t[30]=D,t[31]=E,t[32]=O):O=t[32];let k;return t[33]!==w||t[34]!==O?(k=(0,B.jsx)(Pl.Provider,{value:w,children:O}),t[33]=w,t[34]=O,t[35]=k):k=t[35],k}function Ll(e){return!e}function Rl(e){return!e}function zl(e){let t=(0,H.c)(46),n,r,i,a,o,s;t[0]===e?(n=t[1],r=t[2],i=t[3],a=t[4],o=t[5],s=t[6]):({side:a,variant:o,collapsible:s,className:r,children:n,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a,t[5]=o,t[6]=s);let c=a===void 0?`left`:a,l=o===void 0?`sidebar`:o,u=s===void 0?`offExamples`:s,{isMobile:d,state:f,openMobile:p,setOpenMobile:m}=Fl();if(u===`none`){let e;t[7]===r?e=t[8]:(e=Xe(`flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground`,r),t[7]=r,t[8]=e);let a;return t[9]!==n||t[10]!==i||t[11]!==e?(a=(0,B.jsx)(`div`,{className:e,"data-slot":`sidebar`,...i,children:n}),t[9]=n,t[10]=i,t[11]=e,t[12]=a):a=t[12],a}if(d){let e;t[13]===Symbol.for(`react.memo_cache_sentinel`)?(e={"--sidebar-width":jl},t[13]=e):e=t[13];let r=e,a;t[14]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,B.jsxs)(qc,{className:`sr-only`,children:[(0,B.jsx)(Jc,{children:`Sidebar`}),(0,B.jsx)(Yc,{children:`Displays the mobile sidebar.`})]}),t[14]=a):a=t[14];let o;t[15]===n?o=t[16]:(o=(0,B.jsx)(`div`,{className:`flex h-full w-full flex-col`,children:n}),t[15]=n,t[16]=o);let s;t[17]!==c||t[18]!==o?(s=(0,B.jsxs)(Kc,{className:`w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden`,"data-mobile":`true`,"data-sidebar":`sidebar`,"data-slot":`sidebar`,side:c,style:r,children:[a,o]}),t[17]=c,t[18]=o,t[19]=s):s=t[19];let l;return t[20]!==p||t[21]!==i||t[22]!==m||t[23]!==s?(l=(0,B.jsx)(Uc,{onOpenChange:m,open:p,...i,children:s}),t[20]=p,t[21]=i,t[22]=m,t[23]=s,t[24]=l):l=t[24],l}let h=f===`collapsed`?u:``,g=l===`floating`||l===`inset`?`group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]`:`group-data-[collapsible=icon]:w-(--sidebar-width-icon)`,_;t[25]===g?_=t[26]:(_=Xe(`relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear`,`group-data-[collapsible=offExamples]:w-0`,`group-data-[side=right]:rotate-180`,g),t[25]=g,t[26]=_);let v;t[27]===_?v=t[28]:(v=(0,B.jsx)(`div`,{className:_,"data-slot":`sidebar-gap`}),t[27]=_,t[28]=v);let y=c===`left`?`left-0 group-data-[collapsible=offExamples]:left-[calc(var(--sidebar-width)*-1)]`:`right-0 group-data-[collapsible=offExamples]:right-[calc(var(--sidebar-width)*-1)]`,b=l===`floating`||l===`inset`?`p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]`:`group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l`,x;t[29]!==r||t[30]!==y||t[31]!==b?(x=Xe(`fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex`,y,b,r),t[29]=r,t[30]=y,t[31]=b,t[32]=x):x=t[32];let S;t[33]===n?S=t[34]:(S=(0,B.jsx)(`div`,{className:`flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border`,"data-sidebar":`sidebar`,"data-slot":`sidebar-inner`,children:n}),t[33]=n,t[34]=S);let C;t[35]!==i||t[36]!==x||t[37]!==S?(C=(0,B.jsx)(`div`,{className:x,"data-slot":`sidebar-container`,...i,children:S}),t[35]=i,t[36]=x,t[37]=S,t[38]=C):C=t[38];let w;return t[39]!==c||t[40]!==f||t[41]!==C||t[42]!==h||t[43]!==v||t[44]!==l?(w=(0,B.jsxs)(`div`,{className:`group peer hidden text-sidebar-foreground md:block`,"data-collapsible":h,"data-side":c,"data-slot":`sidebar`,"data-state":f,"data-variant":l,children:[v,C]}),t[39]=c,t[40]=f,t[41]=C,t[42]=h,t[43]=v,t[44]=l,t[45]=w):w=t[45],w}function Bl(e){let t=(0,H.c)(15),n,r,i;t[0]===e?(n=t[1],r=t[2],i=t[3]):({className:n,onClick:r,...i}=e,t[0]=e,t[1]=n,t[2]=r,t[3]=i);let{toggleSidebar:a}=Fl(),o;t[4]===n?o=t[5]:(o=Xe(n),t[4]=n,t[5]=o);let s;t[6]!==r||t[7]!==a?(s=e=>{r?.(e),a()},t[6]=r,t[7]=a,t[8]=s):s=t[8];let c,l;t[9]===Symbol.for(`react.memo_cache_sentinel`)?(c=(0,B.jsx)(Xs,{}),l=(0,B.jsx)(`span`,{className:`sr-only`,children:`Toggle Sidebar`}),t[9]=c,t[10]=l):(c=t[9],l=t[10]);let u;return t[11]!==i||t[12]!==o||t[13]!==s?(u=(0,B.jsxs)(_t,{className:o,"data-sidebar":`trigger`,"data-slot":`sidebar-trigger`,onClick:s,size:`icon-lg`,variant:`ghost`,...i,children:[c,l]}),t[11]=i,t[12]=o,t[13]=s,t[14]=u):u=t[14],u}function Vl(e){let t=(0,H.c)(9),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let{toggleSidebar:i}=Fl(),a;t[3]===n?a=t[4]:(a=Xe(`absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex`,`in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize`,`[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize`,`group-data-[collapsible=offExamples]:translate-x-0 group-data-[collapsible=offExamples]:after:left-full hover:group-data-[collapsible=offExamples]:bg-sidebar`,`[[data-side=left][data-collapsible=offExamples]_&]:-right-2`,`[[data-side=right][data-collapsible=offExamples]_&]:-left-2`,n),t[3]=n,t[4]=a);let o;return t[5]!==r||t[6]!==a||t[7]!==i?(o=(0,B.jsx)(`button`,{"aria-label":`Toggle Sidebar`,className:a,"data-sidebar":`rail`,"data-slot":`sidebar-rail`,onClick:i,tabIndex:-1,title:`Toggle Sidebar`,type:`button`,...r}),t[5]=r,t[6]=a,t[7]=i,t[8]=o):o=t[8],o}function Hl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex flex-col gap-2 p-2`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`header`,"data-slot":`sidebar-header`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Ul(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex flex-col gap-2 p-2`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`footer`,"data-slot":`sidebar-footer`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Wl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`mx-2 w-auto bg-sidebar-border`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(pn,{className:i,"data-sidebar":`separator`,"data-slot":`sidebar-separator`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Gl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-x-hidden overflow-y-auto`,`group-data-[collapsible=icon]:[scrollbar-width:none] group-data-[collapsible=icon]:[&::-webkit-scrollbar]:hidden`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`content`,"data-slot":`sidebar-content`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function Kl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`relative flex w-full min-w-0 flex-col px-2 py-1`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`div`,{className:i,"data-sidebar":`group`,"data-slot":`sidebar-group`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function ql(e){let t=(0,H.c)(10),n,r,i,a;if(t[0]!==e){let{className:o,render:s,...c}=e;n=s,a=Ge,r=`div`,i=ze({className:Xe(`flex h-8 shrink-0 items-center rounded-md px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0`,o)},c),t[0]=e,t[1]=n,t[2]=r,t[3]=i,t[4]=a}else n=t[1],r=t[2],i=t[3],a=t[4];let o;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(o={slot:`sidebar-group-label`,sidebar:`group-label`},t[5]=o):o=t[5];let s;return t[6]!==n||t[7]!==r||t[8]!==i?(s={defaultTagName:r,props:i,render:n,state:o},t[6]=n,t[7]=r,t[8]=i,t[9]=s):s=t[9],a(s)}function Jl(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`flex w-full min-w-0 flex-col gap-px`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`ul`,{className:i,"data-sidebar":`menu`,"data-slot":`sidebar-menu`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}function U(e){let t=(0,H.c)(8),n,r;t[0]===e?(n=t[1],r=t[2]):({className:n,...r}=e,t[0]=e,t[1]=n,t[2]=r);let i;t[3]===n?i=t[4]:(i=Xe(`group/menu-item relative`,n),t[3]=n,t[4]=i);let a;return t[5]!==r||t[6]!==i?(a=(0,B.jsx)(`li`,{className:i,"data-sidebar":`menu-item`,"data-slot":`sidebar-menu-item`,...r}),t[5]=r,t[6]=i,t[7]=a):a=t[7],a}var Yl=Ue(`peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[calc(var(--radius-sm)+2px)] p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate`,{variants:{variant:{default:`hover:bg-sidebar-accent hover:text-sidebar-accent-foreground`,outline:`bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]`},size:{default:`h-8 text-xs`,sm:`h-7 text-xs`,lg:`h-12 text-xs group-data-[collapsible=icon]:p-0!`}},defaultVariants:{variant:`default`,size:`default`}});function W(e){let t=(0,H.c)(18),{render:n,isActive:r,variant:i,size:a,tooltip:o,className:s,...c}=e,l=r!==void 0&&r,u=i===void 0?`default`:i,d=a===void 0?`default`:a,{isMobile:f,state:p}=Fl(),m=ze({className:Xe(Yl({variant:u,size:d}),s)},c),h;t[0]!==n||t[1]!==o?(h=o?(0,B.jsx)(Sl,{}):n,t[0]=n,t[1]=o,t[2]=h):h=t[2];let g;t[3]!==l||t[4]!==d?(g={slot:`sidebar-menu-button`,sidebar:`menu-button`,size:d,active:l},t[3]=l,t[4]=d,t[5]=g):g=t[5];let _;t[6]!==m||t[7]!==h||t[8]!==g?(_={defaultTagName:`button`,props:m,render:h,state:g},t[6]=m,t[7]=h,t[8]=g,t[9]=_):_=t[9];let v=Ge(_);if(!o)return v;let y;t[10]===o?y=t[11]:(y=typeof o==`string`?{children:o}:o,t[10]=o,t[11]=y);let b=y,x=p!==`collapsed`||f,S;t[12]!==x||t[13]!==b?(S=(0,B.jsx)(Cl,{align:`center`,hidden:x,side:`right`,...b}),t[12]=x,t[13]=b,t[14]=S):S=t[14];let C;return t[15]!==v||t[16]!==S?(C=(0,B.jsxs)(xl,{children:[v,S]}),t[15]=v,t[16]=S,t[17]=C):C=t[17],C}var Xl=new Set([`InvalidClientTokenId`,`SignatureDoesNotMatch`,`AuthFailure`,`MissingAuthenticationToken`,`IncompleteSignature`,`UnrecognizedClientException`,`ExpiredToken`,`ExpiredTokenException`,`InvalidAccessKeyId`]);function Zl(e){return typeof e==`object`&&!!e&&`name`in e&&typeof e.name==`string`}function Ql(e){return Zl(e)&&Xl.has(e.name)}var $l=!1;function eu(e,t){!$l&&Ql(e)&&($l=!0,Ke(),Cn(),t.clear(),window.location.pathname!==`/login`&&(window.location.href=`/login?reason=expired`))}function tu(){let e=new $r({queryCache:new Qr({onError:t=>{eu(t,e)}}),mutationCache:new Xr({onError:t=>{eu(t,e)}}),defaultOptions:{queries:{staleTime:5e3,refetchIntervalInBackground:!1}}});return e}function nu(){let e=(0,H.c)(8),{theme:t,setTheme:n}=cs(),r;e[0]!==n||e[1]!==t?(r=()=>{n(t===`light`?`dark`:t===`dark`?`light`:`dark`)},e[0]=n,e[1]=t,e[2]=r):r=e[2];let i=r,a,o,s;e[3]===Symbol.for(`react.memo_cache_sentinel`)?(a=(0,B.jsx)(nc,{className:`size-[1.2rem] scale-100 rotate-0 transition-transform dark:scale-0 dark:-rotate-90`}),o=(0,B.jsx)(Ks,{className:`absolute size-[1.2rem] scale-0 rotate-90 transition-transform dark:scale-100 dark:rotate-0`}),s=(0,B.jsx)(`span`,{className:`sr-only`,children:`Toggle theme`}),e[3]=a,e[4]=o,e[5]=s):(a=e[3],o=e[4],s=e[5]);let c;return e[6]===i?c=e[7]:(c=(0,B.jsxs)(_t,{className:`rounded-full transition-none hover:bg-transparent dark:hover:bg-transparent`,onClick:i,size:`icon-sm`,variant:`ghost`,children:[a,o,s]}),e[6]=i,e[7]=c),c}function ru(){let e=(0,H.c)(7),{isAdmin:t,license:n}=wn(),r=t&&n===`open-source`,i,a;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(i=Xe(`z-50 flex h-10 shrink-0 items-center gap-2 border-b border-sidebar-border bg-sidebar px-4 text-sidebar-foreground`,`sticky top-0`),a=(0,B.jsx)(Bl,{className:`-ml-1`}),e[0]=i,e[1]=a):(i=e[0],a=e[1]);let o;e[2]===r?o=e[3]:(o=r&&(0,B.jsx)(`a`,{href:`https://mulgadc.com/purchase`,target:`_blank`,rel:`noopener noreferrer`,className:Xe(mt({variant:`outline`,size:`sm`})),children:`Upgrade`}),e[2]=r,e[3]=o);let s;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(s=(0,B.jsx)(nu,{}),e[4]=s):s=e[4];let c;return e[5]===o?c=e[6]:(c=(0,B.jsxs)(`header`,{className:i,children:[a,(0,B.jsxs)(`div`,{className:`flex w-full items-center justify-end gap-2`,children:[o,s]})]}),e[5]=o,e[6]=c),c}function iu(){let e=(0,H.c)(3),t,n;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t=(0,B.jsx)(`h1`,{className:`text-4xl font-bold`,children:`404`}),n=(0,B.jsx)(`p`,{className:`text-muted-foreground`,children:`Page not found`}),e[0]=t,e[1]=n):(t=e[0],n=e[1]);let r;return e[2]===Symbol.for(`react.memo_cache_sentinel`)?(r=(0,B.jsxs)(`div`,{className:`flex flex-1 flex-col items-center justify-center gap-4`,children:[t,n,(0,B.jsx)(A,{to:`/`,children:(0,B.jsx)(_t,{variant:`outline`,children:`Go back home`})})]}),e[2]=r):r=e[2],r}function G(){let e=(0,H.c)(259),t;e[0]===Symbol.for(`react.memo_cache_sentinel`)?(t={select:au},e[0]=t):t=e[0];let n=Jo(t),r=Ne(),i=a(),{isAdmin:o}=wn(),s;e[1]!==r||e[2]!==i?(s=function(){Ke(),Cn(),i.clear(),r({to:`/login`})},e[1]=r,e[2]=i,e[3]=s):s=e[3];let c=s,l;e[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsxs)(Hl,{className:`flex flex-row items-center gap-2 px-4 py-3`,children:[(0,B.jsx)(`img`,{src:`/mulga-logo.svg`,alt:`Mulga`,className:`size-6 shrink-0 dark:invert`}),(0,B.jsx)(`span`,{className:`truncate text-sm font-semibold group-data-[collapsible=icon]:hidden`,children:`Spinifex`})]}),e[4]=l):l=e[4];let u;e[5]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,B.jsx)(ql,{children:`General`}),e[5]=u):u=e[5];let d=n===`/`,f,p;e[6]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,B.jsx)(Os,{className:`size-4`}),p=(0,B.jsx)(`span`,{children:`Dashboard`}),e[6]=f,e[7]=p):(f=e[6],p=e[7]);let m;e[8]===d?m=e[9]:(m=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/`,children:(0,B.jsxs)(W,{isActive:d,tooltip:`Dashboard`,children:[f,p]})})}),e[8]=d,e[9]=m);let h;e[10]!==o||e[11]!==n?(h=o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/nodes`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/nodes`),tooltip:`Nodes`,children:[(0,B.jsx)(nt,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Nodes`})]})})}),e[10]=o,e[11]=n,e[12]=h):h=e[12];let g;e[13]!==m||e[14]!==h?(g=(0,B.jsxs)(Kl,{children:[u,(0,B.jsxs)(Jl,{children:[m,h]})]}),e[13]=m,e[14]=h,e[15]=g):g=e[15];let _;e[16]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,B.jsx)(ql,{children:`EC2`}),e[16]=_):_=e[16];let v;e[17]===n?v=e[18]:(v=n.startsWith(`/ec2/describe-instances`)||n.startsWith(`/ec2/run-instances`),e[17]=n,e[18]=v);let y,b;e[19]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,B.jsx)(nt,{className:`size-4`}),b=(0,B.jsx)(`span`,{children:`Instances`}),e[19]=y,e[20]=b):(y=e[19],b=e[20]);let x;e[21]===v?x=e[22]:(x=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-instances`,children:(0,B.jsxs)(W,{isActive:v,tooltip:`Instances`,children:[y,b]})})}),e[21]=v,e[22]=x);let S;e[23]===n?S=e[24]:(S=n.startsWith(`/ec2/describe-launch-templates`)||n.startsWith(`/ec2/create-launch-template`),e[23]=n,e[24]=S);let C,w;e[25]===Symbol.for(`react.memo_cache_sentinel`)?(C=(0,B.jsx)(Es,{className:`size-4`}),w=(0,B.jsx)(`span`,{children:`Launch Templates`}),e[25]=C,e[26]=w):(C=e[25],w=e[26]);let T;e[27]===S?T=e[28]:(T=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-launch-templates`,children:(0,B.jsxs)(W,{isActive:S,tooltip:`Launch Templates`,children:[C,w]})})}),e[27]=S,e[28]=T);let E;e[29]===n?E=e[30]:(E=n.startsWith(`/ec2/describe-images`),e[29]=n,e[30]=E);let D,O;e[31]===Symbol.for(`react.memo_cache_sentinel`)?(D=(0,B.jsx)(at,{className:`size-4`}),O=(0,B.jsx)(`span`,{children:`Images`}),e[31]=D,e[32]=O):(D=e[31],O=e[32]);let k;e[33]===E?k=e[34]:(k=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-images`,children:(0,B.jsxs)(W,{isActive:E,tooltip:`Images`,children:[D,O]})})}),e[33]=E,e[34]=k);let ee;e[35]===n?ee=e[36]:(ee=n.startsWith(`/ec2/describe-key-pairs`),e[35]=n,e[36]=ee);let te,ne;e[37]===Symbol.for(`react.memo_cache_sentinel`)?(te=(0,B.jsx)(Qe,{className:`size-4`}),ne=(0,B.jsx)(`span`,{children:`Key Pairs`}),e[37]=te,e[38]=ne):(te=e[37],ne=e[38]);let re;e[39]===ee?re=e[40]:(re=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-key-pairs`,children:(0,B.jsxs)(W,{isActive:ee,tooltip:`Key Pairs`,children:[te,ne]})})}),e[39]=ee,e[40]=re);let ie;e[41]===n?ie=e[42]:(ie=n.startsWith(`/ec2/describe-addresses`)||n.startsWith(`/ec2/allocate-address`),e[41]=n,e[42]=ie);let ae,oe;e[43]===Symbol.for(`react.memo_cache_sentinel`)?(ae=(0,B.jsx)(Hs,{className:`size-4`}),oe=(0,B.jsx)(`span`,{children:`Elastic IPs`}),e[43]=ae,e[44]=oe):(ae=e[43],oe=e[44]);let se;e[45]===ie?se=e[46]:(se=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-addresses`,children:(0,B.jsxs)(W,{isActive:ie,tooltip:`Elastic IPs`,children:[ae,oe]})})}),e[45]=ie,e[46]=se);let ce;e[47]===n?ce=e[48]:(ce=n.startsWith(`/ec2/describe-volumes`)||n.startsWith(`/ec2/create-volume`)||n.startsWith(`/ec2/modify-volume`),e[47]=n,e[48]=ce);let le,ue;e[49]===Symbol.for(`react.memo_cache_sentinel`)?(le=(0,B.jsx)(tt,{className:`size-4`}),ue=(0,B.jsx)(`span`,{children:`Volumes`}),e[49]=le,e[50]=ue):(le=e[49],ue=e[50]);let de;e[51]===ce?de=e[52]:(de=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-volumes`,children:(0,B.jsxs)(W,{isActive:ce,tooltip:`Volumes`,children:[le,ue]})})}),e[51]=ce,e[52]=de);let fe;e[53]===n?fe=e[54]:(fe=n.startsWith(`/ec2/describe-snapshots`)||n.startsWith(`/ec2/create-snapshot`),e[53]=n,e[54]=fe);let pe,me;e[55]===Symbol.for(`react.memo_cache_sentinel`)?(pe=(0,B.jsx)(Ze,{className:`size-4`}),me=(0,B.jsx)(`span`,{children:`Snapshots`}),e[55]=pe,e[56]=me):(pe=e[55],me=e[56]);let he;e[57]===fe?he=e[58]:(he=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-snapshots`,children:(0,B.jsxs)(W,{isActive:fe,tooltip:`Snapshots`,children:[pe,me]})})}),e[57]=fe,e[58]=he);let ge;e[59]===n?ge=e[60]:(ge=n.startsWith(`/ec2/describe-placement-groups`)||n.startsWith(`/ec2/create-placement-group`),e[59]=n,e[60]=ge);let _e,j;e[61]===Symbol.for(`react.memo_cache_sentinel`)?(_e=(0,B.jsx)(Ps,{className:`size-4`}),j=(0,B.jsx)(`span`,{children:`Placement Groups`}),e[61]=_e,e[62]=j):(_e=e[61],j=e[62]);let M;e[63]===ge?M=e[64]:(M=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-placement-groups`,children:(0,B.jsxs)(W,{isActive:ge,tooltip:`Placement Groups`,children:[_e,j]})})}),e[63]=ge,e[64]=M);let ve;e[65]===n?ve=e[66]:(ve=n.startsWith(`/ec2/describe-load-balancers`)||n.startsWith(`/ec2/create-load-balancer`),e[65]=n,e[66]=ve);let ye,be;e[67]===Symbol.for(`react.memo_cache_sentinel`)?(ye=(0,B.jsx)(it,{className:`size-4`}),be=(0,B.jsx)(`span`,{children:`Load Balancers`}),e[67]=ye,e[68]=be):(ye=e[67],be=e[68]);let xe;e[69]===ve?xe=e[70]:(xe=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-load-balancers`,children:(0,B.jsxs)(W,{isActive:ve,tooltip:`Load Balancers`,children:[ye,be]})})}),e[69]=ve,e[70]=xe);let Se;e[71]===n?Se=e[72]:(Se=n.startsWith(`/ec2/describe-target-groups`)||n.startsWith(`/ec2/create-target-group`),e[71]=n,e[72]=Se);let N,Ce;e[73]===Symbol.for(`react.memo_cache_sentinel`)?(N=(0,B.jsx)(Ss,{className:`size-4`}),Ce=(0,B.jsx)(`span`,{children:`Target Groups`}),e[73]=N,e[74]=Ce):(N=e[73],Ce=e[74]);let we;e[75]===Se?we=e[76]:(we=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-target-groups`,children:(0,B.jsxs)(W,{isActive:Se,tooltip:`Target Groups`,children:[N,Ce]})})}),e[75]=Se,e[76]=we);let Te;e[77]!==x||e[78]!==T||e[79]!==k||e[80]!==re||e[81]!==se||e[82]!==de||e[83]!==he||e[84]!==M||e[85]!==xe||e[86]!==we?(Te=(0,B.jsxs)(Kl,{children:[_,(0,B.jsxs)(Jl,{children:[x,T,k,re,se,de,he,M,xe,we]})]}),e[77]=x,e[78]=T,e[79]=k,e[80]=re,e[81]=se,e[82]=de,e[83]=he,e[84]=M,e[85]=xe,e[86]=we,e[87]=Te):Te=e[87];let Ee;e[88]===Symbol.for(`react.memo_cache_sentinel`)?(Ee=(0,B.jsx)(ql,{children:`Networking`}),e[88]=Ee):Ee=e[88];let De;e[89]===n?De=e[90]:(De=n.startsWith(`/ec2/describe-vpcs`)||n.startsWith(`/ec2/create-vpc`),e[89]=n,e[90]=De);let Oe,ke;e[91]===Symbol.for(`react.memo_cache_sentinel`)?(Oe=(0,B.jsx)(st,{className:`size-4`}),ke=(0,B.jsx)(`span`,{children:`VPCs`}),e[91]=Oe,e[92]=ke):(Oe=e[91],ke=e[92]);let Ae;e[93]===De?Ae=e[94]:(Ae=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-vpcs`,children:(0,B.jsxs)(W,{isActive:De,tooltip:`VPCs`,children:[Oe,ke]})})}),e[93]=De,e[94]=Ae);let je;e[95]===n?je=e[96]:(je=n.startsWith(`/ec2/describe-subnets`)||n.startsWith(`/ec2/create-subnet`),e[95]=n,e[96]=je);let Me,Pe;e[97]===Symbol.for(`react.memo_cache_sentinel`)?(Me=(0,B.jsx)(Is,{className:`size-4`}),Pe=(0,B.jsx)(`span`,{children:`Subnets`}),e[97]=Me,e[98]=Pe):(Me=e[97],Pe=e[98]);let Fe;e[99]===je?Fe=e[100]:(Fe=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-subnets`,children:(0,B.jsxs)(W,{isActive:je,tooltip:`Subnets`,children:[Me,Pe]})})}),e[99]=je,e[100]=Fe);let Ie;e[101]===n?Ie=e[102]:(Ie=n.startsWith(`/ec2/describe-security-groups`)||n.startsWith(`/ec2/create-security-group`),e[101]=n,e[102]=Ie);let Le,Re;e[103]===Symbol.for(`react.memo_cache_sentinel`)?(Le=(0,B.jsx)(ec,{className:`size-4`}),Re=(0,B.jsx)(`span`,{children:`Security Groups`}),e[103]=Le,e[104]=Re):(Le=e[103],Re=e[104]);let ze;e[105]===Ie?ze=e[106]:(ze=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-security-groups`,children:(0,B.jsxs)(W,{isActive:Ie,tooltip:`Security Groups`,children:[Le,Re]})})}),e[105]=Ie,e[106]=ze);let Be;e[107]===n?Be=e[108]:(Be=n.startsWith(`/ec2/describe-route-tables`)||n.startsWith(`/ec2/create-route-table`),e[107]=n,e[108]=Be);let Ve,He;e[109]===Symbol.for(`react.memo_cache_sentinel`)?(Ve=(0,B.jsx)(Qs,{className:`size-4`}),He=(0,B.jsx)(`span`,{children:`Route Tables`}),e[109]=Ve,e[110]=He):(Ve=e[109],He=e[110]);let Ue;e[111]===Be?Ue=e[112]:(Ue=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-route-tables`,children:(0,B.jsxs)(W,{isActive:Be,tooltip:`Route Tables`,children:[Ve,He]})})}),e[111]=Be,e[112]=Ue);let We;e[113]===n?We=e[114]:(We=n.startsWith(`/ec2/describe-internet-gateways`),e[113]=n,e[114]=We);let Ge,P;e[115]===Symbol.for(`react.memo_cache_sentinel`)?(Ge=(0,B.jsx)(rt,{className:`size-4`}),P=(0,B.jsx)(`span`,{children:`Internet Gateways`}),e[115]=Ge,e[116]=P):(Ge=e[115],P=e[116]);let Je;e[117]===We?Je=e[118]:(Je=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-internet-gateways`,children:(0,B.jsxs)(W,{isActive:We,tooltip:`Internet Gateways`,children:[Ge,P]})})}),e[117]=We,e[118]=Je);let Ye;e[119]===n?Ye=e[120]:(Ye=n.startsWith(`/ec2/describe-nat-gateways`)||n.startsWith(`/ec2/create-nat-gateway`),e[119]=n,e[120]=Ye);let Xe,F;e[121]===Symbol.for(`react.memo_cache_sentinel`)?(Xe=(0,B.jsx)($e,{className:`size-4`}),F=(0,B.jsx)(`span`,{children:`NAT Gateways`}),e[121]=Xe,e[122]=F):(Xe=e[121],F=e[122]);let ot;e[123]===Ye?ot=e[124]:(ot=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ec2/describe-nat-gateways`,children:(0,B.jsxs)(W,{isActive:Ye,tooltip:`NAT Gateways`,children:[Xe,F]})})}),e[123]=Ye,e[124]=ot);let lt;e[125]!==Ae||e[126]!==Fe||e[127]!==ze||e[128]!==Ue||e[129]!==Je||e[130]!==ot?(lt=(0,B.jsxs)(Kl,{children:[Ee,(0,B.jsxs)(Jl,{children:[Ae,Fe,ze,Ue,Je,ot]})]}),e[125]=Ae,e[126]=Fe,e[127]=ze,e[128]=Ue,e[129]=Je,e[130]=ot,e[131]=lt):lt=e[131];let ut;e[132]===Symbol.for(`react.memo_cache_sentinel`)?(ut=(0,B.jsx)(ql,{children:`Containers`}),e[132]=ut):ut=e[132];let dt;e[133]===n?dt=e[134]:(dt=n.startsWith(`/eks/list-clusters`)||n.startsWith(`/eks/create-cluster`),e[133]=n,e[134]=dt);let ft,pt;e[135]===Symbol.for(`react.memo_cache_sentinel`)?(ft=(0,B.jsx)(ms,{className:`size-4`}),pt=(0,B.jsx)(`span`,{children:`EKS`}),e[135]=ft,e[136]=pt):(ft=e[135],pt=e[136]);let mt;e[137]===dt?mt=e[138]:(mt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/eks/list-clusters`,children:(0,B.jsxs)(W,{isActive:dt,tooltip:`EKS clusters`,children:[ft,pt]})})}),e[137]=dt,e[138]=mt);let ht;e[139]===n?ht=e[140]:(ht=n.startsWith(`/ecr/list-repositories`),e[139]=n,e[140]=ht);let gt,_t;e[141]===Symbol.for(`react.memo_cache_sentinel`)?(gt=(0,B.jsx)(Js,{className:`size-4`}),_t=(0,B.jsx)(`span`,{children:`ECR`}),e[141]=gt,e[142]=_t):(gt=e[141],_t=e[142]);let vt;e[143]===ht?vt=e[144]:(vt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ecr/list-repositories`,children:(0,B.jsxs)(W,{isActive:ht,tooltip:`ECR repositories`,children:[gt,_t]})})}),e[143]=ht,e[144]=vt);let yt;e[145]===n?yt=e[146]:(yt=n.startsWith(`/ecs/`),e[145]=n,e[146]=yt);let bt,xt;e[147]===Symbol.for(`react.memo_cache_sentinel`)?(bt=(0,B.jsx)(bs,{className:`size-4`}),xt=(0,B.jsx)(`span`,{children:`ECS`}),e[147]=bt,e[148]=xt):(bt=e[147],xt=e[148]);let St;e[149]===yt?St=e[150]:(St=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/ecs/list-clusters`,children:(0,B.jsxs)(W,{isActive:yt,tooltip:`ECS clusters`,children:[bt,xt]})})}),e[149]=yt,e[150]=St);let Ct;e[151]!==mt||e[152]!==vt||e[153]!==St?(Ct=(0,B.jsxs)(Kl,{children:[ut,(0,B.jsxs)(Jl,{children:[mt,vt,St]})]}),e[151]=mt,e[152]=vt,e[153]=St,e[154]=Ct):Ct=e[154];let wt;e[155]===Symbol.for(`react.memo_cache_sentinel`)?(wt=(0,B.jsx)(ql,{children:`RDS`}),e[155]=wt):wt=e[155];let I;e[156]===n?I=e[157]:(I=n.startsWith(`/rds/describe-db-instances`)||n.startsWith(`/rds/create-db-instance`),e[156]=n,e[157]=I);let Tt,Et;e[158]===Symbol.for(`react.memo_cache_sentinel`)?(Tt=(0,B.jsx)(ws,{className:`size-4`}),Et=(0,B.jsx)(`span`,{children:`Databases`}),e[158]=Tt,e[159]=Et):(Tt=e[158],Et=e[159]);let Dt;e[160]===I?Dt=e[161]:(Dt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-instances`,children:(0,B.jsxs)(W,{isActive:I,tooltip:`Databases`,children:[Tt,Et]})})}),e[160]=I,e[161]=Dt);let Ot;e[162]===n?Ot=e[163]:(Ot=n.startsWith(`/rds/describe-db-snapshots`)||n.startsWith(`/rds/restore-db-instance-from-db-snapshot`),e[162]=n,e[163]=Ot);let kt,At;e[164]===Symbol.for(`react.memo_cache_sentinel`)?(kt=(0,B.jsx)(Ze,{className:`size-4`}),At=(0,B.jsx)(`span`,{children:`Snapshots`}),e[164]=kt,e[165]=At):(kt=e[164],At=e[165]);let jt;e[166]===Ot?jt=e[167]:(jt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-snapshots`,children:(0,B.jsxs)(W,{isActive:Ot,tooltip:`DB Snapshots`,children:[kt,At]})})}),e[166]=Ot,e[167]=jt);let Mt;e[168]===n?Mt=e[169]:(Mt=n.startsWith(`/rds/describe-db-subnet-groups`)||n.startsWith(`/rds/create-db-subnet-group`),e[168]=n,e[169]=Mt);let Nt,Pt;e[170]===Symbol.for(`react.memo_cache_sentinel`)?(Nt=(0,B.jsx)(Is,{className:`size-4`}),Pt=(0,B.jsx)(`span`,{children:`Subnet Groups`}),e[170]=Nt,e[171]=Pt):(Nt=e[170],Pt=e[171]);let Ft;e[172]===Mt?Ft=e[173]:(Ft=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-subnet-groups`,children:(0,B.jsxs)(W,{isActive:Mt,tooltip:`DB Subnet Groups`,children:[Nt,Pt]})})}),e[172]=Mt,e[173]=Ft);let It;e[174]===n?It=e[175]:(It=n.startsWith(`/rds/describe-db-parameter-groups`)||n.startsWith(`/rds/create-db-parameter-group`),e[174]=n,e[175]=It);let Lt,Rt;e[176]===Symbol.for(`react.memo_cache_sentinel`)?(Lt=(0,B.jsx)(ct,{className:`size-4`}),Rt=(0,B.jsx)(`span`,{children:`Parameter Groups`}),e[176]=Lt,e[177]=Rt):(Lt=e[176],Rt=e[177]);let zt;e[178]===It?zt=e[179]:(zt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/rds/describe-db-parameter-groups`,children:(0,B.jsxs)(W,{isActive:It,tooltip:`DB Parameter Groups`,children:[Lt,Rt]})})}),e[178]=It,e[179]=zt);let Bt;e[180]!==jt||e[181]!==Ft||e[182]!==zt||e[183]!==Dt?(Bt=(0,B.jsxs)(Kl,{children:[wt,(0,B.jsxs)(Jl,{children:[Dt,jt,Ft,zt]})]}),e[180]=jt,e[181]=Ft,e[182]=zt,e[183]=Dt,e[184]=Bt):Bt=e[184];let Vt;e[185]===Symbol.for(`react.memo_cache_sentinel`)?(Vt=(0,B.jsx)(ql,{children:`IAM`}),e[185]=Vt):Vt=e[185];let Ht;e[186]===n?Ht=e[187]:(Ht=n.startsWith(`/iam/list-users`)||n.startsWith(`/iam/create-user`),e[186]=n,e[187]=Ht);let Ut,Wt;e[188]===Symbol.for(`react.memo_cache_sentinel`)?(Ut=(0,B.jsx)(cc,{className:`size-4`}),Wt=(0,B.jsx)(`span`,{children:`Users`}),e[188]=Ut,e[189]=Wt):(Ut=e[188],Wt=e[189]);let Gt;e[190]===Ht?Gt=e[191]:(Gt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-users`,children:(0,B.jsxs)(W,{isActive:Ht,tooltip:`Users`,children:[Ut,Wt]})})}),e[190]=Ht,e[191]=Gt);let Kt;e[192]===n?Kt=e[193]:(Kt=n.startsWith(`/iam/list-groups`)||n.startsWith(`/iam/create-group`),e[192]=n,e[193]=Kt);let L,qt;e[194]===Symbol.for(`react.memo_cache_sentinel`)?(L=(0,B.jsx)(oc,{className:`size-4`}),qt=(0,B.jsx)(`span`,{children:`Groups`}),e[194]=L,e[195]=qt):(L=e[194],qt=e[195]);let Jt;e[196]===Kt?Jt=e[197]:(Jt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-groups`,children:(0,B.jsxs)(W,{isActive:Kt,tooltip:`Groups`,children:[L,qt]})})}),e[196]=Kt,e[197]=Jt);let Yt;e[198]===n?Yt=e[199]:(Yt=n.startsWith(`/iam/list-policies`)||n.startsWith(`/iam/create-policy`),e[198]=n,e[199]=Yt);let Xt,Zt;e[200]===Symbol.for(`react.memo_cache_sentinel`)?(Xt=(0,B.jsx)(et,{className:`size-4`}),Zt=(0,B.jsx)(`span`,{children:`Policies`}),e[200]=Xt,e[201]=Zt):(Xt=e[200],Zt=e[201]);let Qt;e[202]===Yt?Qt=e[203]:(Qt=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-policies`,children:(0,B.jsxs)(W,{isActive:Yt,tooltip:`Policies`,children:[Xt,Zt]})})}),e[202]=Yt,e[203]=Qt);let $t;e[204]===n?$t=e[205]:($t=n.startsWith(`/iam/list-roles`)||n.startsWith(`/iam/create-role`),e[204]=n,e[205]=$t);let en,tn;e[206]===Symbol.for(`react.memo_cache_sentinel`)?(en=(0,B.jsx)(ic,{className:`size-4`}),tn=(0,B.jsx)(`span`,{children:`Roles`}),e[206]=en,e[207]=tn):(en=e[206],tn=e[207]);let nn;e[208]===$t?nn=e[209]:(nn=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-roles`,children:(0,B.jsxs)(W,{isActive:$t,tooltip:`Roles`,children:[en,tn]})})}),e[208]=$t,e[209]=nn);let rn;e[210]===n?rn=e[211]:(rn=n.startsWith(`/iam/list-instance-profiles`)||n.startsWith(`/iam/create-instance-profile`),e[210]=n,e[211]=rn);let an,on;e[212]===Symbol.for(`react.memo_cache_sentinel`)?(an=(0,B.jsx)(As,{className:`size-4`}),on=(0,B.jsx)(`span`,{children:`Instance Profiles`}),e[212]=an,e[213]=on):(an=e[212],on=e[213]);let sn;e[214]===rn?sn=e[215]:(sn=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/iam/list-instance-profiles`,children:(0,B.jsxs)(W,{isActive:rn,tooltip:`Instance Profiles`,children:[an,on]})})}),e[214]=rn,e[215]=sn);let cn;e[216]!==Gt||e[217]!==Jt||e[218]!==Qt||e[219]!==nn||e[220]!==sn?(cn=(0,B.jsxs)(Kl,{children:[Vt,(0,B.jsxs)(Jl,{children:[Gt,Jt,Qt,nn,sn]})]}),e[216]=Gt,e[217]=Jt,e[218]=Qt,e[219]=nn,e[220]=sn,e[221]=cn):cn=e[221];let ln;e[222]===Symbol.for(`react.memo_cache_sentinel`)?(ln=(0,B.jsx)(ql,{children:`S3`}),e[222]=ln):ln=e[222];let un;e[223]===n?un=e[224]:(un=n.startsWith(`/s3/ls`),e[223]=n,e[224]=un);let dn,fn;e[225]===Symbol.for(`react.memo_cache_sentinel`)?(dn=(0,B.jsx)(nt,{className:`size-4`}),fn=(0,B.jsx)(`span`,{children:`Buckets`}),e[225]=dn,e[226]=fn):(dn=e[225],fn=e[226]);let pn;e[227]===un?pn=e[228]:(pn=(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/s3/ls`,children:(0,B.jsxs)(W,{isActive:un,tooltip:`Buckets`,children:[dn,fn]})})}),e[227]=un,e[228]=pn);let mn;e[229]!==o||e[230]!==n?(mn=o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/s3/service-metrics`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/s3/service-metrics`),tooltip:`Service Metrics`,children:[(0,B.jsx)(us,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Service Metrics`})]})})}),e[229]=o,e[230]=n,e[231]=mn):mn=e[231];let hn;e[232]!==pn||e[233]!==mn?(hn=(0,B.jsxs)(Kl,{children:[ln,(0,B.jsxs)(Jl,{children:[pn,mn]})]}),e[232]=pn,e[233]=mn,e[234]=hn):hn=e[234];let gn;e[235]!==o||e[236]!==n?(gn=qe()&&(0,B.jsxs)(Kl,{children:[(0,B.jsx)(ql,{children:`Ochre`}),(0,B.jsxs)(Jl,{children:[(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-knowledge-bases`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-knowledge-bases`),tooltip:`Knowledge Bases`,children:[(0,B.jsx)(gs,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Knowledge Bases`})]})})}),(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-guardrails`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-guardrails`),tooltip:`Guardrails`,children:[(0,B.jsx)(et,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Guardrails`})]})})}),(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/playground`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/playground`),tooltip:`Playground`,children:[(0,B.jsx)(Ws,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Playground`})]})})}),o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-model-catalog`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-model-catalog`),tooltip:`Model Catalog`,children:[(0,B.jsx)(Rs,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Model Catalog`})]})})}),o&&(0,B.jsx)(U,{children:(0,B.jsx)(A,{to:`/bedrock/list-model-access`,children:(0,B.jsxs)(W,{isActive:n.startsWith(`/bedrock/list-model-access`),tooltip:`Model Access`,children:[(0,B.jsx)(Ms,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Model Access`})]})})})]})]}),e[235]=o,e[236]=n,e[237]=gn):gn=e[237];let _n;e[238]===o?_n=e[239]:(_n=o&&(0,B.jsxs)(Kl,{children:[(0,B.jsx)(ql,{children:`Documentation`}),(0,B.jsx)(Jl,{children:(0,B.jsx)(U,{children:(0,B.jsx)(`a`,{href:`https://docs.mulgadc.com`,target:`_blank`,rel:`noopener noreferrer`,children:(0,B.jsxs)(W,{tooltip:`Documentation`,children:[(0,B.jsx)(fs,{className:`size-4`}),(0,B.jsx)(`span`,{children:`Docs`})]})})})})]}),e[238]=o,e[239]=_n);let vn;e[240]!==Bt||e[241]!==cn||e[242]!==hn||e[243]!==gn||e[244]!==_n||e[245]!==Te||e[246]!==lt||e[247]!==g||e[248]!==Ct?(vn=(0,B.jsxs)(Gl,{children:[g,Te,lt,Ct,Bt,cn,hn,gn,_n]}),e[240]=Bt,e[241]=cn,e[242]=hn,e[243]=gn,e[244]=_n,e[245]=Te,e[246]=lt,e[247]=g,e[248]=Ct,e[249]=vn):vn=e[249];let yn;e[250]===Symbol.for(`react.memo_cache_sentinel`)?(yn=(0,B.jsx)(Wl,{className:`mx-0 w-full`}),e[250]=yn):yn=e[250];let bn,xn;e[251]===Symbol.for(`react.memo_cache_sentinel`)?(bn=(0,B.jsx)(Bs,{className:`size-4`}),xn=(0,B.jsx)(`span`,{children:`Sign out`}),e[251]=bn,e[252]=xn):(bn=e[251],xn=e[252]);let Sn;e[253]===c?Sn=e[254]:(Sn=(0,B.jsxs)(Ul,{children:[yn,(0,B.jsx)(Jl,{children:(0,B.jsx)(U,{children:(0,B.jsxs)(W,{onClick:c,tooltip:`Sign out`,children:[bn,xn]})})})]}),e[253]=c,e[254]=Sn);let Tn;e[255]===Symbol.for(`react.memo_cache_sentinel`)?(Tn=(0,B.jsx)(Vl,{}),e[255]=Tn):Tn=e[255];let En;return e[256]!==vn||e[257]!==Sn?(En=(0,B.jsxs)(zl,{collapsible:`icon`,children:[l,vn,Sn,Tn]}),e[256]=vn,e[257]=Sn,e[258]=En):En=e[258],En}function au(e){return e.pathname}function ou(e){let t=(0,H.c)(18),{error:n,reset:r}=e,i=ke(),o=a(),s;t[0]!==o||t[1]!==r||t[2]!==i?(s=function(){o.invalidateQueries(),r(),i.invalidate()},t[0]=o,t[1]=r,t[2]=i,t[3]=s):s=t[3];let c=s,l;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(l=(0,B.jsx)(G,{}),t[4]=l):l=t[4];let u;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(u=(0,B.jsx)(ru,{}),t[5]=u):u=t[5];let d,f;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(d=(0,B.jsx)(`div`,{className:`flex justify-center`,children:(0,B.jsx)(vs,{className:`size-12 text-destructive`})}),f=(0,B.jsx)(`h1`,{className:`text-2xl font-semibold`,children:`Something went wrong`}),t[6]=d,t[7]=f):(d=t[6],f=t[7]);let p=(n instanceof Error?n.message:String(n))||`An unexpected error occurred`,m;t[8]===p?m=t[9]:(m=(0,B.jsx)(`p`,{className:`text-muted-foreground`,children:p}),t[8]=p,t[9]=m);let h;t[10]===c?h=t[11]:(h=(0,B.jsx)(_t,{onClick:c,variant:`default`,children:`Try Again`}),t[10]=c,t[11]=h);let g;t[12]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,B.jsx)(A,{to:`/`,children:(0,B.jsx)(_t,{variant:`outline`,children:`Go Home`})}),t[12]=g):g=t[12];let _;t[13]===h?_=t[14]:(_=(0,B.jsxs)(`div`,{className:`flex justify-center gap-2`,children:[h,g]}),t[13]=h,t[14]=_);let v;return t[15]!==_||t[16]!==m?(v=(0,B.jsxs)(B.Fragment,{children:[l,(0,B.jsxs)(`main`,{className:`flex flex-1 flex-col`,children:[u,(0,B.jsx)(`div`,{className:`flex flex-1 items-center justify-center p-6 pt-0`,children:(0,B.jsxs)(`div`,{className:`max-w-md space-y-4 text-center`,children:[d,f,m,_]})})]})]}),t[15]=_,t[16]=m,t[17]=v):v=t[17],v}var su=E()({head:()=>({meta:[{title:`Mulga`}]}),component:()=>(0,B.jsxs)(B.Fragment,{children:[(0,B.jsx)(es,{}),(0,B.jsx)(G,{}),(0,B.jsxs)(`main`,{className:`flex flex-1 flex-col`,children:[(0,B.jsx)(ru,{}),(0,B.jsx)(`div`,{className:`flex flex-1 flex-col p-8`,children:(0,B.jsx)(be,{})})]})]}),errorComponent:ou,notFoundComponent:iu}),cu=w(`/_auth`)({beforeLoad:()=>{if(!Je())throw Ee({to:`/login`})},component:D(()=>P(()=>import(`./route-DPFKZkGs.js`),__vite__mapDeps([0,1,2,3,4,5])),`component`)}),lu=w(`/_auth/`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...zn,staleTime:`static`}),e.queryClient.query({...An,staleTime:`static`}),e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Hn,staleTime:`static`}),e.queryClient.query({...lr,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`}),e.queryClient.query({...Fn,staleTime:`static`}),e.queryClient.query({...Bn,staleTime:`static`}),e.queryClient.query({...Ln,staleTime:`static`})])},head:()=>({meta:[{title:`Dashboard | Mulga`}]}),component:D(()=>P(()=>import(`./_auth-Cg86DRhs.js`),__vite__mapDeps([6,1,7,8,9,10,11,4,5,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39])),`component`)}),uu=w(`/_auth/bedrock`)({beforeLoad:()=>{qe()||N({throw:!0})},component:D(()=>P(()=>import(`./route-n5jLl8qw.js`),__vite__mapDeps([40,1,2,3,4,5])),`component`)}),du=w(`/_auth/nodes`)({head:()=>({meta:[{title:`Nodes | Mulga`}]}),component:D(()=>P(()=>import(`./nodes-DExo87Po.js`),__vite__mapDeps([41,1,7,8,9,10,11,4,5,42,12,13,15,14,16,19,20,21,27,28,29,30,31,32,23])),`component`)}),fu=w(`/_auth/s3/service-metrics`)({head:()=>({meta:[{title:`Service Metrics | S3 | Mulga`}]}),component:D(()=>P(()=>import(`./service-metrics-C6gBIZci.js`),__vite__mapDeps([43,1,7,8,9,10,42,19,12,13,20,21,27,28,29,30,31,32,23])),`component`)}),pu=w(`/_auth/ec2/(elastic-ips)/allocate-address`)({head:()=>({meta:[{title:`Allocate Elastic IP | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./allocate-address-CDvylWBR.js`),__vite__mapDeps([44,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,63])),`component`)}),mu=w(`/_auth/ec2/(internet-gateways)/create-internet-gateway`)({head:()=>({meta:[{title:`Create Internet Gateway | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-internet-gateway-CGStzV_5.js`),__vite__mapDeps([64,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,63])),`component`)}),hu=w(`/_auth/ec2/(key)/create-key-pair`)({head:()=>({meta:[{title:`Create Key Pair | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-key-pair-CKL_t5ui.js`),__vite__mapDeps([65,1,45,4,5,46,11,14,48,66,36,29,12,13,34,37,15,52,38,67,68,53,69,70,47,49,50,71,51,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,33,35,63])),`component`)}),gu=w(`/_auth/ec2/(key)/import-key-pair`)({head:()=>({meta:[{title:`Import Key Pair | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./import-key-pair-kETKjfrj.js`),__vite__mapDeps([72,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,73,63])),`component`)}),_u=w(`/_auth/ec2/(launch-templates)/create-launch-template`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Vn,staleTime:`static`}),e.queryClient.query({...In,staleTime:`static`}),e.queryClient.query({...Hn,staleTime:`static`}),e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Launch Template | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-launch-template-vwPKoZ8a.js`),__vite__mapDeps([74,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,63,75,76,8,10,66,36,38,67,68,69,73,77])),`component`)}),vu=w(`/_auth/ec2/(load-balancers)/create-load-balancer`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`}),e.queryClient.query({...Qn,staleTime:`static`}),e.queryClient.query({...En,staleTime:`static`}),e.queryClient.query({...Zn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Load Balancer | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-load-balancer-BBKiIxj3.js`),__vite__mapDeps([78,1,76,8,9,10,45,4,5,12,13,46,11,14,66,36,29,34,37,48,15,52,38,67,68,53,69,47,49,50,51,79,23,24,25,80,55,56,59,61,62,73,33,35,22,81,82,83,27,58,60,84,85,86,87,77,88])),`component`)}),yu=w(`/_auth/ec2/(nat-gateways)/create-nat-gateway`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Bn,staleTime:`static`})])},head:()=>({meta:[{title:`Create NAT Gateway | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-nat-gateway-C0zhuUio.js`),__vite__mapDeps([89,1,76,8,9,10,11,4,5,45,12,13,46,14,66,36,29,34,37,48,15,52,38,67,68,53,69,47,49,50,51,22,23,24,25,54,55,56,57,27,58,59,60,61,62,63])),`component`)}),bu=w(`/_auth/ec2/(placement-groups)/create-placement-group`)({head:()=>({meta:[{title:`Create Placement Group | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-placement-group-D_3YKZZQ.js`),__vite__mapDeps([90,1,45,4,5,46,11,14,66,36,29,12,13,34,37,48,15,52,38,67,68,53,69,47,49,50,51,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,63])),`component`)}),xu=w(`/_auth/ec2/(route-tables)/create-route-table`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`Create Route Table | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-route-table-q3_xIq0T.js`),__vite__mapDeps([91,1,76,8,9,10,45,4,5,12,13,46,11,14,66,36,29,34,37,48,15,52,38,67,68,53,69,51,22,23,24,25,54,55,56,57,27,58,59,60,61,62,63])),`component`)}),Su=w(`/_auth/ec2/(security-groups)/create-security-group`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`Create Security Group | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-security-group-BD7bwg9p.js`),__vite__mapDeps([92,1,76,8,9,10,45,4,5,12,13,46,11,14,66,36,29,34,37,48,15,52,38,67,68,53,69,47,49,50,51,22,23,24,25,54,55,56,57,27,58,59,60,61,62,63])),`component`)}),Cu=w(`/_auth/ec2/(subnet)/create-subnet`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Un,staleTime:`static`})])},head:()=>({meta:[{title:`Create Subnet | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-subnet-BXdYKpy-.js`),__vite__mapDeps([93,1,76,8,9,10,45,4,5,12,13,46,11,14,66,36,29,34,37,48,15,52,38,67,68,53,69,47,49,50,51,22,23,24,25,54,55,56,57,27,58,59,60,61,62,63])),`component`)}),wu=w(`/_auth/ec2/(target-groups)/create-target-group`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`Create Target Group | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-target-group-CSrYTK6r.js`),__vite__mapDeps([94,1,76,8,9,10,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,22,23,24,25,81,55,56,82,27,58,59,60,87,66,36,37,52,38,67,68,53,69,51,61,62,84,85,86,77,88])),`component`)}),Tu=w(`/_auth/ec2/(volumes)/create-volume`)({loader:async({context:e})=>{await e.queryClient.query({...Un,staleTime:`static`})},head:()=>({meta:[{title:`Create Volume | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-volume-BamGr8Qm.js`),__vite__mapDeps([95,1,76,8,9,10,45,4,5,46,11,14,66,36,29,12,13,34,37,48,15,52,38,67,68,53,69,47,49,50,51,22,23,24,25,54,55,56,57,27,58,59,60,61,62,63])),`component`)}),Eu=w(`/_auth/ec2/(vpc)/create-vpc`)({head:()=>({meta:[{title:`Create VPC | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./create-vpc-CyDH_vpB.js`),__vite__mapDeps([96,1,45,4,5,46,11,14,15,16,66,36,29,12,13,34,37,48,52,38,67,68,53,69,47,49,50,51,54,9,55,56,24,25,22,23,57,27,58,59,60,61,62,63])),`component`)}),Du=w(`/_auth/ecs/(clusters)/task-definitions`)({loader:async({context:e})=>{await e.queryClient.query({...Jn,staleTime:`static`})},head:()=>({meta:[{title:`Task Definitions | ECS | Mulga`}]}),component:D(()=>P(()=>import(`./task-definitions-qkfdMMhk.js`),__vite__mapDeps([97,1,76,8,9,10,11,4,5,34,29,12,13,98,23,24,25,99,55,56,21,27,59,100,33,35,36,37,38,101])),`component`)}),Ou=w(`/_auth/eks/(clusters)/create-cluster`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...rr,staleTime:`static`}),e.queryClient.query({...Vn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Cluster | EKS | Mulga`}]}),component:D(()=>P(()=>import(`./create-cluster-eQiUzhUk.js`),__vite__mapDeps([102,1,9,76,8,10,45,4,5,12,13,46,11,14,66,36,29,34,37,48,15,52,38,67,68,53,69,51,22,23,24,25,103,55,56,104,105,57,106,107,108,27,58,59,60,61,62,33,35,109,110,49,111,71,50,112])),`component`)}),ku=w(`/_auth/iam/(groups)/create-group`)({head:()=>({meta:[{title:`Create Group | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-group-6mVlm4h5.js`),__vite__mapDeps([113,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,106,9,55,56,24,25,107,27,58,59,60,61,62,114,105])),`component`)}),Au=w(`/_auth/iam/(instance-profiles)/create-instance-profile`)({head:()=>({meta:[{title:`Create Instance Profile | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-instance-profile-CgetPfkP.js`),__vite__mapDeps([115,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,106,9,55,56,24,25,107,27,58,59,60,61,62,114,105])),`component`)}),ju=w(`/_auth/iam/(policies)/create-policy`)({head:()=>({meta:[{title:`Create Policy | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-policy-BJFldcK3.js`),__vite__mapDeps([116,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,106,9,55,56,24,25,107,27,58,59,60,61,62,117,105,73,114])),`component`)}),Mu=w(`/_auth/iam/(roles)/create-role`)({head:()=>({meta:[{title:`Create Role | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-role-DvSx0q-L.js`),__vite__mapDeps([118,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,106,9,55,56,24,25,107,27,58,59,60,61,62,117,105,73,114])),`component`)}),Nu=w(`/_auth/iam/(users)/create-user`)({head:()=>({meta:[{title:`Create User | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./create-user-Bv8sj586.js`),__vite__mapDeps([119,1,45,4,5,46,11,14,47,12,13,48,15,49,34,29,50,51,37,52,53,106,9,55,56,24,25,107,27,58,59,60,61,62,114,105])),`component`)}),Pu=w(`/_auth/rds/(db-instances)/create-db-instance`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...sr,staleTime:`static`}),e.queryClient.query({...or,staleTime:`static`}),e.queryClient.query({...ar,staleTime:`static`}),e.queryClient.query({...Wn,staleTime:`static`}),e.queryClient.query({...Mn,staleTime:`static`}),e.queryClient.query({...Vn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Database | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./create-db-instance-CY_ivPtb.js`),__vite__mapDeps([120,1,9,7,8,10,76,45,4,5,46,11,14,66,36,29,12,13,34,37,48,15,52,38,67,68,53,69,47,49,50,51,22,23,24,25,121,122,55,56,123,27,58,59,60,61,62,84,85,86,109,110,111,71,124,125])),`component`)}),Fu=w(`/_auth/rds/(parameter-groups)/create-db-parameter-group`)({loader:async({context:e})=>{await e.queryClient.query({...sr,staleTime:`static`})},head:()=>({meta:[{title:`Create Parameter Group | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./create-db-parameter-group-CkK-hDRM.js`),__vite__mapDeps([126,1,76,8,9,10,45,4,5,46,11,14,66,36,29,12,13,34,37,48,15,52,38,67,68,53,69,47,49,50,51,121,24,25,122,55,56,123,23,27,58,59,60,61,62,84,85,86])),`component`)}),Iu=w(`/_auth/rds/(subnet-groups)/create-db-subnet-group`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...Nn,staleTime:`static`}),e.queryClient.query({...Mn,staleTime:`static`})])},head:()=>({meta:[{title:`Create Subnet Group | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./create-db-subnet-group-BV65rf5O.js`),__vite__mapDeps([127,1,76,8,9,10,45,4,5,12,13,46,11,14,47,48,15,49,34,29,50,51,37,52,53,22,23,24,25,121,122,55,56,27,58,59,60,61,62,84,85,86])),`component`)}),Lu=w(`/_auth/s3/(buckets)/create-bucket`)({head:()=>({meta:[{title:`Create Bucket | S3 | Mulga`}]}),component:D(()=>P(()=>import(`./create-bucket-B5kEkFpz.js`),__vite__mapDeps([128,1,45,4,5,12,13,46,11,14,47,48,15,49,34,29,50,51,37,52,53,129,9,55,56,24,25,27,58,59,60,61,62])),`component`)}),Ru=w(`/_auth/s3/ls/`)({loader:async({context:e})=>{await e.queryClient.query({...lr,staleTime:`static`})},head:()=>({meta:[{title:`Buckets | S3 | Mulga`}]}),component:D(()=>P(()=>import(`./ls-XgxEPnDq.js`),__vite__mapDeps([130,1,76,8,9,10,11,4,5,12,13,34,29,26,23,24,25,27,131])),`component`)}),zu=w(`/_auth/s3/ls/$bucket`)({component:D(()=>P(()=>import(`./route-DtXjPAiX.js`),__vite__mapDeps([132,1,2,3,4,5])),`component`)}),Bu=w(`/_auth/bedrock/(catalog)/list-model-catalog/`)({head:()=>({meta:[{title:`Model Catalog | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-model-catalog-CPZ8xUbd.js`),__vite__mapDeps([133,1,7,8,9,10,42,12,13,19,20,21,27,28,29,30,31,32,23])),`component`)}),Vu=w(`/_auth/bedrock/(guardrails)/list-guardrails/`)({loader:async({context:e})=>{await e.queryClient.query({...Dn,staleTime:`static`})},head:()=>({meta:[{title:`Guardrails | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-guardrails-DYYlNj8n.js`),__vite__mapDeps([134,1,76,8,9,10,11,4,5,34,29,12,13,135,23,24,25,136,27,39,131])),`component`)}),Hu=w(`/_auth/bedrock/(guardrails)/list-guardrails/create`)({head:()=>({meta:[{title:`Create Guardrail | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./create-CoZioQAG.js`),__vite__mapDeps([137,1,138,9,55,56,12,13,24,25,136,139,45,4,5,46,11,14,66,36,29,34,37,48,15,52,38,67,68,53,69,85,86,51,27,58,59,60,61,62,84,73])),`component`)}),Uu=w(`/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/`)({loader:async({context:e})=>{await e.queryClient.query({...kn,staleTime:`static`})},head:()=>({meta:[{title:`Knowledge Bases | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-knowledge-bases-CH_zlnnr.js`),__vite__mapDeps([140,1,76,8,9,10,11,4,5,34,29,12,13,141,23,24,25,142,27,39,131])),`component`)}),Wu=w(`/_auth/bedrock/(knowledge-bases)/list-knowledge-bases/create`)({loader:async({context:e})=>{await e.queryClient.query({...On,staleTime:`static`})},head:()=>({meta:[{title:`Create Knowledge Base | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./create-uNWIyxX8.js`),__vite__mapDeps([143,1,76,8,9,10,45,4,5,46,11,14,66,36,29,12,13,34,37,48,15,52,38,67,68,53,69,51,135,23,24,25,136,144,55,56,142,27,58,59,60,61,62,73])),`component`)}),Gu=w(`/_auth/bedrock/(model-access)/list-model-access/`)({head:()=>({meta:[{title:`Model Access | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./list-model-access-Ds5MpzNH.js`),__vite__mapDeps([145,1,9,55,56,7,8,10,42,66,36,29,12,13,34,37,14,48,15,52,38,5,67,68,53,69,51,19,20,21,27,28,30,31,32,23,61,62])),`component`)}),Ku=w(`/_auth/bedrock/(playground)/playground/`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...On,staleTime:`static`}),e.queryClient.query({...Dn,staleTime:`static`})])},head:()=>({meta:[{title:`Playground | Ochre | Mulga`}]}),component:D(()=>P(()=>import(`./playground-zdI5o66q.js`),__vite__mapDeps([146,1,55,9,56,76,8,10,12,13,15,14,66,36,29,34,37,48,52,38,5,67,68,53,69,47,49,50,51,24,25,135,23,136,147,27,28,30,61,62,73])),`component`)}),qu=w(`/_auth/ec2/(elastic-ips)/describe-addresses/`)({loader:async({context:e})=>{await e.queryClient.query({...Bn,staleTime:`static`})},head:()=>({meta:[{title:`Elastic IPs | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-addresses-DKlK9Anw.js`),__vite__mapDeps([148,1,76,8,9,10,11,4,5,12,13,34,29,22,23,24,25,27,131])),`component`)}),Ju=w(`/_auth/ec2/(images)/describe-images/`)({loader:async({context:e})=>{await e.queryClient.query({...Vn,staleTime:`static`})},head:()=>({meta:[{title:`Images | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-images-Zp7giKqa.js`),__vite__mapDeps([149,1,76,8,9,10,22,23,12,13,24,25,27,39,131,11,4,5,109])),`component`)}),Yu=w(`/_auth/ec2/(instances)/describe-instances/`)({loader:async({context:e})=>{await e.queryClient.query({...zn,staleTime:`static`})},head:()=>({meta:[{title:`Instances | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-instances-BK0FW0LB.js`),__vite__mapDeps([150,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,39,131])),`component`)}),Xu=w(`/_auth/ec2/(internet-gateways)/describe-internet-gateways/`)({loader:async({context:e})=>{await e.queryClient.query({...Ln,staleTime:`static`})},head:()=>({meta:[{title:`Internet Gateways | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-internet-gateways-CiiBScpk.js`),__vite__mapDeps([151,1,76,8,9,10,11,4,5,12,13,34,29,22,23,24,25,27,131])),`component`)}),Zu=w(`/_auth/ec2/(key)/describe-key-pairs/`)({loader:async({context:e})=>{await e.queryClient.query({...Hn,staleTime:`static`})},head:()=>({meta:[{title:`Key Pairs | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-key-pairs-BRKxsJWn.js`),__vite__mapDeps([152,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,131])),`component`)}),Qu=w(`/_auth/ec2/(launch-templates)/describe-launch-templates/`)({loader:async({context:e})=>{await e.queryClient.query({...Rn,staleTime:`static`})},head:()=>({meta:[{title:`Launch Templates | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-launch-templates-BuaCkV_y.js`),__vite__mapDeps([153,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,28,30,131])),`component`)}),$u=w(`/_auth/ec2/(load-balancers)/describe-load-balancers/`)({loader:async({context:e})=>{await e.queryClient.query({...Xn,staleTime:`static`})},head:()=>({meta:[{title:`Load Balancers | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-load-balancers-ayJnDdyt.js`),__vite__mapDeps([154,1,76,8,9,10,11,4,5,45,12,13,34,29,83,23,24,25,82,27,39])),`component`)}),K=w(`/_auth/ec2/(nat-gateways)/describe-nat-gateways/`)({loader:async({context:e})=>{await e.queryClient.query({...Fn,staleTime:`static`})},head:()=>({meta:[{title:`NAT Gateways | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-nat-gateways-B8p3GKN1.js`),__vite__mapDeps([155,1,76,8,9,10,11,4,5,12,13,34,29,22,23,24,25,27,39,131])),`component`)}),ed=w(`/_auth/ec2/(placement-groups)/describe-placement-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...Gn,staleTime:`static`})},head:()=>({meta:[{title:`Placement Groups | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-placement-groups-XiQbPRZ5.js`),__vite__mapDeps([156,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,39,131])),`component`)}),q=w(`/_auth/ec2/(route-tables)/describe-route-tables/`)({loader:async({context:e})=>{await e.queryClient.query({...Pn,staleTime:`static`})},head:()=>({meta:[{title:`Route Tables | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-route-tables-Cz9uMOpx.js`),__vite__mapDeps([157,1,76,8,9,10,11,4,5,12,13,34,29,22,23,24,25,27,28,30,131])),`component`)}),J=w(`/_auth/ec2/(security-groups)/describe-security-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...Wn,staleTime:`static`})},head:()=>({meta:[{title:`Security Groups | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-security-groups-uryLUVIn.js`),__vite__mapDeps([158,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,131])),`component`)}),Y=w(`/_auth/ec2/(snapshots)/describe-snapshots/`)({loader:async({context:e})=>{await e.queryClient.query({...jn,staleTime:`static`})},head:()=>({meta:[{title:`Snapshots | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-snapshots-63g9NU9g.js`),__vite__mapDeps([159,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,39,131])),`component`)}),td=w(`/_auth/ec2/(subnet)/describe-subnets/`)({loader:async({context:e})=>{await e.queryClient.query({...Nn,staleTime:`static`})},head:()=>({meta:[{title:`Subnets | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-subnets-lWLraP1d.js`),__vite__mapDeps([160,1,76,8,9,10,11,4,5,12,13,34,29,22,23,24,25,27,39,131])),`component`)}),nd=w(`/_auth/ec2/(target-groups)/describe-target-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...Qn,staleTime:`static`})},head:()=>({meta:[{title:`Target Groups | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-target-groups-5RCbjKmg.js`),__vite__mapDeps([161,1,7,8,9,10,76,11,4,5,45,34,29,12,13,83,23,24,25,82,27])),`component`)}),rd=w(`/_auth/ec2/(volumes)/describe-volumes/`)({loader:async({context:e})=>{await e.queryClient.query({...An,staleTime:`static`})},head:()=>({meta:[{title:`Volumes | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-volumes-Dj4fTW0o.js`),__vite__mapDeps([162,1,76,8,9,10,11,4,5,34,29,12,13,22,23,24,25,27,39,131])),`component`)}),id=w(`/_auth/ec2/(vpc)/describe-vpcs/`)({loader:async({context:e})=>{await e.queryClient.query({...Mn,staleTime:`static`})},head:()=>({meta:[{title:`VPCs | EC2 | Mulga`}]}),component:D(()=>P(()=>import(`./describe-vpcs-cdkXn9V9.js`),__vite__mapDeps([163,1,76,8,9,10,11,4,5,12,13,34,29,22,23,24,25,27,39,131])),`component`)}),ad=w(`/_auth/ecr/(repositories)/list-repositories/`)({loader:async({context:e})=>{await e.queryClient.query({...Kn,staleTime:`static`})},head:()=>({meta:[{title:`Repositories | ECR | Mulga`}]}),component:D(()=>P(()=>import(`./list-repositories-B-9oI4MN.js`),__vite__mapDeps([164,1,76,8,9,10,11,4,5,45,12,13,34,29,51,37,52,53,165,23,24,25,166,55,56,27,61,62,33,35,36,38])),`component`)}),od=w(`/_auth/ecs/(clusters)/list-clusters/`)({loader:async({context:e})=>{await Promise.all([e.queryClient.query({...qn,staleTime:`static`}),e.queryClient.query({...Vn,staleTime:`static`})])},head:()=>({meta:[{title:`Clusters | ECS | Mulga`}]}),component:D(()=>P(()=>import(`./list-clusters-UynY0efZ.js`),__vite__mapDeps([167,1,9,76,8,10,11,4,5,45,34,29,12,13,51,37,52,53,22,23,24,25,98,99,55,56,21,27,59,61,62,33,35,36,38,100,39,109,110,48,14,49,111,71,50,101])),`component`)}),sd=w(`/_auth/eks/(clusters)/list-clusters/`)({loader:async({context:e})=>{await e.queryClient.query({...Yn,staleTime:`static`})},head:()=>({meta:[{title:`Clusters | EKS | Mulga`}]}),component:D(()=>P(()=>import(`./list-clusters-CIHqWLhW.js`),__vite__mapDeps([168,1,76,8,9,10,11,4,5,34,29,12,13,169,23,24,25,104,27,131])),`component`)}),cd=w(`/_auth/iam/(groups)/list-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...tr,staleTime:`static`})},head:()=>({meta:[{title:`Groups | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-groups-B-aCbhJM.js`),__vite__mapDeps([170,1,76,8,9,10,11,4,5,34,29,12,13,108,23,24,25,107,105,27,131])),`component`)}),ld=w(`/_auth/iam/(instance-profiles)/list-instance-profiles/`)({loader:async({context:e})=>{await e.queryClient.query({...er,staleTime:`static`})},head:()=>({meta:[{title:`Instance Profiles | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-instance-profiles-BSt7PimM.js`),__vite__mapDeps([171,1,76,8,9,10,11,4,5,34,29,12,13,108,23,24,25,107,105,27,131])),`component`)}),ud=w(`/_auth/iam/(policies)/list-policies/`)({loader:async({context:e})=>{await e.queryClient.query({...nr,staleTime:`static`})},head:()=>({meta:[{title:`Policies | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-policies-DeMAXGlh.js`),__vite__mapDeps([172,1,76,8,9,10,11,4,5,34,29,12,13,108,23,24,25,107,105,27,131])),`component`)}),dd=w(`/_auth/iam/(roles)/list-roles/`)({loader:async({context:e})=>{await e.queryClient.query({...rr,staleTime:`static`})},head:()=>({meta:[{title:`Roles | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-roles-DKJKr-De.js`),__vite__mapDeps([173,1,76,8,9,10,11,4,5,34,29,12,13,108,23,24,25,107,105,27,131])),`component`)}),fd=w(`/_auth/iam/(users)/list-users/`)({loader:async({context:e})=>{await e.queryClient.query({...$n,staleTime:`static`})},head:()=>({meta:[{title:`Users | IAM | Mulga`}]}),component:D(()=>P(()=>import(`./list-users-B87cjyLt.js`),__vite__mapDeps([174,1,76,8,9,10,11,4,5,34,29,12,13,108,23,24,25,107,105,27,131])),`component`)}),pd=w(`/_auth/rds/(db-instances)/describe-db-instances/`)({loader:async({context:e})=>{await e.queryClient.query({...cr,staleTime:`static`})},head:()=>({meta:[{title:`Databases | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-instances-ririu0gg.js`),__vite__mapDeps([175,1,76,8,9,10,11,4,5,45,34,29,12,13,121,24,25,122,55,56,123,23,27,59,39,176,51,37,52,53,61,62,33,35,36,38])),`component`)}),md=w(`/_auth/rds/(parameter-groups)/describe-db-parameter-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...ar,staleTime:`static`})},head:()=>({meta:[{title:`Parameter Groups | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-parameter-groups-Vj5U5CsP.js`),__vite__mapDeps([177,1,76,8,9,10,11,4,5,45,34,29,12,13,121,24,25,123,23,27,178,122,55,56,179,100,33,35,36,37,38])),`component`)}),hd=w(`/_auth/rds/(snapshots)/describe-db-snapshots/`)({loader:async({context:e})=>{await e.queryClient.query({...ir,staleTime:`static`})},head:()=>({meta:[{title:`Snapshots | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-snapshots-DXVrzU4j.js`),__vite__mapDeps([180,1,76,8,9,10,11,4,5,45,12,13,34,29,121,24,25,123,23,27,39,181,7,66,36,37,14,48,15,52,38,67,68,53,69,51,122,55,56,58,61,62,84,85,86,33,35,124,182,179,100])),`component`)}),gd=w(`/_auth/rds/(subnet-groups)/describe-db-subnet-groups/`)({loader:async({context:e})=>{await e.queryClient.query({...or,staleTime:`static`})},head:()=>({meta:[{title:`Subnet Groups | RDS | Mulga`}]}),component:D(()=>P(()=>import(`./describe-db-subnet-groups-B9VO6RnW.js`),__vite__mapDeps([183,1,76,8,9,10,11,4,5,45,34,29,12,13,123,23,24,25,121,27,39,184,122,55,56,179,100,33,35,36,37,38])),`component`)}),X=cu.update({id:`/_auth`,getParentRoute:()=>su}),_d=ur.update({id:`/login`,path:`/login`,getParentRoute:()=>su}),vd=lu.update({id:`/`,path:`/`,getParentRoute:()=>X}),Z=uu.update({id:`/bedrock`,path:`/bedrock`,getParentRoute:()=>X}),yd=du.update({id:`/nodes`,path:`/nodes`,getParentRoute:()=>X}),bd=fu.update({id:`/s3/service-metrics`,path:`/s3/service-metrics`,getParentRoute:()=>X}),xd=pu.update({id:`/ec2/(elastic-ips)/allocate-address`,path:`/ec2/allocate-address`,getParentRoute:()=>X}),Sd=dr.update({id:`/ec2/(instances)/run-instances`,path:`/ec2/run-instances`,getParentRoute:()=>X}),Cd=mu.update({id:`/ec2/(internet-gateways)/create-internet-gateway`,path:`/ec2/create-internet-gateway`,getParentRoute:()=>X}),wd=hu.update({id:`/ec2/(key)/create-key-pair`,path:`/ec2/create-key-pair`,getParentRoute:()=>X}),Td=gu.update({id:`/ec2/(key)/import-key-pair`,path:`/ec2/import-key-pair`,getParentRoute:()=>X}),Ed=_u.update({id:`/ec2/(launch-templates)/create-launch-template`,path:`/ec2/create-launch-template`,getParentRoute:()=>X}),Dd=vu.update({id:`/ec2/(load-balancers)/create-load-balancer`,path:`/ec2/create-load-balancer`,getParentRoute:()=>X}),Od=yu.update({id:`/ec2/(nat-gateways)/create-nat-gateway`,path:`/ec2/create-nat-gateway`,getParentRoute:()=>X}),kd=bu.update({id:`/ec2/(placement-groups)/create-placement-group`,path:`/ec2/create-placement-group`,getParentRoute:()=>X}),Ad=xu.update({id:`/ec2/(route-tables)/create-route-table`,path:`/ec2/create-route-table`,getParentRoute:()=>X}),jd=Su.update({id:`/ec2/(security-groups)/create-security-group`,path:`/ec2/create-security-group`,getParentRoute:()=>X}),Md=fr.update({id:`/ec2/(snapshots)/create-snapshot`,path:`/ec2/create-snapshot`,getParentRoute:()=>X}),Nd=Cu.update({id:`/ec2/(subnet)/create-subnet`,path:`/ec2/create-subnet`,getParentRoute:()=>X}),Pd=wu.update({id:`/ec2/(target-groups)/create-target-group`,path:`/ec2/create-target-group`,getParentRoute:()=>X}),Fd=Tu.update({id:`/ec2/(volumes)/create-volume`,path:`/ec2/create-volume`,getParentRoute:()=>X}),Id=Eu.update({id:`/ec2/(vpc)/create-vpc`,path:`/ec2/create-vpc`,getParentRoute:()=>X}),Ld=pr.update({id:`/ecs/(clusters)/create-service`,path:`/ecs/create-service`,getParentRoute:()=>X}),Rd=mr.update({id:`/ecs/(clusters)/register-task-definition`,path:`/ecs/register-task-definition`,getParentRoute:()=>X}),zd=hr.update({id:`/ecs/(clusters)/run-task`,path:`/ecs/run-task`,getParentRoute:()=>X}),Bd=Du.update({id:`/ecs/(clusters)/task-definitions`,path:`/ecs/task-definitions`,getParentRoute:()=>X}),Vd=Ou.update({id:`/eks/(clusters)/create-cluster`,path:`/eks/create-cluster`,getParentRoute:()=>X}),Hd=ku.update({id:`/iam/(groups)/create-group`,path:`/iam/create-group`,getParentRoute:()=>X}),Ud=Au.update({id:`/iam/(instance-profiles)/create-instance-profile`,path:`/iam/create-instance-profile`,getParentRoute:()=>X}),Wd=ju.update({id:`/iam/(policies)/create-policy`,path:`/iam/create-policy`,getParentRoute:()=>X}),Gd=Mu.update({id:`/iam/(roles)/create-role`,path:`/iam/create-role`,getParentRoute:()=>X}),Kd=Nu.update({id:`/iam/(users)/create-user`,path:`/iam/create-user`,getParentRoute:()=>X}),qd=Pu.update({id:`/rds/(db-instances)/create-db-instance`,path:`/rds/create-db-instance`,getParentRoute:()=>X}),Jd=Fu.update({id:`/rds/(parameter-groups)/create-db-parameter-group`,path:`/rds/create-db-parameter-group`,getParentRoute:()=>X}),Yd=Iu.update({id:`/rds/(subnet-groups)/create-db-subnet-group`,path:`/rds/create-db-subnet-group`,getParentRoute:()=>X}),Xd=Lu.update({id:`/s3/(buckets)/create-bucket`,path:`/s3/create-bucket`,getParentRoute:()=>X}),Zd=Ru.update({id:`/s3/ls/`,path:`/s3/ls/`,getParentRoute:()=>X}),Qd=zu.update({id:`/s3/ls/$bucket`,path:`/s3/ls/$bucket`,getParentRoute:()=>X}),$d=Bu.update({id:`/(catalog)/list-model-catalog/`,path:`/list-model-catalog/`,getParentRoute:()=>Z}),ef=Vu.update({id:`/(guardrails)/list-guardrails/`,path:`/list-guardrails/`,getParentRoute:()=>Z}),tf=Hu.update({id:`/(guardrails)/list-guardrails/create`,path:`/list-guardrails/create`,getParentRoute:()=>Z}),nf=Uu.update({id:`/(knowledge-bases)/list-knowledge-bases/`,path:`/list-knowledge-bases/`,getParentRoute:()=>Z}),rf=gr.update({id:`/(knowledge-bases)/list-knowledge-bases/$knowledgeBaseId`,path:`/list-knowledge-bases/$knowledgeBaseId`,getParentRoute:()=>Z}),af=Wu.update({id:`/(knowledge-bases)/list-knowledge-bases/create`,path:`/list-knowledge-bases/create`,getParentRoute:()=>Z}),of=Gu.update({id:`/(model-access)/list-model-access/`,path:`/list-model-access/`,getParentRoute:()=>Z}),sf=Ku.update({id:`/(playground)/playground/`,path:`/playground/`,getParentRoute:()=>Z}),cf=qu.update({id:`/ec2/(elastic-ips)/describe-addresses/`,path:`/ec2/describe-addresses/`,getParentRoute:()=>X}),lf=_r.update({id:`/ec2/(elastic-ips)/describe-addresses/$id`,path:`/ec2/describe-addresses/$id`,getParentRoute:()=>X}),uf=Ju.update({id:`/ec2/(images)/describe-images/`,path:`/ec2/describe-images/`,getParentRoute:()=>X}),df=vr.update({id:`/ec2/(images)/describe-images/$id`,path:`/ec2/describe-images/$id`,getParentRoute:()=>X}),ff=Yu.update({id:`/ec2/(instances)/describe-instances/`,path:`/ec2/describe-instances/`,getParentRoute:()=>X}),Q=yr.update({id:`/ec2/(instances)/describe-instances/$id`,path:`/ec2/describe-instances/$id`,getParentRoute:()=>X}),pf=Xu.update({id:`/ec2/(internet-gateways)/describe-internet-gateways/`,path:`/ec2/describe-internet-gateways/`,getParentRoute:()=>X}),mf=br.update({id:`/ec2/(internet-gateways)/describe-internet-gateways/$id`,path:`/ec2/describe-internet-gateways/$id`,getParentRoute:()=>X}),hf=Zu.update({id:`/ec2/(key)/describe-key-pairs/`,path:`/ec2/describe-key-pairs/`,getParentRoute:()=>X}),gf=xr.update({id:`/ec2/(key)/describe-key-pairs/$id`,path:`/ec2/describe-key-pairs/$id`,getParentRoute:()=>X}),_f=Qu.update({id:`/ec2/(launch-templates)/describe-launch-templates/`,path:`/ec2/describe-launch-templates/`,getParentRoute:()=>X}),vf=Sr.update({id:`/ec2/(launch-templates)/describe-launch-templates/$id`,path:`/ec2/describe-launch-templates/$id`,getParentRoute:()=>X}),yf=$u.update({id:`/ec2/(load-balancers)/describe-load-balancers/`,path:`/ec2/describe-load-balancers/`,getParentRoute:()=>X}),bf=Cr.update({id:`/ec2/(load-balancers)/describe-load-balancers/$id`,path:`/ec2/describe-load-balancers/$id`,getParentRoute:()=>X}),xf=K.update({id:`/ec2/(nat-gateways)/describe-nat-gateways/`,path:`/ec2/describe-nat-gateways/`,getParentRoute:()=>X}),Sf=wr.update({id:`/ec2/(nat-gateways)/describe-nat-gateways/$id`,path:`/ec2/describe-nat-gateways/$id`,getParentRoute:()=>X}),Cf=ed.update({id:`/ec2/(placement-groups)/describe-placement-groups/`,path:`/ec2/describe-placement-groups/`,getParentRoute:()=>X}),wf=Tr.update({id:`/ec2/(placement-groups)/describe-placement-groups/$id`,path:`/ec2/describe-placement-groups/$id`,getParentRoute:()=>X}),Tf=q.update({id:`/ec2/(route-tables)/describe-route-tables/`,path:`/ec2/describe-route-tables/`,getParentRoute:()=>X}),Ef=Er.update({id:`/ec2/(route-tables)/describe-route-tables/$id`,path:`/ec2/describe-route-tables/$id`,getParentRoute:()=>X}),Df=J.update({id:`/ec2/(security-groups)/describe-security-groups/`,path:`/ec2/describe-security-groups/`,getParentRoute:()=>X}),Of=dt.update({id:`/ec2/(security-groups)/describe-security-groups/$id`,path:`/ec2/describe-security-groups/$id`,getParentRoute:()=>X}),kf=Y.update({id:`/ec2/(snapshots)/describe-snapshots/`,path:`/ec2/describe-snapshots/`,getParentRoute:()=>X}),Af=ot.update({id:`/ec2/(snapshots)/describe-snapshots/$id`,path:`/ec2/describe-snapshots/$id`,getParentRoute:()=>X}),jf=td.update({id:`/ec2/(subnet)/describe-subnets/`,path:`/ec2/describe-subnets/`,getParentRoute:()=>X}),Mf=Dr.update({id:`/ec2/(subnet)/describe-subnets/$id`,path:`/ec2/describe-subnets/$id`,getParentRoute:()=>X}),Nf=nd.update({id:`/ec2/(target-groups)/describe-target-groups/`,path:`/ec2/describe-target-groups/`,getParentRoute:()=>X}),Pf=Or.update({id:`/ec2/(target-groups)/describe-target-groups/$id`,path:`/ec2/describe-target-groups/$id`,getParentRoute:()=>X}),Ff=rd.update({id:`/ec2/(volumes)/describe-volumes/`,path:`/ec2/describe-volumes/`,getParentRoute:()=>X}),If=kr.update({id:`/ec2/(volumes)/describe-volumes/$id`,path:`/ec2/describe-volumes/$id`,getParentRoute:()=>X}),Lf=Ar.update({id:`/ec2/(volumes)/modify-volume/$id`,path:`/ec2/modify-volume/$id`,getParentRoute:()=>X}),Rf=id.update({id:`/ec2/(vpc)/describe-vpcs/`,path:`/ec2/describe-vpcs/`,getParentRoute:()=>X}),zf=jr.update({id:`/ec2/(vpc)/describe-vpcs/$id`,path:`/ec2/describe-vpcs/$id`,getParentRoute:()=>X}),Bf=ad.update({id:`/ecr/(repositories)/list-repositories/`,path:`/ecr/list-repositories/`,getParentRoute:()=>X}),$=Mr.update({id:`/ecr/(repositories)/list-repositories/$id`,path:`/ecr/list-repositories/$id`,getParentRoute:()=>X}),Vf=od.update({id:`/ecs/(clusters)/list-clusters/`,path:`/ecs/list-clusters/`,getParentRoute:()=>X}),Hf=Nr.update({id:`/ecs/(clusters)/list-clusters/$clusterName`,path:`/ecs/list-clusters/$clusterName`,getParentRoute:()=>X}),Uf=sd.update({id:`/eks/(clusters)/list-clusters/`,path:`/eks/list-clusters/`,getParentRoute:()=>X}),Wf=lt.update({id:`/eks/(clusters)/list-clusters/$clusterName`,path:`/eks/list-clusters/$clusterName`,getParentRoute:()=>X}),Gf=cd.update({id:`/iam/(groups)/list-groups/`,path:`/iam/list-groups/`,getParentRoute:()=>X}),Kf=Pr.update({id:`/iam/(groups)/list-groups/$groupName`,path:`/iam/list-groups/$groupName`,getParentRoute:()=>X}),qf=ld.update({id:`/iam/(instance-profiles)/list-instance-profiles/`,path:`/iam/list-instance-profiles/`,getParentRoute:()=>X}),Jf=Fr.update({id:`/iam/(instance-profiles)/list-instance-profiles/$instanceProfileName`,path:`/iam/list-instance-profiles/$instanceProfileName`,getParentRoute:()=>X}),Yf=ud.update({id:`/iam/(policies)/list-policies/`,path:`/iam/list-policies/`,getParentRoute:()=>X}),Xf=Ir.update({id:`/iam/(policies)/list-policies/$policyArn`,path:`/iam/list-policies/$policyArn`,getParentRoute:()=>X}),Zf=dd.update({id:`/iam/(roles)/list-roles/`,path:`/iam/list-roles/`,getParentRoute:()=>X}),Qf=Lr.update({id:`/iam/(roles)/list-roles/$roleName`,path:`/iam/list-roles/$roleName`,getParentRoute:()=>X}),$f=fd.update({id:`/iam/(users)/list-users/`,path:`/iam/list-users/`,getParentRoute:()=>X}),ep=Rr.update({id:`/iam/(users)/list-users/$userName`,path:`/iam/list-users/$userName`,getParentRoute:()=>X}),tp=pd.update({id:`/rds/(db-instances)/describe-db-instances/`,path:`/rds/describe-db-instances/`,getParentRoute:()=>X}),np=zr.update({id:`/rds/(db-instances)/describe-db-instances/$id`,path:`/rds/describe-db-instances/$id`,getParentRoute:()=>X}),rp=md.update({id:`/rds/(parameter-groups)/describe-db-parameter-groups/`,path:`/rds/describe-db-parameter-groups/`,getParentRoute:()=>X}),ip=Br.update({id:`/rds/(parameter-groups)/describe-db-parameter-groups/$name`,path:`/rds/describe-db-parameter-groups/$name`,getParentRoute:()=>X}),ap=hd.update({id:`/rds/(snapshots)/describe-db-snapshots/`,path:`/rds/describe-db-snapshots/`,getParentRoute:()=>X}),op=Vr.update({id:`/rds/(snapshots)/describe-db-snapshots/$id`,path:`/rds/describe-db-snapshots/$id`,getParentRoute:()=>X}),sp=Hr.update({id:`/rds/(snapshots)/restore-db-instance-from-db-snapshot/$id`,path:`/rds/restore-db-instance-from-db-snapshot/$id`,getParentRoute:()=>X}),cp=gd.update({id:`/rds/(subnet-groups)/describe-db-subnet-groups/`,path:`/rds/describe-db-subnet-groups/`,getParentRoute:()=>X}),lp=Ur.update({id:`/rds/(subnet-groups)/describe-db-subnet-groups/$name`,path:`/rds/describe-db-subnet-groups/$name`,getParentRoute:()=>X}),up=Wr.update({id:`/`,path:`/`,getParentRoute:()=>Qd}),dp=Gr.update({id:`/$`,path:`/$`,getParentRoute:()=>Qd}),fp=Kr.update({id:`/(guardrails)/list-guardrails/$guardrailId/`,path:`/list-guardrails/$guardrailId/`,getParentRoute:()=>Z}),pp=qr.update({id:`/(guardrails)/list-guardrails/$guardrailId/edit`,path:`/list-guardrails/$guardrailId/edit`,getParentRoute:()=>Z}),mp=Jr.update({id:`/ecs/(clusters)/list-clusters/$clusterName_/services/$serviceName`,path:`/ecs/list-clusters/$clusterName/services/$serviceName`,getParentRoute:()=>X}),hp=Yr.update({id:`/ecs/(clusters)/list-clusters/$clusterName_/tasks/$taskId`,path:`/ecs/list-clusters/$clusterName/tasks/$taskId`,getParentRoute:()=>X}),gp={AuthBedrockguardrailsListGuardrailsCreateRoute:tf,AuthBedrockknowledgeBasesListKnowledgeBasesKnowledgeBaseIdRoute:rf,AuthBedrockknowledgeBasesListKnowledgeBasesCreateRoute:af,AuthBedrockcatalogListModelCatalogIndexRoute:$d,AuthBedrockguardrailsListGuardrailsIndexRoute:ef,AuthBedrockknowledgeBasesListKnowledgeBasesIndexRoute:nf,AuthBedrockmodelAccessListModelAccessIndexRoute:of,AuthBedrockplaygroundPlaygroundIndexRoute:sf,AuthBedrockguardrailsListGuardrailsGuardrailIdEditRoute:pp,AuthBedrockguardrailsListGuardrailsGuardrailIdIndexRoute:fp},_p=Z._addFileChildren(gp),vp={AuthS3LsBucketSplatRoute:dp,AuthS3LsBucketIndexRoute:up},yp={AuthBedrockRouteRoute:_p,AuthNodesRoute:yd,AuthIndexRoute:vd,AuthS3ServiceMetricsRoute:bd,AuthS3LsBucketRouteRoute:Qd._addFileChildren(vp),AuthEc2elasticIpsAllocateAddressRoute:xd,AuthEc2instancesRunInstancesRoute:Sd,AuthEc2internetGatewaysCreateInternetGatewayRoute:Cd,AuthEc2keyCreateKeyPairRoute:wd,AuthEc2keyImportKeyPairRoute:Td,AuthEc2launchTemplatesCreateLaunchTemplateRoute:Ed,AuthEc2loadBalancersCreateLoadBalancerRoute:Dd,AuthEc2natGatewaysCreateNatGatewayRoute:Od,AuthEc2placementGroupsCreatePlacementGroupRoute:kd,AuthEc2routeTablesCreateRouteTableRoute:Ad,AuthEc2securityGroupsCreateSecurityGroupRoute:jd,AuthEc2snapshotsCreateSnapshotRoute:Md,AuthEc2subnetCreateSubnetRoute:Nd,AuthEc2targetGroupsCreateTargetGroupRoute:Pd,AuthEc2volumesCreateVolumeRoute:Fd,AuthEc2vpcCreateVpcRoute:Id,AuthEcsclustersCreateServiceRoute:Ld,AuthEcsclustersRegisterTaskDefinitionRoute:Rd,AuthEcsclustersRunTaskRoute:zd,AuthEcsclustersTaskDefinitionsRoute:Bd,AuthEksclustersCreateClusterRoute:Vd,AuthIamgroupsCreateGroupRoute:Hd,AuthIaminstanceProfilesCreateInstanceProfileRoute:Ud,AuthIampoliciesCreatePolicyRoute:Wd,AuthIamrolesCreateRoleRoute:Gd,AuthIamusersCreateUserRoute:Kd,AuthRdsdbInstancesCreateDbInstanceRoute:qd,AuthRdsparameterGroupsCreateDbParameterGroupRoute:Jd,AuthRdssubnetGroupsCreateDbSubnetGroupRoute:Yd,AuthS3bucketsCreateBucketRoute:Xd,AuthS3LsIndexRoute:Zd,AuthEc2elasticIpsDescribeAddressesIdRoute:lf,AuthEc2imagesDescribeImagesIdRoute:df,AuthEc2instancesDescribeInstancesIdRoute:Q,AuthEc2internetGatewaysDescribeInternetGatewaysIdRoute:mf,AuthEc2keyDescribeKeyPairsIdRoute:gf,AuthEc2launchTemplatesDescribeLaunchTemplatesIdRoute:vf,AuthEc2loadBalancersDescribeLoadBalancersIdRoute:bf,AuthEc2natGatewaysDescribeNatGatewaysIdRoute:Sf,AuthEc2placementGroupsDescribePlacementGroupsIdRoute:wf,AuthEc2routeTablesDescribeRouteTablesIdRoute:Ef,AuthEc2securityGroupsDescribeSecurityGroupsIdRoute:Of,AuthEc2snapshotsDescribeSnapshotsIdRoute:Af,AuthEc2subnetDescribeSubnetsIdRoute:Mf,AuthEc2targetGroupsDescribeTargetGroupsIdRoute:Pf,AuthEc2volumesDescribeVolumesIdRoute:If,AuthEc2volumesModifyVolumeIdRoute:Lf,AuthEc2vpcDescribeVpcsIdRoute:zf,AuthEcrrepositoriesListRepositoriesIdRoute:$,AuthEcsclustersListClustersClusterNameRoute:Hf,AuthEksclustersListClustersClusterNameRoute:Wf,AuthIamgroupsListGroupsGroupNameRoute:Kf,AuthIaminstanceProfilesListInstanceProfilesInstanceProfileNameRoute:Jf,AuthIampoliciesListPoliciesPolicyArnRoute:Xf,AuthIamrolesListRolesRoleNameRoute:Qf,AuthIamusersListUsersUserNameRoute:ep,AuthRdsdbInstancesDescribeDbInstancesIdRoute:np,AuthRdsparameterGroupsDescribeDbParameterGroupsNameRoute:ip,AuthRdssnapshotsDescribeDbSnapshotsIdRoute:op,AuthRdssnapshotsRestoreDbInstanceFromDbSnapshotIdRoute:sp,AuthRdssubnetGroupsDescribeDbSubnetGroupsNameRoute:lp,AuthEc2elasticIpsDescribeAddressesIndexRoute:cf,AuthEc2imagesDescribeImagesIndexRoute:uf,AuthEc2instancesDescribeInstancesIndexRoute:ff,AuthEc2internetGatewaysDescribeInternetGatewaysIndexRoute:pf,AuthEc2keyDescribeKeyPairsIndexRoute:hf,AuthEc2launchTemplatesDescribeLaunchTemplatesIndexRoute:_f,AuthEc2loadBalancersDescribeLoadBalancersIndexRoute:yf,AuthEc2natGatewaysDescribeNatGatewaysIndexRoute:xf,AuthEc2placementGroupsDescribePlacementGroupsIndexRoute:Cf,AuthEc2routeTablesDescribeRouteTablesIndexRoute:Tf,AuthEc2securityGroupsDescribeSecurityGroupsIndexRoute:Df,AuthEc2snapshotsDescribeSnapshotsIndexRoute:kf,AuthEc2subnetDescribeSubnetsIndexRoute:jf,AuthEc2targetGroupsDescribeTargetGroupsIndexRoute:Nf,AuthEc2volumesDescribeVolumesIndexRoute:Ff,AuthEc2vpcDescribeVpcsIndexRoute:Rf,AuthEcrrepositoriesListRepositoriesIndexRoute:Bf,AuthEcsclustersListClustersIndexRoute:Vf,AuthEksclustersListClustersIndexRoute:Uf,AuthIamgroupsListGroupsIndexRoute:Gf,AuthIaminstanceProfilesListInstanceProfilesIndexRoute:qf,AuthIampoliciesListPoliciesIndexRoute:Yf,AuthIamrolesListRolesIndexRoute:Zf,AuthIamusersListUsersIndexRoute:$f,AuthRdsdbInstancesDescribeDbInstancesIndexRoute:tp,AuthRdsparameterGroupsDescribeDbParameterGroupsIndexRoute:rp,AuthRdssnapshotsDescribeDbSnapshotsIndexRoute:ap,AuthRdssubnetGroupsDescribeDbSubnetGroupsIndexRoute:cp,AuthEcsclustersListClustersClusterNameServicesServiceNameRoute:mp,AuthEcsclustersListClustersClusterNameTasksTaskIdRoute:hp},bp={AuthRouteRoute:X._addFileChildren(yp),LoginRoute:_d},xp=su._addFileChildren(bp)._addFileTypes(),Sp=tu(),Cp=Wo({routeTree:xp,context:{queryClient:Sp},defaultPreload:`intent`,scrollRestoration:!0,defaultStructuralSharing:!0,defaultPreloadStaleTime:0});async function wp(e){try{await Ye()}catch(t){e.render((0,B.jsxs)(`div`,{style:{fontFamily:`sans-serif`,margin:`4rem auto`,maxWidth:`40rem`},children:[(0,B.jsx)(`h1`,{children:`Console unavailable`}),(0,B.jsx)(`p`,{children:`This cluster did not report its region, so the console cannot sign requests. Check that the serving node has a region set in its configuration.`}),(0,B.jsx)(`pre`,{children:t instanceof Error?t.message:String(t)})]}));return}e.render((0,B.jsx)(z.StrictMode,{children:(0,B.jsx)(ss,{defaultTheme:`dark`,storageKey:`spinifex-ui-theme`,children:(0,B.jsx)(p,{client:Sp,children:(0,B.jsx)(Tn,{children:(0,B.jsx)(Il,{children:(0,B.jsx)(qo,{router:Cp})})})})})}))}var Tp=document.querySelector(`#app`);Tp&&!Tp.innerHTML&&await wp(is.createRoot(Tp)); \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/index-z1WTrxZE.css b/spinifex/services/spinifexui/frontend/dist/assets/index-z1WTrxZE.css deleted file mode 100644 index 5c9a39a64..000000000 --- a/spinifex/services/spinifexui/frontend/dist/assets/index-z1WTrxZE.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100:#ffe2e2;--color-red-800:#9f0712;--color-red-900:#82181a;--color-amber-400:#fcbb00;--color-amber-500:#f99c00;--color-amber-700:#b75000;--color-yellow-100:#fef9c2;--color-yellow-800:#874b00;--color-yellow-900:#733e0a;--color-green-100:#dcfce7;--color-green-400:#05df72;--color-green-600:#00a544;--color-green-800:#016630;--color-green-900:#0d542b;--color-emerald-400:#00d294;--color-emerald-500:#00bb7f;--color-emerald-700:#007956;--color-blue-500:#3080ff;--color-gray-100:#f3f4f6;--color-gray-800:#1e2939;--color-zinc-100:#f4f4f5;--color-zinc-400:#9f9fa9;--color-zinc-500:#71717b;--color-zinc-800:#27272a;--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-4xl:56rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wider:.05em;--tracking-widest:.1em;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:calc(var(--radius) - 2px);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-xs:4px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-foreground:var(--foreground)}@supports (color:lab(0% 0 0)){:root,:host{--color-red-100:lab(92.243% 10.2865 3.83865);--color-red-800:lab(33.7174% 55.8993 41.0293);--color-red-900:lab(28.5139% 44.5539 29.0463);--color-amber-400:lab(80.1641% 16.6016 99.2089);--color-amber-500:lab(72.7183% 31.8672 97.9407);--color-amber-700:lab(47.2709% 42.9082 69.2966);--color-yellow-100:lab(97.3564% -4.51407 27.344);--color-yellow-800:lab(38.7484% 23.5833 51.4916);--color-yellow-900:lab(32.3865% 21.1273 38.5959);--color-green-100:lab(96.1861% -13.8464 6.52365);--color-green-400:lab(78.503% -64.9265 39.7492);--color-green-600:lab(59.0978% -58.6621 41.2579);--color-green-800:lab(37.4616% -36.7971 22.9692);--color-green-900:lab(30.797% -29.6927 17.382);--color-emerald-400:lab(75.0771% -60.7313 19.4147);--color-emerald-500:lab(66.9756% -58.27 19.5419);--color-emerald-700:lab(44.4871% -41.0396 11.0361);--color-blue-500:lab(54.1736% 13.3369 -74.6839);--color-gray-100:lab(96.1596% -.0823438 -1.13575);--color-gray-800:lab(16.1051% -1.18239 -11.7533);--color-zinc-100:lab(96.1634% .0993311 -.364041);--color-zinc-400:lab(65.6464% 1.53497 -5.42429);--color-zinc-500:lab(47.8878% 1.65477 -5.77283);--color-zinc-800:lab(15.7305% .613764 -2.16959)}}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}*{scrollbar-width:thin}html,body{overscroll-behavior:none}body{background-color:var(--background);width:100%;min-height:100svh;color:var(--foreground);font-family:var(--font-sans), sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.font-mono{font-family:var(--font-mono), monospace}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.\@container\/field-group{container:field-group/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-3\.5{top:calc(var(--spacing) * 3.5)}.top-4{top:calc(var(--spacing) * 4)}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.left-0{left:0}.left-1\/2{left:50%}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.order-first{order:-9999}.order-last{order:9999}.col-span-2{grid-column:span 2/span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0{margin-inline:0}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3\.5{margin-inline:calc(var(--spacing) * 3.5)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-auto{margin-right:auto}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-1{margin-left:var(--spacing)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.aspect-square{aspect-ratio:1}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-\[1\.2rem\]{width:1.2rem;height:1.2rem}.size-full{width:100%;height:100%}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-\[calc\(--spacing\(4\.75\)\)\]{height:calc(calc(var(--spacing) * 4.75))}.h-\[calc\(100dvh-15rem\)\]{height:calc(100dvh - 15rem)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-svh{height:100svh}.max-h-\(--available-height\){max-height:var(--available-height)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[min\(calc\(--spacing\(72\)---spacing\(9\)\)\,calc\(var\(--available-height\)---spacing\(9\)\)\)\]{max-height:min(calc(calc(var(--spacing) * 72) - calc(var(--spacing) * 9)), calc(var(--available-height) - calc(var(--spacing) * 9)))}.min-h-0{min-height:0}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-\[24rem\]{min-height:24rem}.min-h-svh{min-height:100svh}.w-\(--anchor-width\){width:var(--anchor-width)}.w-\(--sidebar-width\){width:var(--sidebar-width)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-36{width:calc(var(--spacing) * 36)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\(--available-width\){max-width:var(--available-width)}.max-w-\(--skeleton-width\){max-width:var(--skeleton-width)}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-32{min-width:calc(var(--spacing) * 32)}.min-w-\[calc\(var\(--anchor-width\)\+--spacing\(7\)\)\]{min-width:calc(var(--anchor-width) + calc(var(--spacing) * 7))}.flex-1{flex:1}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.origin-\(--transform-origin\){transform-origin:var(--transform-origin)}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-px{--tw-translate-x:-1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-px{--tw-translate-x:1px;translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[calc\(-50\%-2px\)\]{--tw-translate-y:calc(-50% - 2px);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-0{rotate:0deg}.rotate-45{rotate:45deg}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:var(--spacing)}.scroll-py-1{scroll-padding-block:var(--spacing)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-\[minmax\(0\,1fr\)\]{grid-template-columns:minmax(0,1fr)}.grid-rows-\[auto_1fr\]{grid-template-rows:auto 1fr}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-px{gap:1px}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * -2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * -2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\[2px\]{border-radius:2px}.rounded-\[calc\(var\(--radius-sm\)\+2px\)\]{border-radius:calc(var(--radius-sm) + 2px)}.rounded-\[calc\(var\(--radius-sm\)-2px\)\]{border-radius:calc(var(--radius-sm) - 2px)}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:calc(var(--radius) + 2px)}.rounded-md{border-radius:var(--radius)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-t-lg{border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}.rounded-b-lg{border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-border{border-color:var(--border)}.border-destructive{border-color:var(--destructive)}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-primary{border-color:var(--primary)}.border-sidebar-border{border-color:var(--sidebar-border)}.border-tactical-amber\/40{border-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.border-tactical-amber\/40{border-color:color-mix(in oklab, var(--tactical-amber) 40%, transparent)}}.border-transparent{border-color:#0000}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background{background-color:var(--background)}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-border,.bg-border\/50{background-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.bg-border\/50{background-color:color-mix(in oklab, var(--border) 50%, transparent)}}.bg-card{background-color:var(--card)}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-foreground{background-color:var(--foreground)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-100{background-color:var(--color-green-100)}.bg-input\/20{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.bg-input\/20{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/10{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/10{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}.bg-muted\/20{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/20{background-color:color-mix(in oklab, var(--muted) 20%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-secondary{background-color:var(--secondary)}.bg-sidebar{background-color:var(--sidebar)}.bg-sidebar-border{background-color:var(--sidebar-border)}.bg-tactical-amber\/5{background-color:var(--tactical-amber)}@supports (color:color-mix(in lab, red, red)){.bg-tactical-amber\/5{background-color:color-mix(in oklab, var(--tactical-amber) 5%, transparent)}}.bg-transparent{background-color:#0000}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-clip-padding{background-clip:padding-box}.fill-foreground{fill:var(--foreground)}.object-cover{object-fit:cover}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0{padding-inline:0}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-0{padding-top:0}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:0}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/relaxed{font-size:var(--text-sm);line-height:var(--leading-relaxed)}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.625rem\]{font-size:.625rem}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-balance{text-wrap:balance}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-700{color:var(--color-amber-700)}.text-background{color:var(--background)}.text-card-foreground{color:var(--card-foreground)}.text-destructive{color:var(--destructive)}.text-emerald-700{color:var(--color-emerald-700)}.text-foreground{color:var(--foreground)}.text-gray-800{color:var(--color-gray-800)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground{color:var(--muted-foreground)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-sidebar-foreground,.text-sidebar-foreground\/70{color:var(--sidebar-foreground)}@supports (color:color-mix(in lab, red, red)){.text-sidebar-foreground\/70{color:color-mix(in oklab, var(--sidebar-foreground) 70%, transparent)}}.text-tactical-amber{color:var(--tactical-amber)}.text-tactical-cyan{color:var(--tactical-cyan)}.text-tactical-green{color:var(--tactical-green)}.text-yellow-800{color:var(--color-yellow-800)}.text-zinc-500{color:var(--color-zinc-500)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.underline-offset-3{text-underline-offset:3px}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.bg-blend-color{background-blend-mode:color}.shadow-\[0_0_0_1px_hsl\(var\(--sidebar-border\)\)\]{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-border)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-background{--tw-ring-color:var(--background)}.ring-foreground\/10{--tw-ring-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.ring-foreground\/10{--tw-ring-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.ring-sidebar-ring{--tw-ring-color:var(--sidebar-ring)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\,right\,width\]{transition-property:left,right,width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[margin\,opacity\]{transition-property:margin,opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\,height\,padding\]{transition-property:width,height,padding;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.group-focus-within\/menu-item\:opacity-100:is(:where(.group\/menu-item):focus-within *){opacity:1}@media (hover:hover){.group-hover\/menu-item\:opacity-100:is(:where(.group\/menu-item):hover *){opacity:1}}.group-focus\/dropdown-menu-item\:text-accent-foreground:is(:where(.group\/dropdown-menu-item):focus *){color:var(--accent-foreground)}.group-has-data-\[orientation\=horizontal\]\/field\:text-balance:is(:where(.group\/field):has([data-orientation=horizontal]) *){text-wrap:balance}.group-has-data-\[sidebar\=menu-action\]\/menu-item\:pr-8:is(:where(.group\/menu-item):has([data-sidebar=menu-action]) *){padding-right:calc(var(--spacing) * 8)}.group-has-data-\[size\=lg\]\/avatar-group\:size-10:is(:where(.group\/avatar-group):has([data-size=lg]) *){width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.group-has-data-\[size\=sm\]\/avatar-group\:size-6:is(:where(.group\/avatar-group):has([data-size=sm]) *){width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.group-has-data-\[slot\=combobox-clear\]\/input-group\:hidden:is(:where(.group\/input-group):has([data-slot=combobox-clear]) *){display:none}.group-has-\[\>input\]\/input-group\:pt-2:is(:where(.group\/input-group):has(>input) *){padding-top:calc(var(--spacing) * 2)}.group-has-\[\>input\]\/input-group\:pb-2:is(:where(.group\/input-group):has(>input) *){padding-bottom:calc(var(--spacing) * 2)}.group-data-empty\/combobox-content\:flex:is(:where(.group\/combobox-content)[data-empty] *){display:flex}.group-data-\[collapsible\=icon\]\:-mt-8:is(:where(.group)[data-collapsible=icon] *){margin-top:calc(var(--spacing) * -8)}.group-data-\[collapsible\=icon\]\:hidden:is(:where(.group)[data-collapsible=icon] *){display:none}.group-data-\[collapsible\=icon\]\:size-8\!:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--spacing) * 8)!important;height:calc(var(--spacing) * 8)!important}.group-data-\[collapsible\=icon\]\:w-\(--sidebar-width-icon\):is(:where(.group)[data-collapsible=icon] *){width:var(--sidebar-width-icon)}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)))}.group-data-\[collapsible\=icon\]\:w-\[calc\(var\(--sidebar-width-icon\)\+\(--spacing\(4\)\)\+2px\)\]:is(:where(.group)[data-collapsible=icon] *){width:calc(var(--sidebar-width-icon) + (calc(var(--spacing) * 4)) + 2px)}.group-data-\[collapsible\=icon\]\:\[scrollbar-width\:none\]:is(:where(.group)[data-collapsible=icon] *){scrollbar-width:none}.group-data-\[collapsible\=icon\]\:p-0\!:is(:where(.group)[data-collapsible=icon] *){padding:0!important}.group-data-\[collapsible\=icon\]\:p-2\!:is(:where(.group)[data-collapsible=icon] *){padding:calc(var(--spacing) * 2)!important}.group-data-\[collapsible\=icon\]\:opacity-0:is(:where(.group)[data-collapsible=icon] *){opacity:0}.group-data-\[collapsible\=offExamples\]\:right-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){right:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:left-\[calc\(var\(--sidebar-width\)\*-1\)\]:is(:where(.group)[data-collapsible=offExamples] *){left:calc(var(--sidebar-width) * -1)}.group-data-\[collapsible\=offExamples\]\:w-0:is(:where(.group)[data-collapsible=offExamples] *){width:0}.group-data-\[collapsible\=offExamples\]\:translate-x-0:is(:where(.group)[data-collapsible=offExamples] *){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *),.group-data-\[disabled\=true\]\/field\:opacity-50:is(:where(.group\/field)[data-disabled=true] *),.group-data-\[disabled\=true\]\/input-group\:opacity-50:is(:where(.group\/input-group)[data-disabled=true] *){opacity:.5}.group-data-\[panel-open\]\:rotate-180:is(:where(.group)[data-panel-open] *){rotate:180deg}.group-data-\[side\=left\]\:-right-4:is(:where(.group)[data-side=left] *){right:calc(var(--spacing) * -4)}.group-data-\[side\=left\]\:border-r:is(:where(.group)[data-side=left] *){border-right-style:var(--tw-border-style);border-right-width:1px}.group-data-\[side\=right\]\:left-0:is(:where(.group)[data-side=right] *){left:0}.group-data-\[side\=right\]\:rotate-180:is(:where(.group)[data-side=right] *){rotate:180deg}.group-data-\[side\=right\]\:border-l:is(:where(.group)[data-side=right] *){border-left-style:var(--tw-border-style);border-left-width:1px}.group-data-\[size\=default\]\/avatar\:size-2\.5:is(:where(.group\/avatar)[data-size=default] *){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.group-data-\[size\=lg\]\/avatar\:size-3:is(:where(.group\/avatar)[data-size=lg] *){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=sm\]\/alert-dialog-content\:grid:is(:where(.group\/alert-dialog-content)[data-size=sm] *){display:grid}.group-data-\[size\=sm\]\/alert-dialog-content\:grid-cols-2:is(:where(.group\/alert-dialog-content)[data-size=sm] *){grid-template-columns:repeat(2,minmax(0,1fr))}.group-data-\[size\=sm\]\/avatar\:size-2:is(:where(.group\/avatar)[data-size=sm] *){width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:text-xs:is(:where(.group\/avatar)[data-size=sm] *){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.group-data-\[size\=sm\]\/card\:px-3:is(:where(.group\/card)[data-size=sm] *){padding-inline:calc(var(--spacing) * 3)}.group-data-\[variant\=floating\]\:rounded-lg:is(:where(.group)[data-variant=floating] *){border-radius:calc(var(--radius) + 2px)}.group-data-\[variant\=floating\]\:shadow-sm:is(:where(.group)[data-variant=floating] *){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-1:is(:where(.group)[data-variant=floating] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-data-\[variant\=floating\]\:ring-sidebar-border:is(:where(.group)[data-variant=floating] *){--tw-ring-color:var(--sidebar-border)}.group-data-\[variant\=outline\]\/field-group\:-mb-2:is(:where(.group\/field-group)[data-variant=outline] *){margin-bottom:calc(var(--spacing) * -2)}@media (hover:hover){.peer-hover\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button):hover~*){color:var(--sidebar-accent-foreground)}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-data-active\/menu-button\:text-sidebar-accent-foreground:is(:where(.peer\/menu-button)[data-active]~*){color:var(--sidebar-accent-foreground)}.peer-data-\[size\=default\]\/menu-button\:top-1\.5:is(:where(.peer\/menu-button)[data-size=default]~*){top:calc(var(--spacing) * 1.5)}.peer-data-\[size\=lg\]\/menu-button\:top-2\.5:is(:where(.peer\/menu-button)[data-size=lg]~*){top:calc(var(--spacing) * 2.5)}.peer-data-\[size\=sm\]\/menu-button\:top-1:is(:where(.peer\/menu-button)[data-size=sm]~*){top:var(--spacing)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-6::file-selector-button{height:calc(var(--spacing) * 6)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-xs\/relaxed::file-selector-button{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:-inset-2:after{content:var(--tw-content);inset:calc(var(--spacing) * -2)}.after\:inset-0:after{content:var(--tw-content);inset:0}.after\:inset-y-0:after{content:var(--tw-content);inset-block:0}.after\:left-1\/2:after{content:var(--tw-content);left:50%}.after\:w-\[2px\]:after{content:var(--tw-content);width:2px}.after\:rounded-full:after{content:var(--tw-content);border-radius:2147483647px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-border:after{content:var(--tw-content);border-color:var(--border)}.after\:mix-blend-darken:after{content:var(--tw-content);mix-blend-mode:darken}.group-data-\[collapsible\=offExamples\]\:after\:left-full:is(:where(.group)[data-collapsible=offExamples] *):after{content:var(--tw-content);left:100%}.last\:mt-0:last-child{margin-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.autofill\:\[-webkit-text-fill-color\:var\(--color-foreground\)\]:autofill{-webkit-text-fill-color:var(--color-foreground)}.autofill\:\[transition\:background-color_9999s_ease-in-out_0s\]:autofill{transition:background-color 9999s ease-in-out}.focus-within\:border-ring:focus-within{border-color:var(--ring)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-ring\/30:focus-within{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}@media (hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.hover\:bg-input\/40:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/40:hover{background-color:color-mix(in oklab, var(--input) 40%, transparent)}}.hover\:bg-input\/50:hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-input\/50:hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.hover\:bg-muted:hover{background-color:var(--muted)}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-sidebar-accent:hover{background-color:var(--sidebar-accent)}.hover\:bg-transparent:hover{background-color:#0000}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-muted-foreground:hover{color:var(--muted-foreground)}.hover\:text-sidebar-accent-foreground:hover{color:var(--sidebar-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-\[0_0_0_1px_hsl\(var\(--sidebar-accent\)\)\]:hover{--tw-shadow:0 0 0 1px var(--tw-shadow-color,hsl(var(--sidebar-accent)));box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:group-data-\[collapsible\=offExamples\]\:bg-sidebar:hover:is(:where(.group)[data-collapsible=offExamples] *){background-color:var(--sidebar)}.hover\:after\:bg-sidebar-border:hover:after{content:var(--tw-content);background-color:var(--sidebar-border)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus,:is(.focus\:\*\*\:text-accent-foreground:focus *),:is(.not-data-\[variant\=destructive\]\:focus\:\*\*\:text-accent-foreground:not([data-variant=destructive]):focus *){color:var(--accent-foreground)}.focus-visible\:border-destructive\/40:focus-visible{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:border-destructive\/40:focus-visible{border-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible,.focus-visible\:ring-\[2px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/30:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-sidebar-accent:active{background-color:var(--sidebar-accent)}.active\:text-sidebar-accent-foreground:active{color:var(--sidebar-accent-foreground)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}:where([data-side=left]) .in-data-\[side\=left\]\:cursor-w-resize{cursor:w-resize}:where([data-side=right]) .in-data-\[side\=right\]\:cursor-e-resize{cursor:e-resize}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:border-inherit:focus-within{border-color:inherit}:where([data-slot=combobox-content]) .in-data-\[slot\=combobox-content\]\:focus-within\:ring-0:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-disabled\:pointer-events-none:has(:disabled){pointer-events:none}.has-disabled\:cursor-not-allowed:has(:disabled){cursor:not-allowed}.has-disabled\:opacity-50:has(:disabled){opacity:.5}.has-aria-invalid\:border-destructive:has([aria-invalid=true]){border-color:var(--destructive)}.has-aria-invalid\:ring-2:has([aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-aria-invalid\:ring-destructive\/20:has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.has-data-checked\:bg-primary\/5:has([data-checked]){background-color:color-mix(in oklab, var(--primary) 5%, transparent)}}.has-data-\[align\=block-end\]\:rounded-md:has([data-align=block-end]),.has-data-\[align\=block-start\]\:rounded-md:has([data-align=block-start]){border-radius:var(--radius)}.has-data-\[icon\=inline-end\]\:pr-1\.5:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-end\]\:pr-2:has([data-icon=inline-end]){padding-right:calc(var(--spacing) * 2)}.has-data-\[icon\=inline-start\]\:pl-1\.5:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 1.5)}.has-data-\[icon\=inline-start\]\:pl-2:has([data-icon=inline-start]){padding-left:calc(var(--spacing) * 2)}.has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_auto_1fr\]:has([data-slot=alert-dialog-media]){grid-template-rows:auto auto 1fr}.has-data-\[slot\=alert-dialog-media\]\:gap-x-4:has([data-slot=alert-dialog-media]){column-gap:calc(var(--spacing) * 4)}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-data-\[slot\=card-description\]\:grid-rows-\[auto_auto\]:has([data-slot=card-description]){grid-template-rows:auto auto}.has-data-\[slot\=combobox-chip\]\:px-1:has([data-slot=combobox-chip]){padding-inline:var(--spacing)}.has-data-\[slot\=combobox-chip-remove\]\:pr-0:has([data-slot=combobox-chip-remove]){padding-right:0}.has-data-\[variant\=inset\]\:bg-sidebar:has([data-variant=inset]){background-color:var(--sidebar)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:border-ring:has([data-slot=input-group-control]:focus-visible){border-color:var(--ring)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-2:has([data-slot=input-group-control]:focus-visible){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\=input-group-control\]\:focus-visible\]\:ring-ring\/30:has([data-slot=input-group-control]:focus-visible){--tw-ring-color:color-mix(in oklab, var(--ring) 30%, transparent)}}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:border-destructive:has([data-slot][aria-invalid=true]){border-color:var(--destructive)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-2:has([data-slot][aria-invalid=true]){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/20:has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.has-\[textarea\]\:rounded-md:has(:is(textarea)){border-radius:var(--radius)}.has-\[\>\[data-align\=block-end\]\]\:h-auto:has(>[data-align=block-end]){height:auto}.has-\[\>\[data-align\=block-end\]\]\:flex-col:has(>[data-align=block-end]){flex-direction:column}.has-\[\>\[data-align\=block-start\]\]\:h-auto:has(>[data-align=block-start]){height:auto}.has-\[\>\[data-align\=block-start\]\]\:flex-col:has(>[data-align=block-start]){flex-direction:column}.has-\[\>\[data-slot\=checkbox-group\]\]\:gap-3:has(>[data-slot=checkbox-group]){gap:calc(var(--spacing) * 3)}.has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}.has-\[\>\[data-slot\=field\]\]\:w-full:has(>[data-slot=field]){width:100%}.has-\[\>\[data-slot\=field\]\]\:flex-col:has(>[data-slot=field]){flex-direction:column}.has-\[\>\[data-slot\=field\]\]\:rounded-md:has(>[data-slot=field]){border-radius:var(--radius)}.has-\[\>\[data-slot\=field\]\]\:border:has(>[data-slot=field]){border-style:var(--tw-border-style);border-width:1px}.has-\[\>\[data-slot\=radio-group\]\]\:gap-3:has(>[data-slot=radio-group]){gap:calc(var(--spacing) * 3)}.has-\[\>button\]\:mr-\[-0\.275rem\]:has(>button){margin-right:-.275rem}.has-\[\>button\]\:ml-\[-0\.275rem\]:has(>button){margin-left:-.275rem}.has-\[\>img\:first-child\]\:pt-0:has(>img:first-child){padding-top:0}.has-\[\>kbd\]\:mr-\[-0\.275rem\]:has(>kbd){margin-right:-.275rem}.has-\[\>kbd\]\:ml-\[-0\.275rem\]:has(>kbd){margin-left:-.275rem}.has-\[\>svg\]\:p-0:has(>svg){padding:0}.has-\[\>textarea\]\:h-auto:has(>textarea){height:auto}.aria-disabled\:pointer-events-none[aria-disabled=true]{pointer-events:none}.aria-disabled\:opacity-50[aria-disabled=true]{opacity:.5}.aria-expanded\:bg-muted[aria-expanded=true]{background-color:var(--muted)}.aria-expanded\:bg-secondary[aria-expanded=true]{background-color:var(--secondary)}.aria-expanded\:text-foreground[aria-expanded=true]{color:var(--foreground)}.aria-expanded\:text-secondary-foreground[aria-expanded=true]{color:var(--secondary-foreground)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-0[aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-2[aria-invalid=true],.aria-invalid\:ring-\[2px\][aria-invalid=true]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-active\:bg-sidebar-accent[data-active]{background-color:var(--sidebar-accent)}.data-active\:font-medium[data-active]{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.data-active\:text-sidebar-accent-foreground[data-active]{color:var(--sidebar-accent-foreground)}.data-closed\:overflow-hidden[data-closed]{overflow:hidden}.data-disabled\:pointer-events-none[data-disabled]{pointer-events:none}.data-disabled\:opacity-50[data-disabled]{opacity:.5}.data-empty\:p-0[data-empty]{padding:0}.data-ending-style\:opacity-0[data-ending-style]{opacity:0}.data-highlighted\:bg-accent[data-highlighted]{background-color:var(--accent)}.data-highlighted\:text-accent-foreground[data-highlighted],:is(.not-data-\[variant\=destructive\]\:data-highlighted\:\*\*\:text-accent-foreground:not([data-variant=destructive])[data-highlighted] *){color:var(--accent-foreground)}.data-inset\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-open\:bg-accent[data-open]{background-color:var(--accent)}.data-open\:text-accent-foreground[data-open]{color:var(--accent-foreground)}.data-open\:opacity-100[data-open]{opacity:1}@media (hover:hover){.data-open\:hover\:bg-sidebar-accent[data-open]:hover{background-color:var(--sidebar-accent)}.data-open\:hover\:text-sidebar-accent-foreground[data-open]:hover{color:var(--sidebar-accent-foreground)}}.data-placeholder\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-pressed\:bg-transparent[data-pressed]{background-color:#0000}.data-selected\:border-primary[data-selected]{border-color:var(--primary)}.data-selected\:text-foreground[data-selected]{color:var(--foreground)}.data-starting-style\:opacity-0[data-starting-style]{opacity:0}.data-\[chips\=true\]\:min-w-\(--anchor-width\)[data-chips=true]{min-width:var(--anchor-width)}.data-\[invalid\=true\]\:text-destructive[data-invalid=true]{color:var(--destructive)}.data-\[orientation\=horizontal\]\:h-px[data-orientation=horizontal]{height:1px}.data-\[orientation\=horizontal\]\:w-full[data-orientation=horizontal]{width:100%}.data-\[orientation\=vertical\]\:w-px[data-orientation=vertical]{width:1px}.data-\[orientation\=vertical\]\:self-stretch[data-orientation=vertical]{align-self:stretch}.data-\[side\=bottom\]\:inset-x-0[data-side=bottom]{inset-inline:0}.data-\[side\=bottom\]\:top-1[data-side=bottom]{top:var(--spacing)}.data-\[side\=bottom\]\:bottom-0[data-side=bottom]{bottom:0}.data-\[side\=bottom\]\:h-auto[data-side=bottom]{height:auto}.data-\[side\=bottom\]\:border-t[data-side=bottom]{border-top-style:var(--tw-border-style);border-top-width:1px}.data-\[side\=left\]\:inset-y-0[data-side=left]{inset-block:0}.data-\[side\=left\]\:top-1\/2\![data-side=left]{top:50%!important}.data-\[side\=left\]\:-right-1[data-side=left]{right:calc(var(--spacing) * -1)}.data-\[side\=left\]\:left-0[data-side=left]{left:0}.data-\[side\=left\]\:h-full[data-side=left]{height:100%}.data-\[side\=left\]\:w-3\/4[data-side=left]{width:75%}.data-\[side\=left\]\:-translate-y-1\/2[data-side=left]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:border-r[data-side=left]{border-right-style:var(--tw-border-style);border-right-width:1px}.data-\[side\=right\]\:inset-y-0[data-side=right]{inset-block:0}.data-\[side\=right\]\:top-1\/2\![data-side=right]{top:50%!important}.data-\[side\=right\]\:right-0[data-side=right]{right:0}.data-\[side\=right\]\:-left-1[data-side=right]{left:calc(var(--spacing) * -1)}.data-\[side\=right\]\:h-full[data-side=right]{height:100%}.data-\[side\=right\]\:w-3\/4[data-side=right]{width:75%}.data-\[side\=right\]\:-translate-y-1\/2[data-side=right]{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:border-l[data-side=right]{border-left-style:var(--tw-border-style);border-left-width:1px}.data-\[side\=top\]\:inset-x-0[data-side=top]{inset-inline:0}.data-\[side\=top\]\:top-0[data-side=top]{top:0}.data-\[side\=top\]\:-bottom-2\.5[data-side=top]{bottom:calc(var(--spacing) * -2.5)}.data-\[side\=top\]\:h-auto[data-side=top]{height:auto}.data-\[side\=top\]\:border-b[data-side=top]{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.data-\[size\=default\]\:h-7[data-size=default]{height:calc(var(--spacing) * 7)}.data-\[size\=default\]\:max-w-xs[data-size=default]{max-width:var(--container-xs)}.data-\[size\=lg\]\:size-10[data-size=lg]{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.data-\[size\=md\]\:text-xs[data-size=md]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.data-\[size\=sm\]\:size-6[data-size=sm]{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:h-6[data-size=sm]{height:calc(var(--spacing) * 6)}.data-\[size\=sm\]\:max-w-64[data-size=sm]{max-width:calc(var(--spacing) * 64)}.data-\[size\=sm\]\:gap-3[data-size=sm]{gap:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:py-3[data-size=sm]{padding-block:calc(var(--spacing) * 3)}.data-\[size\=sm\]\:text-xs[data-size=sm]{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}:is(.\*\:data-\[slot\=avatar\]\:ring-2>*)[data-slot=avatar]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\:data-\[slot\=avatar\]\:ring-background>*)[data-slot=avatar]{--tw-ring-color:var(--background)}.data-\[slot\=checkbox-group\]\:gap-3[data-slot=checkbox-group]{gap:calc(var(--spacing) * 3)}:is(.\*\:data-\[slot\=field\]\:p-2>*)[data-slot=field]{padding:calc(var(--spacing) * 2)}:is(.\*\:data-\[slot\=field-group\]\:gap-4>*)[data-slot=field-group]{gap:calc(var(--spacing) * 4)}:is(.\*\:data-\[slot\=input-group\]\:m-1>*)[data-slot=input-group]{margin:var(--spacing)}:is(.\*\:data-\[slot\=input-group\]\:mb-0>*)[data-slot=input-group]{margin-bottom:0}:is(.\*\:data-\[slot\=input-group\]\:h-7>*)[data-slot=input-group]{height:calc(var(--spacing) * 7)}:is(.\*\:data-\[slot\=input-group\]\:border-none>*)[data-slot=input-group]{--tw-border-style:none;border-style:none}:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){:is(.\*\:data-\[slot\=input-group\]\:bg-input\/20>*)[data-slot=input-group]{background-color:color-mix(in oklab, var(--input) 20%, transparent)}}:is(.\*\:data-\[slot\=input-group\]\:shadow-none>*)[data-slot=input-group]{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-\[calc\(var\(--radius-sm\)-2px\)\] *)[data-slot=kbd]{border-radius:calc(var(--radius-sm) - 2px)}:is(.\*\*\:data-\[slot\=kbd\]\:rounded-md *)[data-slot=kbd]{border-radius:var(--radius)}:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){:is(.\*\*\:data-\[slot\=kbd\]\:bg-muted-foreground\/10 *)[data-slot=kbd]{background-color:color-mix(in oklab, var(--muted-foreground) 10%, transparent)}}:is(.\*\*\:data-\[slot\=kbd\]\:px-1 *)[data-slot=kbd]{padding-inline:var(--spacing)}:is(.\*\*\:data-\[slot\=kbd\]\:text-\[0\.625rem\] *)[data-slot=kbd]{font-size:.625rem}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-1\.5>*)[data-slot=select-value]{gap:calc(var(--spacing) * 1.5)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}.data-\[variant\=label\]\:text-xs\/relaxed[data-variant=label]{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.data-\[variant\=legend\]\:text-sm[data-variant=legend]{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.nth-last-2\:-mt-1:nth-last-child(2){margin-top:calc(var(--spacing) * -1)}@supports ((-webkit-backdrop-filter:var(--tw)) or (backdrop-filter:var(--tw))){.supports-backdrop-filter\:backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}}@media (width>=40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:col-span-3{grid-column:span 3/span 3}.sm\:mr-auto{margin-right:auto}.sm\:flex{display:flex}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-\[1fr_1fr_1fr_auto\]{grid-template-columns:1fr 1fr 1fr auto}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:self-center{align-self:center}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:row-span-2:is(:where(.group\/alert-dialog-content)[data-size=default] *){grid-row:span 2/span 2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:place-items-start:is(:where(.group\/alert-dialog-content)[data-size=default] *){place-items:start}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:text-left:is(:where(.group\/alert-dialog-content)[data-size=default] *){text-align:left}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:group-has-data-\[slot\=alert-dialog-media\]\/alert-dialog-content\:col-start-2:is(:where(.group\/alert-dialog-content)[data-size=default] *):is(:where(.group\/alert-dialog-content):has([data-slot=alert-dialog-media]) *){grid-column-start:2}.sm\:group-data-\[size\=default\]\/alert-dialog-content\:has-data-\[slot\=alert-dialog-media\]\:grid-rows-\[auto_1fr\]:is(:where(.group\/alert-dialog-content)[data-size=default] *):has([data-slot=alert-dialog-media]){grid-template-rows:auto 1fr}.data-\[side\=left\]\:sm\:max-w-sm[data-side=left],.data-\[side\=right\]\:sm\:max-w-sm[data-side=right],.data-\[size\=default\]\:sm\:max-w-sm[data-size=default]{max-width:var(--container-sm)}}@media (width>=48rem){.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:text-xs\/relaxed{font-size:var(--text-xs);line-height:var(--leading-relaxed)}.md\:text-pretty{text-wrap:pretty}.md\:opacity-0{opacity:0}.md\:peer-data-\[variant\=inset\]\:m-2:is(:where(.peer)[data-variant=inset]~*){margin:calc(var(--spacing) * 2)}.md\:peer-data-\[variant\=inset\]\:ml-0:is(:where(.peer)[data-variant=inset]~*){margin-left:0}.md\:peer-data-\[variant\=inset\]\:rounded-xl:is(:where(.peer)[data-variant=inset]~*){border-radius:calc(var(--radius) + 4px)}.md\:peer-data-\[variant\=inset\]\:shadow-sm:is(:where(.peer)[data-variant=inset]~*){--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:peer-data-\[variant\=inset\]\:peer-data-\[state\=collapsed\]\:ml-2:is(:where(.peer)[data-variant=inset]~*):is(:where(.peer)[data-state=collapsed]~*){margin-left:calc(var(--spacing) * 2)}.md\:after\:hidden:after{content:var(--tw-content);display:none}}@media (width>=64rem){.lg\:block{display:block}.lg\:grid-cols-\[minmax\(0\,1fr\)_20rem\]{grid-template-columns:minmax(0,1fr) 20rem}}@media (width>=80rem){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@container field-group (width>=28rem){.\@md\/field-group\:flex-row{flex-direction:row}.\@md\/field-group\:items-center{align-items:center}.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:items-start:has(>[data-slot=field-content]){align-items:flex-start}}.dark\:scale-0:where(.dark,.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:where(.dark,.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:where(.dark,.dark *){rotate:-90deg}.dark\:rotate-0:where(.dark,.dark *){rotate:0deg}.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-green-900:where(.dark,.dark *){background-color:var(--color-green-900)}.dark\:bg-input\/30:where(.dark,.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-muted\/30:where(.dark,.dark *){background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-muted\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.dark\:bg-popover:where(.dark,.dark *){background-color:var(--popover)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-yellow-900:where(.dark,.dark *){background-color:var(--color-yellow-900)}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:invert:where(.dark,.dark *){--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.dark\:after\:mix-blend-lighten:where(.dark,.dark *):after{content:var(--tw-content);mix-blend-mode:lighten}@media (hover:hover){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-destructive\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-muted\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.dark\:hover\:bg-transparent:where(.dark,.dark *):hover{background-color:#0000}}.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:border-destructive\/50:where(.dark,.dark *):has([aria-invalid=true]){border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-aria-invalid\:ring-destructive\/40:where(.dark,.dark *):has([aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.dark\:has-data-checked\:bg-primary\/10:where(.dark,.dark *):has([data-checked]){background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:has-\[\[data-slot\]\[aria-invalid\=true\]\]\:ring-destructive\/40:where(.dark,.dark *):has([data-slot][aria-invalid=true]){--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:border-destructive\/50:where(.dark,.dark *)[aria-invalid=true]{border-color:color-mix(in oklab, var(--destructive) 50%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:where(.dark,.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:where(.dark,.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-2\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3 svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5 svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.group-data-\[collapsible\=icon\]\:\[\&\:\:-webkit-scrollbar\]\:hidden:is(:where(.group)[data-collapsible=icon] *)::-webkit-scrollbar{display:none}.\[\.border-b\]\:pb-2.border-b{padding-bottom:calc(var(--spacing) * 2)}.\[\.border-b\]\:pb-4.border-b{padding-bottom:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-b\]\:pb-3:is(:where(.group\/card)[data-size=sm] *).border-b{padding-bottom:calc(var(--spacing) * 3)}.\[\.border-t\]\:pt-2.border-t{padding-top:calc(var(--spacing) * 2)}.\[\.border-t\]\:pt-4.border-t{padding-top:calc(var(--spacing) * 4)}.group-data-\[size\=sm\]\/card\:\[\.border-t\]\:pt-3:is(:where(.group\/card)[data-size=sm] *).border-t{padding-top:calc(var(--spacing) * 3)}:is(.\*\:\[a\]\:underline>*):is(a){text-decoration-line:underline}:is(.\*\:\[a\]\:underline-offset-3>*):is(a){text-underline-offset:3px}@media (hover:hover){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-destructive\/20:is(a):hover{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[a\]\:hover\:bg-muted:is(a):hover{background-color:var(--muted)}.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-primary\/80:is(a):hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.\[a\]\:hover\:bg-secondary\/80:is(a):hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.\[a\]\:hover\:text-muted-foreground:is(a):hover{color:var(--muted-foreground)}:is(.\*\:\[a\]\:hover\:text-foreground>*):is(a):hover{color:var(--foreground)}}:is(.\*\:\[img\:first-child\]\:rounded-t-lg>*):is(img:first-child){border-top-left-radius:calc(var(--radius) + 2px);border-top-right-radius:calc(var(--radius) + 2px)}:is(.\*\:\[img\:last-child\]\:rounded-b-lg>*):is(img:last-child){border-bottom-right-radius:calc(var(--radius) + 2px);border-bottom-left-radius:calc(var(--radius) + 2px)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)}:is(.\*\:\[svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4>*):is(svg:not([class*=size-])){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>\*\]\:w-full>*{width:100%}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\*\]\:w-auto>*{width:auto}}.\[\&\>\.sr-only\]\:w-auto>.sr-only{width:auto}.\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}@container field-group (width>=28rem){.\@md\/field-group\:\[\&\>\[data-slot\=field-label\]\]\:flex-auto>[data-slot=field-label]{flex:auto}}.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}@container field-group (width>=28rem){.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content])>[role=checkbox],.\@md\/field-group\:has-\[\>\[data-slot\=field-content\]\]\:\[\&\>\[role\=checkbox\]\,\[role\=radio\]\]\:mt-px:has(>[data-slot=field-content]) [role=radio]{margin-top:1px}}.\[\&\>a\]\:underline>a{text-decoration-line:underline}.\[\&\>a\]\:underline-offset-4>a{text-underline-offset:4px}.\[\&\>a\:hover\]\:text-primary>a:hover{color:var(--primary)}.\[\&\>button\]\:hidden>button{display:none}.\[\&\>code\]\:bg-transparent>code{background-color:#0000}.\[\&\>code\]\:p-0>code{padding:0}.has-\[\>\[data-align\=block-end\]\]\:\[\&\>input\]\:pt-3:has(>[data-align=block-end])>input{padding-top:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=block-start\]\]\:\[\&\>input\]\:pb-3:has(>[data-align=block-start])>input{padding-bottom:calc(var(--spacing) * 3)}.has-\[\>\[data-align\=inline-end\]\]\:\[\&\>input\]\:pr-1\.5:has(>[data-align=inline-end])>input{padding-right:calc(var(--spacing) * 1.5)}.has-\[\>\[data-align\=inline-start\]\]\:\[\&\>input\]\:pl-1\.5:has(>[data-align=inline-start])>input{padding-left:calc(var(--spacing) * 1.5)}.\[\&\>span\:last-child\]\:truncate>span:last-child{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-2\.5\!>svg{width:calc(var(--spacing) * 2.5)!important;height:calc(var(--spacing) * 2.5)!important}.\[\&\>svg\]\:size-4>svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&\>svg\]\:shrink-0>svg{flex-shrink:0}.\[\&\>svg\]\:text-sidebar-accent-foreground>svg{color:var(--sidebar-accent-foreground)}.group-has-data-\[size\=lg\]\/avatar-group\:\[\&\>svg\]\:size-5:is(:where(.group\/avatar-group):has([data-size=lg]) *)>svg{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.group-has-data-\[size\=sm\]\/avatar-group\:\[\&\>svg\]\:size-3:is(:where(.group\/avatar-group):has([data-size=sm]) *)>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.group-data-\[size\=default\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=default] *)>svg,.group-data-\[size\=lg\]\/avatar\:\[\&\>svg\]\:size-2:is(:where(.group\/avatar)[data-size=lg] *)>svg{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.group-data-\[size\=sm\]\/avatar\:\[\&\>svg\]\:hidden:is(:where(.group\/avatar)[data-size=sm] *)>svg{display:none}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3>svg:not([class*=size-]){width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-3\.5>svg:not([class*=size-]){width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}[data-side=left][data-collapsible=offExamples] .\[\[data-side\=left\]\[data-collapsible\=offExamples\]_\&\]\:-right-2{right:calc(var(--spacing) * -2)}[data-side=left][data-state=collapsed] .\[\[data-side\=left\]\[data-state\=collapsed\]_\&\]\:cursor-e-resize{cursor:e-resize}[data-side=right][data-collapsible=offExamples] .\[\[data-side\=right\]\[data-collapsible\=offExamples\]_\&\]\:-left-2{left:calc(var(--spacing) * -2)}[data-side=right][data-state=collapsed] .\[\[data-side\=right\]\[data-state\=collapsed\]_\&\]\:cursor-w-resize{cursor:w-resize}[data-variant=legend]+.\[\[data-variant\=legend\]\+\&\]\:-mt-1\.5{margin-top:calc(var(--spacing) * -1.5)}}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Geist Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/geist-latin-wght-normal-BgDaEnEv.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(data:font/woff2;base64,d09GMgABAAAAAAfsABQAAAAAEAwAAAeCAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhwbHhwoP0hWQVJbBmA/U1RBVIFiJyYAdC9qEQgKhGSEAAsgADCGCAE2AiQDOgQgBYlMB4EUDAcbLQ4onoexrSC/2ZyLAa8p8VHB8/x3Vue+V0hVJalMJg2nx/TCrQXxBeqLjQG7FyM1WEa/X1tEXN7cFz9EJEMmMUz3RihWSSKeQCbcIou0izz/C8v+fq3VfajEa9gDD11CImXS7qL/RJFVzC1qiB6KmKeD6TZdQ6IRGv78dL6uSVVCfgni5mzu7kcgQBgAEAQTQRCoL++STTYybkJxNfQxAAIAGu8OdEB9teW2jh4BpgDqFjAeSEByW3zFP0CBBgNMsMCGEDjgggdhiEAUAeIIED7ABTDUEnkIE9Q9ahFgKttcVhApo4ACB4qobHaccgDfEjFO6aaWUhjMLt2SyIvHKoDqoA4CSUwEIYQCEjhAO9R1G6keDeDZGjNo+AhxOjCEGTr1WeIF3kYBiLAOKvkJSMiKX0VdAyQt3SDJClCkxJCHkCzfqyVTriJZLcolS32JZHUekq2TYNkYtCtjYHMQXSxGjXDz2t/yLWXzDzxz+o3zFwDEaN23F+13pyMdQAEaSKAR9vcGq4A4MTSKCElGW+M7UcY7xqkggITb28ZJhlqc9q2twYKTt0NjixBgYvO9BIihEBLYuOFXQzfIQ7dXGUEEEgFDooBfAzqiQbpJrhiWSuKJCRFKYbHCyJKI2G5GiZbNAvgAu5pc3vwx4G+g3aDkhklABiSz0BICXrYghtYhx/cdJ+44rY2oZ0aMNRFz3VZjb6W33F3gzltqtOCV8tTHSpOeXuItfvr5lCdfzFpqtEitvqdcdGGFd28ZqqC0tPbeChGXgrIlnhSWu/eUso4uKWFLugyDzQJhflY4659+WjQ++6x72WUMv9G8mw6QJl7BVxX5fe/kpUsOvnZwee9uQ0cGXYd0o89XB2748sDSnt8d2VphdOTTgceDVvOds0v9P/s7HPq15aGun/6Vllb56f1dl0t1LejqrNkpdRZsG8TOnM5vkBG5oiVyVGnS8LHps5cfNWJs6qKPfaNSxiQNBUm3cKNWROr0GSur7Za31k1vieq7LH11VF+jXdRIasRKflc7jkobm1Z9te1IyZA0pDkhLR98+H37Zf1c/8at+dB7x+7GfVyTfJMPiYztsnl59Y5l4j+0n1RXlpHnF3Tq7HecmNF/CJodEMAikruxiyJaGLvHOdAfoA+oDvpjBm2b91cHGRZMU9n25xEU0A8fgEEAdKI3Q1iDtc034sug5YVMkE2jsE+BIkwSoQ3gxXMqz9tELp48bd0cFKOKS7xYjEuXBnZP5ia7DyiO/X/YI+PQSbt2uSdqAkWL9nQbV1XB94/+uPfdZz8dnXYFBYrcTl2SIR/ybxJNJPz/Gupb0JaZeens2ekC7EKr8t+Ls/P5VJPYJdHKyqfg2nqU6bhlidzcddQV/7MmecTzJ5VPcKXkNKSEogHjYFx6QZ7rQ+FSe8njaiNuOnXS8H2ScQ619c2mC3VTtauL0rRbXd/CkSOP37FY9Zkjz8+GibYUMOEWF+RdrFS8Ecv1SHOpPUPZGEIpjPvFyU5cXKjd6OXqorTqy9GwRd++HVufPGnVsW+aO3vggKZ18jR9sXaTC1PWTEsVUaK0FkNySbTQDqlm2PfDjZcu4aalnSLKjnOoYQ0nUlqqXcGpPu/4VgV/xU2pAqW4BW3qzhQ8/hFKhV2qE3+BKAtDqBXjfgnVdH4y0wg5tbVNRenNdTWOrenWLcupQdmsbq5b+18piTe/xRdp1xbILxNPJGInm2z6hoB21Lal0i+ePTtd7B45+3XhFJ329evskXm7qurUVREotqSluSo/L29d3qDhI4YOQqWhI4YNvBNfsMHeXKemXrxQfKeuPOGRVayA3JtkJKEgbPp+dXUDluddutRYLFoXGXWX6N3WFaGLbQtRSitVYNacTNSdy7AaG/HSaUEANcBoGXNdcZvZsOqQ1icBDv21/gzAoYPHH/WDW0qNR3QTYKEAEHig6o13NXbND06CQPlRtYjGNnSktRc09k1mAMDvAlDKfQjgy6fssInlfzmNAjKkDxoxHOBLdVRAIVt9j4qo+hA1w9T1aNBNTUOTTNUHLbqokE+UAfJXCIGw/IxCSL5GRUJeR40rL/UxTm4Q08H6MbCs70ObuNyIIXrINHQYInF06UUlevTjbQzTh5upiDMzMMogUtEnjPs/Y7jAHCJeB0GBHh04tC6FiB6ZFB1oArUSIoFoqhzCeAN6lHwm0T4C3VVPWvjpSMXReuWesMEcoqrmgtNBGd2noWeV0hNAz9rFeShNJxHGsPa3HXeKTk8b55hahySYHaYKKFFLpCfN8rsoaJn01CR04Gkc+5k7KVTCmClX8Q10HCrUEkVlSX+XO33oQR9609tJ516H497WSobWs5Up6TLaS10/dessIskgJSLiDlWvHVUywpkQ7hdPZqGyiEF0uVQerVcPamT1A3eKXdyI1vG9OoflrSXihZ1qqGE3nhmAgiIbRCQgPLEPtOM3UQwTLYaYYomNlpA44opnjV6jkD6id80OOrzf6BzmMD6eEa1zKyeYG1fzfEf16V6jw9XYOaar1/b2kP/IYX8oR2mcFvv2GtBV3JXgd437AQAA)format("woff2-variations");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2)format("woff2-variations");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2)format("woff2-variations");unicode-range:U+370-377,U+37A-37F,U+384-38A,U+38C,U+38E-3A1,U+3A3-3FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2)format("woff2-variations");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:JetBrains Mono Variable;font-style:normal;font-display:swap;font-weight:100 800;src:url(/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--font-sans:"Geist Variable", sans-serif;--font-mono:"JetBrains Mono Variable", monospace;--background:#f4f1ec;--foreground:#1d2930;--card:#f9f8f6;--card-foreground:#1d2930;--popover:#fbfaf9;--popover-foreground:#1d2930;--primary:#305030;--primary-foreground:#f5f3ef;--secondary:#eae5d7;--secondary-foreground:#263740;--muted:#e9e7e2;--muted-foreground:#576b75;--accent:#e7e2da;--accent-foreground:#1d2930;--destructive:#c32222;--destructive-foreground:#fff;--border:#b6c9b6;--input:#dedbd3;--ring:#3e743e;--chart-1:#2e6b2e;--chart-2:#cfa117;--chart-3:#297aa3;--chart-4:#cc8c33;--chart-5:#b82e2e;--radius:.25rem;--sidebar:#f5f3f0;--sidebar-background:#f5f3f0;--sidebar-foreground:#263740;--sidebar-primary:#305030;--sidebar-primary-foreground:#f5f3ef;--sidebar-accent:#e7e2da;--sidebar-accent-foreground:#1d2930;--sidebar-border:#c4d4c4;--sidebar-ring:#3e743e;--tactical-amber:#c2940a;--tactical-amber-dim:#d6b85c;--tactical-green:#267326;--tactical-green-dim:#7ab87a;--tactical-cyan:#24868f;--tactical-cyan-dim:#5eb2ba;--tactical-red:#bd2828;--tactical-olive:#64783a;--scanline-opacity:.015}.dark{--background:#0d1012;--foreground:#e0ddd1;--card:#14181a;--card-foreground:#e0ddd1;--popover:#111517;--popover-foreground:#e0ddd1;--primary:#fa0;--primary-foreground:#0d1012;--secondary:#323922;--secondary-foreground:#c6d2ac;--muted:#202527;--muted-foreground:#819098;--accent:#292f32;--accent-foreground:#ebe8e0;--destructive:#c32222;--destructive-foreground:#fff;--border:#203c32;--input:#292f32;--ring:#ffbf00;--chart-1:#ffbf00;--chart-2:#22c38e;--chart-3:#1aa1e6;--chart-4:#86ac39;--chart-5:#d92626;--sidebar:#111517;--sidebar-background:#111517;--sidebar-foreground:#e0ddd1;--sidebar-primary:#ffbf00;--sidebar-primary-foreground:#0d1012;--sidebar-accent:#292f32;--sidebar-accent-foreground:#ebe8e0;--sidebar-border:#203c32;--sidebar-ring:#ffbf00;--tactical-amber:#ffbf00;--tactical-amber-dim:#a17d12;--tactical-green:#22c38e;--tactical-green-dim:#20604a;--tactical-cyan:#17bfcf;--tactical-cyan-dim:#196066;--tactical-red:#d92626;--tactical-olive:#576b2e;--scanline-opacity:.03}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/spinifex/services/spinifexui/frontend/dist/assets/network-RAx0KOlf.js b/spinifex/services/spinifexui/frontend/dist/assets/network-RAx0KOlf.js new file mode 100644 index 000000000..aefbaf74a --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/network-RAx0KOlf.js @@ -0,0 +1 @@ +import{t as e}from"./createLucideIcon-BQgzNCBR.js";var t={name:`network`,size:24,node:[[`rect`,{x:`16`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`4q2zg0`}],[`rect`,{x:`2`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`8cvhb9`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`6`,rx:`1`,key:`1egb70`}],[`path`,{d:`M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3`,key:`1jsf9p`}],[`path`,{d:`M12 12V8`,key:`2874zd`}]]};t.node;var n=e(t);export{n as t}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/waypoints-CMfoqtKm.js b/spinifex/services/spinifexui/frontend/dist/assets/waypoints-CMfoqtKm.js deleted file mode 100644 index 603f97e3d..000000000 --- a/spinifex/services/spinifexui/frontend/dist/assets/waypoints-CMfoqtKm.js +++ /dev/null @@ -1 +0,0 @@ -import{t as e}from"./createLucideIcon-BQgzNCBR.js";var t={name:`globe`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]]};t.node;var n=e(t),r={name:`hard-drive`,size:24,node:[[`path`,{d:`M10 16h.01`,key:`1bzywj`}],[`path`,{d:`M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z`,key:`18tbho`}],[`path`,{d:`M21.946 12.013H2.054`,key:`zqlbp7`}],[`path`,{d:`M6 16h.01`,key:`1pmjb7`}]]};r.node;var i=e(r),a={name:`key`,size:24,node:[[`path`,{d:`m2 21 9.6-9.6`,key:`9l79m3`}],[`path`,{d:`m7.5 15.5 2.3 2.3a1 1 0 0 1 0 1.4l-2.1 2.1a1 1 0 0 1-1.4 0L4 19`,key:`fw8biw`}],[`circle`,{cx:`15.5`,cy:`7.5`,r:`5.5`,key:`4wxmhb`}]]};a.node;var o=e(a),s={name:`network`,size:24,node:[[`rect`,{x:`16`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`4q2zg0`}],[`rect`,{x:`2`,y:`16`,width:`6`,height:`6`,rx:`1`,key:`8cvhb9`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`6`,rx:`1`,key:`1egb70`}],[`path`,{d:`M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3`,key:`1jsf9p`}],[`path`,{d:`M12 12V8`,key:`2874zd`}]]};s.node;var c=e(s),l={name:`router`,size:24,node:[[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,key:`w68u3i`}],[`path`,{d:`M6.01 18H6`,key:`19vcac`}],[`path`,{d:`M10.01 18H10`,key:`uamcmx`}],[`path`,{d:`M15 10v4`,key:`qjz1xs`}],[`path`,{d:`M17.84 7.17a4 4 0 0 0-5.66 0`,key:`1rif40`}],[`path`,{d:`M20.66 4.34a8 8 0 0 0-11.31 0`,key:`6a5xfq`}]]};l.node;var u=e(l),d={name:`server`,size:24,node:[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]};d.node;var f=e(d),p={name:`shield`,size:24,node:[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]};p.node;var m=e(p),h={name:`waypoints`,size:24,node:[[`path`,{d:`m10.586 5.414-5.172 5.172`,key:`4mc350`}],[`path`,{d:`m18.586 13.414-5.172 5.172`,key:`8c96vv`}],[`path`,{d:`M6 12h12`,key:`8npq4p`}],[`circle`,{cx:`12`,cy:`20`,r:`2`,key:`144qzu`}],[`circle`,{cx:`12`,cy:`4`,r:`2`,key:`muu5ef`}],[`circle`,{cx:`20`,cy:`12`,r:`2`,key:`1xzzfp`}],[`circle`,{cx:`4`,cy:`12`,r:`2`,key:`1hvhnz`}]]};h.node;var g=e(h);export{c as a,n as c,u as i,m as n,o,f as r,i as s,g as t}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/assets/waypoints-DE7eIn9V.js b/spinifex/services/spinifexui/frontend/dist/assets/waypoints-DE7eIn9V.js new file mode 100644 index 000000000..94869d21f --- /dev/null +++ b/spinifex/services/spinifexui/frontend/dist/assets/waypoints-DE7eIn9V.js @@ -0,0 +1 @@ +import{t as e}from"./createLucideIcon-BQgzNCBR.js";var t={name:`globe`,size:24,node:[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]]};t.node;var n=e(t),r={name:`hard-drive`,size:24,node:[[`path`,{d:`M10 16h.01`,key:`1bzywj`}],[`path`,{d:`M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z`,key:`18tbho`}],[`path`,{d:`M21.946 12.013H2.054`,key:`zqlbp7`}],[`path`,{d:`M6 16h.01`,key:`1pmjb7`}]]};r.node;var i=e(r),a={name:`key`,size:24,node:[[`path`,{d:`m2 21 9.6-9.6`,key:`9l79m3`}],[`path`,{d:`m7.5 15.5 2.3 2.3a1 1 0 0 1 0 1.4l-2.1 2.1a1 1 0 0 1-1.4 0L4 19`,key:`fw8biw`}],[`circle`,{cx:`15.5`,cy:`7.5`,r:`5.5`,key:`4wxmhb`}]]};a.node;var o=e(a),s={name:`router`,size:24,node:[[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,key:`w68u3i`}],[`path`,{d:`M6.01 18H6`,key:`19vcac`}],[`path`,{d:`M10.01 18H10`,key:`uamcmx`}],[`path`,{d:`M15 10v4`,key:`qjz1xs`}],[`path`,{d:`M17.84 7.17a4 4 0 0 0-5.66 0`,key:`1rif40`}],[`path`,{d:`M20.66 4.34a8 8 0 0 0-11.31 0`,key:`6a5xfq`}]]};s.node;var c=e(s),l={name:`server`,size:24,node:[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]};l.node;var u=e(l),d={name:`shield`,size:24,node:[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]};d.node;var f=e(d),p={name:`waypoints`,size:24,node:[[`path`,{d:`m10.586 5.414-5.172 5.172`,key:`4mc350`}],[`path`,{d:`m18.586 13.414-5.172 5.172`,key:`8c96vv`}],[`path`,{d:`M6 12h12`,key:`8npq4p`}],[`circle`,{cx:`12`,cy:`20`,r:`2`,key:`144qzu`}],[`circle`,{cx:`12`,cy:`4`,r:`2`,key:`muu5ef`}],[`circle`,{cx:`20`,cy:`12`,r:`2`,key:`1xzzfp`}],[`circle`,{cx:`4`,cy:`12`,r:`2`,key:`1hvhnz`}]]};p.node;var m=e(p);export{o as a,c as i,f as n,i as o,u as r,n as s,m as t}; \ No newline at end of file diff --git a/spinifex/services/spinifexui/frontend/dist/index.html b/spinifex/services/spinifexui/frontend/dist/index.html index 6dc031efe..b91f4ed15 100644 --- a/spinifex/services/spinifexui/frontend/dist/index.html +++ b/spinifex/services/spinifexui/frontend/dist/index.html @@ -7,7 +7,7 @@ - + @@ -29,12 +29,13 @@ - + + @@ -74,7 +75,7 @@ - + @@ -105,7 +106,7 @@ - +
diff --git a/spinifex/services/spinifexui/frontend/src/lib/ssh-login.test.ts b/spinifex/services/spinifexui/frontend/src/lib/ssh-login.test.ts new file mode 100644 index 000000000..7061f13c8 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/lib/ssh-login.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest" + +import { + buildSSHCommand, + resolveSSHLogin, + resolveSSHTarget, + SSH_KEY_PLACEHOLDER, + SSH_USER_PLACEHOLDER, +} from "./ssh-login" + +describe("resolveSSHLogin", () => { + // Every name here is the distro's own default_user, verified against the + // upstream cloud image rather than inferred from the distro name: Oracle + // Linux is cloud-user, not oracle, and Debian is debian, not admin. + it.each([ + ["ami-ubuntu-26.04-x86_64", "ubuntu", "ubuntu"], + ["ami-debian-13-x86_64", "debian", "debian"], + ["ami-oracle-10.1-x86_64", "cloud-user", "oracle"], + ["ami-oracle-9.8-arm64", "cloud-user", "oracle"], + ["ami-rocky-10-x86_64", "rocky", "rocky"], + ["ami-alpine-3.24.1-x86_64", "alpine", "alpine"], + ])("maps %s to %s", (name, user, distro) => { + expect(resolveSSHLogin({ Name: name })).toStrictEqual({ user, distro }) + }) + + it("reads the description when the name carries no distro", () => { + expect( + resolveSSHLogin({ + Name: "ami-0abc123", + Description: "Oracle Linux 10.1 cloud image prepared for Spinifex", + }), + ).toStrictEqual({ user: "cloud-user", distro: "oracle" }) + }) + + it("returns no user for an unrecognised image rather than guessing", () => { + expect(resolveSSHLogin({ Name: "my-hardened-appliance" })).toStrictEqual({ + user: "", + }) + expect(resolveSSHLogin(undefined)).toStrictEqual({ user: "" }) + }) + + it("does not match a distro name buried inside a longer word", () => { + expect(resolveSSHLogin({ Name: "ami-ubuntuish-1" })).toStrictEqual({ + user: "", + }) + }) + + it("prefers the more specific of two overlapping tokens", () => { + expect(resolveSSHLogin({ Name: "amazonlinux amzn" }).user).toBe("ec2-user") + }) +}) + +describe("resolveSSHTarget", () => { + it("prefers the public address", () => { + expect( + resolveSSHTarget({ + PublicIpAddress: "149.118.74.90", + PrivateIpAddress: "172.31.0.4", + }), + ).toStrictEqual({ address: "149.118.74.90", reachability: "public" }) + }) + + it("flags a private-only instance", () => { + expect(resolveSSHTarget({ PrivateIpAddress: "172.31.0.4" })).toStrictEqual({ + address: "172.31.0.4", + reachability: "private", + }) + }) + + it("reports none when the instance has no address at all", () => { + expect(resolveSSHTarget({})).toStrictEqual({ + address: "", + reachability: "none", + }) + expect(resolveSSHTarget(undefined).reachability).toBe("none") + }) +}) + +describe("buildSSHCommand", () => { + it("names the key pair's file", () => { + expect( + buildSSHCommand({ + keyName: "oracle-test", + user: "cloud-user", + address: "149.118.74.90", + }), + ).toBe("ssh -i path/to/oracle-test.pem cloud-user@149.118.74.90") + }) + + it("falls back to placeholders rather than emitting a broken command", () => { + expect(buildSSHCommand({ user: "", address: "172.31.0.4" })).toBe( + `ssh -i ${SSH_KEY_PLACEHOLDER} ${SSH_USER_PLACEHOLDER}@172.31.0.4`, + ) + }) +}) diff --git a/spinifex/services/spinifexui/frontend/src/lib/ssh-login.ts b/spinifex/services/spinifexui/frontend/src/lib/ssh-login.ts new file mode 100644 index 000000000..aceef5ec5 --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/lib/ssh-login.ts @@ -0,0 +1,85 @@ +import type { Image, Instance } from "@aws-sdk/client-ec2" + +// The SSH login user belongs to the image, not to Spinifex. Nothing in the +// launch path creates an account or renames one: stock cloud-init reads the +// EC2 datasource and applies the distro's own default_user, so every name +// below is whatever the upstream image ships with. +// Ordered longest name first, so an image naming two of them resolves to the +// more specific: "amazonlinux" must not be read as "amzn". +const DEFAULT_USERS = [ + ["amazonlinux", "ec2-user"], + ["almalinux", "almalinux"], + ["oracle", "cloud-user"], + ["ubuntu", "ubuntu"], + ["debian", "debian"], + ["fedora", "fedora"], + ["centos", "cloud-user"], + ["alpine", "alpine"], + ["rocky", "rocky"], + ["amzn", "ec2-user"], + ["rhel", "cloud-user"], +] as const + +export type KnownDistro = (typeof DEFAULT_USERS)[number][0] + +export interface SSHLogin { + user: string + distro?: KnownDistro +} + +// resolveSSHLogin reads the distro out of an AMI's own name and description. +// Our catalog images register as "ami---", and an AMI a +// customer captured with CreateImage inherits that name, so the token survives +// a re-capture. An unrecognised image yields no user rather than a guess: a +// wrong username in a copy-paste command is worse than an honest placeholder. +export function resolveSSHLogin(image: Image | undefined): SSHLogin { + const tokens = new Set( + `${image?.Name ?? ""} ${image?.Description ?? ""}` + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean), + ) + const match = DEFAULT_USERS.find(([name]) => tokens.has(name)) + + return match ? { user: match[1], distro: match[0] } : { user: "" } +} + +export type SSHReachability = "public" | "private" | "none" + +export interface SSHTarget { + address: string + reachability: SSHReachability +} + +// A public address is reachable from anywhere the security group allows. A +// private one is only reachable from inside the VPC, which is a material +// difference to whoever is about to paste the command, so it is returned as a +// distinct state rather than as a plain address. +export function resolveSSHTarget(instance: Instance | undefined): SSHTarget { + if (instance?.PublicIpAddress) { + return { address: instance.PublicIpAddress, reachability: "public" } + } + if (instance?.PrivateIpAddress) { + return { address: instance.PrivateIpAddress, reachability: "private" } + } + return { address: "", reachability: "none" } +} + +export const SSH_KEY_PLACEHOLDER = "path/to/key.pem" +export const SSH_USER_PLACEHOLDER = "" + +// buildSSHCommand assembles the line to paste. The key path stays a +// placeholder even when the key pair's name is known, because the private half +// never leaves the machine that generated it — Spinifex stores only the public +// key and cannot know where the operator put the other one. +export function buildSSHCommand(options: { + keyName?: string + user: string + address: string +}): string { + const keyPath = options.keyName + ? `path/to/${options.keyName}.pem` + : SSH_KEY_PLACEHOLDER + const user = options.user || SSH_USER_PLACEHOLDER + return `ssh -i ${keyPath} ${user}@${options.address}` +} diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(instances)/describe-instances/$id.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(instances)/describe-instances/$id.tsx index bd34a577c..33dd6c829 100644 --- a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(instances)/describe-instances/$id.tsx +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/(instances)/describe-instances/$id.tsx @@ -40,6 +40,7 @@ import { CreateImageDialog } from "../../-components/create-image-dialog" import { GpuDetailCard } from "../../-components/gpu-detail-card" import { IamRolePanel } from "../../-components/iam-role-panel" import { InstanceActions } from "../../-components/instance-actions" +import { SSHAccessCard } from "../../-components/ssh-access-card" export const Route = createFileRoute( "/_auth/ec2/(instances)/describe-instances/$id", @@ -211,6 +212,9 @@ function InstanceDetail() { state={instance.State?.Name} /> + {/* SSH Access — only a running instance has a listening sshd */} + {isRunning && } + {/* Console Output */} {showConsole && consoleOutput !== null && ( diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.test.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.test.tsx new file mode 100644 index 000000000..db1f37ecb --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { SSHAccessCard } from "./ssh-access-card" + +const oracleImage = { Name: "ami-oracle-10.1-x86_64" } + +describe("SSHAccessCard", () => { + it("shows the command with the AMI's own login user", () => { + render( + , + ) + expect( + screen.getByText( + "ssh -i path/to/oracle-test.pem cloud-user@149.118.74.90", + ), + ).toBeInTheDocument() + }) + + it("warns that a private-only instance needs a host inside the VPC", () => { + render( + , + ) + expect( + screen.getByText(/reachable only from inside the VPC/), + ).toBeInTheDocument() + }) + + it("says so plainly when the AMI does not identify a login user", () => { + render( + , + ) + expect( + screen.getByText(/could not be determined from this AMI/), + ).toBeInTheDocument() + expect( + screen.getByText("ssh -i path/to/key.pem @149.118.74.90"), + ).toBeInTheDocument() + }) + + it("renders nothing for an instance with no address", () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.tsx b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.tsx new file mode 100644 index 000000000..488058c9a --- /dev/null +++ b/spinifex/services/spinifexui/frontend/src/routes/_auth/ec2/-components/ssh-access-card.tsx @@ -0,0 +1,145 @@ +import type { Image, Instance } from "@aws-sdk/client-ec2" +import { Check, Copy, Info, Network, TriangleAlert } from "lucide-react" + +import { DetailCard } from "@/components/detail-card" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" +import { + buildSSHCommand, + resolveSSHLogin, + resolveSSHTarget, +} from "@/lib/ssh-login" + +interface SSHAccessCardProps { + instance: Instance + image: Image | undefined +} + +// SSHAccessCard shows the one command needed to reach a running instance. +// The login user is the part nobody can guess — it comes from the AMI, not +// from Spinifex — so the card names it explicitly and says where it came from. +export function SSHAccessCard({ instance, image }: SSHAccessCardProps) { + const { copied, copy } = useCopyToClipboard() + + const { address, reachability } = resolveSSHTarget(instance) + const { user, distro } = resolveSSHLogin(image) + + if (reachability === "none") { + return null + } + + const command = buildSSHCommand({ + keyName: instance.KeyName, + user, + address, + }) + + return ( + + Connect over SSH +
+
+ $ + + {command} + + +
+ +
+
+
Login user
+
+ {user || "unknown — check the AMI"} +
+
+
+
Address
+
{address}
+
+
+
Key pair
+
+ {instance.KeyName ?? "none assigned"} +
+
+
+ + {reachability === "private" && ( + + This instance has no public IP address, so{" "} + {address} is reachable only from + inside the VPC. Run the command from another instance in the same + VPC whose security group allows it, or associate an Elastic IP with + this one. + + )} + + {user ? ( + + {user} is the default login user + for {distro} images. It comes from the AMI itself — Spinifex does + not create or rename the account — so an image built with a + different default will want a different name here. + + ) : ( + + The login user could not be determined from this AMI. Every + distribution ships its own default —{" "} + ubuntu,{" "} + debian,{" "} + cloud-user on Oracle Linux and + RHEL — so check the image's documentation before connecting. + + )} + +

+ Spinifex stores only the public half of a key pair, so the path above + is where you saved the private key when you created it. It + must be readable only by you ( + chmod 600 + ), and the instance's security group must allow TCP 22 from + wherever you are connecting. +

+
+
+ ) +} + +const CALLOUT_TONES = { + amber: "border-tactical-amber/40 bg-tactical-amber/5 text-tactical-amber", + muted: "border-border bg-muted/40 text-muted-foreground", +} as const + +function Callout({ + tone, + icon: Icon, + children, +}: { + tone: keyof typeof CALLOUT_TONES + icon: typeof Info + children: React.ReactNode +}) { + return ( +
+ +

+ {children} +

+
+ ) +} diff --git a/spinifex/utils/images.go b/spinifex/utils/images.go index 994f888f3..7d629b792 100644 --- a/spinifex/utils/images.go +++ b/spinifex/utils/images.go @@ -368,7 +368,11 @@ type Images struct { URL string `json:"url"` Checksum string `json:"checksum"` ChecksumType string `json:"checksum_type"` - BootMode string `json:"boot_mode"` + // ChecksumDigest pins the expected hex digest here instead of naming a sums + // file in Checksum. Only valid for an immutable URL: a "latest" URL whose + // bytes change would fail every import until someone edited this file. + ChecksumDigest string `json:"checksum_digest,omitempty"` + BootMode string `json:"boot_mode"` // Tags are copied onto the imported AMI's metadata for UI filtering (e.g. spinifex:managed-by). Tags map[string]string `json:"tags,omitempty"` } @@ -379,6 +383,7 @@ var distroFamilies = map[string]string{ "ubuntu": "debian", "rocky": "rhel", "rhel": "rhel", + "oracle": "rhel", "alma": "rhel", "fedora": "rhel", "centos": "rhel", @@ -510,6 +515,67 @@ var AvailableImages = map[string]Images{ BootMode: "uefi", }, + // Oracle publishes these digests inline on yum.oracle.com/oracle-linux-templates.html + // and ships no sums file, so all four pin ChecksumDigest. Safe here and only + // here: the URLs name a build (b291, b293, b178, b182) and never move. + // aarch64 has a -kvm-cloud- build alongside the plain -kvm- one; the cloud + // build is the cloud-init image and is the one that matches x86_64's -kvm-. + "oracle-10.1-x86_64": { + Name: "oracle-10.1-x86_64", + Description: "Oracle Linux 10.1 x86_64 KVM cloud image (UEK 8, cloud-init)", + Distro: "oracle", + Version: "10.1", + Arch: "x86_64", + Platform: "Linux/UNIX", + CreatedAt: time.Date(2026, 5, 27, 0, 0, 0, 0, time.UTC), + URL: "https://yum.oracle.com/templates/OracleLinux/OL10/u1/x86_64/OL10U1_x86_64-kvm-b291.qcow2", + ChecksumType: "sha256", + ChecksumDigest: "8e59326c4bf7cfa58a6cac404db8ed583fe3a5f4c460e2b73c64988785bb4f0f", + BootMode: "uefi", + }, + + "oracle-10.1-arm64": { + Name: "oracle-10.1-arm64", + Description: "Oracle Linux 10.1 arm64 KVM cloud image (UEK 8, cloud-init)", + Distro: "oracle", + Version: "10.1", + Arch: "arm64", + Platform: "Linux/UNIX", + CreatedAt: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC), + URL: "https://yum.oracle.com/templates/OracleLinux/OL10/u1/aarch64/OL10U1_aarch64-kvm-cloud-b178.qcow2", + ChecksumType: "sha256", + ChecksumDigest: "e203063e0e8b2752a896787ecc44fc506527055ec819b1422a297d60ad968b20", + BootMode: "uefi", + }, + + "oracle-9.8-x86_64": { + Name: "oracle-9.8-x86_64", + Description: "Oracle Linux 9.8 x86_64 KVM cloud image (UEK, cloud-init)", + Distro: "oracle", + Version: "9.8", + Arch: "x86_64", + Platform: "Linux/UNIX", + CreatedAt: time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), + URL: "https://yum.oracle.com/templates/OracleLinux/OL9/u8/x86_64/OL9U8_x86_64-kvm-b293.qcow2", + ChecksumType: "sha256", + ChecksumDigest: "b12103391327abee8090686759c0d62dac9a7af2bf0f45fdf6b0d085a0fbb52b", + BootMode: "uefi", + }, + + "oracle-9.8-arm64": { + Name: "oracle-9.8-arm64", + Description: "Oracle Linux 9.8 arm64 KVM cloud image (UEK, cloud-init)", + Distro: "oracle", + Version: "9.8", + Arch: "arm64", + Platform: "Linux/UNIX", + CreatedAt: time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), + URL: "https://yum.oracle.com/templates/OracleLinux/OL9/u8/aarch64/OL9U8_aarch64-kvm-cloud-b182.qcow2", + ChecksumType: "sha256", + ChecksumDigest: "4793c3ec49d9f8b27a932d3bd88ec45d08249c5195faa3b0fac876ab0d5f5856", + BootMode: "uefi", + }, + "ubuntu-26.04-nvidia-gpu-x86_64": { Name: "ubuntu-26.04-nvidia-gpu-x86_64", Description: "Ubuntu 26.04 NVIDIA GPU base image — NVIDIA server driver, Python toolchain, Docker, nvidia-container-toolkit", diff --git a/spinifex/utils/images_test.go b/spinifex/utils/images_test.go index 9de9ec340..eb3807b01 100644 --- a/spinifex/utils/images_test.go +++ b/spinifex/utils/images_test.go @@ -14,6 +14,8 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // resolveServingAMI filters purely on these two tags and never on the image @@ -491,3 +493,60 @@ func TestDecompressXz_TruncatedStreamSurfacesStderr(t *testing.T) { t.Errorf("err = %q, want it to surface xz's stderr about the truncated stream", err) } } + +// Oracle publishes its checksums inline on yum.oracle.com/oracle-linux-templates.html +// and ships no sums file, so these four are the catalog's only pinned-digest +// entries. The pin is what makes a verified import possible at all here. +func TestAvailableImages_OracleLinux(t *testing.T) { + cases := []struct { + name, version, arch, urlSub, digest string + }{ + {"oracle-10.1-x86_64", "10.1", "x86_64", "OL10U1_x86_64-kvm-b291.qcow2", + "8e59326c4bf7cfa58a6cac404db8ed583fe3a5f4c460e2b73c64988785bb4f0f"}, + {"oracle-10.1-arm64", "10.1", "arm64", "OL10U1_aarch64-kvm-cloud-b178.qcow2", + "e203063e0e8b2752a896787ecc44fc506527055ec819b1422a297d60ad968b20"}, + {"oracle-9.8-x86_64", "9.8", "x86_64", "OL9U8_x86_64-kvm-b293.qcow2", + "b12103391327abee8090686759c0d62dac9a7af2bf0f45fdf6b0d085a0fbb52b"}, + {"oracle-9.8-arm64", "9.8", "arm64", "OL9U8_aarch64-kvm-cloud-b182.qcow2", + "4793c3ec49d9f8b27a932d3bd88ec45d08249c5195faa3b0fac876ab0d5f5856"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + img, ok := AvailableImages[tc.name] + require.True(t, ok, "catalog must contain %q", tc.name) + assert.Equal(t, tc.name, img.Name) + assert.Equal(t, "oracle", img.Distro) + assert.Equal(t, tc.version, img.Version) + assert.Equal(t, tc.arch, img.Arch) + assert.Equal(t, "uefi", img.BootMode) + assert.Equal(t, "sha256", img.ChecksumType) + assert.Equal(t, tc.digest, img.ChecksumDigest) + assert.Empty(t, img.Checksum, "Oracle publishes no sums file; the digest is pinned instead") + assert.Contains(t, img.URL, tc.urlSub) + // The build number in the URL is what makes pinning safe: Oracle + // adds a new build beside the old one rather than replacing it. + assert.Equal(t, "rhel", DistroFamily(img.Distro)) + }) + } +} + +// Every entry must name exactly one source of truth for its digest. Neither +// set means resolveDigest refuses the import; both set means the pinned value +// silently wins and the sums file is never read. +func TestAvailableImages_EachEntryHasExactlyOneChecksumSource(t *testing.T) { + for name, img := range AvailableImages { + t.Run(name, func(t *testing.T) { + hasURL, hasPinned := img.Checksum != "", img.ChecksumDigest != "" + assert.NotEqualf(t, hasURL, hasPinned, + "exactly one of Checksum (sums file URL) or ChecksumDigest must be set; got url=%q pinned=%q", + img.Checksum, img.ChecksumDigest) + assert.NotEmpty(t, img.ChecksumType, "ChecksumType is required") + assert.NotEmpty(t, img.URL, "URL is required") + if hasPinned { + assert.NotContains(t, strings.ToLower(img.URL), "latest", + "a pinned digest needs an immutable URL; a 'latest' alias re-points under it") + assert.Len(t, img.ChecksumDigest, 64, "sha256 digests are 64 hex characters") + } + }) + } +} diff --git a/spinifex/vm/crash_recovery_test.go b/spinifex/vm/crash_recovery_test.go index 226d29e5a..db9e1d83d 100644 --- a/spinifex/vm/crash_recovery_test.go +++ b/spinifex/vm/crash_recovery_test.go @@ -25,12 +25,13 @@ type gpuBackedCleaner struct { mgr *gpu.Manager } -func (c *gpuBackedCleaner) DeleteVolumes(*VM) error { return nil } -func (c *gpuBackedCleaner) CleanupMgmtNetwork(*VM) {} -func (c *gpuBackedCleaner) ReleasePublicIP(*VM) error { return nil } -func (c *gpuBackedCleaner) DetachAndDeleteENI(*VM) error { return nil } -func (c *gpuBackedCleaner) RemoveFromPlacementGroup(*VM) error { return nil } -func (c *gpuBackedCleaner) RemoveFromSpotRequest(*VM) error { return nil } +func (c *gpuBackedCleaner) DeleteVolumes(*VM) error { return nil } +func (c *gpuBackedCleaner) CleanupMgmtNetwork(*VM) {} +func (c *gpuBackedCleaner) ReleasePublicIP(*VM) error { return nil } +func (c *gpuBackedCleaner) ReleaseAutoAssignedPublicIP(*VM) (bool, error) { return false, nil } +func (c *gpuBackedCleaner) DetachAndDeleteENI(*VM) error { return nil } +func (c *gpuBackedCleaner) RemoveFromPlacementGroup(*VM) error { return nil } +func (c *gpuBackedCleaner) RemoveFromSpotRequest(*VM) error { return nil } func (c *gpuBackedCleaner) ReleaseGPU(v *VM) error { if c.mgr == nil || len(v.GPUAttachments) == 0 { diff --git a/spinifex/vm/fakes_test.go b/spinifex/vm/fakes_test.go index 57803ee42..203da097b 100644 --- a/spinifex/vm/fakes_test.go +++ b/spinifex/vm/fakes_test.go @@ -215,14 +215,20 @@ type recordingInstanceCleaner struct { removeFromPlacement []string removeFromSpot []string releaseGPU []string + releaseAutoAssigned []string + + // releaseAutoAssignedOK is what ReleaseAutoAssignedPublicIP reports: false is + // an Elastic IP the cleaner refuses to free, true an auto-assigned address. + releaseAutoAssignedOK bool // Injectable per-method errors so tests can drive failed-teardown marks. - deleteVolumesErr error - releasePublicIPErr error - detachENIErr error - removePlacementErr error - removeSpotErr error - releaseGPUErr error + deleteVolumesErr error + releasePublicIPErr error + releaseAutoAssignedErr error + detachENIErr error + removePlacementErr error + removeSpotErr error + releaseGPUErr error } func (c *recordingInstanceCleaner) DeleteVolumes(v *VM) error { @@ -245,6 +251,16 @@ func (c *recordingInstanceCleaner) ReleasePublicIP(v *VM) error { return c.releasePublicIPErr } +// ReleaseAutoAssignedPublicIP records the stop-time release and reports what +// releaseAutoAssigned was set to, so a test can stand in for either an +// auto-assigned address or an Elastic IP the cleaner refuses to free. +func (c *recordingInstanceCleaner) ReleaseAutoAssignedPublicIP(v *VM) (bool, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.releaseAutoAssigned = append(c.releaseAutoAssigned, v.ID) + return c.releaseAutoAssignedOK, c.releaseAutoAssignedErr +} + func (c *recordingInstanceCleaner) DetachAndDeleteENI(v *VM) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/spinifex/vm/instance_cleaner.go b/spinifex/vm/instance_cleaner.go index a26b0bfd1..7866a1fa0 100644 --- a/spinifex/vm/instance_cleaner.go +++ b/spinifex/vm/instance_cleaner.go @@ -18,6 +18,13 @@ type InstanceCleaner interface { // instance has a public IP allocated. Returns the IPAM release error. ReleasePublicIP(v *VM) error + // ReleaseAutoAssignedPublicIP gives an auto-assigned address back to its pool + // the way AWS does when an instance stops. An Elastic IP is the customer's + // and is left untouched, so the implementation is what tells the two apart. + // Reports whether the address was released; false leaves the VM holding it. + // Called only on an operator Stop, never on a host drain or a Terminate. + ReleaseAutoAssignedPublicIP(v *VM) (bool, error) + // DetachAndDeleteENI detaches and force-deletes the primary ENI (bypassing // the in-use guard for the owning instance). NotFound is tolerated. Called // only on Terminate. Returns the delete error. diff --git a/spinifex/vm/record.go b/spinifex/vm/record.go index 989c931b0..8c8b2bf56 100644 --- a/spinifex/vm/record.go +++ b/spinifex/vm/record.go @@ -74,6 +74,9 @@ type InstanceStatus struct { PublicIPPool string `json:"public_ip_pool,omitempty"` PublicIPAllocID string `json:"public_ip_alloc_id,omitempty"` PublicIPAssocID string `json:"public_ip_assoc_id,omitempty"` + // AutoAssignPublicIP marks that the stop released an auto-assigned address, + // so the start that picks this record up must take a new one. + AutoAssignPublicIP bool `json:"auto_assign_public_ip,omitempty"` DevMAC string `json:"dev_mac,omitempty"` MgmtMAC string `json:"mgmt_mac,omitempty"` @@ -138,6 +141,7 @@ func (v *VM) Record() *InstanceRecord { PublicIPPool: v.PublicIPPool, PublicIPAllocID: v.PublicIPAllocID, PublicIPAssocID: v.PublicIPAssocID, + AutoAssignPublicIP: v.AutoAssignPublicIP, DevMAC: v.DevMAC, MgmtMAC: v.MgmtMAC, MgmtIP: v.MgmtIP, @@ -193,6 +197,7 @@ func VMFromRecord(r *InstanceRecord) *VM { PublicIPPool: r.Status.PublicIPPool, PublicIPAllocID: r.Status.PublicIPAllocID, PublicIPAssocID: r.Status.PublicIPAssocID, + AutoAssignPublicIP: r.Status.AutoAssignPublicIP, DevMAC: r.Status.DevMAC, MgmtMAC: r.Status.MgmtMAC, MgmtIP: r.Status.MgmtIP, diff --git a/spinifex/vm/record_test.go b/spinifex/vm/record_test.go index f4c725696..fcb17e4f6 100644 --- a/spinifex/vm/record_test.go +++ b/spinifex/vm/record_test.go @@ -222,6 +222,7 @@ func populatedVM() *vm.VM { PublicIPPool: "pool-1", PublicIPAllocID: "eipalloc-1", PublicIPAssocID: "eipassoc-1", + AutoAssignPublicIP: true, DevMAC: "02:00:00:00:00:01", MgmtMAC: "02:a0:00:00:00:01", MgmtIP: "10.255.0.5", diff --git a/spinifex/vm/shutdown.go b/spinifex/vm/shutdown.go index 518fc1cc6..80a9b4d1d 100644 --- a/spinifex/vm/shutdown.go +++ b/spinifex/vm/shutdown.go @@ -170,9 +170,15 @@ func (m *Manager) stopOne(instance *VM) (bool, error) { // map at StateStopped so Restore relaunches it on the next boot. Do // not migrate to the operator-stopped shared bucket or fire // OnInstanceDown; QEMU is already down and resources released. + // + // The address is kept for the same reason. A drain is not a customer + // stop, and Restore brings the instance back on the same address rather + // than changing it under a guest nobody asked to move. return false, sealErr } + m.releaseAutoAssignedPublicIP(instance) + if !m.MigrateStoppedToSharedKV(instance) { // Either StateStore unavailable / write failed (instance stays in // local map; restoreInstances retries on next boot) OR a concurrent @@ -522,6 +528,32 @@ func (m *Manager) stopCleanup(instance *VM) error { return sealErr } +// releaseAutoAssignedPublicIP returns the instance's auto-assigned address to +// its pool on an operator stop, matching AWS: the free address is borrowed for +// as long as the instance runs, and the start that follows takes a new one. +// An Elastic IP is refused by the cleaner and the VM keeps it, so the only +// state cleared here is state the pool no longer backs. +func (m *Manager) releaseAutoAssignedPublicIP(instance *VM) { + if m.deps.InstanceCleaner == nil || instance.PublicIP == "" { + return + } + released, err := m.deps.InstanceCleaner.ReleaseAutoAssignedPublicIP(instance) + if err != nil { + slog.Warn("Failed to release auto-assigned public IP on stop", + "instanceId", instance.ID, "ip", instance.PublicIP, "err", err) + } + if !released { + return + } + // Written under the manager lock so the record migrated to the stopped + // bucket a moment later cannot still advertise the address. + m.UpdateState(instance.ID, func(v *VM) { + v.PublicIP = "" + v.PublicIPPool = "" + v.AutoAssignPublicIP = true + }) +} + // terminateCleanup is stopCleanup plus the AWS-resource cleanup that // only applies on terminate: volume deletion, public IP release, ENI // deletion, placement-group removal. diff --git a/spinifex/vm/stop_public_ip_test.go b/spinifex/vm/stop_public_ip_test.go new file mode 100644 index 000000000..3495eaf99 --- /dev/null +++ b/spinifex/vm/stop_public_ip_test.go @@ -0,0 +1,122 @@ +// Drives Stop and StopAll against shutdownTestManager, whose fakes are +// unexported, and turns on DesiredState — the field that separates an operator +// stop from a host drain and decides whether the address is released. +// +//test:in-package — no exported seam reaches Manager.stopOne. +package vm + +import ( + "errors" + "testing" + + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stoppableWithAddress returns a running VM holding an auto-assigned address, +// already in m, shaped so stopOne takes the operator-stop path. +func stoppableWithAddress(t *testing.T, m *Manager, id string) *VM { + t.Helper() + v := &VM{ + ID: id, + Status: StateRunning, + InstanceType: "t3.micro", + Instance: &ec2.Instance{}, + DesiredState: DesiredStopped, + ENIId: "eni-1", + PublicIP: "203.0.113.10", + PublicIPPool: "wan", + } + m.Insert(v) + return v +} + +// An auto-assigned address is borrowed for as long as the instance runs, so a +// stop hands it back and the record that migrates to the stopped bucket must +// not still advertise it. +func TestAnOperatorStopReturnsAnAutoAssignedAddressToItsPool(t *testing.T) { + m, store, _, cleaner, _ := shutdownTestManager(t) + cleaner.releaseAutoAssignedOK = true + v := stoppableWithAddress(t, m, "i-auto") + + require.NoError(t, m.Stop(v.ID)) + + assert.Equal(t, []string{"i-auto"}, cleaner.releaseAutoAssigned, + "stop did not offer the address back to its pool") + stored, err := store.LoadStoppedInstance("i-auto") + require.NoError(t, err) + require.NotNil(t, stored, "stopped instance never reached the shared bucket") + assert.Empty(t, stored.PublicIP, + "a stopped instance still advertises an address that went back to the pool") + assert.Empty(t, stored.PublicIPPool) + assert.True(t, stored.AutoAssignPublicIP, + "the start that follows has nothing telling it to take a new address") +} + +// An Elastic IP is the customer's and outlives every stop. The cleaner is what +// tells the two apart, so a refusal here has to leave the VM exactly as it was. +func TestAStopKeepsAnAddressTheCleanerRefusesToRelease(t *testing.T) { + m, store, _, cleaner, _ := shutdownTestManager(t) + cleaner.releaseAutoAssignedOK = false + v := stoppableWithAddress(t, m, "i-eip") + + require.NoError(t, m.Stop(v.ID)) + + stored, err := store.LoadStoppedInstance("i-eip") + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "203.0.113.10", stored.PublicIP, + "a stop took an Elastic IP off the instance it is associated with") + assert.Equal(t, "wan", stored.PublicIPPool) + assert.False(t, stored.AutoAssignPublicIP, + "the start would auto-assign a second address beside the Elastic IP") +} + +// A release that fails leaves the address where it is rather than clearing the +// record: an instance that reports no address while the pool still holds one is +// an address nothing will ever reclaim. +func TestAFailedReleaseLeavesTheAddressOnTheInstance(t *testing.T) { + m, store, _, cleaner, _ := shutdownTestManager(t) + cleaner.releaseAutoAssignedErr = errors.New("pool unreachable") + v := stoppableWithAddress(t, m, "i-failed") + + require.NoError(t, m.Stop(v.ID)) + + stored, err := store.LoadStoppedInstance("i-failed") + require.NoError(t, err) + require.NotNil(t, stored) + assert.Equal(t, "203.0.113.10", stored.PublicIP, + "the instance forgot an address the pool never took back") +} + +// A host drain is not a customer stop. Restore relaunches the guest on the next +// boot, and changing its address under it would be a visible outage nobody +// asked for. +func TestAHostDrainKeepsTheAddress(t *testing.T) { + m, _, _, cleaner, _ := shutdownTestManager(t) + cleaner.releaseAutoAssignedOK = true + v := stoppableWithAddress(t, m, "i-drain") + v.DesiredState = DesiredRunning + + require.NoError(t, m.StopAll()) + + assert.Empty(t, cleaner.releaseAutoAssigned, + "a drain released the address of a guest it is about to relaunch") + live, ok := m.Get("i-drain") + require.True(t, ok) + assert.Equal(t, "203.0.113.10", live.PublicIP) +} + +// An instance with no public address has nothing to hand back, so the pool is +// never asked — the common case for a private-subnet guest. +func TestAStopWithNoAddressAsksThePoolNothing(t *testing.T) { + m, _, _, cleaner, _ := shutdownTestManager(t) + v := stoppableWithAddress(t, m, "i-private") + v.PublicIP = "" + v.PublicIPPool = "" + + require.NoError(t, m.Stop(v.ID)) + + assert.Empty(t, cleaner.releaseAutoAssigned) +} diff --git a/spinifex/vm/vm.go b/spinifex/vm/vm.go index 58507a1c3..24b6d9d53 100644 --- a/spinifex/vm/vm.go +++ b/spinifex/vm/vm.go @@ -157,6 +157,13 @@ type VM struct { PublicIPAllocID string `json:"public_ip_alloc_id,omitempty"` PublicIPAssocID string `json:"public_ip_assoc_id,omitempty"` + // AutoAssignPublicIP records that a stop returned an auto-assigned address to + // its pool, so the start that follows must take a new one. It carries the + // launch-time decision across a stop, which is where the subnet attribute + // that made it is no longer the right thing to ask: changing it must not + // retroactively add or remove an address from an instance already launched. + AutoAssignPublicIP bool `json:"auto_assign_public_ip,omitempty"` + // DevMAC is the MAC for the dev/hostfwd NIC (DEV_NETWORKING mode), used by // appendDevHostfwdNIC to give the user-mode NIC a stable address. DevMAC string `json:"dev_mac,omitempty"` diff --git a/spinifex/vpcd/bridge_detect_test.go b/spinifex/vpcd/bridge_detect_test.go new file mode 100644 index 000000000..ad44fb43f --- /dev/null +++ b/spinifex/vpcd/bridge_detect_test.go @@ -0,0 +1,115 @@ +//test:in-package — detectBridgeMode, resolveBridgeConfig and the ifaceExists +// probe they read are all unexported. + +package vpcd + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/mulgadc/spinifex/spinifex/network/host" +) + +// captureLogs redirects slog for one test and returns the accumulated output. +// The detection's whole job is to say what it saw, so the log line is the +// behaviour under test and not incidental output. +func captureLogs(t *testing.T) func() string { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return buf.String +} + +// A wrong verdict here misroutes every external packet, and the previous log +// line named only the branch it took — so a node detected as veth when it was +// wired for nat looked identical to one that was right. +func TestDetectBridgeModeReportsBothProbes(t *testing.T) { + for _, tc := range []struct { + name string + links []string + want string + }{ + {"routed NAT transit veth", []string{host.NATTransitOVSEnd}, BridgeModeNAT}, + {"Linux bridge linked by veth", []string{wanVethOVSEnd}, BridgeModeVeth}, + {"WAN NIC is an OVS port", nil, BridgeModeDirect}, + } { + t.Run(tc.name, func(t *testing.T) { + stubDetectProbes(t, tc.links) + logs := captureLogs(t) + + if got := detectBridgeMode("enp0s3", ""); got != tc.want { + t.Errorf("mode = %q, want %q", got, tc.want) + } + out := logs() + for _, probe := range []string{host.NATTransitOVSEnd, wanVethOVSEnd} { + if !strings.Contains(out, probe) { + t.Errorf("log does not report the %s probe: %s", probe, out) + } + } + }) + } +} + +// A node set up twice in two modes carries both veths. Picking one silently is +// how it stays broken; --teardown is the thing to reach for, so the log says so. +func TestDetectBridgeModeFlagsANodeWiredTwice(t *testing.T) { + stubDetectProbes(t, []string{host.NATTransitOVSEnd, wanVethOVSEnd}) + logs := captureLogs(t) + + if got := detectBridgeMode("enp0s3", ""); got != BridgeModeNAT { + t.Errorf("mode = %q, want %q — nat must still win so behaviour is unchanged", got, BridgeModeNAT) + } + out := logs() + if !strings.Contains(out, "level=ERROR") || !strings.Contains(out, "--teardown") { + t.Errorf("both veths present must log an error naming --teardown: %s", out) + } +} + +// The wiring is what the datapath uses, so external_mode losing the argument is +// correct — but silently is not. This is the shape that had a cluster running +// nat config on pool wiring with every signal green. +func TestDetectBridgeModeFlagsAConfigThatDisagreesWithTheWiring(t *testing.T) { + for _, tc := range []struct { + name string + links []string + externalMode string + wantErrorLog bool + }{ + {"nat asked for, nat wired", []string{host.NATTransitOVSEnd}, "nat", false}, + {"nat asked for, veth wired", []string{wanVethOVSEnd}, "nat", true}, + {"pool asked for, nat wired", []string{host.NATTransitOVSEnd}, "pool", true}, + {"pool asked for, veth wired", []string{wanVethOVSEnd}, "pool", false}, + {"no external mode is not checked", []string{host.NATTransitOVSEnd}, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + stubDetectProbes(t, tc.links) + logs := captureLogs(t) + + detectBridgeMode("enp0s3", tc.externalMode) + logged := strings.Contains(logs(), "external_mode and the uplink wiring disagree") + if logged != tc.wantErrorLog { + t.Errorf("disagreement logged = %v, want %v: %s", logged, tc.wantErrorLog, logs()) + } + }) + } +} + +// An explicitly configured bridge_mode is obeyed, and is exactly as capable of +// being wrong as a guess — nothing else ever checks it against the wiring. +func TestResolveBridgeConfigFlagsAnExplicitModeThatDoesNotMatch(t *testing.T) { + stubDetectProbes(t, []string{wanVethOVSEnd}) + logs := captureLogs(t) + + mode, br := resolveBridgeConfig(BridgeModeNAT, "enp0s3", "nat") + if mode != BridgeModeNAT || br != host.NATTransitHostEnd { + t.Errorf("got (%q,%q), want (%q,%q) — the configured value is still obeyed", + mode, br, BridgeModeNAT, host.NATTransitHostEnd) + } + if !strings.Contains(logs(), "configured bridge_mode does not match the uplink wiring") { + t.Errorf("a configured mode contradicting the wiring must be reported: %s", logs()) + } +} diff --git a/spinifex/vpcd/host_eip_loop.go b/spinifex/vpcd/host_eip_loop.go new file mode 100644 index 000000000..96ff49569 --- /dev/null +++ b/spinifex/vpcd/host_eip_loop.go @@ -0,0 +1,48 @@ +package vpcd + +import ( + "context" + "log/slog" + "time" + + "github.com/mulgadc/spinifex/spinifex/network/reconcile" + "github.com/mulgadc/spinifex/spinifex/otelsetup" +) + +// Host EIP pass cadence. Shorter than the drift interval because the state it +// repairs is one node's routes: a guest that lands here between passes has no +// inbound path at all until the next one, and the pass is a handful of local +// `ip` calls. +var ( + hostEIPInterval = 30 * time.Second + hostEIPStartDelay = 5 * time.Second +) + +// runHostEIPLoop re-asserts this node's host-side EIP plumbing until ctx ends. +// +// Every node runs it, unlike the drift loop: the NB rows it reads are shared, +// but the routes and proxy-ARP it writes belong to this host, and the binder +// leaves alone any EIP whose ENI is bound elsewhere. Outside routed NAT the +// pass is a no-op, so the loop is unconditional. +func runHostEIPLoop(ctx context.Context, rec reconcile.Reconciler) { + slog.Info("vpcd: host EIP loop started", "interval_ms", otelsetup.Millis(hostEIPInterval)) + + select { + case <-ctx.Done(): + return + case <-time.After(hostEIPStartDelay): + } + + ticker := time.NewTicker(hostEIPInterval) + defer ticker.Stop() + for { + if err := rec.ReconcileHostEIPs(ctx); err != nil && ctx.Err() == nil { + slog.Warn("vpcd: host EIP pass failed", "err", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/spinifex/vpcd/host_eip_loop_test.go b/spinifex/vpcd/host_eip_loop_test.go new file mode 100644 index 000000000..831d8ebf8 --- /dev/null +++ b/spinifex/vpcd/host_eip_loop_test.go @@ -0,0 +1,95 @@ +//test:in-package — runHostEIPLoop and its two timing vars are unexported, +// and the test has to shrink the interval to run at all. + +package vpcd + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/network/reconcile" +) + +type countingReconciler struct { + mu sync.Mutex + n int + err error + seen chan struct{} +} + +var _ reconcile.Reconciler = (*countingReconciler)(nil) + +func (c *countingReconciler) Reconcile(context.Context, reconcile.IntentState) error { return nil } + +func (c *countingReconciler) ReconcileApplyOnly(context.Context, reconcile.IntentState) error { + return nil +} + +func (c *countingReconciler) ReconcileHostEIPs(context.Context) error { + c.mu.Lock() + c.n++ + c.mu.Unlock() + select { + case c.seen <- struct{}{}: + default: + } + return c.err +} + +func (c *countingReconciler) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.n +} + +func withShortHostEIPTiming(t *testing.T) { + t.Helper() + interval, delay := hostEIPInterval, hostEIPStartDelay + hostEIPInterval, hostEIPStartDelay = 10*time.Millisecond, time.Millisecond + t.Cleanup(func() { hostEIPInterval, hostEIPStartDelay = interval, delay }) +} + +// The loop is what makes the host plumbing node-local: it runs on every vpcd, +// leader or not, so a node that never wins the reconcile lease still has a +// route to the guests it is running. +func TestRunHostEIPLoopKeepsPassing(t *testing.T) { + withShortHostEIPTiming(t) + rec := &countingReconciler{seen: make(chan struct{}, 1)} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { runHostEIPLoop(ctx, rec); close(done) }() + + for range 2 { + select { + case <-rec.seen: + case <-time.After(2 * time.Second): + t.Fatal("host EIP pass did not run") + } + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("loop did not stop on context cancel") + } +} + +// A failed pass is a node with no inbound path, and the next tick is the only +// thing that repairs it — so the loop must log and carry on, never exit. +func TestRunHostEIPLoopSurvivesAFailedPass(t *testing.T) { + withShortHostEIPTiming(t) + rec := &countingReconciler{err: errors.New("NB unreachable"), seen: make(chan struct{}, 1)} + go runHostEIPLoop(t.Context(), rec) + + deadline := time.After(2 * time.Second) + for rec.count() < 3 { + select { + case <-deadline: + t.Fatalf("loop stopped after %d failed passes", rec.count()) + case <-time.After(5 * time.Millisecond): + } + } +} diff --git a/spinifex/vpcd/vpcd.go b/spinifex/vpcd/vpcd.go index edf0a4bee..7417334f0 100644 --- a/spinifex/vpcd/vpcd.go +++ b/spinifex/vpcd/vpcd.go @@ -21,6 +21,7 @@ import ( handlers_imds "github.com/mulgadc/spinifex/spinifex/handlers/imds" "github.com/mulgadc/spinifex/spinifex/network/external" "github.com/mulgadc/spinifex/spinifex/network/external/dhcp" + "github.com/mulgadc/spinifex/spinifex/network/external/ocinet" "github.com/mulgadc/spinifex/spinifex/network/host" "github.com/mulgadc/spinifex/spinifex/network/ovn" "github.com/mulgadc/spinifex/spinifex/network/policy" @@ -166,6 +167,12 @@ type Config struct { // UnderlayMTU is the fabric MTU between nodes; the advertised guest MTU is // derived from it. UnderlayMTU int + // IMDSHostMetaIP and IMDSHostDNSIP move the per-tap responder's binds off + // 169.254.169.254 / .253 on a host that needs those addresses for itself. + // Empty means bind the guest-facing pair, which is every bare-metal node. + // The daemon reads the same keys to configure the endpoint and its DNAT. + IMDSHostMetaIP string + IMDSHostDNSIP string // BlockedWANPorts are TCP destination ports dropped for guest egress to // public destinations (AWS-parity outbound-SMTP block). Empty disables it. BlockedWANPorts []int @@ -449,14 +456,35 @@ func launchService(cfg *Config) error { flowsBarrier := newFlowsBarrier(cfg.OVNNBAddr) - bridgeMode, wanBridge := resolveBridgeConfig(cfg.BridgeMode, cfg.ExternalInterface) + bridgeMode, wanBridge := resolveBridgeConfig(cfg.BridgeMode, cfg.ExternalInterface, cfg.ExternalMode) slog.Info("External bridge mode", "mode", bridgeMode, "wan_bridge", wanBridge) if err := verifyBridgeMode(bridgeMode, cfg.ExternalInterface, wanBridge); err != nil { slog.Error("vpcd: bridge mode sanity check failed", "err", err) return err } + // The IMDS firewall rules are wildcarded over every ime- endpoint and so + // belong here, once per start, rather than per launch in the daemon — which + // holds neither CAP_NET_ADMIN nor an iptables sudo grant, by design. + // Re-ensured every start so they survive a reboot or a firewall flush. + host.SetIMDSHostAddrs(cfg.IMDSHostMetaIP, cfg.IMDSHostDNSIP) + if err := host.EnsureIMDSInputRule(ctx, host.NewExecRunner()); err != nil { + slog.Error("vpcd: IMDS input rule install failed", "err", err) + return err + } + if err := host.EnsureIMDSRemapRules(ctx, host.NewExecRunner()); err != nil { + slog.Error("vpcd: IMDS remap rule install failed", "err", err) + return err + } + if bridgeMode == BridgeModeNAT { + // OVN carries one MAC binding for the transit nexthop cluster-wide, so a + // node whose veth kept a random address receives none of the egress that + // binding points at. Re-asserted here because the veth outlives a deploy. + if err := host.EnsureTransitHostMAC(ctx, host.NewExecRunner()); err != nil { + slog.Error("vpcd: transit host MAC could not be set", "err", err) + return err + } // Re-ensure kernel egress rules on every start so they survive reboots // and firewall flushes without iptables-persistent. if err := host.EnsureNATEgressRules(ctx, host.NewExecRunner()); err != nil { @@ -515,6 +543,12 @@ func launchService(cfg *Config) error { } } sgMgr := policy.NewSecurityGroupManager(liveClient, egressPolicy) + + js, err := jetstream.New(nc) + if err != nil { + return fmt.Errorf("get JetStream context: %w", err) + } + natOpts := []policy.Option{ policy.WithFlowsBarrier(flowsBarrier), policy.WithNeighFlusher(neighFlusher(wanBridge)), @@ -525,7 +559,19 @@ func launchService(cfg *Config) error { append([]string{host.NATTransitCIDR}, cfg.NATExemptCIDRs...))) } if natMode == policy.NATModeRouted && publicPool != nil { - natOpts = append(natOpts, policy.WithHostEIPBinder(hostEIPBinder(publicPool))) + natOpts = append(natOpts, policy.WithHostEIPBinder(hostEIPBinder(publicPool, cfg.OVNSBAddr))) + } + // Also handed to the reconciler, whose EIP datapath probe has to ARP the same + // on-wire address the NAT rule is built from. + var datapathIP policy.DatapathResolver + var bindGatewayVPC func(ctx context.Context, externalIP, vpcID string) error + if ociPools := ociPoolNames(cfg.ExternalPools); len(ociPools) > 0 { + lookup := ocinet.NewLookup(ocinet.NewKVStore(js), ociPools) + datapathIP = lookup.DatapathIP + bindGatewayVPC = lookup.BindGateway + natOpts = append(natOpts, policy.WithDatapathResolver(datapathIP)) + slog.Info("vpcd: OCI address pairs in use; NAT rules and host routes follow the private half", + "pools", ociPools) } natMgr, err := policy.NewNATManager(liveClient, natMode, natOpts...) if err != nil { @@ -533,11 +579,6 @@ func launchService(cfg *Config) error { } routeMgr := policy.NewRouteManager(liveClient) - js, err := jetstream.New(nc) - if err != nil { - return fmt.Errorf("get JetStream context: %w", err) - } - // vpcd holds the network capabilities needed for IMDS; STS/IAM stay in awsgw over NATS. imdsCtx, cancelIMDS := context.WithCancel(ctx) defer cancelIMDS() @@ -575,6 +616,7 @@ func launchService(cfg *Config) error { cfg.ServicesDomain, cfg.CACert, cfg.ResolverNameservers, + handlers_imds.NewHostBindAddrs(cfg.IMDSHostMetaIP, cfg.IMDSHostDNSIP), ) if err != nil { return fmt.Errorf("construct IMDS service: %w", err) @@ -705,24 +747,32 @@ func launchService(cfg *Config) error { }() rec, err := reconcile.New(reconcile.Config{ - OVN: liveClient, - SG: sgMgr, - NAT: natMgr, - Routes: routeMgr, - IGW: igwMgr, - Topology: topoMgr, - LocalAZ: cfg.AZ, - NodeHostname: holder, - Chassis: chassisNames, - GatewayClaim: host.NewGatewayClaimProber(cfg.OVNSBAddr), - DNSServer: resolverDNSServer(cfg), - IPSecDisabled: !cfg.IPSecEnabled, - UnderlayMTU: cfg.UnderlayMTU, + OVN: liveClient, + SG: sgMgr, + NAT: natMgr, + Routes: routeMgr, + IGW: igwMgr, + Topology: topoMgr, + LocalAZ: cfg.AZ, + NodeHostname: holder, + Chassis: chassisNames, + GatewayClaim: host.NewGatewayClaimProber(cfg.OVNSBAddr), + DNSServer: resolverDNSServer(cfg), + IPSecDisabled: !cfg.IPSecEnabled, + UnderlayMTU: cfg.UnderlayMTU, + DatapathIP: datapathIP, + BindGatewayVPC: bindGatewayVPC, // Re-read intent at prune time so a guest launched during a long apply // phase is not mistaken for an orphan and its dnat_and_snat swept. FreshIntent: func(ctx context.Context) (reconcile.IntentState, error) { return reconcile.LoadIntentFromKV(ctx, js, cfg.AZ) }, + // Local OVS is the only liveness signal the orphan sweep has that does not + // come through KV, so it is what stops an unreadable ENI bucket reading as + // an empty one and taking every running guest's port with it. + LocalPorts: func(ctx context.Context) (map[string]struct{}, error) { + return host.ListLocalPorts(ctx, host.NewExecRunner()) + }, MarkIGWAttached: func(ctx context.Context, recordKey, vpcID string) error { kv, err := js.KeyValue(ctx, handlers_ec2_igw.KVBucketIGW) if err != nil { @@ -766,6 +816,12 @@ func launchService(cfg *Config) error { // SB re-bootstraps (snapshot install / compaction) with no deploy in flight. go runOVNControllerWatchdog(loopCtx, newOVNWatchdog(host.NewGatewayClaimProber(cfg.OVNSBAddr))) + // Host-side EIP plumbing, on every node. The drift loop above is leader-gated + // because it writes the shared NB DB; routes and proxy-ARP are this host's + // own, and a node that never wins the lease would otherwise have no path to + // the guests it is running. + go runHostEIPLoop(loopCtx, rec) + slog.Info("vpcd service started, waiting for VPC lifecycle events", "subscriptions", len(subs)) @@ -925,10 +981,17 @@ func pickGatewayAllocator(pool *external.ExternalPoolConfig, ovnClient ovn.Clien // resolveBridgeConfig picks bridge mode (auto-detecting when unset) and the WAN // bridge: "br-wan" for bridged modes, the transit veth host end for nat mode. -func resolveBridgeConfig(cfgBridgeMode, externalIface string) (string, string) { +func resolveBridgeConfig(cfgBridgeMode, externalIface, externalMode string) (string, string) { bridgeMode := cfgBridgeMode if bridgeMode == "" && externalIface != "" { - bridgeMode = detectBridgeMode(externalIface) + bridgeMode = detectBridgeMode(externalIface, externalMode) + } else if bridgeMode != "" { + // An explicit value is obeyed, but it is as capable of being wrong as a + // guess and nothing else ever checks it against the wiring. + if ev := readBridgeEvidence(); mustBeNAT(bridgeMode) != ev.natTransit { + slog.Error("vpcd: configured bridge_mode does not match the uplink wiring", + append(ev.attrs(), "bridge_mode", bridgeMode, "external_iface", externalIface)...) + } } if bridgeMode == BridgeModeNAT { return bridgeMode, host.NATTransitHostEnd @@ -956,17 +1019,38 @@ func selectExternalPools(externalMode string, pools []external.ExternalPoolConfi return igwPool, publicPool } +// ociPoolNames lists the pools whose addresses are OCI pairs, and so need the +// public half mapped to a private one before it reaches OVN or a host route. +func ociPoolNames(pools []external.ExternalPoolConfig) []string { + var names []string + for _, p := range pools { + if p.IsOCI() { + names = append(names, p.Name) + } + } + return names +} + // hostEIPBinder builds the routed-mode host plumbing hooks for EIPs on the // public pool: /32 route into OVN plus proxy-ARP on the uplink. Static pools // locate the uplink via the pool gateway; dhcp pools via their bind bridge. -func hostEIPBinder(pool *external.ExternalPoolConfig) policy.HostEIPBinder { +func hostEIPBinder(pool *external.ExternalPoolConfig, sbAddr string) policy.HostEIPBinder { runner := host.NewExecRunner() + prober := host.NewGatewayClaimProber(sbAddr) gateway, uplinkHint := pool.Gateway, pool.BindBridge return policy.HostEIPBinder{ Bind: func(eip policy.EIPSpec, gwLrpIP string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - return host.EnsureEIPIngress(ctx, runner, eip.ExternalIP, gwLrpIP, gateway, uplinkHint) + if skip, err := skipForeignEIP(ctx, runner, eip); err != nil || skip { + return err + } + return host.EnsureEIPIngress(ctx, runner, host.EIPIngress{ + EIP: eip.ExternalIP, + GwLrpIP: gwLrpIP, + PoolGateway: gateway, + UplinkHint: uplinkHint, + }) }, Unbind: func(externalIP string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -978,9 +1062,49 @@ func hostEIPBinder(pool *external.ExternalPoolConfig) policy.HostEIPBinder { defer cancel() return host.ListEIPIngress(ctx, runner) }, + // The same authority skipForeignEIP uses, asked again at prune time: a + // guest can move after the bind, and nothing else would take the route + // and proxy-ARP off the node it left. + Owns: func(portName string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return host.HasLocalPort(ctx, runner, portName) + }, + // A NAT gateway has no port, so locality is the VPC's gateway chassis: + // its SNAT runs there and its egress leaves that node's uplink. + GatewayElsewhere: func(vpcID string) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return prober.GatewayPortElsewhere(ctx, topology.GatewayChassisRedirectPort(vpcID)) + }, } } +// skipForeignEIP reports whether this EIP belongs to an instance on another +// chassis, and so is not this node's to plumb. +// +// Only a distributed EIP is skippable: it is reachable solely through the +// chassis its ENI is bound to, so a route installed anywhere else points at a +// MAC that will never answer. A centralised EIP hairpins through the gateway +// chassis and every node plumbs it, which is the behaviour before this gate. +// Local OVS is asked rather than the Southbound DB — the question is about +// this host, and nothing else can answer it wrongly. +func skipForeignEIP(ctx context.Context, runner host.Runner, eip policy.EIPSpec) (bool, error) { + if eip.PortName == "" || eip.MAC == "" { + return false, nil + } + local, err := host.HasLocalPort(ctx, runner, eip.PortName) + if err != nil { + return false, fmt.Errorf("check local binding for %s: %w", eip.PortName, err) + } + if local { + return false, nil + } + slog.Debug("vpcd: EIP belongs to an instance on another chassis, leaving it alone", + "eip", eip.ExternalIP, "port", eip.PortName) + return true, nil +} + // neighFlusher builds the ARP-flush hook for AddEIP/DeleteEIP so recycled IPs re-resolve L2 immediately. // No-op when wanBridge is unset. func neighFlusher(wanBridge string) policy.NeighFlusher { @@ -1012,22 +1136,77 @@ var ifaceExists = func(name string) bool { return exec.Command("ip", "link", "show", name).Run() == nil } -// detectBridgeMode infers bridge mode: nat when spx-nat-ovs exists, veth when -// veth-wan-ovs exists, direct otherwise. -// Each branch logs at Info/Warn so `journalctl | grep bridge` shows the full detection trail. -func detectBridgeMode(externalIface string) string { - if ifaceExists(host.NATTransitOVSEnd) { - slog.Info("vpcd: detected routed-NAT transit veth", "mode", BridgeModeNAT) - return BridgeModeNAT +// mustBeNAT reports whether a bridge mode requires the routed-NAT transit veth. +func mustBeNAT(bridgeMode string) bool { return bridgeMode == BridgeModeNAT } + +// wanVethOVSEnd is the OVS end of the pair that links a Linux WAN bridge to +// br-ext. Its presence is what distinguishes veth mode from direct. +const wanVethOVSEnd = "veth-wan-ovs" + +// bridgeEvidence is what the kernel says about the two veths bridge mode is +// inferred from. Kept as a value so the detection can report what it saw +// alongside what it concluded. +type bridgeEvidence struct { + natTransit bool + wanVeth bool +} + +func (e bridgeEvidence) attrs() []any { + return []any{host.NATTransitOVSEnd, e.natTransit, wanVethOVSEnd, e.wanVeth} +} + +// mode is the bridge mode the evidence alone implies, and whether the two +// signals contradict each other. Both veths present is a node that has been set +// up twice in different modes, which is the state --teardown exists for. +func (e bridgeEvidence) mode() (string, bool) { + switch { + case e.natTransit && e.wanVeth: + return BridgeModeNAT, true + case e.natTransit: + return BridgeModeNAT, false + case e.wanVeth: + return BridgeModeVeth, false + default: + return BridgeModeDirect, false } - if ifaceExists("veth-wan-ovs") { - slog.Info("vpcd: detected veth pair linking Linux bridge to OVS", "mode", BridgeModeVeth) - return BridgeModeVeth +} + +// readBridgeEvidence asks the kernel which uplink veths exist. +func readBridgeEvidence() bridgeEvidence { + return bridgeEvidence{ + natTransit: ifaceExists(host.NATTransitOVSEnd), + wanVeth: ifaceExists(wanVethOVSEnd), } - slog.Warn("vpcd: no veth interface found, assuming direct bridge mode", - "external_iface", externalIface, "checked_veth", "veth-wan-ovs", - "mode", BridgeModeDirect) - return BridgeModeDirect +} + +// detectBridgeMode infers bridge mode from the uplink veths, and reports the +// evidence rather than only the verdict — a wrong answer here misroutes every +// external packet, and the old log line named only the branch it took. +// +// externalMode is what the operator asked for, and is checked against the +// evidence rather than overriding it: the veths are what the datapath actually +// uses, so a disagreement is a broken node and saying so beats silently +// picking one. Passing "" skips that check. +func detectBridgeMode(externalIface, externalMode string) string { + ev := readBridgeEvidence() + mode, conflict := ev.mode() + + if conflict { + slog.Error("vpcd: both uplink veths exist — this node has been set up in two modes; run setup-ovn.sh --teardown and set it up once", + append(ev.attrs(), "external_iface", externalIface, "using_mode", mode)...) + } + if mismatch := externalMode != "" && (externalMode == "nat") != (mode == BridgeModeNAT); mismatch { + slog.Error("vpcd: external_mode and the uplink wiring disagree — the datapath follows the wiring, so external traffic will not behave as configured", + append(ev.attrs(), "external_mode", externalMode, "detected_mode", mode, + "fix", "re-run setup-ovn.sh for external_mode, or correct external_mode")...) + } + if mode == BridgeModeDirect { + slog.Warn("vpcd: no uplink veth found, assuming the WAN NIC is an OVS port", + append(ev.attrs(), "external_iface", externalIface, "mode", mode)...) + return mode + } + slog.Info("vpcd: bridge mode detected", append(ev.attrs(), "external_iface", externalIface, "mode", mode)...) + return mode } // portToBr returns the OVS bridge owning port, or "" if not in OVSDB. diff --git a/spinifex/vpcd/vpcd_eip_chassis_test.go b/spinifex/vpcd/vpcd_eip_chassis_test.go new file mode 100644 index 000000000..06ba79a33 --- /dev/null +++ b/spinifex/vpcd/vpcd_eip_chassis_test.go @@ -0,0 +1,109 @@ +//test:in-package — skipForeignEIP is unexported and is the gate that keeps +// one node from claiming another node's EIP. + +package vpcd + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/mulgadc/spinifex/spinifex/network/policy" +) + +// ovsIfaceRunner answers the one `ovs-vsctl list Interface` query the chassis +// check makes, and records that it was asked. +type ovsIfaceRunner struct { + ifaceIDs []string + err error + calls int +} + +func (r *ovsIfaceRunner) Run(_ context.Context, _ string, _ ...string) ([]byte, error) { + r.calls++ + if r.err != nil { + return []byte("ovs-vsctl: database connection failed"), r.err + } + data := make([]any, 0, len(r.ifaceIDs)) + for i, id := range r.ifaceIDs { + data = append(data, []any{ + fmt.Sprintf("tap%d", i), + []any{"map", [][]string{{"iface-id", id}}}, + }) + } + return json.Marshal(map[string]any{ + "headings": []string{"name", "external_ids"}, + "data": data, + }) +} + +// A distributed EIP is reachable only through the chassis its ENI is bound to. +// Every node runs vpcd and every node sees every EIP, so without this gate all +// three nodes claimed all three addresses — measured on the OCI cluster, and +// the reason two of the three guests were unreachable. +func TestSkipForeignEIP(t *testing.T) { + distributed := policy.EIPSpec{ + VPCID: "vpc-1", ExternalIP: "192.0.2.10", LogicalIP: "10.0.1.5", + PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc", + } + for _, tc := range []struct { + name string + eip policy.EIPSpec + local []string + wantSkip bool + wantAsked bool + }{ + { + name: "the instance is on this node", + eip: distributed, + local: []string{"port-eni-9", "port-eni-1"}, + wantSkip: false, + wantAsked: true, + }, + { + name: "the instance is on another node", + eip: distributed, + local: []string{"port-eni-9"}, + wantSkip: true, + wantAsked: true, + }, + { + // A centralised EIP hairpins through the gateway chassis, so every + // node plumbs it exactly as before. Gating it would break the + // pre-distribution shape during an upgrade. + name: "a centralised EIP is never skipped", + eip: policy.EIPSpec{VPCID: "vpc-1", ExternalIP: "192.0.2.10", PortName: "port-eni-1"}, + local: []string{"port-eni-9"}, + wantSkip: false, + wantAsked: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + r := &ovsIfaceRunner{ifaceIDs: tc.local} + skip, err := skipForeignEIP(context.Background(), r, tc.eip) + if err != nil { + t.Fatalf("skipForeignEIP: %v", err) + } + if skip != tc.wantSkip { + t.Errorf("skip = %v, want %v", skip, tc.wantSkip) + } + if asked := r.calls > 0; asked != tc.wantAsked { + t.Errorf("queried OVS = %v, want %v", asked, tc.wantAsked) + } + }) + } +} + +// A failed OVS query must not read as "not mine". That verdict drops the host +// plumbing for an EIP this node is in fact serving, and the next pass would +// reach the same wrong answer — so the error has to reach reconcile's retry. +func TestSkipForeignEIPSurfacesQueryFailure(t *testing.T) { + r := &ovsIfaceRunner{err: fmt.Errorf("exit 1")} + _, err := skipForeignEIP(context.Background(), r, policy.EIPSpec{ + ExternalIP: "192.0.2.10", PortName: "port-eni-1", MAC: "52:54:00:aa:bb:cc", + }) + if err == nil { + t.Error("a failed OVS query must surface, not silently skip the bind") + } +} diff --git a/spinifex/vpcd/vpcd_nat_test.go b/spinifex/vpcd/vpcd_nat_test.go index 3b885533b..af43f293b 100644 --- a/spinifex/vpcd/vpcd_nat_test.go +++ b/spinifex/vpcd/vpcd_nat_test.go @@ -51,13 +51,13 @@ func TestVerifyBridgeMode_UnknownModeListsNAT(t *testing.T) { func TestDetectBridgeMode_NATWinsOverVeth(t *testing.T) { stubDetectProbes(t, []string{host.NATTransitOVSEnd, "veth-wan-ovs"}) - if got := detectBridgeMode("enp0s3"); got != BridgeModeNAT { + if got := detectBridgeMode("enp0s3", ""); got != BridgeModeNAT { t.Errorf("want %q, got %q", BridgeModeNAT, got) } } func TestResolveBridgeConfig_NATUsesTransitHostEnd(t *testing.T) { - mode, br := resolveBridgeConfig(BridgeModeNAT, "") + mode, br := resolveBridgeConfig(BridgeModeNAT, "", "") if mode != BridgeModeNAT || br != host.NATTransitHostEnd { t.Errorf("got (%q,%q), want (%q,%q)", mode, br, BridgeModeNAT, host.NATTransitHostEnd) } diff --git a/spinifex/vpcd/vpcd_test.go b/spinifex/vpcd/vpcd_test.go index 8c2c82468..672d343ca 100644 --- a/spinifex/vpcd/vpcd_test.go +++ b/spinifex/vpcd/vpcd_test.go @@ -300,21 +300,21 @@ func stubDetectProbes(t *testing.T, links []string) { func TestDetectBridgeMode_VethWins(t *testing.T) { stubDetectProbes(t, []string{"veth-wan-ovs"}) - if got := detectBridgeMode("enp0s3"); got != BridgeModeVeth { + if got := detectBridgeMode("enp0s3", ""); got != BridgeModeVeth { t.Errorf("want %q, got %q", BridgeModeVeth, got) } } func TestDetectBridgeMode_FallthroughDirect(t *testing.T) { stubDetectProbes(t, nil) - if got := detectBridgeMode("enp0s3"); got != BridgeModeDirect { + if got := detectBridgeMode("enp0s3", ""); got != BridgeModeDirect { t.Errorf("want %q, got %q", BridgeModeDirect, got) } } func TestResolveBridgeConfig_UsesExplicitMode(t *testing.T) { stubDetectProbes(t, nil) - mode, br := resolveBridgeConfig(BridgeModeVeth, "enp0s3") + mode, br := resolveBridgeConfig(BridgeModeVeth, "enp0s3", "") if mode != BridgeModeVeth || br != "br-wan" { t.Errorf("got (%q,%q), want (%q,br-wan)", mode, br, BridgeModeVeth) } @@ -322,21 +322,21 @@ func TestResolveBridgeConfig_UsesExplicitMode(t *testing.T) { func TestResolveBridgeConfig_AutoDetects(t *testing.T) { stubDetectProbes(t, []string{"veth-wan-ovs"}) - mode, _ := resolveBridgeConfig("", "enp0s3") + mode, _ := resolveBridgeConfig("", "enp0s3", "") if mode != BridgeModeVeth { t.Errorf("want auto-detect veth, got %q", mode) } } func TestResolveBridgeConfig_EmptyStaysEmptyWithNoIface(t *testing.T) { - mode, _ := resolveBridgeConfig("", "") + mode, _ := resolveBridgeConfig("", "", "") if mode != "" { t.Errorf("empty mode + no iface should stay empty; got %q", mode) } } func TestResolveBridgeConfig_DefaultsBindBridge(t *testing.T) { - _, br := resolveBridgeConfig(BridgeModeDirect, "enp0s3") + _, br := resolveBridgeConfig(BridgeModeDirect, "enp0s3", "") if br != "br-wan" { t.Errorf("WAN bridge should default to br-wan, got %q", br) } diff --git a/tests/e2e/natuplink/natuplink_test.go b/tests/e2e/natuplink/natuplink_test.go index 08cffac5f..233c5566b 100644 --- a/tests/e2e/natuplink/natuplink_test.go +++ b/tests/e2e/natuplink/natuplink_test.go @@ -1,18 +1,26 @@ //go:build e2e -// Package natuplink is the routed-NAT (external_mode = "nat") single-node -// suite. It runs ON the spinifex node itself (like the single suite): the -// host-wiring and OVN phases shell out to ip/iptables/ovn-nbctl locally, -// and instance egress is proven via the serial console because nat mode -// has no inbound path to VMs (outbound-only by design). +// Package natuplink is the routed-NAT (external_mode = "nat") suite. It runs +// ON the spinifex node itself (like the single suite): the host-wiring and OVN +// phases shell out to ip/iptables/ovn-nbctl locally, and default egress is +// proven via the serial console since an instance with no public IP has no +// inbound path. Routed mode does carry public IPs when a public pool is +// configured, and phase 9 walks that ingress path. // -// The node must be provisioned with `setup-ovn.sh --management --nat-uplink` -// followed by `spx admin init --external-mode=nat`; the suite skips unless -// spinifex.toml carries external_mode = "nat". +// On a multi-node cluster phase 9 becomes the distributed lane instead: one +// guest per node, each public IP delivered by the node running its guest and by +// no other. The two phases that need host-to-guest delivery over the transit +// veth run only where that is possible, since ext-shared is a localnet and the +// segment does not leave the chassis holding the VPC's gateway router port. +// +// Every node must be provisioned with `setup-ovn.sh --nat-uplink` followed by +// `spx admin init --external-mode=nat`; the suite skips unless spinifex.toml +// carries external_mode = "nat". package natuplink import ( "bufio" + "context" "encoding/base64" "fmt" "io" @@ -20,12 +28,14 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/mulgadc/spinifex/spinifex/network/host" "github.com/mulgadc/spinifex/tests/e2e/harness" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,10 +44,15 @@ import ( const ( transitCIDR = "100.127.0.0/24" transitGatewayIP = "100.127.0.1" - transitHostEnd = "spx-nat-host" - transitOVSEnd = "spx-nat-ovs" uplinkBridge = "br-ext" + // Taken from the package the daemon uses, not copied: the transit MAC in + // particular is one address cluster-wide, and a stale duplicate here would + // assert the wrong one everywhere at once. + transitHostEnd = host.NATTransitHostEnd + transitOVSEnd = host.NATTransitOVSEnd + transitHostMAC = host.NATTransitHostMAC + egressOKMarker = "NAT-E2E-EGRESS-OK" egressFailMarker = "NAT-E2E-EGRESS-FAIL" @@ -82,6 +97,9 @@ type fixture struct { // publicPool is true when spinifex.toml carries a non-transit pool — // the Tier 2 (EIP / public IP) lane is exercised only then. publicPool bool + // cluster is nil on a single node and is what distinguishes the two + // shapes of phase 9: the lifecycle on one host, or delivery across all. + cluster *harness.Cluster } // TestNATUplink runs the routed-NAT lane end to end. Phases are sequential: @@ -89,8 +107,8 @@ type fixture struct { // phases read state the earlier phases prove exists. func TestNATUplink(t *testing.T) { env := harness.LoadEnv(t) - if env.Mode != harness.ModeSingle { - t.Skipf("natuplink suite is single-node only (mode=%s)", env.Mode) + if env.Mode != harness.ModeSingle && env.Mode != harness.ModeMultinode { + t.Skipf("natuplink suite runs on single and multinode clusters (mode=%s)", env.Mode) } cfgPath := configPath(env) if mode := readExternalMode(t, cfgPath); mode != "nat" { @@ -115,6 +133,12 @@ func TestNATUplink(t *testing.T) { configTOML: cfgPath, publicPool: hasPublicPool(t, cfgPath), } + if env.Mode == harness.ModeMultinode { + cluster, cerr := harness.ClusterFromEnv() + require.NoError(t, cerr, "multinode run needs SPINIFEX_NODES/SSH_USER/SSH_KEY") + fix.cluster = cluster + harness.Detail(t, "nodes", len(cluster.Nodes)) + } harness.Phase(t, "NAT Uplink — Phase 1: host wiring") phaseHostWiring(t) @@ -136,20 +160,38 @@ func TestNATUplink(t *testing.T) { harness.Phase(t, "NAT Uplink — Phase 5: OVN gateway + SNAT on transit net") defaultGwIP := phaseOVNGateway(t, fix, def.VPCID) + // ext-shared is a localnet, so the transit segment never leaves the node: + // only the chassis holding the VPC's gateway router port can reach the + // gateway LRP. On one node that is always this host. + gwLocal := fix.cluster == nil || localIsGatewayChassis(t, def.VPCID) + harness.Phase(t, "NAT Uplink — Phase 6: instance boots and reaches WAN outbound") probe := phaseInstanceEgress(t, fix, def) - harness.Phase(t, "NAT Uplink — Phase 7: host reaches instance private IP (Tier 1 ingress)") - phaseHostIngress(t, fix, def, probe) + // Tier 1 ingress needs both ends here: the gateway LRP is reachable from + // its own chassis alone, and the guest has to be one this host is running. + switch { + case gwLocal && (fix.cluster == nil || hostsInstanceLocally(t, fix, probe.instanceID)): + harness.Phase(t, "NAT Uplink — Phase 7: host reaches instance private IP (Tier 1 ingress)") + phaseHostIngress(t, fix, def, probe) + case gwLocal: + t.Logf("Phase 7 (Tier 1 host ingress) skipped: %s is running on another node", probe.instanceID) + default: + t.Log("Phase 7 (Tier 1 host ingress) skipped: the gateway router port for this VPC is on another chassis") + } harness.Phase(t, "NAT Uplink — Phase 8: second VPC gets a unique transit gateway IP") phaseUniqueTransitIP(t, fix, defaultGwIP) - if fix.publicPool { + switch { + case !fix.publicPool: + t.Log("Phase 9 (Tier 2 EIP ingress) skipped: no public pool in config") + case fix.cluster != nil: + harness.Phase(t, "NAT Uplink — Phase 9: every node delivers its own guests' public IPs") + phaseDistributedEIPs(t, fix, def) + default: harness.Phase(t, "NAT Uplink — Phase 9: EIP ingress lifecycle (Tier 2)") phaseEIPIngress(t, fix, def, probe) - } else { - t.Log("Phase 9 (Tier 2 EIP ingress) skipped: no public pool in config") } } @@ -186,6 +228,29 @@ func phaseHostWiring(t *testing.T) { out, err := exec.Command("sudo", append([]string{"-n", "iptables"}, args...)...).CombinedOutput() assert.NoErrorf(t, err, "iptables %s missing: %s", strings.Join(args, " "), string(out)) } + + // -C says the rule exists, never where. Ubuntu ships a catch-all REJECT in + // FORWARD, so a present-but-appended ACCEPT is dead and guests lose egress + // with every rule reporting healthy. + harness.Step(t, "NAT egress FORWARD rules precede any catch-all reject") + fwdOut, err := exec.Command("sudo", "-n", "iptables", "-t", "filter", "-S", "FORWARD").CombinedOutput() + require.NoErrorf(t, err, "iptables -S FORWARD: %s", string(fwdOut)) + firstDeny, lastAccept := -1, -1 + for i, line := range strings.Split(strings.TrimSpace(string(fwdOut)), "\n") { + switch { + case strings.HasPrefix(line, "-A FORWARD") && strings.Contains(line, "spinifex-nat-egress"): + lastAccept = i + case strings.HasPrefix(line, "-A FORWARD") && !strings.Contains(line, "-j LIBVIRT") && + (strings.Contains(line, "-j REJECT") || strings.Contains(line, "-j DROP")): + if firstDeny < 0 { + firstDeny = i + } + } + } + if firstDeny >= 0 { + assert.Lessf(t, lastAccept, firstDeny, + "spinifex-nat-egress ACCEPT rules must sit above the first REJECT/DROP in FORWARD\n%s", string(fwdOut)) + } } // --- Phase 2: config --------------------------------------------------------- @@ -361,25 +426,7 @@ func phaseInstanceEgress(t *testing.T, fix *fixture, def harness.VPCInfo) egress assert.NotEmptyf(t, aws.StringValue(inst.PrivateIpAddress), "instance %s missing private IP", instanceID) - harness.Step(t, "wait for egress verdict on serial console") - var console string - // Registered before the wait so a budget overrun — which Fatals inside - // EventuallyErr — still leaves the last console read behind. - harness.OnFailure(t, func() { - harness.DumpFile(t, fix.artifacts, "egress-console.log", []byte(console)) - }) - harness.EventuallyErr(t, func() error { - console = consoleOutput(t, fix, instanceID) - if strings.Contains(console, egressOKMarker) || strings.Contains(console, egressFailMarker) { - return nil - } - return fmt.Errorf("no egress marker on console yet (%d bytes)", len(console)) - }, egressVerdictBudget, 10*time.Second) - - if strings.Contains(console, egressFailMarker) { - t.Fatalf("guest reported %s — outbound WAN unreachable through routed NAT (console saved to artifacts)", egressFailMarker) - } - harness.Step(t, "guest reported %s", egressOKMarker) + awaitEgressVerdict(t, fix, instanceID) var sgID string if len(inst.SecurityGroups) > 0 { @@ -522,6 +569,9 @@ func phaseUniqueTransitIP(t *testing.T, fix *fixture, defaultGwIP string) { return nil }, 2*time.Minute, 3*time.Second) + // Every node installs this route, not only the gateway chassis: the nexthop + // is the same transit address everywhere, and a node that never held the + // gateway port still has to reach the VPC the day it takes it over. harness.Step(t, "host ingress route for second VPC installed on attach") harness.EventuallyErr(t, func() error { out, _ := exec.Command("ip", "route", "show", secondVPCCIDR).CombinedOutput() @@ -543,15 +593,13 @@ func phaseUniqueTransitIP(t *testing.T, fix *fixture, defaultGwIP string) { func phaseEIPIngress(t *testing.T, fix *fixture, def harness.VPCInfo, probe egressProbe) { t.Helper() - gwIP := gatewayLRPIP(t, def.VPCID) - require.NotEmpty(t, gwIP, "gateway LRP IP") - harness.Step(t, "auto-assigned public IP %s has host delivery plumbing", probe.publicIP) require.NotEmpty(t, probe.publicIP, "phase 6 probe carries no public IP") harness.EventuallyErr(t, func() error { - return eipHostPlumbing(probe.publicIP, gwIP) + return eipHostPlumbing(probe.publicIP) }, 2*time.Minute, 3*time.Second) assertDNATExempt(t, probe.publicIP) + assertDistributedEIP(t, probe.publicIP) harness.Step(t, "allocate + associate a fresh EIP") // e2e:allow-create — scratch EIP owned end to end by this phase. @@ -574,9 +622,10 @@ func phaseEIPIngress(t *testing.T, fix *fixture, def harness.VPCInfo, probe egre harness.Step(t, "host delivery plumbing lands for %s", eip) harness.EventuallyErr(t, func() error { - return eipHostPlumbing(eip, gwIP) + return eipHostPlumbing(eip) }, 2*time.Minute, 3*time.Second) assertDNATExempt(t, eip) + assertDistributedEIP(t, eip) // EC2 releases an instance's auto-assigned public IPv4 when an EIP is // associated: "that public IPv4 address is released back into Amazon's pool @@ -614,7 +663,7 @@ func phaseEIPIngress(t *testing.T, fix *fixture, def harness.VPCInfo, probe egre t.Logf("skipping reconcile-replay check — cannot restart spinifex-vpcd: %s", string(out)) } else { harness.EventuallyErr(t, func() error { - return eipHostPlumbing(eip, gwIP) + return eipHostPlumbing(eip) }, 3*time.Minute, 5*time.Second) } @@ -634,15 +683,176 @@ func phaseEIPIngress(t *testing.T, fix *fixture, def harness.VPCInfo, probe egre require.NoError(t, eipHostPlumbingGone(probe.publicIP)) } +// --- Phase 9 (multi-node): every node delivers its own guests ---------------- + +// spreadGuest is one phase-9 guest and the node its QEMU process runs on. +type spreadGuest struct { + instanceID string + publicIP string + node harness.Node +} + +// phaseDistributedEIPs proves what a single-node run cannot see: with a guest +// on every node, each public IP is delivered by the node running that guest and +// by no other. It guards the two defects that made routed NAT work on one node +// only — a per-node transit MAC against OVN's single cluster-wide binding for +// the nexthop, and host EIP plumbing that ran on the reconcile leader alone. +func phaseDistributedEIPs(t *testing.T, fix *fixture, def harness.VPCInfo) { + t.Helper() + nodes := fix.cluster.Nodes + ssh := harness.NewPeerSSH() + + harness.Step(t, "every node's transit veth carries the shared MAC %s", transitHostMAC) + for _, n := range nodes { + link := nodeCmd(t, ssh, n, "ip -o link show dev "+transitHostEnd) + assert.Containsf(t, strings.ToLower(link), transitHostMAC, + "%s: OVN holds one MAC binding for %s cluster-wide, so a node keeping its own address receives none of the egress that binding points at\n%s", + n.Name, transitGatewayIP, link) + } + + for _, g := range launchSpreadGuests(t, fix, def, len(nodes)) { + harness.Step(t, "%s on %s carries public IP %s", g.instanceID, g.node.Name, g.publicIP) + assertDistributedEIP(t, g.publicIP) + assertSoleEIPHolder(t, ssh, nodes, g) + + harness.Step(t, "TCP handshake to %s:22 — inbound over %s", g.publicIP, g.node.Name) + harness.EventuallyErr(t, func() error { + return sshHandshake(g.publicIP) + }, 2*time.Minute, 5*time.Second) + + awaitEgressVerdict(t, fix, g.instanceID) + } +} + +// launchSpreadGuests boots one egress-probing guest per node in a spread +// placement group and returns each with the node running it. Colocation fails +// the phase: two guests on one node prove nothing about the second node's +// datapath, which is the whole subject here. +func launchSpreadGuests(t *testing.T, fix *fixture, def harness.VPCInfo, count int) []spreadGuest { + t.Helper() + + instType, arch := harness.DiscoverNanoInstanceType(t, fix.harness) + amiID := harness.DiscoverUbuntuAMI(t, fix.harness, arch) + keyName, _ := harness.EnsureKeyPair(t, fix.harness) + harness.Detail(t, "type", instType, "ami", amiID, "key", keyName, "count", count) + + harness.Step(t, "create spread placement group + %d egress-probe guests", count) + pgName := "natuplink-spread" + // e2e:allow-create — scratch placement group owned end to end by this phase. + _, err := fix.aws.EC2.CreatePlacementGroup(&ec2.CreatePlacementGroupInput{ + GroupName: aws.String(pgName), Strategy: aws.String("spread"), + }) + require.NoError(t, err, "create-placement-group %s", pgName) + t.Cleanup(func() { + _, _ = fix.aws.EC2.DeletePlacementGroup(&ec2.DeletePlacementGroupInput{GroupName: aws.String(pgName)}) + }) + + // e2e:allow-create — throwaway probe VMs; the egress-probe user-data makes them unshareable. + runOut, err := fix.aws.EC2.RunInstances(&ec2.RunInstancesInput{ + ImageId: aws.String(amiID), + InstanceType: aws.String(instType), + KeyName: aws.String(keyName), + SubnetId: aws.String(def.SubnetID), + MinCount: aws.Int64(int64(count)), + MaxCount: aws.Int64(int64(count)), + Placement: &ec2.Placement{GroupName: aws.String(pgName)}, + UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(egressUserData))), + }) + require.NoError(t, err, "run-instances x%d", count) + require.Lenf(t, runOut.Instances, count, "run-instances returned %d instances", len(runOut.Instances)) + + ids := make([]string, 0, count) + for _, inst := range runOut.Instances { + ids = append(ids, aws.StringValue(inst.InstanceId)) + } + t.Cleanup(func() { + _, _ = fix.aws.EC2.TerminateInstances(&ec2.TerminateInstancesInput{InstanceIds: aws.StringSlice(ids)}) + harness.WaitForInstanceTerminated(t, fix.aws, ids, 5*time.Minute) + }) + + guests := make([]spreadGuest, 0, count) + owner := make(map[string]string, count) + sgIDs := make(map[string]struct{}, 1) + for _, id := range ids { + inst := harness.WaitForInstanceState(t, fix.aws, id, "running") + publicIP := aws.StringValue(inst.PublicIpAddress) + require.NotEmptyf(t, publicIP, "instance %s must auto-assign a public IP (MapPublicIpOnLaunch)", id) + for _, sg := range inst.SecurityGroups { + sgIDs[aws.StringValue(sg.GroupId)] = struct{}{} + } + node := harness.InstanceHostingNode(t, fix.cluster, id) + require.NotNilf(t, node, "no node runs a QEMU process for %s", id) + require.Emptyf(t, owner[node.Name], + "spread placement failed: %s and %s both landed on %s", id, owner[node.Name], node.Name) + owner[node.Name] = id + harness.Detail(t, "instance", id, "node", node.Name, "public_ip", publicIP) + guests = append(guests, spreadGuest{instanceID: id, publicIP: publicIP, node: *node}) + } + + harness.Step(t, "open SSH from anywhere on %d security group(s)", len(sgIDs)) + perms := []*ec2.IpPermission{{ + IpProtocol: aws.String("tcp"), FromPort: aws.Int64(22), ToPort: aws.Int64(22), + IpRanges: []*ec2.IpRange{{CidrIp: aws.String("0.0.0.0/0")}}, + }} + for sgID := range sgIDs { + _, err := fix.aws.EC2.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ + GroupId: aws.String(sgID), IpPermissions: perms, + }) + require.NoError(t, err, "authorize-security-group-ingress %s", sgID) + t.Cleanup(func() { + _, _ = fix.aws.EC2.RevokeSecurityGroupIngress(&ec2.RevokeSecurityGroupIngressInput{ + GroupId: aws.String(sgID), IpPermissions: perms, + }) + }) + } + return guests +} + +// assertSoleEIPHolder proves g's public IP is plumbed on g's node and nowhere +// else. Two nodes holding the same address answer ARP for it from two places, +// which delivers the guest's traffic to the wrong host about half the time. +func assertSoleEIPHolder(t *testing.T, ssh *harness.PeerSSH, nodes []harness.Node, g spreadGuest) { + t.Helper() + routes := make(map[string]string, len(nodes)) + harness.EventuallyErr(t, func() error { + holders := make([]string, 0, 1) + for _, n := range nodes { + route := strings.TrimSpace(nodeCmd(t, ssh, n, "ip route show "+g.publicIP+"/32")) + routes[n.Name] = route + if route != "" { + holders = append(holders, n.Name) + } + } + if len(holders) != 1 || holders[0] != g.node.Name { + return fmt.Errorf("public IP %s plumbed by %v, want [%s] alone", g.publicIP, holders, g.node.Name) + } + return nil + }, 2*time.Minute, 5*time.Second) + + route := routes[g.node.Name] + assert.Containsf(t, route, "dev "+transitHostEnd, + "%s: EIP route must point into OVN\n%s", g.node.Name, route) + assert.NotContainsf(t, route, "via ", + "%s: a distributed EIP is answered on-link by its own node, not forwarded to a gateway LRP\n%s", + g.node.Name, route) + + proxy := nodeCmd(t, ssh, g.node, "ip neigh show proxy") + assert.Containsf(t, proxy, g.publicIP, + "%s must answer ARP for %s on its uplink\n%s", g.node.Name, g.publicIP, proxy) +} + // eipHostPlumbing returns nil when the full Tier 2 host state for eip is in -// place: the /32 route into OVN via the gateway LRP, a proxy-ARP neighbor on -// the uplink, and both per-EIP FORWARD accepts. -func eipHostPlumbing(eip, gwIP string) error { +// place: the /32 route into OVN, a proxy-ARP neighbor on the uplink, and both +// per-EIP FORWARD accepts. The route is on-link — a distributed EIP is answered +// by this node's own transit veth, so no gateway LRP is in the path. +func eipHostPlumbing(eip string) error { out, _ := exec.Command("ip", "route", "show", eip+"/32").CombinedOutput() - route := string(out) - if !strings.Contains(route, "via "+gwIP) || !strings.Contains(route, "dev "+transitHostEnd) { - return fmt.Errorf("EIP route for %s missing (want via %s dev %s): %q", - eip, gwIP, transitHostEnd, strings.TrimSpace(route)) + route := strings.TrimSpace(string(out)) + if !strings.Contains(route, "dev "+transitHostEnd) { + return fmt.Errorf("EIP route for %s missing (want dev %s): %q", eip, transitHostEnd, route) + } + if strings.Contains(route, "via ") { + return fmt.Errorf("EIP route for %s is forwarded to a gateway LRP, not answered on-link: %q", eip, route) } out, _ = exec.Command("ip", "neigh", "show", "proxy").CombinedOutput() if !strings.Contains(string(out), eip) { @@ -693,11 +903,119 @@ func assertDNATExempt(t *testing.T, eip string) { require.NotEmptyf(t, exemptRef, "dnat_and_snat for %s missing or has no exempted_ext_ips ref", eip) } -// sshHandshake dials hostPort:22 and reads the SSH banner prefix. -func sshHandshake(host string) error { - conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, "22"), 5*time.Second) +// assertDistributedEIP checks the NAT row for eip is the distributed kind. +// external_mac and logical_port are what let the chassis running the guest +// answer for the address itself; without them the row is centralised and only +// the gateway chassis can deliver it. +func assertDistributedEIP(t *testing.T, eip string) { + t.Helper() + var row string + harness.EventuallyErr(t, func() error { + row = harness.OvnNbctl(t, "--format=csv", "--no-headings", "--data=bare", + "--columns=external_mac,logical_port", "find", "nat", + "type=dnat_and_snat", "external_ip="+eip) + if row == "" { + return fmt.Errorf("no dnat_and_snat row for %s yet", eip) + } + if lines := strings.Split(row, "\n"); len(lines) != 1 { + return fmt.Errorf("%d dnat_and_snat rows for %s, want 1:\n%s", len(lines), eip, row) + } + mac, port, _ := strings.Cut(row, ",") + if strings.TrimSpace(mac) == "" || strings.TrimSpace(port) == "" { + return fmt.Errorf("dnat_and_snat for %s is centralised (external_mac=%q logical_port=%q)", + eip, strings.TrimSpace(mac), strings.TrimSpace(port)) + } + return nil + }, time.Minute, 3*time.Second) + harness.Detail(t, "eip", eip, "external_mac,logical_port", row) +} + +// localIsGatewayChassis reports whether this host holds the VPC's active +// gateway router port. Every node carries a gateway_chassis row for every VPC, +// so the row alone means nothing — the highest priority is the one OVN uses, +// and only that chassis reaches the gateway LRP over the transit veth, since +// ext-shared is a localnet and the segment stops at the node. +func localIsGatewayChassis(t *testing.T, vpcID string) bool { + t.Helper() + rows := harness.OvnNbctl(t, "--format=csv", "--no-headings", "--data=bare", + "--columns=name,chassis_name,priority", "list", "gateway_chassis") + best, gwChassis := -1, "" + for _, row := range strings.Split(rows, "\n") { + fields := strings.Split(strings.TrimSpace(row), ",") + if len(fields) != 3 || !strings.Contains(fields[0], vpcID) { + continue + } + prio, err := strconv.Atoi(fields[2]) + if err != nil || prio <= best { + continue + } + best, gwChassis = prio, fields[1] + } + local := localChassis(t) + harness.Detail(t, "vpc", vpcID, "gateway_chassis", gwChassis, "local_chassis", local) + return gwChassis != "" && gwChassis == local +} + +// localChassis is this host's OVN chassis name, which the multi-node bootstrap +// pins to the cluster node label so it compares with harness.Node.Name. +func localChassis(t *testing.T) string { + t.Helper() + return strings.Trim(harness.OvsVsctl(t, "get", "Open_vSwitch", ".", "external_ids:system-id"), `"`) +} + +// hostsInstanceLocally reports whether this host runs the instance's QEMU. +func hostsInstanceLocally(t *testing.T, fix *fixture, instanceID string) bool { + t.Helper() + node := harness.InstanceHostingNode(t, fix.cluster, instanceID) + if node == nil { + return false + } + harness.Detail(t, "instance", instanceID, "node", node.Name, "local_chassis", localChassis(t)) + return node.Name == localChassis(t) +} + +// awaitEgressVerdict blocks until the guest prints its egress marker on the +// serial console — the only channel back from a routed-NAT guest — and fails on +// the negative one. The console is saved either way a failure arrives. +func awaitEgressVerdict(t *testing.T, fix *fixture, instanceID string) { + t.Helper() + harness.Step(t, "wait for egress verdict from %s on the serial console", instanceID) + var console string + // Registered before the wait so a budget overrun — which Fatals inside + // EventuallyErr — still leaves the last console read behind. + harness.OnFailure(t, func() { + harness.DumpFile(t, fix.artifacts, "egress-console-"+instanceID+".log", []byte(console)) + }) + harness.EventuallyErr(t, func() error { + console = consoleOutput(t, fix, instanceID) + if strings.Contains(console, egressOKMarker) || strings.Contains(console, egressFailMarker) { + return nil + } + return fmt.Errorf("no egress marker on console yet (%d bytes)", len(console)) + }, egressVerdictBudget, 10*time.Second) + + if strings.Contains(console, egressFailMarker) { + t.Fatalf("guest %s reported %s — outbound WAN unreachable through routed NAT (console saved to artifacts)", + instanceID, egressFailMarker) + } + harness.Step(t, "guest %s reported %s", instanceID, egressOKMarker) +} + +// nodeCmd runs cmd on a cluster node over SSH and fails the test on error. +func nodeCmd(t *testing.T, ssh *harness.PeerSSH, n harness.Node, cmd string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := ssh.Run(ctx, n.Addr, cmd) + require.NoErrorf(t, err, "%s: %s", n.Name, cmd) + return string(out) +} + +// sshHandshake dials addr:22 and reads the SSH banner prefix. +func sshHandshake(addr string) error { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(addr, "22"), 5*time.Second) if err != nil { - return fmt.Errorf("dial %s:22: %w", host, err) + return fmt.Errorf("dial %s:22: %w", addr, err) } defer conn.Close() _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) diff --git a/tests/e2e/run-tofu-examples-e2e.sh b/tests/e2e/run-tofu-examples-e2e.sh index 147b6a719..34b67c774 100755 --- a/tests/e2e/run-tofu-examples-e2e.sh +++ b/tests/e2e/run-tofu-examples-e2e.sh @@ -332,6 +332,135 @@ assert_s3_webapp() { curl -sf "http://${ip}/" | grep -q "$sentinel" } +# A container instance registers only after its agent has booted, read IMDS, and +# reached the gateway, so the service reaching its desired count proves the whole +# ECS path rather than just that CreateService was accepted. The ALB check is the +# same shape as nginx-alb's: .elb.spinifex.local does not resolve here, so the +# public address comes from describe-load-balancers. +assert_ecs_quickstart() { + local cluster service name ip tg_arn + cluster=$(tofu output -raw cluster_arn) + cluster=${cluster##*/} + service=$(tofu output -raw service_name) + + local budget=420 running desired + while [ "$budget" -gt 0 ]; do + read -r running desired <<<"$(aws ecs describe-services --cluster "$cluster" --services "$service" \ + --query 'services[0].[runningCount,desiredCount]' --output text 2>/dev/null)" + if [ -n "${desired:-}" ] && [ "$desired" != "None" ] && [ "$running" = "$desired" ] && [ "$running" != "0" ]; then + break + fi + sleep 10 + budget=$((budget - 10)) + done + if [ "$budget" -le 0 ]; then + log " ecs-quickstart: service ${service} never reached its desired count (${running:-?}/${desired:-?})" + aws ecs list-container-instances --cluster "$cluster" --output text 2>&1 | sed 's|^| |' || true + return 1 + fi + log " ecs-quickstart: service ${service} running ${running}/${desired}" + + name=$(tofu output -raw alb_name) + ip=$(aws elbv2 describe-load-balancers --names "$name" \ + --query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \ + --output text 2>/dev/null | awk '{print $1}') + if [ -z "$ip" ] || [ "$ip" = "None" ]; then + log " ecs-quickstart: no public IP for ${name}" + return 1 + fi + tg_arn=$(aws elbv2 describe-target-groups --names "${cluster}-tg" \ + --query 'TargetGroups[0].TargetGroupArn' --output text 2>/dev/null) + if [ -n "$tg_arn" ] && [ "$tg_arn" != "None" ]; then + wait_for_alb_healthy "$tg_arn" 300 || { + log " ecs-quickstart: no healthy targets after 300s" + return 1 + } + fi + wait_for_http_200 "http://${ip}/" 300 || { + log " ecs-quickstart: ALB ${ip} never returned 200" + return 1 + } +} + +# The cluster module stops at a running control plane and worker node group; the +# demo app lives in the nested workloads/ module and needs an image pushed to ECR +# first, which is outside what this harness builds. So the assertion is that the +# cluster is ACTIVE, the node group is ACTIVE, and every worker registered with +# the API server as Ready — the part that proves EKS on this cluster works. +# +# Shared by every EKS workbook: they differ in what they layer on top, and all +# three stand or fall on the same three facts underneath. +assert_eks_cluster_ready() { + local label="$1" name desired budget=900 status + name=$(tofu output -raw cluster_name) + desired=$(tofu output -raw node_desired_size) + + while [ "$budget" -gt 0 ]; do + status=$(aws eks describe-cluster --name "$name" --query 'cluster.status' --output text 2>/dev/null) + [ "$status" = "ACTIVE" ] && break + [ "$status" = "FAILED" ] && { log " ${label}: cluster ${name} FAILED"; return 1; } + sleep 15 + budget=$((budget - 15)) + done + if [ "$status" != "ACTIVE" ]; then + log " ${label}: cluster ${name} still ${status:-unknown} after 900s" + return 1 + fi + log " ${label}: cluster ${name} ACTIVE" + + if ! command -v kubectl >/dev/null 2>&1; then + local kver + kver=$(curl -fsSL https://dl.k8s.io/release/stable.txt 2>/dev/null || echo v1.32.0) + curl -fsSL -o /tmp/kubectl "https://dl.k8s.io/release/${kver}/bin/linux/amd64/kubectl" && + sudo install -m 0755 /tmp/kubectl /usr/local/bin/kubectl || { + log " ${label}: kubectl unavailable, cannot check node readiness" + return 1 + } + fi + aws eks update-kubeconfig --name "$name" >/dev/null 2>&1 || { + log " ${label}: update-kubeconfig failed" + return 1 + } + + # Count agents, not every node. The control plane is three K3s servers, so + # an unfiltered count reaches a one-worker cluster's desired size before any + # worker joins — the assertion passes on exactly the failure it exists to + # catch. Node-group workers boot as k3s-agent and carry no control-plane + # role label (scripts/images/eks-node/eks-node-role.sh). + budget=600 + local ready + while [ "$budget" -gt 0 ]; do + ready=$(kubectl get nodes --no-headers -l '!node-role.kubernetes.io/control-plane' 2>/dev/null | + awk '$2=="Ready"' | wc -l) + [ "$ready" -ge "$desired" ] 2>/dev/null && break + sleep 15 + budget=$((budget - 15)) + done + if [ "${ready:-0}" -lt "$desired" ] 2>/dev/null; then + log " ${label}: ${ready:-0}/${desired} workers Ready after 600s" + kubectl get nodes 2>&1 | sed 's|^| |' || true + return 1 + fi + log " ${label}: ${ready}/${desired} workers Ready" +} + +assert_eks_quickstart() { + assert_eks_cluster_ready eks-quickstart +} + +# Same cluster assertion, over a harder network: these two put their workers in +# private subnets behind a NAT gateway, so a worker only registers if its egress +# to the cluster endpoint and to ECR survives the whole NAT path. A worker that +# never reaches Ready here, with eks-quickstart passing, is a NAT gateway fault +# and not an EKS one. +assert_eks_https_ingress() { + assert_eks_cluster_ready eks-https-ingress +} + +assert_eks_gitops_argocd() { + assert_eks_cluster_ready eks-gitops-argocd +} + # Workbooks whose describe path has to round-trip every attribute their config # sets. A second plan on these must be empty; a diff is a describe response that # failed to echo back a field Terraform sent, which makes the provider unusable @@ -386,13 +515,15 @@ require_rds_image() { # Pick an instance type available on this cluster. Workbooks default to # t3.small (Intel); on AMD-only hosts t3 isn't registered, so we query and -# fall back to the smallest type with ≥2 vCPU / ≥1 GiB RAM (matches the -# nginx-alb README's documented approach). +# fall back to the smallest type meeting the floor (matches the nginx-alb +# README's documented approach). Defaults to 2 vCPU / 1 GiB; callers that need +# more headroom, such as an EKS worker, pass their own. detect_instance_type() { + local vcpus="${1:-2}" mib="${2:-1024}" local endpoint="https://${WAN_IP}:9999" local picked picked=$(aws --endpoint-url "$endpoint" ec2 describe-instance-types \ - --query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \ + --query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus>=\`${vcpus}\` && MemoryInfo.SizeInMiB>=\`${mib}\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \ --output text 2>/dev/null || true) if [ -z "$picked" ] || [ "$picked" = "None" ]; then picked=$(aws --endpoint-url "$endpoint" ec2 describe-instance-types \ @@ -419,12 +550,35 @@ run_workbook() { cd "$path" rm -rf .terraform terraform.tfstate* .terraform.lock.hcl + # EKS workers run a kubelet, a container runtime and the demo pods, so the + # smallest type the cluster offers is not enough; the workbook's own default + # is t3.medium and 4 GiB is the floor that boots reliably. + local instance_type="$INSTANCE_TYPE" + case "$example" in + eks-*) instance_type=$(detect_instance_type 2 4096) ;; + esac + + # Workbooks name the worker size differently — the EKS ones that split + # public and private subnets call it node_instance_type. Read the name from + # the config rather than keeping a list here: tofu rejects a -var for an + # undeclared variable outright, so a stale list fails the apply in seconds + # and reads like a product defect. + local size_var=instance_type + grep -qE '^variable "node_instance_type"' main.tf 2>/dev/null && size_var=node_instance_type + local apply_args=( -input=false -no-color "-var=spinifex_endpoint=https://${WAN_IP}:9999" - "-var=instance_type=${INSTANCE_TYPE}" + "-var=${size_var}=${instance_type}" ) + # ecs-quickstart's gateway_url is required and has no default: the container + # instances read it from their own user-data, so it must be the address a + # guest can reach, never 127.0.0.1. + if [ "$example" = "ecs-quickstart" ]; then + apply_args+=("-var=gateway_url=https://${WAN_IP}:9999") + fi + # s3-webapp has three required-no-default vars. The s3_access/secret keys are # the operator creds the provider uses to create the bucket + IAM role on # predastore; the instance authenticates to S3 via IMDS, not these keys. @@ -492,8 +646,13 @@ log "Using instance_type=${INSTANCE_TYPE}" # launches the client, then the client's own boot and an apt-get. Budget it at # ~15 minutes against the ~7 the other four share, and note that its assertion # needs roughly 2 GiB of free guest memory for the two VMs. +# +# WORKBOOKS selects the list, and KEEP_GOING=1 runs the rest after a failure. +# The nightly uses neither: it wants the default order and an early abort, so a +# broken cluster does not spend an hour proving the same thing five times. Both +# are for running this by hand against a cluster you are qualifying. SUITE_RC=0 -for workbook in nginx-alb bastion-private-subnet nginx-webserver s3-webapp rds-quickstart; do +for workbook in ${WORKBOOKS:-nginx-alb bastion-private-subnet nginx-webserver s3-webapp rds-quickstart}; do tname="TestTofuWorkbook_${workbook//-/_}" wb_start=$SECONDS echo "=== RUN ${tname}" @@ -501,9 +660,12 @@ for workbook in nginx-alb bastion-private-subnet nginx-webserver s3-webapp rds-q printf -- '--- PASS: %s (%d.00s)\n' "$tname" "$((SECONDS - wb_start))" else printf -- '--- FAIL: %s (%d.00s)\n' "$tname" "$((SECONDS - wb_start))" - log "FAIL ${workbook} — aborting remaining workbooks" SUITE_RC=1 - break + if [ "${KEEP_GOING:-0}" != "1" ]; then + log "FAIL ${workbook} — aborting remaining workbooks" + break + fi + log "FAIL ${workbook} — continuing (KEEP_GOING=1)" fi done diff --git a/tests/e2e/single/imagecatalog_test.go b/tests/e2e/single/imagecatalog_test.go new file mode 100644 index 000000000..2bb2f36c3 --- /dev/null +++ b/tests/e2e/single/imagecatalog_test.go @@ -0,0 +1,116 @@ +//go:build e2e + +package single + +import ( + "context" + "crypto/tls" + "fmt" + "maps" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/mulgadc/spinifex/spinifex/utils" + "github.com/mulgadc/spinifex/tests/e2e/harness" + "github.com/stretchr/testify/assert" +) + +// catalogHeadTimeout bounds one HEAD. Several of these hosts are CDN-fronted +// and occasionally slow to first byte, but none is slow to answer a HEAD. +const catalogHeadTimeout = 30 * time.Second + +// runImageCatalogReachable HEADs every URL in the image catalog. +// +// The catalog names artifacts on hosts nobody here controls — Debian, Ubuntu, +// Alpine, Rocky, Oracle and our own iso.mulgadc.com. An upstream that retires +// a build, moves a path or lets a certificate lapse breaks `spx admin images +// import` for a customer, and nothing else in the suite would notice: every +// other test launches from an AMI that is already imported. +// +// This runs in the general single-node suite rather than nightly-only on +// purpose. It is the check most likely to catch a problem that is not ours, +// and the sooner it is seen the smaller the window in which a released binary +// points at a dead URL. +func runImageCatalogReachable(t *testing.T, _ *Fixture) { + harness.Phase(t, "Single — Image catalog URLs still answer") + + client := &http.Client{ + Timeout: catalogHeadTimeout, + // The catalog is pinned to https and every redirect must stay there; + // a downgrade would fetch image bytes in the clear. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing non-https redirect to %s", req.URL.Redacted()) + } + return nil + }, + Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, + } + + for _, name := range slices.Sorted(maps.Keys(utils.AvailableImages)) { + img := utils.AvailableImages[name] + t.Run(name, func(t *testing.T) { + t.Parallel() + + harness.Step(t, "HEAD %s", img.URL) + size, err := headOK(client, img.URL) + if !assert.NoErrorf(t, err, "catalog image %q is unreachable; `spx admin images import --name %s` would fail for every operator", name, name) { + return + } + assert.NotZerof(t, size, "%s answered but reports a zero-length body", name) + harness.Detail(t, "image", name, "bytes", fmt.Sprintf("%d", size)) + + // Exactly one of the two must be set, or a verified import is not + // possible; resolveDigest refuses the entry rather than importing + // it unverified, so an empty pair is a latent hard failure. + hasURL, hasPinned := img.Checksum != "", img.ChecksumDigest != "" + assert.NotEqualf(t, hasURL, hasPinned, + "%s must set exactly one of Checksum (sums file URL) or ChecksumDigest (pinned digest); got url=%q pinned=%q", + name, img.Checksum, img.ChecksumDigest) + assert.NotEmptyf(t, img.ChecksumType, "%s sets no ChecksumType", name) + + switch { + case hasURL: + harness.Step(t, "HEAD %s", img.Checksum) + _, err := headOK(client, img.Checksum) + assert.NoErrorf(t, err, "checksum file for %q is unreachable, so the import cannot verify and will refuse", name) + case hasPinned: + // A pinned digest is only safe on a URL that names one build. + // "latest" upstreams re-point, and the next release would fail + // every import of this entry until someone edited the catalog. + assert.NotContainsf(t, strings.ToLower(img.URL), "latest", + "%s pins a digest against a 'latest' URL, whose bytes change under it", name) + } + }) + } +} + +// headOK issues a HEAD and returns Content-Length, erroring on any non-2xx. +// A HEAD and not a ranged GET: the point is whether the artifact is still +// published, and pulling bytes for twenty-odd multi-hundred-megabyte images +// would make this the longest test in the suite. +func headOK(client *http.Client, rawURL string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), catalogHeadTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil) + if err != nil { + return 0, fmt.Errorf("build request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("HTTP %s", resp.Status) + } + return resp.ContentLength, nil +} diff --git a/tests/e2e/single/main_test.go b/tests/e2e/single/main_test.go index a9c335dc3..94be52b20 100644 --- a/tests/e2e/single/main_test.go +++ b/tests/e2e/single/main_test.go @@ -82,7 +82,7 @@ func requireSingleNodeFixture(t *testing.T) *Fixture { Harness: h, TmpDir: tmpDir, } - fix.PoolMode = detectPoolMode(env) + fix.PublicPool, fix.PublicPoolSource = detectPublicPool(env) // admin init leaves the default SG closed (AWS parity); the e2e suite // drives SSH + ICMP probes from the test runner's external IP, so open // both on the default VPC's default SG once per process. Idempotent — @@ -104,11 +104,17 @@ func requireSingleNodeFixture(t *testing.T) *Fixture { // memoized on Harness (harness.Fixture) and surfaced via the package-local // need* helpers / harness.Ensure* + harness.Discover*. type Fixture struct { - Env *harness.Env - AWS *harness.AWSClient - Harness *harness.Fixture // memoized Ensure* fixture; spans the whole process. - TmpDir string // package-scoped scratch dir; survives every Test* in the package. - PoolMode bool // gates 8b / 8d + Env *harness.Env + AWS *harness.AWSClient + Harness *harness.Fixture // memoized Ensure* fixture; spans the whole process. + TmpDir string // package-scoped scratch dir; survives every Test* in the package. + PublicPool bool // public addresses can be allocated; gates 8b / 8d + + // PublicPoolSource is the first non-transit pool's source ("static", + // "dhcp", "oci"), empty when there is none. A stop returns an auto-assigned + // address whatever the source, but only a pool we number ourselves can be + // asserted to hand out a different one on the start that follows. + PublicPoolSource string } // ArtifactDir returns the artifact directory for the *currently running* test. @@ -121,40 +127,55 @@ func (f *Fixture) ArtifactDir(t *testing.T) string { return harness.ArtifactDir(t, f.Env) } -// detectPoolMode reads external_mode from spinifex.toml. Defaults to false -// (dev_networking) which is the single-node CI fixture. Only external_mode -// "pool" enables Phase 8b/8d — nat clusters have no EIP/NAT-GW support, so -// AllocateAddress returns UnsupportedOperation and those phases must skip. -func detectPoolMode(env *harness.Env) bool { +// detectPublicPool reports whether this cluster can hand out public +// addresses, and where the first customer-facing pool gets them from. Both +// external_mode "pool" and external_mode "nat" with a non-transit pool +// configured can: routed NAT carries EIPs, so gating on the mode name alone +// skipped the egress and SMTP phases on a cluster that supports every call +// they make. +func detectPublicPool(env *harness.Env) (bool, string) { cfg := os.ExpandEnv("$HOME/spinifex/config/spinifex.toml") if env.ConfigDir != "" { cfg = filepath.Join(env.ConfigDir, "spinifex.toml") } f, err := os.Open(cfg) if err != nil { - return false + return false, "" } defer f.Close() + mode, section, source := "", "", "" + hasPool, inTransit := false, false scanner := bufio.NewScanner(f) - inNetwork := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") { - inNetwork = line == "[network]" + section = line + // A new table means a new pool, and its source has not been read + // yet — carrying the previous one over would mislabel it. + inTransit = false continue } - if !inNetwork { + _, rhs, ok := strings.Cut(line, "=") + if !ok { continue } - if !strings.HasPrefix(line, "external_mode") { - continue - } - // external_mode = "pool" — quoted value; only "pool" gates 8b/8d. - if _, rhs, ok := strings.Cut(line, "="); ok { - val := strings.Trim(strings.TrimSpace(rhs), "\"'") - return val == "pool" + val := strings.Trim(strings.TrimSpace(rhs), "\"'") + switch { + case section == "[network]" && strings.HasPrefix(line, "external_mode"): + mode = val + // The transit pool is routed NAT's own plumbing, not addresses a + // customer can allocate, so it is not evidence of a public pool. + case section == "[[network.external_pools]]" && strings.HasPrefix(line, "name"): + inTransit = val == "nat-transit" + hasPool = hasPool || !inTransit + case section == "[[network.external_pools]]" && strings.HasPrefix(line, "source") && !inTransit && source == "": + source = val } } - return false + // An omitted source is the static default, and only a real pool has one. + if hasPool && source == "" { + source = "static" + } + return mode == "pool" || (mode == "nat" && hasPool), source } diff --git a/tests/e2e/single/smtpblock_test.go b/tests/e2e/single/smtpblock_test.go index 696689831..47b800ce6 100644 --- a/tests/e2e/single/smtpblock_test.go +++ b/tests/e2e/single/smtpblock_test.go @@ -47,8 +47,8 @@ func guestConnectProbe(host string, port, timeoutSec int) string { // - SMTPBlocked is the assertion under test and depends on Control having // proven egress is otherwise alive. func runWANEgressSMTPBlock(t *testing.T, fix *Fixture) { - if !fix.PoolMode { - t.Skip("WAN egress SMTP block scenario requires pool-mode networking (real WAN egress)") + if !fix.PublicPool { + t.Skip("WAN egress SMTP block scenario needs a public address on the guest (real WAN egress)") } harness.Phase(t, "Single — Platform egress ACL blocks outbound SMTP (25/465/587) to public destinations, AWS parity") diff --git a/tests/e2e/single/tests_test.go b/tests/e2e/single/tests_test.go index 87e23611c..a1d4b5b8f 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -30,6 +30,13 @@ func TestAccountScoping(t *testing.T) { runAccountScoping(t, requireSingleNodeFixture(t)) } +// Reaches the public internet rather than the cluster: it asks whether the +// upstreams our image catalog names are still publishing what it claims. +func TestImageCatalogReachable(t *testing.T) { + t.Parallel() + runImageCatalogReachable(t, requireSingleNodeFixture(t)) +} + // --- Sequential: singleton VM lifecycle --- // TestClusterStatsCLI exercises the spx cluster CLI; its get-vms baseline is diff --git a/tests/e2e/single/vpcegress_test.go b/tests/e2e/single/vpcegress_test.go index 356b3860d..9e0f4f524 100644 --- a/tests/e2e/single/vpcegress_test.go +++ b/tests/e2e/single/vpcegress_test.go @@ -77,14 +77,23 @@ func vpcEgressNATRounds() int { // NATGatewayUp succeeded — tearing down a gateway that was never // fully provisioned would produce misleading failures rather than // useful signal. +// - StopStartAddress runs second to last, on the same public guest, while it +// still holds the address its launch borrowed from the pool. A stop returns +// that address and the start takes a different one, so it has to come after +// every stage that reaches the guest on the original and before EIPFlip +// replaces it with an address the customer owns. It rebinds pubIP and +// bastionTgt to what the guest came back on. // - EIPFlip runs last and reuses the same public guest as the EIP subject. // Associating an Elastic IP rewrites the instance's public IP, which // would corrupt every stage above that still expects the guest to // answer on its original address — ordering it last is what lets this // scenario reuse the guest at all instead of needing a disposable one. +// Its StopStartKeepsTheEIP step stops and restarts that guest, which is +// safe for the same reason and only while the EIP is still associated: +// the address the guest returns on is the allocation's, not the pool's. func runVPCEgressPaths(t *testing.T, fix *Fixture) { - if !fix.PoolMode { - t.Skip("VPC egress paths scenario requires pool-mode networking") + if !fix.PublicPool { + t.Skip("VPC egress paths scenario needs a pool of public addresses to allocate from") } harness.Phase(t, "Single — Three ways in and out: public subnet, NAT Gateway, and Elastic IP egress around one VPC") @@ -671,6 +680,73 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { }) } + // --- StopStartAddress: the auto-assigned address across a stop, before any EIP --- + + // AWS takes an auto-assigned public address back when an instance stops and + // hands it a different one on the start. This is the only stage that sees + // that, so it runs while the guest still holds the address its launch + // borrowed and before EIPFlip replaces it with one the customer owns. + t.Run("StopStartAddress", func(t *testing.T) { + harness.SkipIfNoOVN(t) + + harness.Step(t, "stop-instances %s (holding %s)", pubInstanceID, pubIP) + _, err := c.EC2.StopInstances(&ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(pubInstanceID)}, + }) + require.NoError(t, err, "stop-instances") + stopped := harness.WaitForInstanceState(t, c, pubInstanceID, "stopped") + + // A stopped instance has no datapath and no address, which is what + // distinguishes a borrowed address from an Elastic IP: the EIP stage + // above asserts the opposite for the same guest. + require.Emptyf(t, aws.StringValue(stopped.PublicIpAddress), + "stopped instance still reports public address %s — it was never returned to the pool", + aws.StringValue(stopped.PublicIpAddress)) + harness.Detail(t, "stopped_public_ip", "released") + + harness.Step(t, "start-instances %s", pubInstanceID) + _, err = c.EC2.StartInstances(&ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(pubInstanceID)}, + }) + require.NoError(t, err, "start-instances") + started := harness.WaitForInstanceState(t, c, pubInstanceID, "running") + + newIP := aws.StringValue(started.PublicIpAddress) + require.NotEmptyf(t, newIP, + "instance %s came back with no public address — the subnet auto-assigns one and the start did not take it", + pubInstanceID) + harness.Detail(t, "restarted_public_ip", newIP) + + // A pool we number hands out the address after the cursor, so the one + // just returned is the last it would pick. A DHCP server is free to + // renew the same lease for the same client, so there the only honest + // assertion is that an address came back at all. + if fix.PublicPoolSource == "static" { + assert.NotEqualf(t, pubIP, newIP, + "restart returned the same address %s — the stop did not release it, or the pool re-handed it", pubIP) + } + + harness.Step(t, "ssh to guest via %s after restart", newIP) + if !trySSHReady(newIP, 22, keyPath, sshReadyBudget) { + harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID, + fmt.Sprintf("StopStartAddress SSH timeout — was=%s now=%s instance=%s", pubIP, newIP, pubInstanceID), + harness.VPCDiagnosticsOpts{ + ExternalIP: newIP, + LogicalIP: pubPrivIP, + ArtifactDir: fix.ArtifactDir(t), + }) + t.Fatalf("guest unreachable via %s within %s after restart (see diagnostics above)", newIP, sshReadyBudget) + } + out := runSSH(t, harness.SSHTarget{User: "ubuntu", Host: newIP, Port: 22, KeyPath: keyPath}, "id") + require.Containsf(t, out, "ubuntu", "ssh after restart did not report ubuntu\n%s", out) + harness.Detail(t, "datapath", "reachable_on_new_address_ok") + + // Every later stage reaching this guest must use the address it is + // actually on, so the stale one is replaced rather than left behind. + pubIP = newIP + bastionTgt = harness.SSHTarget{User: "ubuntu", Host: newIP, Port: 22, KeyPath: keyPath} + }) + // --- EIPFlip: last, on the same public guest --- t.Run("EIPFlip", func(t *testing.T) { @@ -728,6 +804,68 @@ func runVPCEgressPaths(t *testing.T, fix *Fixture) { require.Containsf(t, idOut, "ubuntu", "ssh via EIP id did not report ubuntu\n%s", idOut) harness.Detail(t, "datapath", "eip_reachable_ok") + // An Elastic IP outlives the instance it is attached to, so a stop must + // leave the allocation, the association and the address itself intact — + // and a start must bring the same address back, not a new one. On a DHCP + // pool this is also the only outside view of the lease still renewing. + t.Run("StopStartKeepsTheEIP", func(t *testing.T) { + harness.Step(t, "stop-instances %s", pubInstanceID) + _, err := c.EC2.StopInstances(&ec2.StopInstancesInput{ + InstanceIds: []*string{aws.String(pubInstanceID)}, + }) + require.NoError(t, err, "stop-instances") + harness.WaitForInstanceState(t, c, pubInstanceID, "stopped") + + harness.Step(t, "verify EIP %s survives the stop", eipIP) + addrOut, err := c.EC2.DescribeAddresses(&ec2.DescribeAddressesInput{ + AllocationIds: []*string{aws.String(allocID)}, + }) + require.NoError(t, err, "describe-addresses while stopped") + require.Lenf(t, addrOut.Addresses, 1, + "allocation %s vanished while the instance was stopped — the customer's Elastic IP went back to the pool", allocID) + stopped := addrOut.Addresses[0] + assert.Equal(t, eipIP, aws.StringValue(stopped.PublicIp), "the allocation changed address across a stop") + assert.Equal(t, eipAssocID, aws.StringValue(stopped.AssociationId), "the association was dropped by the stop") + assert.Equal(t, pubInstanceID, aws.StringValue(stopped.InstanceId), "the association no longer names the instance") + + // A stopped instance has no datapath, so it reports no address even + // though the allocation above still holds it. The two views disagree + // on purpose, and only the allocation is the customer's property. + stoppedInst, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(pubInstanceID)}, + }) + require.NoError(t, err, "describe-instances while stopped") + require.NotEmpty(t, stoppedInst.Reservations) + require.NotEmpty(t, stoppedInst.Reservations[0].Instances) + assert.Empty(t, aws.StringValue(stoppedInst.Reservations[0].Instances[0].PublicIpAddress), + "a stopped instance still reports a public address") + + harness.Step(t, "start-instances %s", pubInstanceID) + _, err = c.EC2.StartInstances(&ec2.StartInstancesInput{ + InstanceIds: []*string{aws.String(pubInstanceID)}, + }) + require.NoError(t, err, "start-instances") + started := harness.WaitForInstanceState(t, c, pubInstanceID, "running") + + require.Equalf(t, eipIP, aws.StringValue(started.PublicIpAddress), + "instance came back on a different address than the Elastic IP associated to it") + + harness.Step(t, "ssh to guest via EIP %s after restart", eipIP) + if !trySSHReady(eipIP, 22, keyPath, sshReadyBudget) { + harness.DumpVPCFlowDiagnostics(t, c, pubInstanceID, + fmt.Sprintf("EIP SSH timeout after restart — eip=%s instance=%s", eipIP, pubInstanceID), + harness.VPCDiagnosticsOpts{ + ExternalIP: eipIP, + LogicalIP: aws.StringValue(started.PrivateIpAddress), + ArtifactDir: fix.ArtifactDir(t), + }) + t.Fatalf("guest unreachable via EIP %s within %s after restart (see diagnostics above)", eipIP, sshReadyBudget) + } + out := runSSH(t, harness.SSHTarget{User: "ubuntu", Host: eipIP, Port: 22, KeyPath: keyPath}, "id") + require.Containsf(t, out, "ubuntu", "ssh via EIP after restart did not report ubuntu\n%s", out) + harness.Detail(t, "datapath", "eip_reachable_after_restart_ok") + }) + harness.Step(t, "disassociate-address %s", eipAssocID) _, err = c.EC2.DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String(eipAssocID)}) require.NoError(t, err, "disassociate-address")