Commit 7c4424dc authored by Christos Tranoris's avatar Christos Tranoris
Browse files

KubernetesCluster estimate scales with nodeCount

parent ec2aab4a
Loading
Loading
Loading
Loading
+61 −0
Changes for docs/20260901_1155_kubernetescluster-estimate-scales-with-nodecount.md: 61 added lines, 0 removed lines.
Original line number Diff line number Diff line
# KubernetesCluster estimate scales with nodeCount

**Date:** 2026-09-01 11:55
**Branch:** simulation-planning

## What changed

`KubernetesCluster.estimateProfile` now depends on the requested `nodeCount`, so a
planning (dry-run) estimate for a bigger cluster reports a proportionally bigger
CREATION / OPERATION / TEARDOWN figure instead of a flat number.

Previously the spec returned a single hard-coded profile
(`Profile(900, 1.5, 3.0, 3.0, 0.10, 300, 1.5, 0.0)`) regardless of how many
worker nodes the request asked for.

## The model

A fixed control-plane part plus a per-node increment:

| Figure                | Control plane | Per node |
|-----------------------|--------------:|---------:|
| creationSeconds       |           360 |      180 |
| creationWh            |           0.6 |      0.3 |
| creationCost          |           1.2 |      0.6 |
| operationWatts        |           1.2 |      0.6 |
| operationCostPerHour  |          0.04 |     0.02 |
| teardownSeconds       |           120 |       60 |
| teardownWh            |           0.6 |      0.3 |
| teardownCost          |           0.0 |      0.0 |

The per-node figures are calibrated so that the default 3-node cluster reproduces
the spec's original flat profile exactly — no existing estimate moves unless the
request changes the node count.

## How it reads the request

Follows the `RadioEngineer` pattern (the other request-dependent spec):

- `estimateProfile()` — the no-request figure — sizes against `DEFAULT_NODE_COUNT` (3),
  a constant shared with the `nodeCount` characteristic default so the two cannot drift.
- `estimateProfile(ResourceUpdate requested)` reads `nodeCount` **from the request**,
  not from the mutable `nodeCount` field. Specs are singletons shared across concurrent
  planning requests and the OPERATION callback runs on a background thread after
  `resourceLogic`, so reading the field would let one request's cluster size leak into
  another's estimate.
- `nodeCountFrom` falls back to the default when the characteristic is absent,
  unparseable, or outside `1..500`, logging a warning for the last two cases.

## Tests

New `KubernetesClusterTest` (6 tests): default reproduces the original profile, a
3-node request matches the default, every phase scales linearly with node count, a
bigger cluster costs more to operate, unusable input falls back to the default, and
one request's estimate does not affect the next even after `resourceLogic` mutation.

`KubernetesClusterTest`, `ResourceEstimatorTest`, `RadioEngineerTest` — 22/22 green.

## Files

- `src/main/java/org/etsi/osl/example/gc/api/KubernetesCluster.java`
- `src/test/java/org/etsi/osl/example/gc/api/KubernetesClusterTest.java` (new)
+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 |
|---|---|
| [20260901_1155_kubernetescluster-estimate-scales-with-nodecount.md](20260901_1155_kubernetescluster-estimate-scales-with-nodecount.md) | `KubernetesCluster.estimateProfile` now scales with the requested `nodeCount`: a fixed control-plane part plus a per-node increment, calibrated so the default 3-node cluster reproduces the spec's original flat profile. Reads `nodeCount` from the request (not the singleton's mutable field, which the background OPERATION callback would race on), following the `RadioEngineer` pattern; falls back to 3 nodes when the characteristic is absent, unparseable, or outside `1..500`. New `KubernetesClusterTest` (6 tests); suite 22/22 on the estimator group. |
| [20260827_1600_do-not-release-an-allocation-you-did-not-make.md](20260827_1600_do-not-release-an-allocation-you-did-not-make.md) | The X.731 pass this morning left `setResourceStatus(AVAILABLE)` standing in all ten specs, on the reasoning that `resourceStatus` no longer decides anything. It does not decide the service state, but it is how the platform records **pool allocation** — and OSOM builds the outbound `ResourceUpdate` from the live catalog entry, so that line overwrote an orchestrator's `RESERVED` and returned a pooled resource to the free pool while an order was using it, where a second order can take it and the end-of-operating-date sweep can terminate it. A reference project was teaching every controller author to do it. All ten specs now call the platform's new `declareResourceStatus`, which applies a verdict over a condition or over nothing and leaves a `RESERVED`/`STANDBY` alone; the three health attributes are still set unconditionally, because health is the controller's own to state. Teardown still clears the allocation outright. `ResourceA`/`ResourceB` carry the full explanation as the specs an author reads first. Adds three tests run against every spec in the catalogue; suite 123/123. |
| [20260827_1305_x731-health-on-every-path.md](20260827_1305_x731-health-on-every-path.md) | Adopt the ITU-T X.731 health contract on every path. OSOM now decides a supporting service's state from `Resource.deriveHealth()` — `operationalState` + `administrativeState` — and no longer from `resourceStatus`, so this controller, which reported only `resourceStatus`, derived to `PENDING` and hung every order it backed at `RESERVED` forever, silently, with the delete path equally unable to terminate anything. All ten specs now declare `AVAILABLE` + `ENABLE`/`UNLOCKED`/`ACTIVE` explicitly (written out per spec, not hidden in a base-class helper — a spec is copied as a template); `ResourceRepoService` sends per-phase health headers on the planning callbacks (`HELD` → `UP` on create, `GONE` on teardown), derived by asking `Resource.applyHealth` rather than re-tabulating the mapping, and writes the `SHUTDOWN` tombstone on the real delete. Fixes a dead store that made the delete echo report `AVAILABLE`, and teaches the new contract in the authoring guide (new "Health, not status" section). Two pre-existing failures fixed in passing: the promised `status.*` characteristics were never registered on the spec, and an invented labour rate had drifted from its tests. Suite goes from 67 run / 20 failures to 93/93. |
| [20260719_0017_writing-an-openslice-controller.md](20260719_0017_writing-an-openslice-controller.md) | **Start here to write your own controller.** Architecture and authoring guide: what a generic controller is and why the category is the whole contract; the component map and the one-registry-not-four-lists decision; both request paths end to end (production writes vs planning callbacks) with sequence diagrams; the simulation contract — run the real logic, suppress side effects, model deployment as taking time, target the id OSOM acknowledged; the estimate contract including the null-is-not-zero rule and the duty-cycled operation phase; a step-by-step walkthrough for building your own; and the gotchas that fail silently (spec names as a JMS join key, `specType()` only honoured on first registration, lenient catalog reply parsing, singleton specs and concurrency, `Math.abs` on a hash). Also rewrites the stale README, which still described the original two-spec state. |
+77 −2
Changes for src/main/java/org/etsi/osl/example/gc/api/KubernetesCluster.java: 77 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -17,6 +17,11 @@ import org.slf4j.LoggerFactory;
 *
 * <p>Illustrative only — the "logic" makes up an API-server endpoint and echoes the node count. No
 * cluster is created.
 *
 * <p>This is also a second spec whose estimate depends on the request: the cost of a cluster scales
 * with how many worker nodes it has. The model is a fixed control-plane part plus a per-node
 * increment, so a bigger cluster reports a proportionally bigger CREATION / OPERATION / TEARDOWN
 * estimate (see {@link #estimateProfile(ResourceUpdate)}).
 */
public class KubernetesCluster extends ExampleResourceSpec {

@@ -25,8 +30,19 @@ public class KubernetesCluster extends ExampleResourceSpec {
    public static final String RESOURCE_NAME = "KubernetesCluster";
    public static final String RESOURCE_DESCRIPTION = "A Kubernetes cluster";

    /**
     * The node count the fixed {@link #estimateProfile()} sizes against, and the characteristic default.
     * Shared so the two cannot drift apart. The per-node figures below are calibrated so that a
     * {@code DEFAULT_NODE_COUNT}-node cluster reproduces this spec's original flat profile.
     */
    private static final int DEFAULT_NODE_COUNT = 3;

    /** A cluster with no worker nodes is not a thing we estimate; anything above this is a typo, not a plan. */
    private static final int MIN_NODE_COUNT = 1;
    private static final int MAX_NODE_COUNT = 500;

    private String k8sVersion = "1.29";
    private String nodeCount = "3";
    private String nodeCount = String.valueOf(DEFAULT_NODE_COUNT);
    private String nodeFlavor = "m1.large";

    public KubernetesCluster(String version, String category) {
@@ -40,9 +56,68 @@ public class KubernetesCluster extends ExampleResourceSpec {
        addCharacteristic(rsc, "nodeFlavor", nodeFlavor, EValueType.TEXT);
    }

    /**
     * The figure used when there is no request to consult: a {@link #DEFAULT_NODE_COUNT}-node cluster.
     * {@link #estimateProfile(ResourceUpdate)} is what sizes an estimate to the cluster actually asked
     * for.
     */
    @Override
    public ResourceEstimator.Profile estimateProfile() {
        return new ResourceEstimator.Profile(900, 1.5, 3.0, 3.0, 0.10, 300, 1.5, 0.0);
        return profileFor(DEFAULT_NODE_COUNT);
    }

    /** Sizes the estimate to the number of nodes this request asked for. */
    @Override
    public ResourceEstimator.Profile estimateProfile(ResourceUpdate requested) {
        return profileFor(nodeCountFrom(requested));
    }

    /**
     * Fixed control-plane cost plus a per-node increment. Calibrated so {@code nodes == DEFAULT_NODE_COUNT}
     * yields {@code Profile(900, 1.5, 3.0, 3.0, 0.10, 300, 1.5, 0.0)} — this spec's original flat profile.
     * Always-on, so the 8-arg constructor.
     */
    private static ResourceEstimator.Profile profileFor(int nodes) {
        return new ResourceEstimator.Profile(
                360 + 180.0 * nodes,   // creationSeconds
                0.6 + 0.3 * nodes,     // creationWh
                1.2 + 0.6 * nodes,     // creationCost
                1.2 + 0.6 * nodes,     // operationWatts
                0.04 + 0.02 * nodes,   // operationCostPerHour
                120 + 60.0 * nodes,    // teardownSeconds
                0.6 + 0.3 * nodes,     // teardownWh
                0.0);                  // teardownCost
    }

    /**
     * The requested node count, falling back to {@link #DEFAULT_NODE_COUNT} when the characteristic is
     * absent, unparseable, or outside {@link #MIN_NODE_COUNT}..{@link #MAX_NODE_COUNT}. Reads the request
     * rather than the {@code nodeCount} field on purpose: this spec is a singleton shared across
     * concurrent planning requests, so the field is whatever the last {@code resourceLogic} left behind.
     */
    private static int nodeCountFrom(ResourceUpdate requested) {
        if (requested == null || requested.getResourceCharacteristic() == null) {
            return DEFAULT_NODE_COUNT;
        }
        for (Characteristic c : requested.getResourceCharacteristic()) {
            if (c.getName() == null || !c.getName().equalsIgnoreCase("nodeCount")
                    || c.getValue() == null || c.getValue().getValue() == null) {
                continue;
            }
            try {
                int nodes = Integer.parseInt(c.getValue().getValue().trim());
                if (nodes >= MIN_NODE_COUNT && nodes <= MAX_NODE_COUNT) {
                    return nodes;
                }
                logger.warn("Ignoring out-of-range nodeCount '" + c.getValue().getValue()
                        + "'; estimating against " + DEFAULT_NODE_COUNT + " nodes");
            } catch (NumberFormatException e) {
                logger.warn("Ignoring unparseable nodeCount '" + c.getValue().getValue()
                        + "'; estimating against " + DEFAULT_NODE_COUNT + " nodes");
            }
            return DEFAULT_NODE_COUNT;
        }
        return DEFAULT_NODE_COUNT;
    }

    @Override
+104 −0
Changes for src/test/java/org/etsi/osl/example/gc/api/KubernetesClusterTest.java: 104 added lines, 0 removed lines.
Original line number Diff line number Diff line
package org.etsi.osl.example.gc.api;

import org.etsi.osl.tmf.common.model.EValueType;
import org.etsi.osl.tmf.ri639.model.ResourceUpdate;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;

/**
 * The other spec whose estimate depends on the request: a cluster's cost scales with its worker-node
 * count. What is worth locking in is the shape — a fixed control-plane part plus a per-node increment,
 * the default reproducing the original flat profile, and the request (not the mutated field) driving
 * the number.
 */
class KubernetesClusterTest {

    private static final long DAY = 86400;

    private static KubernetesCluster cluster() {
        return new KubernetesCluster("0.1.0", "test.category");
    }

    private static ResourceUpdate requestWith(String nodeCount) {
        ResourceUpdate r = new ResourceUpdate();
        if (nodeCount != null) {
            r.addResourceCharacteristicItemShort("nodeCount", nodeCount, EValueType.INTEGER.getValue());
        }
        return r;
    }

    /** The default (3 nodes) reproduces the profile this spec had before the estimate scaled with nodes. */
    @Test
    void defaultNodeCount_reproducesTheOriginalFlatProfile() {
        ResourceEstimator.Profile p = cluster().estimateProfile();

        assertThat(p.creationSeconds()).isEqualTo(900.0);
        assertThat(p.creationWh()).isEqualTo(1.5);
        assertThat(p.creationCost()).isEqualTo(3.0);
        assertThat(p.operationWatts()).isEqualTo(3.0);
        assertThat(p.operationCostPerHour()).isCloseTo(0.10, within(1e-9));
        assertThat(p.teardownSeconds()).isEqualTo(300.0);
        assertThat(p.teardownWh()).isEqualTo(1.5);
        assertThat(p.teardownCost()).isEqualTo(0.0);
    }

    /** A request for 3 nodes matches the no-request default. */
    @Test
    void aThreeNodeRequest_matchesTheDefault() {
        assertThat(cluster().estimateProfile(requestWith("3")))
                .isEqualTo(cluster().estimateProfile());
    }

    /** Every phase scales with the node count: control plane fixed, plus a per-node increment. */
    @Test
    void everyPhaseScalesWithNodeCount() {
        ResourceEstimator.Profile one = cluster().estimateProfile(requestWith("1"));
        ResourceEstimator.Profile eleven = cluster().estimateProfile(requestWith("11"));

        // 10 extra nodes add 10x the per-node increment to each figure.
        assertThat(eleven.creationSeconds() - one.creationSeconds()).isCloseTo(10 * 180.0, within(1e-9));
        assertThat(eleven.creationCost() - one.creationCost()).isCloseTo(10 * 0.6, within(1e-9));
        assertThat(eleven.operationWatts() - one.operationWatts()).isCloseTo(10 * 0.6, within(1e-9));
        assertThat(eleven.operationCostPerHour() - one.operationCostPerHour()).isCloseTo(10 * 0.02, within(1e-9));
        assertThat(eleven.teardownSeconds() - one.teardownSeconds()).isCloseTo(10 * 60.0, within(1e-9));
    }

    /** A bigger cluster reports a bigger operation estimate over the same window. */
    @Test
    void aBiggerClusterCostsMoreToOperate() {
        double small = ResourceEstimator.operation(cluster().estimateProfile(requestWith("3")), DAY).cost();
        double big = ResourceEstimator.operation(cluster().estimateProfile(requestWith("30")), DAY).cost();

        assertThat(big).isGreaterThan(small);
    }

    @Test
    void fallsBackToTheDefault_whenTheRequestSaysNothingUsable() {
        ResourceEstimator.Profile deflt = cluster().estimateProfile();

        assertThat(cluster().estimateProfile(requestWith(null))).isEqualTo(deflt);
        assertThat(cluster().estimateProfile(requestWith("lots"))).isEqualTo(deflt);
        assertThat(cluster().estimateProfile(requestWith("0"))).isEqualTo(deflt);
        assertThat(cluster().estimateProfile(requestWith("100000"))).isEqualTo(deflt);
        assertThat(cluster().estimateProfile(new ResourceUpdate())).isEqualTo(deflt);
        assertThat(cluster().estimateProfile((ResourceUpdate) null)).isEqualTo(deflt);
    }

    /**
     * The estimate takes the request rather than reading the {@code nodeCount} field because specs are
     * singletons shared across concurrent planning requests: one request's cluster size must not become
     * another's, even after {@code resourceLogic} has mutated the instance.
     */
    @Test
    void estimatingForOneRequest_doesNotAffectTheNext() {
        KubernetesCluster shared = cluster();
        shared.resourceLogic(requestWith("50"), "res-1", true);

        assertThat(shared.estimateProfile(requestWith("5")))
                .isEqualTo(cluster().estimateProfile(requestWith("5")));
        assertThat(shared.estimateProfile(requestWith(null)))
                .isEqualTo(cluster().estimateProfile());
    }
}