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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/actions/detect-changes/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,17 @@ runs:
- 'source/end2end-tests/**'
- 'source/Makefile'
- '.github/**'
# A "service" deployable spans its domain lib + thin bin crate; the
# tenants binary additionally composes the subscriptions + billing libs
# (ADR-0010), so a change to any of them rebuilds tenants.
tenants:
- 'source/crates/tenants/**'
- 'source/crates/app-tenants/**'
- 'source/crates/subscriptions/**'
- 'source/crates/billing/**'
ddns:
- 'source/crates/ddns/**'
- 'source/crates/app-ddns/**'
tunneller:
- 'source/crates/tunneller/**'
- 'source/crates/app-tunneller/**'
55 changes: 48 additions & 7 deletions .github/workflows/build-service.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,56 @@ jobs:
# bails ("Failed to find targets"). `--all` formats every workspace member.
run: cargo fmt --all --check --manifest-path source/Cargo.toml

- name: Resolve cargo package set
# A "service" is now a domain lib + its thin bin (uniform lib/bin layout,
# ADR-0010); `tenants` additionally composes the `subscriptions` + `billing`
# libs into its binary. Lint/test the full set; build the bin package.
id: pkgs
env:
SERVICE: ${{ inputs.service }}
run: |
set -euo pipefail
case "$SERVICE" in
tenants) LINT="-p wardnet-tenants -p wardnet-subscriptions -p wardnet-billing -p wardnet-tenants-bin" ;;
ddns) LINT="-p wardnet-ddns -p wardnet-ddns-bin" ;;
tunneller) LINT="-p wardnet-tunneller -p wardnet-tunneller-bin" ;;
*) echo "::error::unknown service $SERVICE"; exit 1 ;;
esac
{
echo "lint=$LINT -p wardnet_common"
echo "bin=-p wardnet-${SERVICE}-bin"
} >> "$GITHUB_OUTPUT"

- name: Verify aggregate boundaries (ADR-0010)
# The tenants / subscriptions / billing crates MUST stay mutually
# independent (each promotable to its own host) — they may depend only on
# `wardnet_common`. A sibling dependency would make the boundary a
# convention, not a compiler guarantee. Cheap guard so it cannot regress.
if: inputs.service == 'tenants'
working-directory: source
run: |
set -euo pipefail
fail=0
for c in tenants subscriptions billing; do
if grep -REn 'wardnet[_-](tenants|subscriptions|billing)' "crates/$c/Cargo.toml" \
| grep -v 'name = '; then
echo "::error::crate '$c' depends on a sibling aggregate crate"; fail=1
fi
if grep -REn 'use +wardnet_(tenants|subscriptions|billing)' "crates/$c/src" 2>/dev/null; then
echo "::error::crate '$c' imports a sibling aggregate crate"; fail=1
fi
done
[ "$fail" -eq 0 ] || exit 1

- name: Clippy
# Lint this service crate + the shared common lib. Pipe clippy JSON
# through clippy-sarif so CI gets a SARIF report for Code Scanning
# while still printing human-readable output. `-D warnings` is
# enforced; any warning becomes a build failure.
# Lint this service's crates (domain lib + thin bin, plus the libs it
# composes) + the shared common lib. Pipe clippy JSON through clippy-sarif
# so CI gets a SARIF report for Code Scanning while still printing
# human-readable output. `-D warnings` is enforced.
run: |
set -o pipefail
cargo clippy --manifest-path source/Cargo.toml \
-p wardnet-${{ inputs.service }} -p wardnet_common \
${{ steps.pkgs.outputs.lint }} \
--all-targets --message-format=json -- -D warnings \
| clippy-sarif | tee rust-clippy-${{ inputs.service }}.sarif | sarif-fmt

Expand All @@ -115,7 +156,7 @@ jobs:
TUNNELLER_TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432
run: >
cargo test --manifest-path source/Cargo.toml
-p wardnet-${{ inputs.service }} -p wardnet_common
${{ steps.pkgs.outputs.lint }}
--all-targets -- --include-ignored

build:
Expand Down Expand Up @@ -149,7 +190,7 @@ jobs:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
run: >
cargo build --manifest-path source/Cargo.toml
-p wardnet-${{ inputs.service }}
-p wardnet-${{ inputs.service }}-bin
--release --target aarch64-unknown-linux-gnu

- name: Strip binary
Expand Down
21 changes: 21 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Codecov configuration.
#
# The cloud services' binary entrypoints (`crates/app-*/src/main.rs`) are thin
# composition roots — process wiring (build repos → services → reactors → listeners)
# exercised by the mesh e2e harness, not by unit coverage — so they are excluded from
# coverage accounting. A 1% status threshold keeps sub-percent noise (e.g. a structural
# refactor that moves uncovered wiring) from failing the patch/project gate; the
# `All checks passed` aggregator remains the required branch-protection check.
coverage:
status:
project:
default:
threshold: 1%
patch:
default:
threshold: 1%

ignore:
- "**/crates/app-tenants/src/main.rs"
- "**/crates/app-ddns/src/main.rs"
- "**/crates/app-tunneller/src/main.rs"
20 changes: 20 additions & 0 deletions source/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ Conventions and invariants for agents working inside `source/`.
> account creation now happens only at a credential-proving moment. See `docs/adr/0008`,
> `docs/adr/0009`, and invariants #2/#18/#23/#25.

> **PR1 / My Account (2026-06-28):** the `tenants` crate's intermingled billing was split
> into **three mutually-independent aggregate crates** — **`subscriptions`** (the
> provider-agnostic **license**: status/entitlement/trial+grace + reaper), **`billing`**
> (the **payment** provider: `StripeGateway`, Checkout/Portal, the webhook, the
> `processed_stripe_events` ledger, and a new Billing-owned `billing_customers` table that
> the `stripe_*` columns moved into), and **`tenants`** (identity/networks/daemons +
> Identities). The three depend on `wardnet_common` and **never on each other**
> (compiler-enforced; a CI guard greps for regressions). All cross-crate interaction goes
> through **ports in `wardnet_common`**: the transport-free **`EventBus`/`EventStream`**
> (replacing the tokio-leaking `EventPublisher`; `DomainEvent` is now serde+versioned) and
> the synchronous **`SubscriptionReader`** (entitlement reads), **`SubscriptionCommands`**
> (the one-way Billing→Subscription edge), and **`BillingPort`** (checkout/portal/webhook)
> trait objects, injected by a new composition-root binary crate. The uniform **lib/bin**
> layout was applied to all services: each domain is a pure lib (`crates/<svc>`) and a thin
> bin crate (`crates/app-<svc>`, artifact name unchanged) composes it — for `tenants` the
> bin composes `tenants` + `subscriptions` + `billing`. The cross-aggregate **reconcile**
> moved to the composition root. **Strictly behavior-preserving**; the split is designed so
> a future out-of-process move is an adapter/config change, not a rewrite. See
> `docs/adr/0010` (supersedes `0006`, refines `0007`) and invariants #22/#23/#24.

> **Status:** every invariant below is **live on `main`**. The earlier `[#444]`/`[#445]` planning
> tags are retired — the SNI/tunnel data plane (WS-D), PostgreSQL/Neon, the multi-node `TunnelRouter`
> (`LocalRouter`, its sole impl, already does inter-node forwarding), and the inforge-injected env
Expand Down
40 changes: 31 additions & 9 deletions source/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,21 @@ details. (See `docs/adr/` for the decisions behind these.)
httpOnly cookie (30-day sliding), created at login. Distinct from the short-lived
[JWT](#caller-type) it mints via the silent exchange — the session is what logout /
password-reset / deregister destroys. See `docs/adr/0009`.
- **Subscription** — the billing aggregate that **grants** a tenant's
[entitlement](#entitlement). A tenant has a 1:N history with at most one **live**
(non-canceled) row — its *current* subscription. Status is `trialing → active →
past_due → canceled` (Stripe-driven once paid). The free [trial](#trial) is itself
a subscription row. Owned by `SubscriptionService`; no other service touches it.
- **Subscription** — the **license**: the provider-agnostic aggregate that **grants** a
tenant's [entitlement](#entitlement). A tenant has a 1:N history with at most one
**live** (non-canceled) row — its *current* subscription. Status is `trialing → active
→ past_due → canceled`. The free [trial](#trial) is itself a subscription row. Knows
nothing about payment providers (that is [Billing](#billing)); owned by
`SubscriptionService` in the `subscriptions` crate. See `docs/adr/0010`.
- **Billing** — *how* a subscription is paid for: the payment provider (Stripe today),
hosted [Checkout](#checkout-session)/[Portal](#billing-portal), the webhook, the
idempotency ledger, and the provider-reference ids (the `billing_customers` table).
Swappable; owned by `BillingService` in the `billing` crate. Drives the license only
through the [SubscriptionCommands](#subscriptionreader--subscriptioncommands) port —
Subscription never calls Billing back.
- **PaymentProvider** — the port behind which a concrete provider sits (`StripeGateway`
today). Billing talks to the provider only through it, so the wire format never leaks
into the lifecycle logic.
- **Plan** — a purchasable tier, defined as a Stripe Price whose metadata carries the
`max_networks` / `max_daemons` it grants. Adding a plan is a Stripe change, no deploy.
- **Network** — one wardnet network owned by a tenant. Holds a globally-unique
Expand Down Expand Up @@ -73,10 +83,22 @@ details. (See `docs/adr/` for the decisions behind these.)

## Eventing & reconciliation

- **Domain event** — an in-process signal a service raises so another aggregate can
react, instead of one service reaching into another's repository (`TenantCreated`,
`TenantDeregistered`, `SubscriptionDeactivated`). Best-effort delivery (a broadcast
bus); the reconcile is the guarantee. See `docs/adr/0007`.
- **Domain event** — a signal a service raises so another aggregate can react, instead
of one service reaching into another's repository (`TenantCreated`,
`TenantDeregistered`, `SubscriptionDeactivated`). Best-effort delivery; the
[reconcile](#reconcile) is the guarantee. Serde-serializable with a versioned wire
format. See `docs/adr/0007`, `docs/adr/0010`.
- **EventBus / EventStream** — the transport-free **port** domain events flow through:
`publish(&event)` + `subscribe(group) -> EventStream` (no `tokio` type in any
signature). One in-process adapter today; a durable broker (using `group` for
competing consumers across replicas) drops in later with no reactor change. Supersedes
the in-process-only "broadcast bus" framing.
- **SubscriptionReader / SubscriptionCommands** — the synchronous query/command **ports**
over the [license](#subscription) aggregate (in `wardnet_common`). `SubscriptionReader`
= entitlement reads (`current` / grace-aware `is_active`); `SubscriptionCommands` = the
one-way [Billing](#billing) → Subscription write edge (`convert_trial_to_paid` /
`update_paid` / `mark_past_due` / `cancel`). In-proc adapter = a direct call; a
mesh-mTLS HTTP adapter later. The crates depend on these ports, never on each other.
- **Reactor** — a long-running loop subscribed to the event bus that turns a domain
event into a call on the **owning** service's method (e.g. `TenantCreated` →
`SubscriptionService::create_trial`; `SubscriptionDeactivated` →
Expand Down
93 changes: 91 additions & 2 deletions source/Cargo.lock

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

7 changes: 6 additions & 1 deletion source/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "3"
members = ["crates/common", "crates/tenants", "crates/ddns", "crates/tunneller", "xtask", "end2end-tests/mesh"]
members = ["crates/common", "crates/subscriptions", "crates/billing", "crates/tenants", "crates/app-tenants", "crates/ddns", "crates/app-ddns", "crates/tunneller", "crates/app-tunneller", "xtask", "end2end-tests/mesh"]

[workspace.package]
edition = "2024"
Expand All @@ -16,6 +16,11 @@ missing_panics_doc = "allow"
[workspace.dependencies]
# Internal
wardnet_common = { path = "crates/common" }
wardnet-tenants = { path = "crates/tenants" }
wardnet-subscriptions = { path = "crates/subscriptions" }
wardnet-billing = { path = "crates/billing" }
wardnet-ddns = { path = "crates/ddns" }
wardnet-tunneller = { path = "crates/tunneller" }

# Web stack
axum = { version = "0.8", features = ["macros", "ws"] }
Expand Down
24 changes: 24 additions & 0 deletions source/crates/app-ddns/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "wardnet-ddns-bin"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Wardnet DDNS deployable — thin composition root over the wardnet_ddns library"

# Binary artifact stays `wardnet-ddns` (unchanged deploy unit); only the package that
# owns it moved out of the library crate (uniform lib/bin layout — ADR-0010).
[[bin]]
name = "wardnet-ddns"
path = "src/main.rs"

[dependencies]
wardnet_common = { workspace = true }
wardnet-ddns = { workspace = true }

tokio = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }

[lints]
workspace = true
File renamed without changes.
Loading
Loading