Skip to content
Merged
10 changes: 10 additions & 0 deletions .github/workflows/helm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ env:
HELM_VERSION: v3.17.3
KIND_VERSION: v0.32.0
KUBECONFORM_VERSION: 0.6.7
# v1.x's plugin.yaml uses the platformCommand/platformHooks manifest
# schema, which the pinned Helm v3.17.3 cannot load. v0.8.2 is the last
# release using the command/hooks schema Helm 3.17 supports.
HELM_UNITTEST_VERSION: v0.8.2

jobs:
# ----------------------------------------------------------------------------
Expand Down Expand Up @@ -49,6 +53,12 @@ jobs:
rm /tmp/kubeconform.tar.gz
kubeconform -v

- name: Install helm-unittest
run: helm plugin install https://github.com/helm-unittest/helm-unittest --version "${HELM_UNITTEST_VERSION}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The required Helm validate check currently fails at this install step before any chart tests run. With the workflow-pinned Helm v3.17.3, helm-unittest v1.1.1 cannot load because its plugin manifest contains the unknown platformHooks field. Please pin a Helm/plugin combination that works in CI (and ideally checksum-pin the installed artifact), then confirm this required check is green.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A minimal candidate is to retain Helm 3 and pin the last pre-platformHooks plugin manifest (then verify the required job):

-  HELM_UNITTEST_VERSION: v1.1.1
+  # v0.8.2 uses the Helm 3 `command`/`hooks` plugin manifest.
+  HELM_UNITTEST_VERSION: v0.8.2

The v0.8.2 manifest uses the Helm 3-compatible command/hooks fields; v1.1.1 uses platformCommand/platformHooks. The green CI job should remain the deciding proof.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a1fb5f — pinned exactly as suggested: HELM_UNITTEST_VERSION: v0.8.2, plus a comment explaining why (v1.x's plugin.yaml uses platformCommand/platformHooks, which the pinned Helm v3.17.3 can't load; v0.8.2 is the last release on the command/hooks schema). Confirmed against both plugin manifests directly before pushing.

Heads up: the Helm workflow run on this push is currently sitting at action_required (the fork-PR approval gate), not failing — would appreciate an approve on the run so the green check shows up.


- name: Chart template unit tests
run: helm unittest charts/grid-site

- name: Validate charts (static)
run: ./scripts/verify-helm-chart.sh

Expand Down
78 changes: 78 additions & 0 deletions charts/grid-operator/crds/gridnetwork.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,42 @@
Defines the grid's seed peers, gateway associations, SWIM
tuning, and TLS secret references.
properties:
budgetPolicy:
description: |-
Budget policy configuration for per-tenant spend tracking.

Selects which tenants Grid tracks cumulative spend for. See
[`BudgetPolicyConfig`] for what this does and does not do.

**Default (absent):** no tenants are tracked; `budgetStatus` is
always empty.
nullable: true
properties:
tenants:
default: []
description: Per-tenant budget caps.
items:
description: Per-tenant budget cap declaration.
properties:
capUsd:
description: |-
Maximum cumulative spend in USD before this tenant is considered over budget.

**Minimum value:** `0`. The generated CRD schema rejects negative caps;
[`validate_budget_policy`] additionally rejects `NaN`/infinite values
that the schema's numeric minimum does not catch.
format: double
minimum: 0.0
type: number
tenantId:
description: Tenant identifier. Must be non-empty and unique within the policy.
type: string
required:
- capUsd
- tenantId
type: object
type: array
type: object
gatewayRefs:
default: []
description: References to Praxis Gateways that participate in this grid.
Expand Down Expand Up @@ -416,6 +452,48 @@
description: Observed status of a [`GridNetwork`].
nullable: true
properties:
budgetStatus:
description: |-
Per-tenant budget status, derived from `spec.budgetPolicy` and merged
cross-site CRDT spend state.

Empty when `budgetPolicy` is absent. This is a status signal only —

Check warning on line 460 in charts/grid-operator/crds/gridnetwork.yaml

View workflow job for this annotation

GitHub Actions / Detect hidden unicode characters

Unicode Safety [non-ascii-identifier]

U+2014 <unnamed U+2014> -- Non-ASCII U+2014 <unnamed U+2014> in identifier '—' (policy: ascii-only)
Grid does not enforce budget limits itself (see [`BudgetPolicyConfig`]).
items:
description: |-
Per-tenant budget status derived from policy + merged CRDT spend state.

Populated in [`GridNetworkStatus::budget_status`] for every tenant
declared in `spec.budgetPolicy`, regardless of whether spend has been
recorded for that tenant yet. This is a status signal only — Grid does

Check warning on line 468 in charts/grid-operator/crds/gridnetwork.yaml

View workflow job for this annotation

GitHub Actions / Detect hidden unicode characters

Unicode Safety [non-ascii-identifier]

U+2014 <unnamed U+2014> -- Non-ASCII U+2014 <unnamed U+2014> in identifier '—' (policy: ascii-only)
not enforce budget limits (see [`BudgetPolicyConfig`] doc).
properties:
capUsd:
description: Budget cap for this tenant, in USD, copied from `spec.budgetPolicy`.
format: double
type: number
spendRatio:
description: '`spend_usd / cap_usd`, clamped to `0.0..=1.0`. See [`spend_ratio`].'
format: double
type: number
spendUsd:
description: |-
Cumulative spend observed for this tenant, in USD.

Converged across all sites that have merged CRDT state for this
tenant; may lag briefly during a partition (see [`GCounter`]).
format: double
type: number
tenantId:
description: Tenant identifier, matching `spec.budgetPolicy.tenants[].tenantId`.
type: string
required:
- capUsd
- spendRatio
- spendUsd
- tenantId
type: object
type: array
connectedSites:
default: 0
description: Number of connected (Active) sites.
Expand Down
15 changes: 12 additions & 3 deletions charts/grid-site/templates/gridnetwork.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ metadata:
labels:
{{- include "grid-site.labels" . | nindent 4 }}
spec:
{{- with .Values.gridNetwork.gridId }}
gridId: {{ . | quote }}
{{- end }}
{{- /*
gridId is always rendered (even when empty) so `spec` is never a null
YAML value. The CRD schema requires `spec` to be present, and a bare
`spec:` with no children renders as null when every other field below
is also left at its falsy chart default, which the API server rejects
with "spec: Required value" on both create and update.
*/}}
gridId: {{ .Values.gridNetwork.gridId | quote }}
{{- with .Values.gridNetwork.region }}
region: {{ . | quote }}
{{- end }}
Expand All @@ -29,6 +34,10 @@ spec:
metricsRefreshInterval: {{ . | quote }}
{{- end }}
{{- end }}
{{- with .Values.gridNetwork.budgetPolicy }}
budgetPolicy:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.gridNetwork.swim }}
swim:
{{- toYaml . | nindent 4 }}
Expand Down
63 changes: 63 additions & 0 deletions charts/grid-site/tests/gridnetwork_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
suite: GridNetwork template
templates:
- templates/gridnetwork.yaml
tests:
- it: renders as a GridNetwork resource with no budgetPolicy by default
set:
gridNetwork.name: prod-grid
gridSite.name: prod-site
asserts:
- hasDocuments:
count: 1
- isKind:
of: GridNetwork
- equal:
path: metadata.name
value: prod-grid
- notExists:
path: spec.budgetPolicy

- it: renders a non-null spec when only the required name fields are set
# Regression test: every optional field used to be gated behind `with`,
# so `spec:` rendered as YAML null when all of them were left at their
# falsy chart defaults. A real API server rejects that with
# "spec: Required value" on both create and update — helm-unittest
# can't reproduce server-side CRD validation, so this asserts the
# concrete symptom instead: gridId must always be present.
set:
gridNetwork.name: prod-grid
gridSite.name: prod-site
asserts:
- equal:
path: spec.gridId
value: ""

- it: renders budgetPolicy.tenants verbatim when set
set:
gridNetwork.name: prod-grid
gridSite.name: prod-site
gridNetwork.budgetPolicy:
tenants:
- tenantId: tenant-a
capUsd: 100
- tenantId: tenant-b
capUsd: 250
asserts:
- equal:
path: spec.budgetPolicy.tenants
value:
- tenantId: tenant-a
capUsd: 100
- tenantId: tenant-b
capUsd: 250

- it: renders budgetPolicy with an empty tenants list without erroring
set:
gridNetwork.name: prod-grid
gridSite.name: prod-site
gridNetwork.budgetPolicy:
tenants: []
asserts:
- equal:
path: spec.budgetPolicy.tenants
value: []
18 changes: 18 additions & 0 deletions charts/grid-site/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@
"type": "string",
"pattern": "^([1-9][0-9]*s|[1-9][0-9]{3,}ms)$"
},
"budgetPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"tenants": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["tenantId", "capUsd"],
"properties": {
"tenantId": { "type": "string", "minLength": 1 },
"capUsd": { "type": "number", "minimum": 0 }
}
}
}
}
},
"gatewayRefs": {
"type": "array",
"items": {
Expand Down
82 changes: 81 additions & 1 deletion crdt/src/gcounter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
/// c.increment(10);
/// assert_eq!(c.total(), 10);
/// ```
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct GCounter {
/// Site identifier for this replica.
site_id: String,
Expand Down Expand Up @@ -73,6 +73,30 @@ impl GCounter {
*slot = (*slot).max(*count);
}
}

/// Return a copy of this counter containing only the slot for `origin_site`.
///
/// Used at trust boundaries (e.g. gossip wire-ingest) where a payload's
/// claimed origin should only ever be believed for its own contribution.
/// Any other slot present in `self` — legitimate or forged — is dropped,
/// mirroring how provider records are scoped to their claimed origin
/// before being accepted.
#[must_use]
pub fn retain_origin(&self, origin_site: &str) -> Self {
let mut retained = Self::new(origin_site.to_owned());
if let Some(&value) = self.slots.get(origin_site) {
retained.slots.insert(origin_site.to_owned(), value);
}
retained
}

/// Remove the slot belonging to `site`, if present.
///
/// Used when evicting a dead site so its contribution doesn't linger in
/// other tenants' counters forever. A no-op if `site` never contributed.
pub fn remove_slot(&mut self, site: &str) {
self.slots.remove(site);
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -178,6 +202,62 @@ mod tests {
);
}

#[test]
fn retain_origin_keeps_only_the_named_slot() {
let mut c = GCounter::new("site-a".to_owned());
c.increment(10);
c.slots.insert("site-b".to_owned(), 999);
c.slots.insert("site-c".to_owned(), 42);

let retained = c.retain_origin("site-a");

assert_eq!(retained.total(), 10, "only site-a's slot must survive");
assert_eq!(
retained.local(),
10,
"the retained counter is keyed by site-a, so local() reflects its slot"
);
}

#[test]
fn retain_origin_for_absent_slot_is_zero() {
let mut c = GCounter::new("site-a".to_owned());
c.increment(10);

let retained = c.retain_origin("site-b");

assert_eq!(
retained.total(),
0,
"a slot the origin never wrote must retain as zero, not forged"
);
}

#[test]
fn remove_slot_drops_only_the_named_site() {
let mut c = GCounter::new("site-a".to_owned());
c.increment(10);
c.slots.insert("site-b".to_owned(), 20);

c.remove_slot("site-a");

assert_eq!(
c.total(),
20,
"removing site-a's slot must leave site-b's contribution intact"
);
}

#[test]
fn remove_slot_for_absent_site_is_a_no_op() {
let mut c = GCounter::new("site-a".to_owned());
c.increment(10);

c.remove_slot("site-never-contributed");

assert_eq!(c.total(), 10, "removing an absent slot must not change the total");
}

#[test]
fn gcounter_serde_round_trip() {
let mut c = GCounter::new("site-x".to_owned());
Expand Down
Loading
Loading