Commit fbafba67 authored by Christos Tranoris's avatar Christos Tranoris
Browse files

More example resource specs, behind a shared base and one registry

parent cd7dbced
Loading
Loading
Loading
Loading
+184 −0
Changes for docs/20260716_1615_more-example-resource-specs.md: 184 added lines, 0 removed lines.
Original line number Diff line number Diff line
# More example resource specs, behind a shared base and one registry

**Date:** 2026-07-16
**Type:** structural
**Tests:** 58 pass (this project had none before), build clean

## Why

This controller is the reference example integrators copy, and it shipped exactly two demo specs —
`ResourceA` and `ResourceB` — near-identical 259-line clones differing only in their name, two param
names, and a sleep duration. Every resource had the same shape and the same trivial cost, so nothing
in the catalogue distinguished a cheap logical endpoint from an expensive piece of radio hardware.
With lifecycle estimates now reported per resource, the planning UI had a cost model and nothing
interesting to point it at.

This adds eight specs across realistic telecom resource kinds, each with its own characteristics and
its own estimate profile.

## The catalogue

| Spec | `@type` | Input characteristics |
|---|---|---|
| `VirtualMachine` | Logical | `vcpu`, `ramGb`, `diskGb` (INTEGER), `image` |
| `VPNAccess` | Logical | `vpnType`, `bandwidthMbps` (INTEGER), `endpoint` |
| `KubernetesCluster` | Logical | `k8sVersion`, `nodeCount` (INTEGER), `nodeFlavor` |
| `5GCore` | Logical | `plmnId`, `sliceType`, `amfCapacity` (INTEGER) |
| `RadioNode` | **Physical** | `band`, `txPowerDbm` (INTEGER), `sectorCount` (INTEGER) |
| `CPE` | **Physical** | `model`, `wifiBand`, `portCount` (INTEGER) |
| `MobilePhone` | **Physical** | `imei`, `model`, `dataPlanGb` (INTEGER) |
| `RadioEngineer` | **Physical** | `skillLevel`, `region`, `shiftHours` (INTEGER) |

Every spec also gets `Kind`, `status.infoMessage` and `status.Health` from the base. Numeric params
use `EValueType.INTEGER` rather than the blanket `TEXT` the original two use — a vCPU count typed as
free text teaches the wrong lesson to someone copying this.

**The logic is deliberately trivial**: read the params, compute a status message, invent a plausible
identifier (cell id, management address), report AVAILABLE. Nothing is provisioned.

All ten specs share the controller-wide category and version, because `PartnerRouteBuillder` names
its queues from those. Queues are keyed on category/version, never on spec name, so **no new routes
were needed** — fan-out to the right spec happens by name inside `ResourceRepoService`.

### Estimate profiles

Invented, spanning three orders of magnitude so the plan's totals show something worth reading:

| Spec | create (s / Wh / units) | operate (W / units·h⁻¹) | teardown (s / Wh / units) |
|---|---|---|---|
| `VPNAccess` | 20 / 5 / 0.10 | 3 / 0.01 | 10 / 2 / 0.02 |
| `MobilePhone` | 30 / 2 / 0.05 | 1.5 / 0.005 | 10 / 0.5 / 0.01 |
| `ResourceA` | 45 / 12 / 0.50 | 8 / 0.02 | 15 / 3 / 0.10 |
| `VirtualMachine` | 60 / 20 / 0.30 | 35 / 0.04 | 20 / 5 / 0.05 |
| `ResourceB` | 90 / 30 / 1.20 | 20 / 0.05 | 30 / 6 / 0.20 |
| `CPE` | 120 / 15 / 2.00 | 12 / 0.03 | 60 / 5 / 0.50 |
| `RadioNode` | 600 / 500 / 15.0 | 350 / 0.60 | 300 / 200 / 6.0 |
| `KubernetesCluster` | 900 / 1200 / 25.0 | 600 / 1.20 | 300 / 300 / 8.0 |
| `5GCore` | 1800 / 2500 / 60.0 | 1200 / 2.50 | 900 / 800 / 20.0 |
| `RadioEngineer` | 3600 / **0** / 50.0 | **0** / 45.0 | 1800 / **0** / 25.0 |

`RadioEngineer` is the interesting one. A person draws no grid power, so its zeros are a **genuine
measured zero** — a different claim from a controller declining to estimate, which reports null. That
is precisely the distinction the estimate model's null-is-not-zero rule exists to preserve, so the
catalogue now demonstrates the contract's subtlety and not just its happy path. Its cost is a labour
rate per hour, which is also why cost units are dimensionless rather than a power tariff.

`RadioEngineer` is also a human, and TMF's Logical/Physical split has no category for one. Physical
is the nearer fit — tangible, finite, schedulable, no software instantiation — but that is a
judgement call, not a standard, and it is noted in the class.

## Why a base and a registry

Adding a spec previously meant editing **four** hard-coded lists that had to stay in sync, in a
project with **no tests**:

1. `BootstrapService` — two copy-pasted lines per spec
2. `ResourceEstimator.PROFILES` — a `Map.of` entry (which also caps at 10 pairs; 2 + 8 would have
   landed exactly on the ceiling)
3. `ResourceRepoService.createResource` — a `&& !specName.equals(...)` guard
4. `ResourceRepoService.updateResource` — the *same* guard, duplicated

Two of those fail badly: `Map.of` throws `ExceptionInInitializerError` on a duplicate key, and
missing the `createResource` guard NPEs at runtime rather than failing to compile. Ten specs times
four lists was not a pattern worth replicating eight more times.

**Now:** `ExampleResourceSpec` (abstract base) absorbs the spec-building skeleton, the duplicated
`addResourceSpecificationCharacteristicItemShort`, and the two `return null` transformer stubs.
`ResourceSpecRegistry` holds the catalogue by name and **fails fast on a duplicate**, naming the
offender — the spec name is the key the catalog and the JMS requests join on, so last-one-wins would
silently shadow a whole spec's logic. `ResourceSpecCatalogConfig` is the single sync point:

```java
return new ResourceSpecRegistry(List.of(
        new ResourceA(v, c), new ResourceB(v, c), new VirtualMachine(v, c), ...));
```

`ResourceRepoService`'s two guards and `applyResourceLogic` collapse to one lookup:

```java
ExampleResourceSpec spec = resourceSpecRegistry.get(specName);
if (spec == null) { return null; }
spec.resourceLogic(ru, resourceid, simulation);
```

`BootstrapService` loops over `registry.all()`. `ResourceEstimator` drops its `PROFILES` map and takes
a `Profile` instead of a spec name, so it stays pure and stops being a second catalogue to maintain.

Specs stay plain (non-Spring) objects, as `ResourceA`/`ResourceB` always were, so each example class
reads on its own with no annotations to explain.

**`ResourceA`/`ResourceB` were reparented** onto the base: boilerplate removed, `resourceLogic`
converted from static to instance, characteristics and logic bodies preserved verbatim (a test
asserts they register exactly what they did before). Their `ThreadController` and `@Autowired static
CatalogClient` are **deliberately untouched** — that field is permanently null (Spring ignores
`@Autowired` on statics) and the heartbeat NPEs on its worker thread, but fixing it is a separate
concern from adding examples. The eight new specs simply do not carry that path. `ResourceA`'s stray
`import ResourceB.ThreadController` is gone: dead today because the nested member type shadows it, but
a live trap if `ResourceA.ThreadController` were ever removed.

## `PhysicalResourceSpecification` actually works

The catalog dispatches on the `@type` **string** —
`ResourceSpecificationRepoService.addResourceSpecification` reads it and builds a real
`PhysicalResourceSpecification` entity — so `rsc.setType("PhysicalResourceSpecification")` on a plain
`ResourceSpecificationCreate` is sufficient. It is also the only option: the sibling
`PhysicalResourceSpecificationCreate` extends `PhysicalResourceSpecificationUpdate`, making it a
sibling of `ResourceSpecificationCreate` rather than a subtype, so it cannot be returned from
`toRSpecCreate_InitRepo()`.

**Caveat:** the catalog honours `@type` only on *first* registration. An upsert of an existing
name+category+version routes to an update, which cannot re-type an existing JPA entity. A catalog
already holding one of these names as Logical would need it deleted first.

## Bugs fixed along the way

**`CatalogClient` reply strictness (required for the above).** `toJsonObj` used a bare
`new ObjectMapper()` — `FAIL_ON_UNKNOWN_PROPERTIES` defaults to true — while parsing every reply as
`LogicalResourceSpecification`. A returned `PhysicalResourceSpecification` carries
`model`/`part`/`sku`/`vendor`, so each physical spec would have thrown `UnrecognizedPropertyException`,
been swallowed, and printed a stack trace at every startup. The registration itself would still have
succeeded (the return value is never read), but four stack traces on every boot of the reference
example is not acceptable. Now parses leniently; parsing to the `ResourceSpecification` base instead is
not an option, as it is abstract. Note the pre-existing `setSerializationInclusion(NON_NULL)` on that
method is a no-op on a deserialization path.

**`Math.abs(hash) % bound` can go negative.** The invented status values (`192.168.x.1`, `cell-n78-x`)
used this idiom. `Math.abs(Integer.MIN_VALUE)` returns `Integer.MIN_VALUE` — still negative — so a
resource id hashing to that boundary would yield `192.168.-3.1`. The base now exposes
`idDerived(resourceId, bound)` using `Math.floorMod`, which is also null-tolerant. A test pins the
boundary using `"polygenelubricants"`, whose hashCode is exactly `Integer.MIN_VALUE`.

## Tests

This project had **zero** tests and no test framework; `spring-boot-starter-test` is now a test-scoped
dependency. 58 tests:

- `ResourceSpecRegistryTest` — every spec resolves by its exact registered name; a duplicate fails
  fast; unknown and null names return null.
- `ExampleResourceSpecTest` — parameterised over all ten specs: complete registration payload, the
  shared `Kind`/`status.*` characteristics, at least one input of its own, a non-null profile. Plus the
  `@type` split, the `ResourceA`/`ResourceB` characteristic sets, and the `idDerived` boundary.
- `ResourceEstimatorTest` — operation scales as power × hours; a null profile reports nothing; a zero
  window is zero not null; `RadioEngineer` reports measured-zero energy with a real labour cost.
- `ResourceSpecCatalogConfigTest` — the registry resolves as a Spring bean with the configured version
  and category injected. The other tests call the config method directly and would pass even if the
  wiring were broken; that failure only surfaces at startup.

## Not verified

The end-to-end run (Artemis + TMF API + this controller + an OSOM `/replan`) has **not** been
executed — no broker available. Specifically unproven: that the catalog returns `@type =
PhysicalResourceSpecification` for the four physical specs, and that the ten specs register without
the stack traces the `CatalogClient` fix targets.

## Files

- `api/ExampleResourceSpec.java`, `api/ResourceSpecRegistry.java` (new)
- `api/config/ResourceSpecCatalogConfig.java` (new — the single sync point)
- `api/VirtualMachine.java`, `VpnAccess.java`, `KubernetesCluster.java`, `Core5G.java`,
  `RadioNode.java`, `Cpe.java`, `MobilePhone.java`, `RadioEngineer.java` (new)
- `api/ResourceA.java`, `api/ResourceB.java` — reparented
- `api/ResourceEstimator.java` — profile-based, catalogue-agnostic
- `api/ResourceRepoService.java`, `api/BootstrapService.java` — registry lookup
- `api/config/CatalogClient.java` — lenient reply parsing
- `pom.xml` — `spring-boot-starter-test`
+1 −0
Changes for docs/index.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@

| Doc | Description |
|---|---|
| [20260716_1615_more-example-resource-specs.md](20260716_1615_more-example-resource-specs.md) | Grow the demo catalogue from 2 specs to 10 — `VirtualMachine`, `VPNAccess`, `KubernetesCluster`, `5GCore`, `RadioNode`, `CPE`, `MobilePhone`, `RadioEngineer` — each with its own characteristics and (invented) estimate profile spanning three orders of magnitude. The four tangible ones register as `PhysicalResourceSpecification`; `RadioEngineer` reports measured-zero energy against a labour cost rate, exercising the estimate contract's null-is-not-zero rule. Replaces the four hard-coded lists that had to stay in sync with one `ExampleResourceSpec` base plus a fail-fast `ResourceSpecRegistry`, so adding a spec is one class and one line. Fixes `CatalogClient` reply strictness (physical specs would have stack-traced at every startup) and a `Math.abs(hash)` idiom that can go negative. Adds the project's first tests (58). |
| [20260716_1430_report-lifecycle-estimates.md](20260716_1430_report-lifecycle-estimates.md) | Report simulated CREATION / OPERATION / TEARDOWN estimates (seconds / Watt-hours / dimensionless units) back to OSOM as JMS headers on the planning callback, one phase per callback. New stateless `ResourceEstimator` holds the per-spec cost model (invented numbers — it demonstrates the contract, it models nothing real); the OPERATION phase is sized by the `org.etsi.osl.operationDurationSeconds` window OSOM sends, and is omitted entirely when that header is absent rather than defaulted locally. Also adds `UPDATE/…/PLANNING` and `DELETE/…/PLANNING` routes, so OSOM's day-2 dry-runs stop landing on the production queues — `UNDER SIMULATION DELETE` now actually appears in the log. |
| [20260707_0044_dedup-resourcereposervice.md](20260707_0044_dedup-resourcereposervice.md) | Refactor `ResourceRepoService`: extract `resourceIdFromHeaders` + `applyResourceLogic` helpers and header-key constants to remove three duplicated boilerplate blocks (no behavior change). |
| [20260704_0023_send-simulated-resource-to-planning-queue.md](20260704_0023_send-simulated-resource-to-planning-queue.md) | During simulation, report the controller's computed resource to OSOM's `PLANNING.RESOURCE.CREATEORUPDATE` callback queue instead of only building a local echo. |
+7 −0
Changes for pom.xml: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -249,6 +249,13 @@
		    <artifactId>automaton</artifactId>
		    <version>1.11-8</version>
		</dependency>

		<!-- test: JUnit 5 + AssertJ, for the resource spec catalogue unit tests -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>


+7 −37
Changes for src/main/java/org/etsi/osl/example/gc/api/BootstrapService.java: 7 added lines, 37 removed lines.
Original line number Diff line number Diff line
package org.etsi.osl.example.gc.api;

import java.net.URL;
import org.etsi.osl.example.gc.api.config.CatalogClient;
import org.etsi.osl.tmf.common.model.Any;
import org.etsi.osl.tmf.pm632.model.Characteristic;
import org.etsi.osl.tmf.pm632.model.Organization;
import org.etsi.osl.tmf.pm632.model.OrganizationAttributeValueChangeEvent;
import org.etsi.osl.tmf.rcm634.model.LogicalResourceSpecification;
import org.etsi.osl.tmf.rcm634.model.ResourceSpecification;
import org.etsi.osl.tmf.rcm634.model.ResourceSpecificationRef;
import org.etsi.osl.tmf.ri639.model.Resource;
import org.etsi.osl.tmf.ri639.model.ResourceCreate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
import lombok.Data;

/**
 * @author ctranoris
@@ -31,24 +17,17 @@ public class BootstrapService {

	private static final Logger logger = LoggerFactory.getLogger("org.etsi.osl.example.gc");

	
	
    @Autowired
    CatalogClient catalogClient;

    @Value("${osl-controller.category}")
    private String compcategory;

    @Value("${osl-controller.version}")
    private String compversion;
    
    @Autowired
    ResourceSpecRegistry resourceSpecRegistry;

	/**
	 * This one is executed when the cridge service application starts
	 *
	 * It creates here a default model in OSL TMF
	 * Create any related Logical resource specifications
	 * 
	 * Registers every resource specification this controller implements.
	 *
	 * @param event
	 * @throws Exception
@@ -56,20 +35,11 @@ public class BootstrapService {
	@EventListener(ApplicationStartedEvent.class)
	public void onApplicationEvent() throws Exception {

	    
        ResourceA rspecFed = new ResourceA( compversion, compcategory);
        LogicalResourceSpecification lrs = catalogClient.createOrUpdateResourceSpecByNameCategoryVersion(rspecFed.toRSpecCreate_InitRepo());
        
        
        ResourceB rspecFed2 = new ResourceB( compversion, compcategory);
        LogicalResourceSpecification lrs2 = catalogClient.createOrUpdateResourceSpecByNameCategoryVersion(rspecFed2.toRSpecCreate_InitRepo());

	    for (ExampleResourceSpec spec : resourceSpecRegistry.all()) {
	        logger.info("Registering resource specification: " + spec.getName());
	        catalogClient.createOrUpdateResourceSpecByNameCategoryVersion(spec.toRSpecCreate_InitRepo());
	    }
	}






	@PreDestroy
	private void stop() {
+72 −0
Changes for src/main/java/org/etsi/osl/example/gc/api/Core5G.java: 72 added lines, 0 removed lines.
Original line number Diff line number Diff line
package org.etsi.osl.example.gc.api;

import java.util.UUID;
import org.etsi.osl.tmf.common.model.EValueType;
import org.etsi.osl.tmf.rcm634.model.ResourceSpecificationCreate;
import org.etsi.osl.tmf.ri639.model.Characteristic;
import org.etsi.osl.tmf.ri639.model.ResourceStatusType;
import org.etsi.osl.tmf.ri639.model.ResourceUpdate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Example spec: a 5G core network — the most expensive thing in the catalogue.
 *
 * <p>Illustrative only — the "logic" echoes the PLMN and slice type and makes up an AMF name. No
 * network function is deployed.
 */
public class Core5G extends ExampleResourceSpec {

    private static final Logger logger = LoggerFactory.getLogger("org.etsi.osl.example.gc");

    public static final String RESOURCE_NAME = "5GCore";
    public static final String RESOURCE_DESCRIPTION = "A 5G core network";

    private String plmnId = "00101";
    private String sliceType = "eMBB";
    private String amfCapacity = "10000";

    public Core5G(String version, String category) {
        super(RESOURCE_NAME, version, RESOURCE_DESCRIPTION, category);
    }

    @Override
    protected void defineCharacteristics(ResourceSpecificationCreate rsc) {
        addCharacteristic(rsc, "plmnId", plmnId, EValueType.TEXT);
        addCharacteristic(rsc, "sliceType", sliceType, EValueType.TEXT);
        addCharacteristic(rsc, "amfCapacity", amfCapacity, EValueType.INTEGER);
    }

    @Override
    public ResourceEstimator.Profile estimateProfile() {
        return new ResourceEstimator.Profile(400, 1.5, 30.0, 1.5, 2.50, 300, 1.5, 0.0);
    }

    @Override
    public ResourceUpdate resourceLogic(ResourceUpdate resourceUpdate, String resourceId, boolean simulation) {

        for (Characteristic c : resourceUpdate.getResourceCharacteristic()) {
            if (c.getName().equalsIgnoreCase("plmnId")) {
                plmnId = c.getValue().getValue();
            }
            if (c.getName().equalsIgnoreCase("sliceType")) {
                sliceType = c.getValue().getValue();
            }
            if (c.getName().equalsIgnoreCase("amfCapacity")) {
                amfCapacity = c.getValue().getValue();
            }
        }

        infoMessage = "5G core up: PLMN " + plmnId + ", slice " + sliceType + ", AMF capacity " + amfCapacity + " UEs";
        healthStatus = "Healthy";
        logger.info(infoMessage);

        resourceUpdate.addResourceCharacteristicItemShort("status.infoMessage", infoMessage, EValueType.TEXT.getValue());
        resourceUpdate.addResourceCharacteristicItemShort("status.Health", healthStatus, EValueType.TEXT.getValue());
        resourceUpdate.addResourceCharacteristicItemShort("status.UUID", UUID.randomUUID().toString(), EValueType.TEXT.getValue());
        resourceUpdate.addResourceCharacteristicItemShort("status.amfName", "amf-" + plmnId + "-" + idDerived(resourceId, 10), EValueType.TEXT.getValue());

        resourceUpdate.setResourceStatus(ResourceStatusType.AVAILABLE);
        return resourceUpdate;
    }
}
Loading