Skip to content

Commit a19c3f5

Browse files
committed
add RSP multi-tenancy, JSON API, and NBN domain documentation for v0.2.0
- RSP resource with AshStateMachine lifecycle (inactive/active/suspended) - Ash Policy multi-tenancy: SetRspId change, OwnedByActor and NoActor checks, RspOwnership macro shared across 5 RSP-owned resources - NTD and UNI are NBN-owned infrastructure: readable by any RSP, mutable only by nil actor - JSON API via AshJsonApi and Plug.Cowboy on port 4000 - RSP list action with epid sort; field_policy restricts state visibility to record owner - Livebook moved to documentation/domains/diffo_example_nbn.livemd with Kino RSP selector and actor-scoped provisioning flow - documentation/domains/nbn.md: Perentie ecosystem narrative and RSP spirit animals - README updated to describe both NBN and Access domains
1 parent 61f8a37 commit a19c3f5

22 files changed

Lines changed: 657 additions & 80 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,7 @@ diffo-*.tar
2424

2525
/.elixir_ls
2626

27-
.DS_Store
27+
.DS_Store
28+
29+
# Agent related
30+
.claude/*

README.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,24 @@ SPDX-License-Identifier: MIT
88

99
[![Module Version](https://img.shields.io/hexpm/v/diffo)](https://hex.pm/packages/diffo_example)
1010
[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen)](https://hexdocs.pm/diffo_example/)
11-
[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo-dev%2Fdiffo_example%2Fblob%2Fmain%2Fdiffo_example.livemd)
1211
[![License](https://img.shields.io/hexpm/l/diffo)](https://github.com/diffo-dev/diffo_example/blob/master/LICENSES/MIT.md)
1312
[![REUSE status](https://api.reuse.software/badge/github.com/diffo-dev/diffo_example)](https://api.reuse.software/info/github.com/diffo-dev/diffo_example)
1413

15-
This repo contains Diffo Examples.
16-
1714
[Diffo](https://github.com/diffo-dev/diffo) is a Telecommunications Management Forum (TMF) Service and Resource Manager, built for autonomous networks.
1815

16+
This repo contains two independent example domains, each modelling a different slice of a telco network.
17+
18+
## NBN Domain
19+
20+
A declarative model of a fictional NBN Ethernet access hierarchy — NbnEthernet, UNI, AVC, NTD, CVC, NNI Group, and NNI — built entirely with the Diffo Provider Instance DSL. Includes multi-tenancy via Ash Policy: each RSP can only see and manage the resources they own.
21+
22+
[![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo-dev%2Fdiffo_example%2Fblob%2Fdev%2Fdocumentation%2Fdomains%2Fdiffo_example_nbn.livemd)
23+
24+
The livebook walks through provisioning a complete NBN Ethernet access circuit, selecting an RSP to operate as, and demonstrating how the `mine` actions propagate technology, speeds, CVLAN, and port assignments up the resource hierarchy.
25+
26+
## Access Domain
27+
28+
A copper-network equivalent covering DSL access services — Cable, Card, Path, and Shelf. Explore `lib/access/` for the domain model.
1929

2030
## Installation
2131

@@ -32,13 +42,6 @@ end
3242

3343
You need [Neo4j](https://github.com/neo4j/neo4j) available. We recommend the Neo4j Community 5 latest, available at [Neo4j Deploymnent Centre](https://neo4j.com/deployment-center/) which can be installed locally. You can also configure connection to a cloud based database service such as [Neo4j AuraDB](https://neo4j.com/product/auradb/).
3444

35-
## Tutorial
36-
37-
Click the **Run in Livebook** badge above to open the interactive tutorial, or find it at [diffo_example.livemd](diffo_example.livemd).
38-
39-
The diffo_example livebook walks through provisioning a complete NBN Ethernet access circuit — NTD, UNI, AVC, CVC, NNI Group, and NNI — showing how the `mine` actions propagate technology, speeds, CVLAN, and port assignments up the resource hierarchy.
40-
41-
4245
## Contributions
4346

4447
Contributions are welcome, please start with an [issue](https://github.com/diffo-dev/diffo_example/issues)

diffo_example.livemd renamed to documentation/domains/diffo_example_nbn.livemd

Lines changed: 77 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,24 @@ SPDX-License-Identifier: MIT
1010
Mix.install(
1111
[
1212
{:diffo_example, "~> 0.2.0"},
13+
{:kino, "~> 0.14"},
1314
{:req, "~> 0.5"}
1415
],
15-
config: [
16-
bolty: [{Bolt, [
17-
uri: "bolt://localhost:7687",
18-
auth: [username: "neo4j", password: "password"],
19-
user_agent: "diffoExampleLivebook/1",
20-
pool_size: 15,
21-
max_overflow: 3,
22-
prefix: :default,
23-
name: Bolt,
24-
log: false,
25-
log_hex: false
26-
]}]
16+
config: [
17+
bolty: [
18+
{Bolt,
19+
[
20+
uri: "bolt://localhost:7687",
21+
auth: [username: "neo4j", password: "password"],
22+
user_agent: "diffoExampleLivebook/1",
23+
pool_size: 15,
24+
max_overflow: 3,
25+
prefix: :default,
26+
name: Bolt,
27+
log: false,
28+
log_hex: false
29+
]}
30+
]
2731
],
2832
consolidate_protocols: false
2933
)
@@ -67,42 +71,28 @@ It is helpful to have a Neo4j browser open locally, typically at http://localhos
6771
AshNeo4j.Neo4jHelper.delete_all()
6872
```
6973

70-
## Setup Aliases
71-
72-
```elixir
73-
require Ash.Query
74-
alias DiffoExample.Nbn
75-
alias DiffoExample.Nbn.NbnEthernet
76-
alias DiffoExample.Nbn.Uni
77-
alias DiffoExample.Nbn.Avc
78-
alias DiffoExample.Nbn.Ntd
79-
alias DiffoExample.Nbn.Cvc
80-
alias DiffoExample.Nbn.NniGroup
81-
alias DiffoExample.Nbn.Nni
82-
alias DiffoExample.Nbn.Technology
83-
alias DiffoExample.Nbn.Speeds
84-
import Jason, only: [encode: 2]
85-
```
86-
87-
## About NBN
74+
## About NBN Co
8875

8976
NBN (National Broadband Network) is Australia's wholesale fixed-line access network, operated by NBN Co. It provides standardised access products to Retail Service Providers (RSPs), who in turn deliver internet and other services to end customers.
9077

78+
For the purpose of this example we are going to refer to a simplified, and re-imagined NBN Co as NBN.
79+
9180
An RSP typically combines:
9281

9382
* An **NBN Ethernet** access circuit (UNI + AVC) at the customer premises — the access and aggregation layer modelled in this domain
9483
* A **home gateway** device installed at the UNI, which provides the customer's LAN, Wi-Fi, and sometimes voice
9584
* Transport, aggregation, and edge infrastructure connecting the NNI to the RSP's network and on to the internet
9685

97-
NBN Co connects the customer premises to the RSP's network via a Point of Interconnect (POI). The NNI sits at the POI, grouped into NNI Groups. AVCs carrying customer traffic are aggregated onto a CVC, which terminates at the NNI Group. The RSP purchases CVC capacity to carry the aggregate traffic of its customers at that POI.
86+
NBN connects the customer premises to the RSP's network via a Point of Interconnect (POI). The NNI sits at the POI, grouped into NNI Groups. AVCs carrying customer traffic are aggregated onto a CVC, which terminates at the NNI Group. The RSP purchases CVC capacity to carry the aggregate traffic of its customers at that POI.
9887

99-
NBN is delivered over several access technologies — FTTP, FTTN, FTTB, FTTC, HFC, Fixed Wireless, and Satellite — which determine which bandwidth profiles and speeds are available to a given premises.
88+
NBN delivers over several access technologies — FTTP, FTTN, FTTB, FTTC, HFC, Fixed Wireless, and Satellite — which determine which bandwidth profiles and speeds are available to a given premises.
10089

101-
## Technology and Speeds
90+
## NBN Ethernet Technology and Speeds
10291

10392
The NBN domain defines Technology as an Ash Enum covering all NBN access types:
10493

10594
```elixir
95+
alias DiffoExample.Nbn.{Technology,Speeds}
10696
Technology.values()
10797
```
10898

@@ -125,21 +115,51 @@ Speeds.speeds(:wireless_superfast, :FixedWireless)
125115
Speeds.speeds(:home_fast, :FixedWireless)
126116
```
127117

128-
## Building the Network Hinterland
118+
## Multi-tenancy
119+
120+
Each RSP operates in isolation — they can only see and manage the resources they own. This multi-tenancy is enforced at the Ash policy layer: every NBN resource is stamped with the owning RSP's id at creation, and subsequent reads, updates, and destroys are scoped to the record owner.
121+
122+
Select the RSP you want to operate as for the rest of this livebook. All resources you build will be owned by that RSP and isolated from resources owned by others.
123+
124+
```elixir
125+
alias DiffoExample.Nbn
126+
alias DiffoExample.Nbn.Rsp
127+
import Jason, only: [encode: 2]
128+
DiffoExample.Nbn.Initializer.init()
129+
rsps = Nbn.list_rsps!()
130+
Kino.DataTable.new(rsps, keys: [:epid, :name, :short_name, :state])
131+
```
132+
133+
```elixir
134+
rsp_input = Kino.Input.select(
135+
"Operate as RSP",
136+
Enum.map(rsps, fn rsp -> {rsp.name, Atom.to_string(rsp.short_name)} end)
137+
)
138+
```
139+
140+
```elixir
141+
actor = Enum.find(rsps, fn rsp -> rsp.name == Kino.Input.read(rsp_input) end)
142+
actor
143+
```
144+
145+
## Maintaining Shareable Resources
146+
147+
As an RSP we need maintain some shareable network resources: NNI, NNI Group, and CVC.
129148

130-
Before we can provision an NBN Ethernet access we need the shared network resources: NNI, NNI Group, and CVC.
149+
We'll need these everywhere we operate, in advance of and sufficient for all the NBN Ethernet Accesses we have. We'll just build one of each right now.
131150

132-
Build an NNI — the physical interconnect between the RSP and NBN Co:
151+
Build an NNI — the physical interconnect between the RSP and NBN:
133152

134153
```elixir
135-
nni = Nbn.build_nni!(%{})
154+
alias DiffoExample.Nbn.{Nni, NniGroup, CVC}
155+
nni = Nbn.build_nni!(%{}, actor: actor)
136156
nni |> Jason.encode!(pretty: true) |> IO.puts
137157
```
138158

139159
Build an NNI Group — a logical grouping of NNIs at a point of interconnect:
140160

141161
```elixir
142-
nni_group = Nbn.build_nni_group!(%{})
162+
nni_group = Nbn.build_nni_group!(%{}, actor: actor)
143163
nni_group |> Jason.encode!(pretty: true) |> IO.puts
144164
```
145165

@@ -148,30 +168,33 @@ Define the NNI Group with an SVLAN assignment and relate the NNI:
148168
```elixir
149169
nni_group = Nbn.define_nni_group!(nni_group, %{
150170
characteristic_value_updates: [nni_group: [svlan: 100]]
151-
})
171+
}, actor: actor)
152172
nni_group = Nbn.relate_nni_group!(nni_group, %{
153173
relationships: [%Diffo.Provider.Instance.Relationship{id: nni.id, alias: :nni, type: :isAssigned}]
154-
})
174+
}, actor: actor)
155175
nni_group |> Jason.encode!(pretty: true) |> IO.puts
156176
```
157177

158178
Build a CVC — the aggregation virtual circuit that terminates at the NNI Group:
159179

160180
```elixir
161-
cvc = Nbn.build_cvc!(%{})
181+
cvc = Nbn.build_cvc!(%{}, actor: actor)
162182
cvc = Nbn.relate_cvc!(cvc, %{
163183
relationships: [%Diffo.Provider.Instance.Relationship{id: nni_group.id, alias: :nni_group, type: :isAssigned}]
164-
})
184+
}, actor: actor)
165185
cvc |> Jason.encode!(pretty: true) |> IO.puts
166186
```
167187

168-
## Provisioning an NBN Ethernet Access
188+
## Provisioning NBN Ethernet
189+
190+
For each customer site we want to provide service to, we need an NBN Ethernet composite resource, involving an NTD, UNI, AVC and CVC.
169191

170-
With the hinterland in place we can provision a customer-facing NBN Ethernet access.
192+
The NTD is NBN infrastructure — built and managed by NBN, visible to any RSP. It may not exist at a new or existing customer site, so may be built on demand by NBN.
171193

172194
Build an NTD — the device installed at the customer premises:
173195

174196
```elixir
197+
alias DiffoExample.Nbn.{Ntd, Uni, Avc, NbnEthernet}
175198
ntd = Nbn.build_ntd!(%{})
176199
ntd = Nbn.define_ntd!(ntd, %{
177200
characteristic_value_updates: [ntd: [technology: :FTTP, ports: [1, 2, 3, 4]]]
@@ -203,28 +226,28 @@ uni |> Jason.encode!(pretty: true) |> IO.puts
203226
Build an AVC and assign it a CVLAN from the CVC:
204227

205228
```elixir
206-
avc = Nbn.build_avc!(%{})
229+
avc = Nbn.build_avc!(%{}, actor: actor)
207230
avc = Nbn.define_avc!(avc, %{
208231
characteristic_value_updates: [avc: [bandwidth_profile: :home_ultrafast]]
209-
})
232+
}, actor: actor)
210233
cvc = Nbn.assign_cvlan!(cvc, %{
211234
assignment: %Assignment{assignee_id: avc.id, operation: :auto_assign}
212-
})
213-
avc = Nbn.mine_avc!(avc, %{})
235+
}, actor: actor)
236+
avc = Nbn.mine_avc!(avc, %{}, actor: actor)
214237
avc |> Jason.encode!(pretty: true) |> IO.puts
215238
```
216239

217240
Now build the top-level NBN Ethernet access and relate it to both the UNI and AVC:
218241

219242
```elixir
220-
pri = Nbn.build_nbn_ethernet!(%{})
243+
pri = Nbn.build_nbn_ethernet!(%{}, actor: actor)
221244
pri = Nbn.relate_nbn_ethernet!(pri, %{
222245
relationships: [
223246
%Diffo.Provider.Instance.Relationship{id: uni.id, alias: :uni, type: :isAssigned},
224247
%Diffo.Provider.Instance.Relationship{id: avc.id, alias: :avc, type: :isAssigned}
225248
]
226-
})
227-
pri = Nbn.mine_nbn_ethernet!(pri, %{})
249+
}, actor: actor)
250+
pri = Nbn.mine_nbn_ethernet!(pri, %{}, actor: actor)
228251
pri |> Jason.encode!(pretty: true) |> IO.puts
229252
```
230253

@@ -251,19 +274,19 @@ The NBN domain exposes a JSON API via `Plug.Cowboy` on port 4000. Start the serv
251274
First check the catalog — all NBN specifications are initialised on startup:
252275

253276
```elixir
254-
Req.get!("http://localhost:4000/catalog").body |> Jason.encode!(pretty: true) |> IO.puts()
277+
Req.get!("http://localhost:4000/catalog", decode_body: false).body |> IO.puts()
255278
```
256279

257280
Now retrieve all NBN Ethernet instances:
258281

259282
```elixir
260-
Req.get!("http://localhost:4000/nbnEthernet").body |> Jason.encode!(pretty: true) |> IO.puts()
283+
Req.get!("http://localhost:4000/nbnEthernet", decode_body: false).body |> IO.puts()
261284
```
262285

263286
Or fetch the one we provisioned above by id:
264287

265288
```elixir
266-
Req.get!("http://localhost:4000/nbnEthernet/#{pri.id}").body |> Jason.encode!(pretty: true) |> IO.puts()
289+
Req.get!("http://localhost:4000/nbnEthernet/#{pri.id}", decode_body: false).body |> IO.puts()
267290
```
268291

269292
## What Next?

documentation/domains/nbn.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# The NBN Domain
8+
9+
## The Perentie Ecosystem
10+
11+
NBN Co operates as **Perentie** — Australia's largest monitor lizard, ancient and continent-wide. Perentie owns the territory. It does not compete with the animals moving through its country; it simply defines the ground they all walk on.
12+
13+
The RSPs are the spirit animals of the ecosystem, each finding their niche in Perentie's range:
14+
15+
| RSP | Spirit Animal | Inspiration |
16+
| ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
17+
| Wedge-tail Telecom | Wedge-tailed Eagle | Australia's apex aerial predator — dominant, territorial, commands every landscape it surveys |
18+
| Quokka Connect | Quokka | Famously friendly, genuinely Australian, radiates good energy — operates in WA under bilateral agreement with Perentie |
19+
| Ibis Telecom | White Ibis | Beloved in spite of its reputation, scrappy, surprisingly capable |
20+
| Taipan Group | Taipan | Carries the TPG initials; fast, precise, not to be underestimated |
21+
| Echidna Networks | Echidna | Prickly on the surface, uniquely capable beneath it |
22+
| Dugong Digital | Dugong | Slow and steady, but still very much alive |
23+
| Lyrebird | Lyrebird | Mimics everything, loops back on itself, endlessly clever |

lib/diffo_example/application.ex

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ defmodule DiffoExample.Application do
99

1010
@impl true
1111
def start(_type, _args) do
12-
children = [
13-
{Plug.Cowboy, scheme: :http, plug: DiffoExample.Nbn.Router, options: [port: 4000]},
14-
{Task, &DiffoExample.Nbn.Initializer.init/0}
15-
]
12+
children =
13+
[
14+
{Task, &DiffoExample.Nbn.Initializer.init/0}
15+
] ++
16+
if Mix.env() == :test,
17+
do: [],
18+
else: [{Plug.Cowboy, scheme: :http, plug: DiffoExample.Nbn.Router, options: [port: 4000]}]
1619

1720
Supervisor.start_link(children, strategy: :one_for_one, name: DiffoExample.Supervisor)
1821
end

lib/nbn/changes/set_rsp_id.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule DiffoExample.Nbn.Changes.SetRspId do
6+
use Ash.Resource.Change
7+
8+
def change(changeset, _opts, %{actor: %{id: id}}) do
9+
Ash.Changeset.force_change_attribute(changeset, :rsp_id, id)
10+
end
11+
12+
def change(changeset, _opts, _context), do: changeset
13+
end

lib/nbn/checks/no_actor.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule DiffoExample.Nbn.Checks.NoActor do
6+
@moduledoc false
7+
use Ash.Policy.SimpleCheck
8+
9+
@impl true
10+
def describe(_opts), do: "no actor present (internal Perentie call)"
11+
12+
@impl true
13+
def match?(nil, _context, _opts), do: true
14+
def match?(_actor, _context, _opts), do: false
15+
end

lib/nbn/checks/owned_by_actor.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule DiffoExample.Nbn.Checks.OwnedByActor do
6+
@moduledoc false
7+
use Ash.Policy.FilterCheck
8+
9+
@impl true
10+
def describe(_opts), do: "actor owns resource (rsp_id matches actor id)"
11+
12+
@impl true
13+
def filter(actor, _context, _opts) do
14+
case actor do
15+
%{id: id} -> [rsp_id: id]
16+
_ -> false
17+
end
18+
end
19+
end

0 commit comments

Comments
 (0)