STATE-WP-0099: wave-1 node allocation — API 390m, state-hub-db 70m/416Mi, preflight surge derived from values
Receives the State Hub rows of the 2026-10-21 node allocation review (CUST-WP-0071 T03/T04, RESOURCE-WP-0008). The release preflight now derives the API surge from the values the release deploys instead of the stale 100m STATE-WP-0091 constant, and reports the steady remainder after rollout. Ordering: apply after rail-knative reductions, before forge/telemetry increases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 8340@bnt-lap001 Assistant-Session: 2e192b3d-b3e8-4403-94d4-d55a2c8b476c
This commit is contained in:
parent
39b51a21a5
commit
a6afb08d82
11 changed files with 692 additions and 12 deletions
|
|
@ -28,13 +28,23 @@ BYTE_FACTORS = {
|
|||
"T": 1000**4,
|
||||
}
|
||||
|
||||
# Historical State Hub API surge (maxSurge=1, maxUnavailable=0).
|
||||
DEFAULT_API_SURGE_CPU_M = 100
|
||||
# Fallback surge figures, used only when no values file can be read. With
|
||||
# maxSurge=1 / maxUnavailable=0 the surge pod carries the *target* release's
|
||||
# API request, so the real figure is derived from the Helm values the release
|
||||
# will deploy (STATE-WP-0099). The historical 100m constant under-modelled the
|
||||
# 250m API request; tests keep these fallbacks equal to the shipped values.
|
||||
DEFAULT_API_SURGE_CPU_M = 390
|
||||
DEFAULT_MCP_SURGE_CPU_M = 10
|
||||
DEFAULT_MIGRATE_CPU_M = 50
|
||||
DEFAULT_API_SURGE_MEMORY = 512 * 1024**2
|
||||
DEFAULT_FRESHNESS_SECONDS = 15 * 60
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_VALUES_FILES = (
|
||||
REPO_ROOT / "deploy/railiance/apps/charts/state-hub/values.yaml",
|
||||
REPO_ROOT / "deploy/railiance/apps/helm/state-hub-values.yaml",
|
||||
)
|
||||
|
||||
|
||||
def cpu_millicores(value: str | None) -> int:
|
||||
import re
|
||||
|
|
@ -66,6 +76,55 @@ def pod_effective_requests(pod: dict[str, Any]) -> tuple[int, int]:
|
|||
return max(cpu, init_cpu), max(mem, init_mem)
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _request(section: dict[str, Any] | None, key: str) -> Any:
|
||||
return (((section or {}).get("resources") or {}).get("requests") or {}).get(key)
|
||||
|
||||
|
||||
def release_from_values(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Derive surge figures from merged Helm values (chart defaults + overrides).
|
||||
|
||||
Helm merges `-f` files in order; pass them already merged. Only keys that are
|
||||
present are returned, so missing sections fall back to the defaults.
|
||||
"""
|
||||
release: dict[str, Any] = {"surge_source": "values"}
|
||||
api_cpu = _request(values, "cpu")
|
||||
if api_cpu is not None:
|
||||
release["api_surge_cpu_m"] = cpu_millicores(api_cpu)
|
||||
api_mem = _request(values, "memory")
|
||||
if api_mem is not None:
|
||||
release["api_surge_memory_bytes"] = bytes_value(api_mem)
|
||||
mcp = values.get("mcp") or {}
|
||||
if mcp.get("enabled") is False:
|
||||
release["mcp_surge_cpu_m"] = 0
|
||||
elif _request(mcp, "cpu") is not None:
|
||||
release["mcp_surge_cpu_m"] = cpu_millicores(_request(mcp, "cpu"))
|
||||
migrations = values.get("migrations") or {}
|
||||
if migrations.get("enabled") is False:
|
||||
release["migrate_cpu_m"] = 0
|
||||
elif _request(migrations, "cpu") is not None:
|
||||
release["migrate_cpu_m"] = cpu_millicores(_request(migrations, "cpu"))
|
||||
return release
|
||||
|
||||
|
||||
def load_values(paths: list[Path] | tuple[Path, ...]) -> dict[str, Any]:
|
||||
import yaml
|
||||
|
||||
merged: dict[str, Any] = {}
|
||||
for path in paths:
|
||||
merged = _deep_merge(merged, yaml.safe_load(Path(path).read_text()) or {})
|
||||
return merged
|
||||
|
||||
|
||||
def evaluate(observation: dict[str, Any], release: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Return a preflight report. `ok` is whether Helm may start, not a guarantee."""
|
||||
release = release or {}
|
||||
|
|
@ -75,6 +134,7 @@ def evaluate(observation: dict[str, Any], release: dict[str, Any] | None = None)
|
|||
api_mem = int(release.get("api_surge_memory_bytes", DEFAULT_API_SURGE_MEMORY))
|
||||
freshness_limit = int(release.get("freshness_seconds", DEFAULT_FRESHNESS_SECONDS))
|
||||
atomic = bool(release.get("atomic", True))
|
||||
surge_source = str(release.get("surge_source", "release" if "api_surge_cpu_m" in release else "defaults"))
|
||||
|
||||
reasons: list[str] = []
|
||||
notes: list[str] = []
|
||||
|
|
@ -139,6 +199,21 @@ def evaluate(observation: dict[str, Any], release: dict[str, Any] | None = None)
|
|||
f"unrelated pending memory {pending_mem} leaves {effective_mem} against API surge"
|
||||
)
|
||||
|
||||
live_api_cpu = observation.get("live_api_request_cpu_m")
|
||||
remaining_after_rollout = None
|
||||
if live_api_cpu is not None:
|
||||
remaining_after_rollout = remaining_cpu - (api_cpu - int(live_api_cpu))
|
||||
if int(live_api_cpu) != api_cpu:
|
||||
notes.append(
|
||||
f"release changes the API request {int(live_api_cpu)}m -> {api_cpu}m; "
|
||||
f"steady remaining after rollout is {remaining_after_rollout}m"
|
||||
)
|
||||
if remaining_after_rollout < api_cpu:
|
||||
notes.append(
|
||||
f"after this rollout {remaining_after_rollout}m remains, below the {api_cpu}m "
|
||||
"surge; the next release will be refused until headroom is freed"
|
||||
)
|
||||
|
||||
if not atomic:
|
||||
reasons.append("promotion path must keep helm --atomic; preflight will not start a non-atomic upgrade")
|
||||
|
||||
|
|
@ -160,6 +235,9 @@ def evaluate(observation: dict[str, Any], release: dict[str, Any] | None = None)
|
|||
"remaining_memory_bytes": remaining_mem,
|
||||
"pending_unrelated_cpu_m": pending_cpu,
|
||||
"api_surge_cpu_m": api_cpu,
|
||||
"surge_source": surge_source,
|
||||
"live_api_request_cpu_m": live_api_cpu,
|
||||
"remaining_after_rollout_cpu_m": remaining_after_rollout,
|
||||
"mcp_surge_cpu_m": mcp_cpu,
|
||||
"migrate_cpu_m": migrate_cpu,
|
||||
"atomic": atomic,
|
||||
|
|
@ -209,9 +287,20 @@ def collect_live(kubectl: str = "kubectl") -> dict[str, Any]:
|
|||
if node_name in allocated:
|
||||
allocated[node_name]["allocated_cpu_m"] += cpu
|
||||
allocated[node_name]["allocated_memory_bytes"] += mem
|
||||
live_api_cpu = None
|
||||
try:
|
||||
deploy = json.loads(subprocess.check_output(
|
||||
[kubectl, "-n", "state-hub", "get", "deployment", "state-hub", "-o", "json"], timeout=60
|
||||
))
|
||||
for container in deploy["spec"]["template"]["spec"]["containers"]:
|
||||
if container.get("name") == "state-hub":
|
||||
live_api_cpu = cpu_millicores(_request(container, "cpu"))
|
||||
except (subprocess.SubprocessError, KeyError, ValueError, OSError):
|
||||
live_api_cpu = None
|
||||
return {
|
||||
"observed_at": now.isoformat(),
|
||||
"freshness_seconds": 0,
|
||||
"live_api_request_cpu_m": live_api_cpu,
|
||||
"nodes": list(allocated.values()),
|
||||
"pending_unrelated": pending_unrelated,
|
||||
"source": "kubectl",
|
||||
|
|
@ -221,14 +310,24 @@ def collect_live(kubectl: str = "kubectl") -> dict[str, Any]:
|
|||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--fixture", type=Path, help="JSON observation instead of live kubectl")
|
||||
parser.add_argument("--release", type=Path, help="JSON release surge description")
|
||||
parser.add_argument("--release", type=Path, help="JSON release surge description (overrides values)")
|
||||
parser.add_argument(
|
||||
"--values", type=Path, action="append",
|
||||
help="Helm values file, in -f order (repeatable). Default: chart values + production values",
|
||||
)
|
||||
parser.add_argument("--kubectl", default="kubectl")
|
||||
args = parser.parse_args(argv)
|
||||
if args.fixture:
|
||||
observation = json.loads(args.fixture.read_text())
|
||||
else:
|
||||
observation = collect_live(args.kubectl)
|
||||
release = json.loads(args.release.read_text()) if args.release else {}
|
||||
values_files = args.values or [p for p in DEFAULT_VALUES_FILES if p.exists()]
|
||||
release = release_from_values(load_values(values_files)) if values_files else {}
|
||||
if args.release:
|
||||
explicit = json.loads(args.release.read_text())
|
||||
release.update(explicit)
|
||||
if "api_surge_cpu_m" in explicit:
|
||||
release["surge_source"] = "release"
|
||||
report = evaluate(observation, release)
|
||||
json.dump(report, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue