From 5b2b17ee069220dabff9f3ad100a6a6729bba082 Mon Sep 17 00:00:00 2001
From: Linus Kipkemoi Langat
<142144579+Developer-Linus@users.noreply.github.com>
Date: Fri, 7 Aug 2026 07:05:45 +0300
Subject: [PATCH 01/13] feat(jac-scale): add KEDA HTTP Add-on capability
discovery and preflight diagnostics (#7620)
## Summary
Closes #7404.
`KEDAAutoscaler.preflight()` only ever verified that the core
`keda.sh/v1alpha1 ScaledObject` API existed on a cluster. That check is
not sufficient for HTTP activated workloads, which also depend on the
KEDA HTTP Add-on's `InterceptorRoute` API, two Kubernetes Services (the
external scaler and the interceptor proxy), and a compatible Add-on
version. Without these checks, `apply_http_activation` could create a
valid `ScaledObject` that never actually activates from HTTP traffic,
with no clear diagnostic explaining why.
This PR introduces structured capability discovery for the KEDA HTTP
Add-on and wires it into `apply_http_activation`, so a broken or
partially installed cluster fails with an actionable error instead of
silently deploying a nonfunctional workload.
## Changes
| File | Changes |
|---|---|
| `jac/jaclang/scale/deploy/autoscale/keda_capabilities.jac` (new file)
| Adds the `KEDACapabilities` data object, matching the shape specified
in the issue: `core_available`, `http_addon_available`,
`interceptor_route_api_version`, `external_scaler_address`,
`interceptor_service_address`, `warnings`, and `errors`. Also adds
`SUPPORTED_HTTP_INTERCEPTOR_API_VERSIONS`. |
| `jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac` | Imports
`KEDACapabilities`.
Adds a new `interceptor_service_address` field,
defaulted the same way `http_scaler_address` already is.
Replaces the
old bool only `_http_preflight_cache` with `_capabilities_cache`, which
holds a full `KEDACapabilities` object per cluster.
Declares
`discover_capabilities`, `invalidate_capabilities`,
`_check_service_address`, `format_capabilities`, and
`_capabilities_cache_key`. |
| `jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac` |
`discover_capabilities` checks the core `ScaledObject` API and the HTTP
Add-on's `InterceptorRoute` API independently, so a missing core install
is distinguished from a missing Add-on. A 403 on either check is
reported as a distinct permission error rather than being folded into
"not installed."
If the `v1beta1 InterceptorRoute` API is absent (not
RBAC denied), it additionally probes the legacy `v1alpha1
HTTPScaledObject` API. If that is present, the result is a distinct
"upgrade" warning with the exact `helm upgrade` command, rather than
being reported as "Add-on not installed." A 403 on this legacy probe is
reported as its own permission error rather than being folded into "not
installed."
`_check_service_address` verifies the external scaler and
interceptor proxy Services actually resolve, and reports which one is
missing.
Results are cached per cluster identity via a new
`_capabilities_cache_key` helper, which combines `_get_cluster_key()`
with this instance's configured `http_scaler_address` and
`interceptor_service_address`. This keeps two instances that target the
same cluster but are configured with different service addresses from
reusing an incompatible cached result.
`discover_capabilities(refresh=True)` and `invalidate_capabilities()`
remain the explicit refresh paths.
`format_capabilities` produces a
single concise, human readable line for CLI output.
`preflight()` now
also catches a 403 and returns a distinct permission error instead of
letting the `ApiException` propagate unhandled, which is what happened
previously.
`apply_http_activation` now calls
`discover_capabilities()` instead of its own inline, duplicate check. If
discovery reports any errors (missing service, RBAC denied, or similar),
it raises `ValueError` with the details. If the core KEDA API or the
HTTP Add-on is simply absent (no errors, just not installed), it keeps
the existing behavior of logging a warning and returning `False` rather
than raising. A partial install, where the Add-on and its services are
healthy but core KEDA itself is missing, now also falls into this gate
instead of proceeding to create resources that would fail against a
nonexistent API. |
| `jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac` | Adds
a new capability discovery test section covering: core only clusters,
full installs, missing external scaler service, missing interceptor
service, legacy Add-on detection, RBAC denied on the core check, the
HTTP Add-on check, and the legacy probe, cache and refresh behavior,
cache isolation between instances with different service addresses,
explicit invalidation, the raise on broken capabilities and skip on
missing core behavior in `apply_http_activation`, `format_capabilities`
output, and the `preflight()` 403 fix. Test setup uses small composable
mock builders (`_custom_api_for`, `_core_api_for`) rather than one mock
per scenario, and one table driven test covers the distinct diagnostic
cases to avoid duplicating near identical assertions. |
## Out of scope
As noted in the issue: no auto install or upgrade of KEDA, no ingress
creation, no general health checking of every scaler type. This PR also
does not verify that a workload's traffic is actually routed through the
interceptor at runtime, only that the interceptor's own API and Service
objects exist and resolve. That distinction is called out explicitly in
the implementation plan for this issue.
## Test plan
- [x] Core only clusters report core available and HTTP unavailable.
- [x] Fully installed clusters report HTTP ready with no errors.
- [x] Missing external scaler service, missing interceptor service,
legacy Add-on version, and RBAC denied cases (core check, HTTP Add-on
check, and legacy probe) each produce a distinct diagnostic.
- [x] Results are cached per cluster identity, including the configured
service addresses, and refresh via `discover_capabilities(refresh=True)`
and `invalidate_capabilities()`.
- [x] `apply_http_activation` raises when capabilities are broken,
returns `False` gracefully when the Add-on or core KEDA is simply
absent, and does not proceed to create resources on a partial install.
- [x] Documentation update showing the supported Helm install and
upgrade flow.
- [x] Full deploy test suite (169 tests) passes with no regressions.
---------
Co-authored-by: Kugesan Sivasothynathan
(cherry picked from commit 5234c8172ee60ca058726bb3a816abbc6de55e92)
---
.../reference/plugins/jac-scale-kubernetes.md | 18 ++
.../deploy/autoscale/keda_autoscaler.impl.jac | 205 ++++++++++--
.../deploy/autoscale/keda_autoscaler.jac | 16 +-
.../deploy/autoscale/keda_capabilities.jac | 11 +
.../deploy/test_keda_http_activation.jac | 298 +++++++++++++++++-
.../unreleased/jaclang/7620.feature.md | 1 +
6 files changed, 524 insertions(+), 25 deletions(-)
create mode 100644 jac/jaclang/scale/deploy/autoscale/keda_capabilities.jac
create mode 100644 release_notes/unreleased/jaclang/7620.feature.md
diff --git a/docs/docs/reference/plugins/jac-scale-kubernetes.md b/docs/docs/reference/plugins/jac-scale-kubernetes.md
index 0fa82a42419..40288944e70 100644
--- a/docs/docs/reference/plugins/jac-scale-kubernetes.md
+++ b/docs/docs/reference/plugins/jac-scale-kubernetes.md
@@ -395,6 +395,24 @@ The `"keda"` engine creates a `ScaledObject` custom resource instead of an HPA.
!!! note
KEDA must be installed on the cluster before using this engine. If KEDA CRDs are absent at deploy time, jac-scale emits an install warning with a link to the [KEDA installation docs](https://keda.sh/docs/latest/deploy/) and falls back to static replicas rather than failing the deploy.
+!!! note "HTTP-activated workloads also require the KEDA HTTP Add-on"
+ Workloads scaled via `apply_http_activation` (HTTP request-driven scale-to-zero) require the [KEDA HTTP Add-on](https://keda.sh/docs/latest/deploy/#http-add-on) in addition to core KEDA. Call `KEDAAutoscaler.discover_capabilities()` to check both together: it returns a `KEDACapabilities` object that distinguishes a missing core install from a missing or outdated HTTP Add-on, an RBAC-denied check from a genuinely absent API, and a missing external-scaler or interceptor-proxy Service, each with its own diagnostic. Results are cached per cluster; pass `refresh=True` or call `invalidate_capabilities()` to force a recheck. `apply_http_activation` calls `discover_capabilities()` automatically and raises when the Add-on is installed but broken, instead of deploying a workload that will never receive traffic. If the Add-on is simply absent, it logs a warning and skips activation rather than failing the deploy.
+
+ Install both with Helm:
+ ```bash
+ helm repo add kedacore https://kedacore.github.io/charts
+ helm repo update
+ helm install keda kedacore/keda -n keda --create-namespace --wait
+ helm install http-add-on kedacore/keda-add-ons-http -n keda --wait
+ ```
+
+ Upgrading an older HTTP Add-on install (pre-0.14, `HTTPScaledObject` only) to the current `InterceptorRoute` API:
+ ```bash
+ helm upgrade http-add-on kedacore/keda-add-ons-http -n keda --wait
+ ```
+
+ Chart names, flags, and required values can change over time, so every install/upgrade command surfaced by `discover_capabilities()` also links to the current getting-started guide as the authoritative fallback: [https://keda.sh/http-add-on/0.15/getting-started/](https://keda.sh/http-add-on/0.15/getting-started/).
+
**Switching between engines is safe.** Each engine removes the other engine's resource (`ScaledObject` or `HPA`) on apply, so two autoscalers never compete for `spec.replicas` on the same Deployment.
!!! warning "CPU/memory triggers: scale-down always takes ~5 minutes"
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
index ddc931a25b1..add6fbe3ce5 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
@@ -59,6 +59,13 @@ impl KEDAAutoscaler.preflight{
KEDAAutoscaler._preflight_cache[cluster_key] = False;
return install_warning;
}
+ if e.status == 403 {
+ return [
+ "Permission denied (403) discovering KEDA core "
+ "(keda.sh/v1alpha1 ScaledObject). Grant RBAC list/get on "
+ "this resource to the jac-scale service account."
+ ];
+ }
raise;
}
}
@@ -365,33 +372,22 @@ impl KEDAAutoscaler.http_scaled_object_name_for{
impl KEDAAutoscaler.apply_http_activation{
self._validate_http_activation_spec(spec);
- cluster_key = self._get_cluster_key();
- api = self._custom_api or client.CustomObjectsApi();
- if cluster_key not in KEDAAutoscaler._http_preflight_cache {
- try {
- api.list_cluster_custom_object(
- group="http.keda.sh", version="v1beta1", plural="interceptorroutes"
- );
- KEDAAutoscaler._http_preflight_cache[cluster_key] = True;
- } except ApiException as e {
- if e.status in [404, 422] {
- KEDAAutoscaler._http_preflight_cache[cluster_key] = False;
- } else {
- raise;
- }
- }
+ caps = self.discover_capabilities();
+ if caps.errors {
+ raise ValueError(
+ "KEDA HTTP Add-on capability check failed:\n" + "\n".join(caps.errors)
+ );
}
- if not KEDAAutoscaler._http_preflight_cache[cluster_key] {
+ if not caps.core_available or not caps.http_addon_available {
if self.logger {
- self.logger.warn(
- "KEDA HTTP Add-on CRDs not found on cluster. Install the HTTP "
- "Add-on from the official docs, then redeploy: "
- "https://keda.sh/docs/latest/deploy/#http-add-on"
- );
+ for warning in caps.warnings {
+ self.logger.warn(warning);
+ }
}
return False;
}
+ api = self._custom_api or client.CustomObjectsApi();
apps_api = self._apps_api or client.AppsV1Api();
core_api = self._core_api or client.CoreV1Api();
@@ -741,3 +737,170 @@ impl KEDAAutoscaler._validate_http_activation_spec{
);
}
}
+
+impl KEDAAutoscaler._check_service_address{
+ (host, _, __) = address.partition(":");
+ parts = host.split(".");
+ name = parts[0];
+ namespace = parts[1] if len(parts) > 1 else "keda";
+ try {
+ core_api.read_namespaced_service(name=name, namespace=namespace);
+ return None;
+ } except ApiException as e {
+ if e.status == 403 {
+ return (
+ f"Permission denied (403) checking Service '{name}' in "
+ f"namespace '{namespace}'. Verify RBAC grants get/list on "
+ f"services for the jac-scale service account."
+ );
+ }
+ if e.status in [404, 422] {
+ return f"Service '{name}' not found in namespace '{namespace}' (address: '{address}').";
+ }
+ raise;
+ }
+}
+
+impl KEDAAutoscaler._capabilities_cache_key{
+ return (
+ f"{self._get_cluster_key()}::{self.http_scaler_address}"
+ f"::{self.interceptor_service_address}::{id(self._custom_api)}"
+ f"::{id(self._core_api)}"
+ );
+}
+
+impl KEDAAutoscaler.discover_capabilities{
+ cluster_key = self._capabilities_cache_key();
+ if not refresh and cluster_key in self._capabilities_cache {
+ return self._capabilities_cache[cluster_key];
+ }
+
+ api = self._custom_api or client.CustomObjectsApi();
+ core_api = self._core_api or client.CoreV1Api();
+ caps = KEDACapabilities();
+
+ keda_docs_url = "https://keda.sh/http-add-on/0.15/getting-started/";
+ http_addon_docs_url = "https://keda.sh/http-add-on/0.15/getting-started/";
+ install_core_cmd = (
+ "helm repo add kedacore https://kedacore.github.io/charts && "
+ "helm repo update && helm install keda kedacore/keda -n keda "
+ f"--create-namespace --wait (see {keda_docs_url} for current instructions)"
+ );
+ install_addon_cmd = (
+ "helm install http-add-on kedacore/keda-add-ons-http -n keda --wait "
+ f"(see {http_addon_docs_url} for current instructions)"
+ );
+ upgrade_addon_cmd = (
+ "helm upgrade http-add-on kedacore/keda-add-ons-http -n keda --wait "
+ f"(see {http_addon_docs_url} for current instructions)"
+ );
+
+ try {
+ api.list_cluster_custom_object(
+ group="keda.sh", version="v1alpha1", plural="scaledobjects"
+ );
+ caps.core_available = True;
+ } except ApiException as e {
+ if e.status == 403 {
+ caps.errors.append(
+ "Permission denied (403) discovering KEDA core "
+ "(keda.sh/v1alpha1 ScaledObject). Grant RBAC list/get on "
+ "this resource to the jac-scale service account."
+ );
+ } elif e.status in [404, 422] {
+ caps.warnings.append(
+ f"KEDA core not found on cluster. Install it with: {install_core_cmd}"
+ );
+ } else {
+ raise;
+ }
+ }
+
+ http_addon_status: (int | None) = None;
+ for version in SUPPORTED_HTTP_INTERCEPTOR_API_VERSIONS {
+ try {
+ api.list_cluster_custom_object(
+ group="http.keda.sh", version=version, plural="interceptorroutes"
+ );
+ caps.http_addon_available = True;
+ caps.interceptor_route_api_version = version;
+ break;
+ } except ApiException as e {
+ http_addon_status = e.status;
+ if e.status == 403 {
+ caps.errors.append(
+ "Permission denied (403) discovering the KEDA HTTP Add-on "
+ f"(http.keda.sh/{version} InterceptorRoute). Grant RBAC "
+ "list/get on this resource to the jac-scale service account."
+ );
+ break;
+ } elif e.status not in [404, 422] {
+ raise;
+ }
+ }
+ }
+
+ if not caps.http_addon_available and http_addon_status in [404, 422] {
+ try {
+ api.list_cluster_custom_object(
+ group="http.keda.sh", version="v1alpha1", plural="httpscaledobjects"
+ );
+ caps.warnings.append(
+ "Legacy KEDA HTTP Add-on detected (http.keda.sh/v1alpha1 "
+ "HTTPScaledObject); jac-scale requires v1beta1 "
+ f"InterceptorRoute. Upgrade with: {upgrade_addon_cmd}"
+ );
+ } except ApiException as e {
+ if e.status == 403 {
+ caps.errors.append(
+ "Permission denied (403) probing the legacy KEDA HTTP "
+ "Add-on API (http.keda.sh/v1alpha1 HTTPScaledObject) "
+ "while checking for an outdated install. Grant RBAC "
+ "list/get on this resource to the jac-scale service "
+ "account."
+ );
+ } elif e.status in [404, 422] {
+ caps.warnings.append(
+ f"KEDA HTTP Add-on not found on cluster. Install it with: {install_addon_cmd}"
+ );
+ } else {
+ raise;
+ }
+ }
+ }
+
+ if caps.http_addon_available {
+ caps.external_scaler_address = self.http_scaler_address;
+ caps.interceptor_service_address = self.interceptor_service_address;
+ scaler_error = self._check_service_address(core_api, self.http_scaler_address);
+ if scaler_error is not None {
+ caps.errors.append(scaler_error);
+ }
+ interceptor_error = self._check_service_address(
+ core_api, self.interceptor_service_address
+ );
+ if interceptor_error is not None {
+ caps.errors.append(interceptor_error);
+ }
+ }
+
+ self._capabilities_cache[cluster_key] = caps;
+ return caps;
+}
+
+impl KEDAAutoscaler.invalidate_capabilities{
+ self._capabilities_cache.pop(self._capabilities_cache_key(), None);
+}
+
+impl KEDAAutoscaler.format_capabilities{
+ if caps.core_available and caps.http_addon_available and not caps.errors {
+ return "KEDA HTTP Add-on capabilities ready: core and HTTP Add-on both available.";
+ }
+ if caps.errors {
+ return f"KEDA HTTP Add-on not ready: {caps.errors[0]}";
+ }
+ if caps.warnings {
+ return f"KEDA HTTP Add-on not ready: {caps.warnings[0]}";
+ }
+ return "KEDA HTTP Add-on capabilities unknown.";
+}
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
index 33b78a4eefc..6c601486c09 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
@@ -11,6 +11,10 @@ import from jaclang.scale.deploy.autoscale.http_activation {
HTTPActivationSpec,
HTTPRoutingRule
}
+import from jaclang.scale.deploy.autoscale.keda_capabilities {
+ KEDACapabilities,
+ SUPPORTED_HTTP_INTERCEPTOR_API_VERSIONS
+}
import from jaclang.scale.observability.logger { Logger }
import hashlib;
@@ -20,10 +24,11 @@ obj KEDAAutoscaler(Autoscaler) {
_apps_api: (any | None) = None,
_core_api: (any | None) = None,
http_scaler_address: str = "keda-add-ons-http-external-scaler.keda:9090",
- logger: (Logger | None) = None;
+ interceptor_service_address: str = "keda-add-ons-http-interceptor-proxy.keda:8080",
+ logger: (Logger | None) = None,
+ _capabilities_cache: dict[str, any] = {};
- static has _preflight_cache: dict[str, any] = {},
- _http_preflight_cache: dict[str, any] = {};
+ static has _preflight_cache: dict[str, any] = {};
def _get_cluster_key -> str;
def _trigger_key(trigger: Trigger, trigger_index: int) -> str;
@@ -51,4 +56,9 @@ obj KEDAAutoscaler(Autoscaler) {
def http_scaled_object_name_for(target: str) -> str;
def apply_http_activation(spec: HTTPActivationSpec) -> bool;
def destroy_http_activation(target: str, namespace: str) -> None;
+ def discover_capabilities(refresh: bool = False) -> KEDACapabilities;
+ def invalidate_capabilities -> None;
+ def _capabilities_cache_key -> str;
+ def _check_service_address(core_api: any, address: str) -> (str | None);
+ def format_capabilities(caps: KEDACapabilities) -> str;
}
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_capabilities.jac b/jac/jaclang/scale/deploy/autoscale/keda_capabilities.jac
new file mode 100644
index 00000000000..4da634b85ff
--- /dev/null
+++ b/jac/jaclang/scale/deploy/autoscale/keda_capabilities.jac
@@ -0,0 +1,11 @@
+glob SUPPORTED_HTTP_INTERCEPTOR_API_VERSIONS: list[str] = ["v1beta1"];
+
+obj KEDACapabilities {
+ has core_available: bool = False,
+ http_addon_available: bool = False,
+ interceptor_route_api_version: (str | None) = None,
+ external_scaler_address: (str | None) = None,
+ interceptor_service_address: (str | None) = None,
+ warnings: list[str] = [],
+ errors: list[str] = [];
+}
diff --git a/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac b/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac
index 2c5bf77e20e..99e5af1f39e 100644
--- a/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac
+++ b/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac
@@ -44,7 +44,6 @@ def _mocked_keda(
apps_api: any = None,
core_api: any = None
) -> tuple[KEDAAutoscaler, any, any, any] {
- KEDAAutoscaler._http_preflight_cache.clear();
custom_api = custom_api or unittest.mock.MagicMock();
if http_addon_installed {
custom_api.list_cluster_custom_object.return_value = {};
@@ -491,3 +490,300 @@ test "destroy_collection sweeps InterceptorRoutes alongside ScaledObjects and Tr
plurals = [c[1]["plural"] for c in delete_calls];
assert "interceptorroutes" in plurals;
}
+
+
+# --- Section I: capability discovery -----------------------------------------
+# _capabilities_cache is per-instance (not static), so a freshly constructed
+# KEDAAutoscaler always starts with an empty cache -- no explicit clearing
+# needed. apps_api is mocked too (even though discovery itself never touches
+# it) so that apply_http_activation's target-existence check doesn't fall
+# through to a real, unconfigured AppsV1Api.
+def _keda_for(
+ custom_api: any = None, core_api: any = None, apps_api: any = None
+) -> KEDAAutoscaler {
+ return KEDAAutoscaler(
+ _custom_api=custom_api or unittest.mock.MagicMock(),
+ _core_api=core_api or unittest.mock.MagicMock(),
+ _apps_api=apps_api or unittest.mock.MagicMock()
+ );
+}
+
+# Fails list_cluster_custom_object per API group/version so one mock can stand
+# in for "core missing", "HTTP Add-on missing", "legacy Add-on present", or any
+# RBAC-denied combination of the three. None for a given kwarg means that
+# discovery call succeeds.
+def _custom_api_for(
+ core: (int | None) = None,
+ http_v1beta1: (int | None) = None,
+ legacy_v1alpha1: (int | None) = None
+) -> any {
+ api = unittest.mock.MagicMock();
+ def side_effect(*args: any, **kwargs: any) -> any {
+ status = None;
+ if kwargs.get("group") == "keda.sh" {
+ status = core;
+ } elif kwargs.get("version") == "v1beta1" {
+ status = http_v1beta1;
+ } elif kwargs.get("version") == "v1alpha1" {
+ status = legacy_v1alpha1;
+ }
+ if status is not None {
+ raise ApiException(status=status);
+ }
+ return {};
+ }
+ api.list_cluster_custom_object.side_effect = side_effect;
+ return api;
+}
+
+# Fails read_namespaced_service for the scaler and/or interceptor Service by
+# matching on which name jac-scale looks up.
+def _core_api_for(
+ scaler: (int | None) = None, interceptor: (int | None) = None
+) -> any {
+ api = unittest.mock.MagicMock();
+ def side_effect(name: str, *args: any, **kwargs: any) -> any {
+ status = interceptor if "interceptor" in name else scaler;
+ if status is not None {
+ raise ApiException(status=status);
+ }
+ return {};
+ }
+ api.read_namespaced_service.side_effect = side_effect;
+ return api;
+}
+
+test "discover_capabilities produces a distinct diagnostic for every failure mode" {
+ scenarios = [
+ (
+ "core-only",
+ _custom_api_for(http_v1beta1=404, legacy_v1alpha1=404),
+ _core_api_for()
+ ),
+ ("full-install", _custom_api_for(), _core_api_for()),
+ ("missing-external-scaler", _custom_api_for(), _core_api_for(scaler=404)),
+ (
+ "missing-interceptor-service",
+ _custom_api_for(),
+ _core_api_for(interceptor=404)
+ ),
+ (
+ "legacy-addon-needs-upgrade",
+ _custom_api_for(http_v1beta1=404),
+ _core_api_for()
+ ),
+ ("rbac-denied-on-core", _custom_api_for(core=403), _core_api_for()),
+ (
+ "rbac-denied-on-http-addon",
+ _custom_api_for(http_v1beta1=403),
+ _core_api_for()
+ ),
+ (
+ "rbac-denied-on-legacy-probe",
+ _custom_api_for(http_v1beta1=404, legacy_v1alpha1=403),
+ _core_api_for()
+ )
+ ];
+ for (label, custom_api, core_api) in scenarios {
+ keda = _keda_for(custom_api=custom_api, core_api=core_api);
+ caps = keda.discover_capabilities();
+ msg = f"scenario '{label}' produced: warnings={caps.warnings} errors={caps.errors}";
+
+ if label == "core-only" {
+ assert caps.core_available == True , msg;
+ assert caps.http_addon_available == False , msg;
+ } elif label == "full-install" {
+ assert caps.core_available == True , msg;
+ assert caps.http_addon_available == True , msg;
+ assert caps.errors == [] , msg;
+ assert caps.interceptor_route_api_version == "v1beta1" , msg;
+ } elif label == "missing-external-scaler" {
+ assert any([("scaler" in e.lower()) for e in caps.errors]) , msg;
+ } elif label == "missing-interceptor-service" {
+ assert any([("interceptor" in e.lower()) for e in caps.errors]) , msg;
+ } elif label == "legacy-addon-needs-upgrade" {
+ assert any([("upgrade" in w.lower()) for w in caps.warnings]) , msg;
+ assert caps.http_addon_available == False , msg;
+ } elif label == "rbac-denied-on-core" {
+ assert caps.core_available == False , msg;
+ assert any(
+ [
+ ("permission" in e.lower() or "rbac" in e.lower())
+ for e in caps.errors
+ ]
+ ) , msg;
+ } elif label == "rbac-denied-on-http-addon" {
+ assert any(
+ [
+ ("permission" in e.lower() or "rbac" in e.lower())
+ for e in caps.errors
+ ]
+ ) , msg;
+ legacy_calls = [
+ c
+ for c in custom_api.list_cluster_custom_object.call_args_list
+ if c[1].get("plural") == "httpscaledobjects"
+ ];
+ assert legacy_calls == [] , (
+ f"{label}: RBAC-denied v1beta1 must not fall through to the "
+ "legacy-version probe (that's only for a genuinely absent API)"
+ );
+ } elif label == "rbac-denied-on-legacy-probe" {
+ assert any(
+ [
+ ("permission" in e.lower() or "rbac" in e.lower())
+ for e in caps.errors
+ ]
+ ) , (
+ f"{label}: an RBAC-denied legacy probe must be reported as a "
+ "permission error, not silently treated as 'not installed'"
+ );
+ }
+ }
+}
+
+test "discover_capabilities caches per cluster and only re-queries when refresh=True" {
+ custom_api = _custom_api_for();
+ keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
+ keda.discover_capabilities();
+ keda.discover_capabilities();
+ assert custom_api.list_cluster_custom_object.call_count == 2 , "second call should hit cache, not requery (core + http checks = 2 calls total)";
+ keda.discover_capabilities(refresh=True);
+ assert custom_api.list_cluster_custom_object.call_count == 4 , "refresh=True should bypass the cache and requery";
+}
+
+test "invalidate_capabilities clears the cached result for the current cluster" {
+ custom_api = _custom_api_for();
+ keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
+ keda.discover_capabilities();
+ keda.invalidate_capabilities();
+ keda.discover_capabilities();
+ assert custom_api.list_cluster_custom_object.call_count == 4 , "invalidate_capabilities should force the next call to requery";
+}
+
+test "apply_http_activation raises when capabilities are discovered but broken" {
+ keda = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for(scaler=404));
+ raised = False;
+ error_msg = "";
+ try {
+ keda.apply_http_activation(_spec());
+ } except ValueError as e {
+ raised = True;
+ error_msg = str(e);
+ }
+ assert raised , "Expected ValueError when the HTTP Add-on is installed but its scaler Service is missing";
+ assert "scaler" in error_msg.lower();
+}
+
+test "apply_http_activation skips activation when core KEDA is missing, even if the HTTP Add-on and its services are healthy" {
+ custom_api = _custom_api_for(core=404);
+ keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
+ result = keda.apply_http_activation(_spec());
+ assert result == False , "A partial install (Add-on healthy, core missing) must not proceed to create resources";
+ custom_api.create_namespaced_custom_object.assert_not_called();
+}
+
+test "discover_capabilities cache key accounts for instance-specific service addresses" {
+ keda_a = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
+ caps_a = keda_a.discover_capabilities();
+ assert caps_a.errors == [] , "Sanity check: instance A's own services should resolve cleanly";
+
+ # Instance B targets the same cluster host as instance A but with a
+ # different http_scaler_address -- proves the two don't get conflated
+ # even when both resolve to a cache entry keyed off the same host.
+ keda_b = KEDAAutoscaler(
+ _custom_api=_custom_api_for(),
+ _core_api=_core_api_for(scaler=404),
+ http_scaler_address="custom-scaler.other-ns:9090"
+ );
+ caps_b = keda_b.discover_capabilities();
+ assert any([("scaler" in e.lower()) for e in caps_b.errors]) , (
+ "Instance B has a different http_scaler_address than instance A and "
+ "its scaler Service is missing -- it must not reuse instance A's "
+ "healthy cached result just because they share a cluster host"
+ );
+}
+
+test "discover_capabilities cache key accounts for distinct Kubernetes client identities" {
+ keda_a = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
+ caps_a = keda_a.discover_capabilities();
+ assert caps_a.errors == [] , "Sanity check: instance A's own client resolves cleanly";
+
+ # Same cluster key and same default service addresses as instance A --
+ # only the injected custom_api differs, representing a distinct
+ # Kubernetes client/service account with RBAC denied on core discovery.
+ # _capabilities_cache is per-instance, so there is no shared cache for
+ # this to leak through even before checking the assertion below; this
+ # proves instance B's own result reflects its own (restricted) client
+ # rather than accidentally matching instance A's.
+ keda_b = KEDAAutoscaler(
+ _custom_api=_custom_api_for(core=403), _core_api=_core_api_for()
+ );
+ caps_b = keda_b.discover_capabilities();
+ assert any(
+ [("permission" in e.lower() or "rbac" in e.lower()) for e in caps_b.errors]
+ ) , (
+ "Instance B has a different (RBAC-restricted) Kubernetes client than "
+ "instance A even though cluster key and service addresses match -- "
+ "it must not reuse instance A's healthy cached result"
+ );
+}
+
+test "format_capabilities gives a concise message for both ready and not-ready cases" {
+ keda = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
+ ready = keda.discover_capabilities();
+ assert "ready" in keda.format_capabilities(ready).lower();
+
+ keda2 = _keda_for(
+ custom_api=_custom_api_for(http_v1beta1=404, legacy_v1alpha1=404),
+ core_api=_core_api_for()
+ );
+ not_ready = keda2.discover_capabilities();
+ assert "helm" in keda2.format_capabilities(not_ready).lower();
+}
+
+test "preflight treats a 403 as a distinct error instead of an unhandled exception" {
+ KEDAAutoscaler._preflight_cache.clear();
+ custom_api = unittest.mock.MagicMock();
+ custom_api.list_cluster_custom_object.side_effect = ApiException(status=403);
+ keda = KEDAAutoscaler(_custom_api=custom_api);
+ raised = False;
+ try {
+ keda.preflight();
+ } except ApiException as e {
+ raised = True;
+ }
+ assert not raised , "A 403 must be caught and reported, not propagated as an unhandled ApiException";
+}
+
+test "discover_capabilities resolves fully-qualified service addresses to name and namespace" {
+ core_api = _core_api_for();
+ keda = KEDAAutoscaler(
+ _custom_api=_custom_api_for(),
+ _core_api=core_api,
+ _apps_api=unittest.mock.MagicMock(),
+ http_scaler_address="keda-add-ons-http-external-scaler.keda.svc.cluster.local:9090",
+ interceptor_service_address="keda-add-ons-http-interceptor-proxy.keda.svc.cluster.local:8080"
+ );
+ caps = keda.discover_capabilities();
+ assert caps.errors == [] , f"FQDN service addresses must resolve cleanly, got: {caps.errors}";
+ lookups = [
+ (c[1]["name"], c[1]["namespace"])
+ for c in core_api.read_namespaced_service.call_args_list
+ ];
+ assert lookups
+ == [
+ ("keda-add-ons-http-external-scaler", "keda"),
+ ("keda-add-ons-http-interceptor-proxy", "keda")
+ ] , f"FQDN must parse as name.namespace, ignoring the cluster-domain suffix; got: {lookups}";
+}
+
+test "format_capabilities does not report ready when core KEDA is missing" {
+ keda = _keda_for(custom_api=_custom_api_for(core=404), core_api=_core_api_for());
+ caps = keda.discover_capabilities();
+ assert caps.http_addon_available == True , "Sanity check: only core should be missing in this scenario";
+ msg = keda.format_capabilities(caps);
+ assert "not ready" in msg.lower() , (
+ f"A healthy Add-on with missing core KEDA must not be reported as ready; got: {msg}"
+ );
+}
diff --git a/release_notes/unreleased/jaclang/7620.feature.md b/release_notes/unreleased/jaclang/7620.feature.md
new file mode 100644
index 00000000000..f36979113dd
--- /dev/null
+++ b/release_notes/unreleased/jaclang/7620.feature.md
@@ -0,0 +1 @@
+- **Feature: KEDA HTTP Add-on capability discovery and preflight diagnostics**: jac-scale's KEDA autoscaler engine gains `discover_capabilities`, a structured preflight check that distinguishes a missing KEDA core install from a missing or legacy HTTP Add-on, an RBAC-denied discovery from a genuinely absent API, and a missing external-scaler or interceptor-proxy Service, each with its own actionable diagnostic and the exact Helm install or upgrade command where relevant, alongside a link to the current getting-started guide in case the command has drifted. Results are cached per cluster, with `discover_capabilities(refresh=True)` and `invalidate_capabilities()` as explicit refresh paths, and `format_capabilities` for a concise CLI-ready summary. `apply_http_activation` now calls this check directly: it raises when the Add-on is installed but broken (missing service, unsupported version, RBAC denied) instead of silently deploying a workload that will never activate from HTTP traffic, while still returning `False` gracefully when the Add-on is simply absent.
From f6acb6d1d682c571c746f4ff4bd0bd29f0a4e9bf Mon Sep 17 00:00:00 2001
From: Linus Kipkemoi Langat
<142144579+Developer-Linus@users.noreply.github.com>
Date: Fri, 7 Aug 2026 08:11:36 +0300
Subject: [PATCH 02/13] feat(scale): per-service hpa.behavior overlay for
HPA/KEDA scale-rate policy (#7899)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #7764.
`jac-scale`'s generated HPA `behavior` block (added in #7445) has always
shipped one hardcoded scale-down policy — `{"type": "Percent", "value":
50, "periodSeconds": 60}`, no `selectPolicy` — with no way to override
it per service. Because the deploy path re-applies the full generated
manifest on every `jac start --scale`, any out-of-band `kubectl patch`
on the HPA gets wiped on the next deploy, forcing a
re-patch-after-every-deploy loop in CI.
This PR adds a `hpa.behavior` fragment to the per-service config table,
deep-merged over the generated default. It's the first slice of #7764 —
connection-heavy services (WebSockets, SSE) can now ask for a gentle
scale-down (e.g. at most 2 pods every 5 minutes) without imposing that
rate on every other service through the app-global
`autoscaler_cooldown`, and the override survives every subsequent
deploy.
```toml
[scale.microservices.services.chat_sv.hpa.behavior.scaleDown]
stabilizationWindowSeconds = 600
selectPolicy = "Min"
policies = [{ type = "Pods", value = 2, periodSeconds = 300 }]
```
- **`AutoscalerSpec`** (`autoscaler.jac`) gains a `behavior_overlay:
dict[str, any] = {}` field.
- **`Autoscaler._build_behavior`** deep-merges `behavior_overlay` over
the hardcoded default via the existing `merge_manifest_overlay` (same
semantics already used for `deployment_overlay`: maps merge recursively;
the `policies` list carries no `name` key, so it's replaced wholesale
rather than merged entry-by-entry, matching how you'd actually want to
specify a policy set). Both the HPA and KEDA engines pick this up for
free — they already route through the same `_build_behavior` call, so
this is a single-point change.
- **`ManifestBuilder._get_autoscaler_config`** (`manifest_builder.jac`)
resolves the fragment from the per-service `hpa.behavior` table.
- **`KubernetesTarget`**'s deploy loop (`target.jac`) threads it into
`AutoscalerSpec(...)` as `behavior_overlay=cfg.get("behavior", {})`.
- **Config schema** (`plugin_config.jac`) and the **K8s reference
table** (`runtime/docs.md`) document the new key.
`_build_behavior` resolves `merge_manifest_overlay` via a
function-scoped import rather than a top-level one.
`manifest_builder.jac` already imports constants from `autoscaler.jac`
at module level, so a top-level import in the other direction would be a
circular import; a deferred import inside the function (evaluated well
after both modules are fully loaded) is the same pattern already used
elsewhere in this codebase (e.g. `manifest_builder.jac`'s own
`_pod_entrypoint`) for exactly this situation.
- `service_overlay` / first-class `Service` `annotations` + gateway
`sessionAffinity` knobs
- PDB `min_available` and percentage (`"50%"`) support
Written test-first: 11 new tests across the files that already exercise
the touched code paths (`test_deployment_overlay.jac`,
`test_factories.jac`, `test_keda_autoscaler.jac`,
`test_memory_trigger_guard.jac`), confirmed red against the
pre-implementation tree, then green after:
- `_build_behavior` merge semantics in isolation: no-overlay default,
empty-overlay no-op, `scaleDown` override, `scaleUp` override, wholesale
(not per-item) `policies` replacement.
- Both engines apply the overlay: HPA's `_build_manifests`
(`spec.behavior`) and KEDA's `_build_manifests`
(`advanced.horizontalPodAutoscalerConfig.behavior`).
- Config plumbing: `_get_autoscaler_config` surfaces `hpa.behavior`,
defaults to `{}` when unset, doesn't leak across services, and
`hpa.enabled = false` still short-circuits before behavior is
considered.
Also verified against a real cluster, not just unit tests. Deployed the
`jac-shop` 3-service microservice fixture
(`jaclang/scale/tests/fixtures/k8s_e2e`) to a local `kind` cluster with
a `hpa.behavior.scaleDown` override configured on `products_app`, via
`jac start main.jac --scale`:
- **First deploy**: all 4 services (gateway + 3) reached Ready, and the
live `products-app-hpa` object's `spec.behavior.scaleDown` matched the
configured override exactly (`stabilizationWindowSeconds: 600`,
`selectPolicy: Min`, `policies: [{type: Pods, value: 2, periodSeconds:
300}]`), with `scaleUp` untouched at the generated default.
- **Redeploy**: re-ran `jac start main.jac --scale` against the same
cluster and diffed `products-app-hpa`'s `spec.behavior.scaleDown` before
and after — byte-identical. Confirms the actual pain point from the
issue: the override survives repeated applies instead of being reset to
the old hardcoded `{Percent, 50, 60s}` shape.
- [x] A per-service `hpa.behavior` fragment in `jac.toml` survives
repeated `jac start --scale` applies with no external patching.
- [x] Global `autoscaler_*` keys remain the defaults; per-service
fragment wins on merge.
- [x] Works for both `autoscaler_engine = "hpa"` and `"keda"`.
- [x] Config schema (`plugin_config.jac`) and docs updated;
overlay-merge unit tests added alongside the existing
`test_deployment_overlay.jac` patterns.
---------
Co-authored-by: Kugesan Sivasothynathan <153247429+kugesan1105@users.noreply.github.com>
Co-authored-by: Kugesan Sivasothynathan
(cherry picked from commit 91c7adce8714cc5fff90278d25001cdec19a74bb)
---
jac/jaclang/scale/config/plugin_config.jac | 2 +-
.../scale/deploy/autoscale/autoscaler.jac | 12 ++-
.../target/kubernetes/manifest_builder.jac | 13 ++-
.../scale/deploy/target/kubernetes/target.jac | 3 +-
jac/jaclang/scale/runtime/docs.md | 1 +
.../tests/deploy/test_deployment_overlay.jac | 82 ++++++++++++++
.../scale/tests/deploy/test_factories.jac | 25 +++++
.../tests/deploy/test_keda_autoscaler.jac | 27 +++++
.../deploy/test_memory_trigger_guard.jac | 100 ++++++++++++++++++
.../unreleased/jaclang/7899.feature.md | 1 +
10 files changed, 261 insertions(+), 5 deletions(-)
create mode 100644 jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
create mode 100644 release_notes/unreleased/jaclang/7899.feature.md
diff --git a/jac/jaclang/scale/config/plugin_config.jac b/jac/jaclang/scale/config/plugin_config.jac
index 16c569bbf37..f4fe86daa1f 100644
--- a/jac/jaclang/scale/config/plugin_config.jac
+++ b/jac/jaclang/scale/config/plugin_config.jac
@@ -299,7 +299,7 @@ class JacScalePluginConfig {
"services": {
"type": "dict",
"default": {},
- "description": "Per-service overrides keyed by module name. Subkeys (all optional): `rpc_timeout` (float, default 10s, inter-service sv-import calls), `http_forward_timeout` (float, default 30s, gateway-to-service forward), `replicas` (int, default 1, K8s Deployment.spec.replicas), `cpu_request` / `cpu_limit` (str, e.g. \"100m\", K8s container resources), `memory_request` / `memory_limit` (str, e.g. \"128Mi\"), `env` (dict[str,str], extra container env vars merged with auto-set JAC_SV_NAME), `image_tag` (str, override global image tag for canary), and nested `hpa` / `pdb` sub-tables. `hpa` keys: `enabled` (bool, default true), `min` (int, default 1), `max` (int, default 3), `cpu_target` (int percent, default 70), `memory_target` (int percent of memory request, default 80). `pdb` keys: `enabled` (bool, default true), `max_unavailable` (int, default 1). `deployment_overlay` (table): a raw Deployment-manifest fragment merged into the generated manifest at build time - maps merge recursively, lists whose members all carry `name` (containers, volumes, env, initContainers, volumeMounts) merge by that name, anything else replaces; identity fields (metadata.name/namespace, selector, the app/managed template labels) are reasserted after the merge. The main container's `name` is the k8s-safe service name (e.g. `builder-sv`; gateway is `gateway`). Use it for nodeSelector, probe tuning, extra volumes/env/init containers - anything the schema has no first-class key for - so pods are right on the FIRST rollout instead of being kubectl-patched into a second one. Overlays add and override only: there is no delete directive (setting a key to null stores null rather than removing the generated field). Gateway uses the `__gateway__` key. `[[services.NAME.triggers]]` array (KEDA only): per-service event-driven triggers; each entry has `type` (str), `metadata` (dict[str,str]), optional `name` (str), optional `auth.secret_refs` (dict). Requires `autoscaler_engine = \"keda\"` in [scale.kubernetes]. Example: [scale.microservices.services.llm_app] rpc_timeout = 120.0, replicas = 2, cpu_limit = \"2000m\", memory_limit = \"4Gi\", env = { LOG_LEVEL = \"DEBUG\" }, [scale.microservices.services.llm_app.hpa] max = 20, cpu_target = 60"
+ "description": "Per-service overrides keyed by module name. Subkeys (all optional): `rpc_timeout` (float, default 10s, inter-service sv-import calls), `http_forward_timeout` (float, default 30s, gateway-to-service forward), `replicas` (int, default 1, K8s Deployment.spec.replicas), `cpu_request` / `cpu_limit` (str, e.g. \"100m\", K8s container resources), `memory_request` / `memory_limit` (str, e.g. \"128Mi\"), `env` (dict[str,str], extra container env vars merged with auto-set JAC_SV_NAME), `image_tag` (str, override global image tag for canary), and nested `hpa` / `pdb` sub-tables. `hpa` keys: `enabled` (bool, default true), `min` (int, default 1), `max` (int, default 3), `cpu_target` (int percent, default 70), `memory_target` (int percent of memory request, default 80), `behavior` (table, default `{}`): a raw HPA `behavior` fragment (`scaleUp`/`scaleDown`, each with `stabilizationWindowSeconds`/`policies`/`selectPolicy`) deep-merged over the generated scale-rate defaults - same merge semantics as `deployment_overlay` (maps merge recursively, the `policies` list has no `name` key so it replaces wholesale rather than merging by entry); applies to both \"hpa\" and \"keda\" engines since both route through the same `Autoscaler._build_behavior` (for keda it only governs scaling above zero replicas - the drop to zero remains controlled by `autoscaler_cooldown`, the ScaledObject's cooldownPeriod). Use it to slow scale-down for connection-heavy services (e.g. `[services.NAME.hpa.behavior.scaleDown] stabilizationWindowSeconds = 600, selectPolicy = 'Min', policies = [{ type = 'Pods', value = 2, periodSeconds = 300 }]`) without imposing the same rate limit on every service via the app-global `autoscaler_cooldown`. `pdb` keys: `enabled` (bool, default true), `max_unavailable` (int, default 1). `deployment_overlay` (table): a raw Deployment-manifest fragment merged into the generated manifest at build time - maps merge recursively, lists whose members all carry `name` (containers, volumes, env, initContainers, volumeMounts) merge by that name, anything else replaces; identity fields (metadata.name/namespace, selector, the app/managed template labels) are reasserted after the merge. The main container's `name` is the k8s-safe service name (e.g. `builder-sv`; gateway is `gateway`). Use it for nodeSelector, probe tuning, extra volumes/env/init containers - anything the schema has no first-class key for - so pods are right on the FIRST rollout instead of being kubectl-patched into a second one. Overlays add and override only: there is no delete directive (setting a key to null stores null rather than removing the generated field). Gateway uses the `__gateway__` key. `[[services.NAME.triggers]]` array (KEDA only): per-service event-driven triggers; each entry has `type` (str), `metadata` (dict[str,str]), optional `name` (str), optional `auth.secret_refs` (dict). Requires `autoscaler_engine = \"keda\"` in [scale.kubernetes]. Example: [scale.microservices.services.llm_app] rpc_timeout = 120.0, replicas = 2, cpu_limit = \"2000m\", memory_limit = \"4Gi\", env = { LOG_LEVEL = \"DEBUG\" }, [scale.microservices.services.llm_app.hpa] max = 20, cpu_target = 60"
},
"rate_limit": {
"type": "dict",
diff --git a/jac/jaclang/scale/deploy/autoscale/autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
index 7e07b858ebe..9aaa5d38cd5 100644
--- a/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
@@ -26,7 +26,8 @@ obj AutoscalerSpec {
cooldown_period: int = 300,
initial_cooldown_period: int = 0,
scale_up_stabilization: int = 60,
- scale_up_max_pods: int = 2;
+ scale_up_max_pods: int = 2,
+ behavior_overlay: dict[str, any] = {};
}
@@ -36,7 +37,7 @@ class Autoscaler {
}
static def _build_behavior(spec: AutoscalerSpec) -> dict[str, any] {
- return {
+ default: dict[str, any] = {
"scaleUp": {
"stabilizationWindowSeconds": min(
3600, max(0, spec.scale_up_stabilization)
@@ -54,6 +55,13 @@ class Autoscaler {
"policies": [{"type": "Percent", "value": 50, "periodSeconds": 60}]
}
};
+ if not spec.behavior_overlay {
+ return default;
+ }
+ import from jaclang.scale.deploy.target.kubernetes.manifest_builder {
+ merge_manifest_overlay
+ }
+ return merge_manifest_overlay(default, spec.behavior_overlay);
}
def apply(self: Autoscaler, spec: AutoscalerSpec) -> bool {
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac b/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac
index eb91aa0c206..d9bad72d15a 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac
@@ -948,6 +948,16 @@ obj ManifestBuilder {
raw_triggers.append(dict(item));
}
}
+ behavior = hpa.get("behavior", {});
+ if not isinstance(behavior, dict) {
+ if self.logger {
+ self.logger.warning(
+ f"Ignoring malformed hpa.behavior for '{svc_name}': expected "
+ f"a table, got {type(behavior).__name__}"
+ );
+ }
+ behavior = {};
+ }
return {
"min": int(hpa.get("min", 1)),
"max": int(hpa.get("max", 3)),
@@ -955,7 +965,8 @@ obj ManifestBuilder {
"memory_target": int(
hpa.get("memory_target", DEFAULT_MEMORY_UTILIZATION_TARGET)
),
- "triggers": raw_triggers
+ "triggers": raw_triggers,
+ "behavior": dict(behavior)
};
}
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/target.jac b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
index de4d023e3f4..b0779e0bcb1 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
@@ -1119,7 +1119,8 @@ obj KubernetesTarget(KubernetesTargetBase) {
initial_cooldown_period=self.k8s_config.autoscaler_initial_cooldown,
scale_up_stabilization=self.k8s_config.autoscaler_scale_up_stabilization,
scale_up_max_pods=self.k8s_config.autoscaler_scale_up_max_pods,
- triggers=triggers
+ triggers=triggers,
+ behavior_overlay=cfg.get("behavior", {})
)
);
diff --git a/jac/jaclang/scale/runtime/docs.md b/jac/jaclang/scale/runtime/docs.md
index b90639e8c99..3651e1a5cf6 100644
--- a/jac/jaclang/scale/runtime/docs.md
+++ b/jac/jaclang/scale/runtime/docs.md
@@ -322,6 +322,7 @@ code changes from local mode.
| `rpc_timeout` | `10.0` | `sv import` httpx timeout (s) |
| `http_forward_timeout` | `30.0` | gateway-to-service forward (s) |
| `hpa.enabled` / `min` / `max` / `cpu_target` | `true` / `1` / `3` / `70` | autoscaler bounds (applies to both `"hpa"` and `"keda"` engines) |
+| `hpa.behavior` | `{}` | Raw HPA `behavior` fragment (`scaleUp`/`scaleDown`) deep-merged over the generated scale-rate defaults - same merge rules as `deployment_overlay`. Applies to both engines; for `keda` it only governs scaling while replicas are above zero - the drop to zero is still controlled by `autoscaler_cooldown` (the ScaledObject's `cooldownPeriod`). See example below. |
| `[[triggers]]` | `[]` | Per-service KEDA triggers (requires `autoscaler_engine = "keda"`). Each entry: `type` (required), `metadata` (default `{}`), `name` (default `null`), `auth.secret_refs` (default `{}`). Same shape as `[[scale.kubernetes.extra_triggers]]`. |
| `pdb.enabled` / `max_unavailable` | `true` / `1` | PodDisruptionBudget |
diff --git a/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac b/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac
index 2c4fad2cc97..cfb69367131 100644
--- a/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac
+++ b/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac
@@ -10,6 +10,7 @@ import from jaclang.scale.deploy.target.kubernetes.kubernetes_config {
KubernetesConfig
}
import from jaclang.scale.config.config_loader { JacScaleConfig }
+import from jaclang.scale.deploy.autoscale.autoscaler { Autoscaler, AutoscalerSpec }
def _builder(services: dict) -> ManifestBuilder {
@@ -49,6 +50,87 @@ test "unnamed lists are replaced wholesale" {
assert merged["args"] == ["c"];
}
+test "build_behavior with no overlay returns the hardcoded default" {
+ spec = AutoscalerSpec(scale_target_name="x", namespace="ns");
+ result = Autoscaler._build_behavior(spec);
+ assert result["scaleDown"]["policies"]
+ == [{"type": "Percent", "value": 50, "periodSeconds": 60}];
+ assert result["scaleUp"]["policies"]
+ == [{"type": "Pods", "value": 2, "periodSeconds": 60}];
+}
+
+test "build_behavior with an empty overlay dict is a no-op" {
+ default = Autoscaler._build_behavior(
+ AutoscalerSpec(scale_target_name="x", namespace="ns")
+ );
+ overlaid = Autoscaler._build_behavior(
+ AutoscalerSpec(scale_target_name="x", namespace="ns", behavior_overlay={})
+ );
+ assert overlaid == default;
+}
+
+test "build_behavior overlay replaces scaleDown policy shape, leaves scaleUp default" {
+ spec = AutoscalerSpec(
+ scale_target_name="x",
+ namespace="ns",
+ behavior_overlay={
+ "scaleDown": {
+ "stabilizationWindowSeconds": 600,
+ "selectPolicy": "Min",
+ "policies": [{"type": "Pods", "value": 2, "periodSeconds": 300}]
+ }
+ }
+ );
+ result = Autoscaler._build_behavior(spec);
+ assert result["scaleDown"]["stabilizationWindowSeconds"] == 600;
+ assert result["scaleDown"]["selectPolicy"] == "Min";
+ assert result["scaleDown"]["policies"]
+ == [{"type": "Pods", "value": 2, "periodSeconds": 300}];
+ assert result["scaleUp"]["stabilizationWindowSeconds"] == 60;
+ assert result["scaleUp"]["policies"]
+ == [{"type": "Pods", "value": 2, "periodSeconds": 60}];
+}
+
+test "build_behavior overlay on scaleUp does not disturb the scaleDown default" {
+ spec = AutoscalerSpec(
+ scale_target_name="x",
+ namespace="ns",
+ behavior_overlay={
+ "scaleUp": {
+ "policies": [{"type": "Percent", "value": 100, "periodSeconds": 15}]
+ }
+ }
+ );
+ result = Autoscaler._build_behavior(spec);
+ assert result["scaleUp"]["policies"]
+ == [{"type": "Percent", "value": 100, "periodSeconds": 15}];
+ assert result["scaleUp"]["stabilizationWindowSeconds"] == 60;
+ assert result["scaleDown"]["policies"]
+ == [{"type": "Percent", "value": 50, "periodSeconds": 60}];
+}
+
+test "build_behavior overlay policies list replaces wholesale, not merged by item" {
+ # Policy dicts carry no `name` key, so merge_manifest_overlay treats
+ # `policies` as a plain list and replaces it wholesale rather than
+ # merging entries by position or identity.
+ spec = AutoscalerSpec(
+ scale_target_name="x",
+ namespace="ns",
+ behavior_overlay={
+ "scaleDown": {
+ "policies": [
+ {"type": "Pods", "value": 1, "periodSeconds": 120},
+ {"type": "Percent", "value": 10, "periodSeconds": 120}
+ ]
+ }
+ }
+ );
+ result = Autoscaler._build_behavior(spec);
+ assert len(result["scaleDown"]["policies"]) == 2;
+ assert result["scaleDown"]["policies"][0]["value"] == 1;
+ assert result["scaleDown"]["policies"][1]["value"] == 10;
+}
+
test "deployment manifest applies the service overlay on the first build" {
b = _builder(
{
diff --git a/jac/jaclang/scale/tests/deploy/test_factories.jac b/jac/jaclang/scale/tests/deploy/test_factories.jac
index bf1d1a1fbbb..dba68043dbe 100644
--- a/jac/jaclang/scale/tests/deploy/test_factories.jac
+++ b/jac/jaclang/scale/tests/deploy/test_factories.jac
@@ -307,6 +307,31 @@ test "hpa autoscaler build manifests produces correct dict" {
assert result == expected;
}
+test "hpa autoscaler build manifests applies behavior_overlay onto the default shape" {
+ spec = AutoscalerSpec(
+ scale_target_name="my-service-deployment",
+ autoscaler_name="my-service-hpa",
+ namespace="default",
+ min_replicas=1,
+ max_replicas=3,
+ triggers=[Trigger(type="cpu", metadata={"averageUtilization": "70"})],
+ behavior_overlay={
+ "scaleDown": {
+ "stabilizationWindowSeconds": 600,
+ "selectPolicy": "Min",
+ "policies": [{"type": "Pods", "value": 2, "periodSeconds": 300}]
+ }
+ }
+ );
+ result = HPAAutoscaler()._build_manifests(spec);
+ behavior = result["spec"]["behavior"];
+ assert behavior["scaleDown"]["stabilizationWindowSeconds"] == 600;
+ assert behavior["scaleDown"]["selectPolicy"] == "Min";
+ assert behavior["scaleDown"]["policies"]
+ == [{"type": "Pods", "value": 2, "periodSeconds": 300}];
+ assert behavior["scaleUp"]["stabilizationWindowSeconds"] == 60;
+}
+
test "autoscaler factory creates keda autoscaler for engine=keda" {
autoscaler = AutoscalerFactory.create("keda", {});
assert isinstance(autoscaler, KEDAAutoscaler);
diff --git a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
index 1bfa7d48199..de17cf29258 100644
--- a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
+++ b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
@@ -153,6 +153,33 @@ test "build manifests produces a complete and valid ScaledObject body" {
assert m["body"]["spec"]["triggers"][0]["metadata"]["value"] == "60";
}
+test "build manifests applies behavior_overlay through advanced.horizontalPodAutoscalerConfig" {
+ keda = KEDAAutoscaler();
+ spec = AutoscalerSpec(
+ scale_target_name="orders-deployment",
+ namespace="staging",
+ min_replicas=2,
+ max_replicas=10,
+ triggers=[Trigger(type="cpu", metadata={"averageUtilization": "60"})],
+ behavior_overlay={
+ "scaleDown": {
+ "stabilizationWindowSeconds": 600,
+ "selectPolicy": "Min",
+ "policies": [{"type": "Pods", "value": 2, "periodSeconds": 300}]
+ }
+ }
+ );
+ m = keda._build_manifests(spec);
+ behavior = m["body"]["spec"]["advanced"]["horizontalPodAutoscalerConfig"][
+ "behavior"
+ ];
+ assert behavior["scaleDown"]["stabilizationWindowSeconds"] == 600;
+ assert behavior["scaleDown"]["selectPolicy"] == "Min";
+ assert behavior["scaleDown"]["policies"]
+ == [{"type": "Pods", "value": 2, "periodSeconds": 300}];
+ assert behavior["scaleUp"]["stabilizationWindowSeconds"] == 60;
+}
+
test "build manifests adds idleReplicaCount zero for scale-to-zero" {
keda = KEDAAutoscaler();
spec = AutoscalerSpec(
diff --git a/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac b/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
new file mode 100644
index 00000000000..3b3a75d24e1
--- /dev/null
+++ b/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
@@ -0,0 +1,100 @@
+import from jaclang.scale.deploy.target.kubernetes.manifest_builder {
+ ManifestBuilder,
+ GATEWAY_NAME
+}
+import from jaclang.scale.deploy.target.kubernetes.kubernetes_config {
+ KubernetesConfig
+}
+
+
+def _builder(
+ services: dict, k8s_config: KubernetesConfig | None = None
+) -> ManifestBuilder {
+ return ManifestBuilder(
+ k8s_config=k8s_config or KubernetesConfig(app_name="t", namespace="t-ns"),
+ microservices_config={"services": services},
+ dry_run=True
+ );
+}
+
+test "_get_autoscaler_config surfaces the per-service hpa.behavior fragment" {
+ b = _builder(
+ {
+ "billing_ops": {
+ "hpa": {
+ "behavior": {
+ "scaleDown": {
+ "stabilizationWindowSeconds": 600,
+ "policies": [
+ {"type": "Pods", "value": 2, "periodSeconds": 300}
+ ]
+ }
+ }
+ }
+ }
+ }
+ );
+ cfg = b._get_autoscaler_config("billing_ops");
+ assert cfg["behavior"]["scaleDown"]["stabilizationWindowSeconds"] == 600;
+}
+
+test "_get_autoscaler_config defaults behavior to an empty dict when unset" {
+ b = _builder({"billing_ops": {}});
+ cfg = b._get_autoscaler_config("billing_ops");
+ assert cfg["behavior"] == {};
+}
+
+test "hpa.behavior on one service does not leak onto another" {
+ b = _builder(
+ {
+ "billing_ops": {
+ "hpa": {"behavior": {"scaleDown": {"selectPolicy": "Min"}}}
+ },
+ "orders_ops": {}
+ }
+ );
+ assert b._get_autoscaler_config("billing_ops")["behavior"]
+ == {"scaleDown": {"selectPolicy": "Min"}};
+ assert b._get_autoscaler_config("orders_ops")["behavior"] == {};
+}
+
+test "hpa.enabled false still short-circuits before behavior is considered" {
+ b = _builder(
+ {
+ "billing_ops": {
+ "hpa": {
+ "enabled": False,
+ "behavior": {"scaleDown": {"selectPolicy": "Min"}}
+ }
+ }
+ }
+ );
+ assert b._get_autoscaler_config("billing_ops") is None;
+}
+
+
+test "hpa.behavior works for the gateway via the __gateway__ service key" {
+ b = _builder(
+ {GATEWAY_NAME: {"hpa": {"behavior": {"scaleDown": {"selectPolicy": "Min"}}}}}
+ );
+ assert b._get_autoscaler_config(GATEWAY_NAME)["behavior"]
+ == {"scaleDown": {"selectPolicy": "Min"}};
+}
+
+test "malformed hpa.behavior warns and falls back to the empty fragment" {
+ import unittest.mock;
+
+ logger = unittest.mock.MagicMock();
+ b = ManifestBuilder(
+ k8s_config=KubernetesConfig(app_name="t", namespace="t-ns"),
+ microservices_config={
+ "services": {"billing_ops": {"hpa": {"behavior": "gentle"}}}
+ },
+ dry_run=True,
+ logger=logger
+ );
+ cfg = b._get_autoscaler_config("billing_ops");
+ assert cfg["behavior"] == {};
+ logger.warning.assert_called_once();
+ assert "billing_ops" in logger.warning.call_args[0][0];
+}
diff --git a/release_notes/unreleased/jaclang/7899.feature.md b/release_notes/unreleased/jaclang/7899.feature.md
new file mode 100644
index 00000000000..f38d1bdc9b8
--- /dev/null
+++ b/release_notes/unreleased/jaclang/7899.feature.md
@@ -0,0 +1 @@
+- **Per-service HPA/KEDA scale-down (and scale-up) rate can now be overridden** without being clobbered on the next deploy: `[scale.microservices.services.NAME.hpa.behavior]` accepts a raw HPA `behavior` fragment (`scaleUp`/`scaleDown`, each with `stabilizationWindowSeconds`/`policies`/`selectPolicy`) that is deep-merged over the previously-hardcoded `{"type": "Percent", "value": 50, "periodSeconds": 60}` scale-down shape - same merge semantics as `deployment_overlay`, applied to both the `"hpa"` and `"keda"` autoscaler engines since both route through `Autoscaler._build_behavior`. Connection-heavy services (WebSockets, SSE) can now ask for a gentle scale-down (e.g. at most 2 pods every 5 minutes) without imposing that rate on every other service via the app-global `autoscaler_cooldown`.
From 167b5808b98423263d574502317d864d3cfd9042 Mon Sep 17 00:00:00 2001
From: Musab Mahmoodh <43021789+MusabMahmoodh@users.noreply.github.com>
Date: Tue, 11 Aug 2026 22:44:44 +0530
Subject: [PATCH 03/13] fix(scale): render autoscaler manifests into
--show-yaml, so the reviewed YAML matches what a deploy applies (#8008)
`--show-yaml` printed pvcs, deployments, services, pdbs and ingress but
dropped autoscalers, while the totals line counted them and the service
view rendered HPA details from them. The reviewed YAML was not the
applied
artifact, for exactly the resources most likely to cause a production
surprise.
The bundle's `autoscalers` key holds CONFIGS, not manifests; the real
HPA/ScaledObject is synthesized only inside the apply loop. Adding the
key
to the flatten list would therefore have dumped config dicts. Instead:
- `deploy/target/kubernetes/target.jac`: the AutoscalerSpec construction
is
extracted out of the apply loop into `_autoscaler_spec` (single source),
and a new `render_autoscaler_manifests(bundle)` builds the same objects
the apply path would, via each engine's `_build_manifests`. Pure - no
Kubernetes API calls.
- `runtime/cli/plan.jac`: `Plan.from_target` stashes the rendered
manifests
into the bundle; `_flatten_manifests` emits them into --show-yaml.
- New test_show_yaml_autoscalers (3): real KubernetesTarget with the HPA
and KEDA engines; asserts kind, scaleTargetRef, min/max mapping, and
that
KEDA's ScaledObject body (not its name/body wrapper) is rendered.
- test_plan extended (+2): autoscaler manifests reach the YAML dump,
plus a
sentinel test asserting every manifest-bearing bundle key reaches
--show-yaml (the issue's drift-proofing suggestion).
- jac-ec2 rig, linked-source dev binary on this branch: all passing -
test_show_yaml_autoscalers (3, new), test_plan (19, includes the 2 new),
test_factories (28), test_keda_autoscaler (34),
test_memory_trigger_guard
(8), test_dry_run_purity (12).
- NOT run: a live cluster apply of HPA/KEDA objects (the render path is
pure; the apply path is unchanged except for the spec extraction). Full
repo suite not run locally.
- KEDA TriggerAuthentication objects are applied but still not rendered
into --show-yaml (built inline in `_apply_trigger_auth`; needs the same
build/apply split).
- Normalize the `_build_manifests` contract: KEDA returns a {name, body}
wrapper, HPA returns the bare manifest.
Closes #7915. Part of #7900.
---------
Co-authored-by: Thamirawaran Sathiyalogeswaran <107134124+Thamirawaran@users.noreply.github.com>
(cherry picked from commit 9d2cacecca245a7bdfb330d89d1a80fd89e9e12a)
---
.../scale/deploy/target/kubernetes/target.jac | 176 ++++++++++--------
jac/jaclang/scale/runtime/cli/plan.jac | 7 +
.../deploy/test_memory_trigger_guard.jac | 2 +-
.../deploy/test_show_yaml_autoscalers.jac | 45 +++++
.../scale/tests/microservices/test_plan.jac | 46 +++++
jac/jaclang/vendor/typeshed/PROVENANCE.md | 22 ++-
.../unreleased/jaclang/8008.bugfix.md | 1 +
7 files changed, 214 insertions(+), 85 deletions(-)
create mode 100644 jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
create mode 100644 release_notes/unreleased/jaclang/8008.bugfix.md
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/target.jac b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
index b0779e0bcb1..6ff71875c61 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
@@ -928,6 +928,103 @@ obj KubernetesTarget(KubernetesTargetBase) {
return ctx;
}
+ def _resolve_service_triggers(svc_name: str, cfg: dict[str, any]) -> list[Trigger] {
+ is_gateway = svc_name == GATEWAY_NAME;
+ has_memory_request = bool(cfg.get("has_memory_request", True));
+ effective_extra = self.k8s_config.extra_triggers if is_gateway else [];
+ triggers: list[Trigger] = [];
+ if not cfg.get("triggers", []) and not effective_extra {
+ triggers = [
+ Trigger(
+ type="cpu",
+ metadata={
+ "averageUtilization": str(
+ cfg.get("cpu_target", DEFAULT_CPU_UTILIZATION_TARGET)
+ )
+ }
+ )
+ ];
+ if has_memory_request {
+ triggers.append(
+ Trigger(
+ type="memory",
+ metadata={
+ "averageUtilization": str(
+ cfg.get(
+ "memory_target", DEFAULT_MEMORY_UTILIZATION_TARGET
+ )
+ )
+ }
+ )
+ );
+ }
+ }
+ for raw in cfg.get("triggers", []) + effective_extra {
+ t = Trigger(
+ type=str(raw.get("type", "")),
+ metadata=dict(raw.get("metadata", {})),
+ name=raw.get("name")
+ );
+ if t.type == "memory" and not has_memory_request {
+ if self.logger {
+ self.logger.warn(
+ f"Skipping 'memory' trigger for '{svc_name}': "
+ "memory_request resolves empty (explicitly set to "
+ "\"\"); HPA cannot compute memory utilization "
+ "without a request baseline"
+ );
+ }
+ continue;
+ }
+ raw_auth = raw.get("auth");
+ if raw_auth {
+ t.auth = TriggerAuth(secret_refs=dict(raw_auth.get("secret_refs", {})));
+ }
+ triggers.append(t);
+ }
+ return triggers;
+ }
+
+ def _autoscaler_spec(
+ svc_name: str, cfg: dict[str, any], autoscaler: any, namespace: str
+ ) -> AutoscalerSpec {
+ k8s_name = k8s_safe_name(svc_name);
+ return AutoscalerSpec(
+ scale_target_name=f"{k8s_name}-deployment",
+ autoscaler_name=autoscaler.resource_name_for(k8s_name),
+ app_name=k8s_name,
+ namespace=namespace,
+ min_replicas=int(cfg.get("min", 1)),
+ max_replicas=int(cfg.get("max", 3)),
+ idle_replicas=self.k8s_config.idle_replicas,
+ polling_interval=self.k8s_config.autoscaler_polling_interval,
+ cooldown_period=self.k8s_config.autoscaler_cooldown,
+ initial_cooldown_period=self.k8s_config.autoscaler_initial_cooldown,
+ scale_up_stabilization=self.k8s_config.autoscaler_scale_up_stabilization,
+ scale_up_max_pods=self.k8s_config.autoscaler_scale_up_max_pods,
+ triggers=self._resolve_service_triggers(svc_name, cfg),
+ behavior_overlay=cfg.get("behavior", {})
+ );
+ }
+
+ def render_autoscaler_manifests(bundle: dict[str, any]) -> list[dict[str, any]] {
+ out: list[dict[str, any]] = [];
+ for (svc_name, cfg_raw) in bundle.get("autoscalers", {}).items() {
+ cfg = dict(cfg_raw or {});
+ autoscaler = AutoscalerFactory.create(
+ self.k8s_config.autoscaler_engine, self.k8s_config.to_dict(), None
+ );
+ built = autoscaler._build_manifests(
+ self._autoscaler_spec(
+ svc_name, cfg, autoscaler, self.k8s_config.namespace
+ )
+ );
+
+ out.append(built["body"] if "body" in built else built);
+ }
+ return out;
+ }
+
def apply_manifests(bundle: dict[str, any]) {
self._load_cluster_config();
apps_v1 = client.AppsV1Api();
@@ -1043,90 +1140,19 @@ obj KubernetesTarget(KubernetesTargetBase) {
self.reap_superseded_deployments(apps_v1, namespace, bundle);
for svc_name in bundle.get("autoscalers", {}).keys() {
- k8s_name = k8s_safe_name(svc_name);
cfg = dict(bundle.get("autoscalers", {}).get(svc_name, {}));
autoscaler = AutoscalerFactory.create(
self.k8s_config.autoscaler_engine,
self.k8s_config.to_dict(),
self.logger
);
- triggers = [];
-
- is_gateway = svc_name == GATEWAY_NAME;
- effective_extra = self.k8s_config.extra_triggers if is_gateway else [];
- if not cfg.get("triggers", []) and not effective_extra {
- triggers = [
- Trigger(
- type="cpu",
- metadata={
- "averageUtilization": str(
- cfg.get("cpu_target", DEFAULT_CPU_UTILIZATION_TARGET)
- )
- }
- ),
- Trigger(
- type="memory",
- metadata={
- "averageUtilization": str(
- cfg.get(
- "memory_target", DEFAULT_MEMORY_UTILIZATION_TARGET
- )
- )
- }
- )
- ];
- }
- for raw in cfg.get("triggers", []) {
- t = Trigger(
- type=str(raw.get("type", "")),
- metadata=dict(raw.get("metadata", {})),
- name=raw.get("name")
- );
- raw_auth = raw.get("auth");
- if raw_auth {
- t.auth = TriggerAuth(
- secret_refs=dict(raw_auth.get("secret_refs", {}))
- );
- }
- triggers.append(t);
- }
- for raw in effective_extra {
- t = Trigger(
- type=str(raw.get("type", "")),
- metadata=dict(raw.get("metadata", {})),
- name=raw.get("name")
- );
- raw_auth = raw.get("auth");
- if raw_auth {
- t.auth = TriggerAuth(
- secret_refs=dict(raw_auth.get("secret_refs", {}))
- );
- }
- triggers.append(t);
- }
- autoscaler_name = autoscaler.resource_name_for(k8s_name);
- autoscaler_applied = autoscaler.apply(
- AutoscalerSpec(
- scale_target_name=f"{k8s_name}-deployment",
- autoscaler_name=autoscaler_name,
- app_name=k8s_name,
- namespace=namespace,
- min_replicas=int(cfg.get("min", 1)),
- max_replicas=int(cfg.get("max", 3)),
- idle_replicas=self.k8s_config.idle_replicas,
- polling_interval=self.k8s_config.autoscaler_polling_interval,
- cooldown_period=self.k8s_config.autoscaler_cooldown,
- initial_cooldown_period=self.k8s_config.autoscaler_initial_cooldown,
- scale_up_stabilization=self.k8s_config.autoscaler_scale_up_stabilization,
- scale_up_max_pods=self.k8s_config.autoscaler_scale_up_max_pods,
- triggers=triggers,
- behavior_overlay=cfg.get("behavior", {})
- )
- );
+ spec = self._autoscaler_spec(svc_name, cfg, autoscaler, namespace);
+ autoscaler_applied = autoscaler.apply(spec);
if self.logger and autoscaler_applied {
self.logger.info(
- f"Applied autoscaler for '{svc_name}'", {"name": autoscaler_name}
+ f"Applied autoscaler for '{svc_name}'",
+ {"name": spec.autoscaler_name}
);
}
}
diff --git a/jac/jaclang/scale/runtime/cli/plan.jac b/jac/jaclang/scale/runtime/cli/plan.jac
index d3e4401d8be..0763948f689 100644
--- a/jac/jaclang/scale/runtime/cli/plan.jac
+++ b/jac/jaclang/scale/runtime/cli/plan.jac
@@ -344,6 +344,10 @@ obj Plan {
static def from_target(target: Any, app_config: AppConfig) -> Plan {
bundle_raw = target.generate_manifests(app_config);
bundle: dict = bundle_raw if isinstance(bundle_raw, dict) else {};
+ render_autoscalers = target?.render_autoscaler_manifests;
+ if render_autoscalers is not None {
+ bundle["autoscaler_manifests"] = render_autoscalers(bundle);
+ }
(ms_cfg, svc_configs) = Plan._load_ms_cfg();
routes_any: Any = ms_cfg.get("routes", {});
@@ -532,6 +536,9 @@ obj Plan {
out.append(m);
}
}
+ for m in lget(self.bundle, "autoscaler_manifests") {
+ out.append(m);
+ }
if self.bundle.get("ingress") {
out.append(self.bundle["ingress"]);
}
diff --git a/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac b/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
index 3b3a75d24e1..d9d6a57a4b4 100644
--- a/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
+++ b/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
@@ -96,5 +96,5 @@ test "malformed hpa.behavior warns and falls back to the empty fragment" {
cfg = b._get_autoscaler_config("billing_ops");
assert cfg["behavior"] == {};
logger.warning.assert_called_once();
- assert "billing_ops" in logger.warning.call_args[0][0];
+ assert "billing_ops" in str(logger.warning.call_args[0][0]);
}
diff --git a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
new file mode 100644
index 00000000000..72f7493ae61
--- /dev/null
+++ b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
@@ -0,0 +1,45 @@
+"""--show-yaml must print the autoscaler objects a deploy would apply. The
+bundle carries autoscaler CONFIGS, not manifests; render_autoscaler_manifests
+turns them into the same objects the apply path builds, from the same spec."""
+
+import from jaclang.scale.deploy.target.kubernetes.kubernetes_config {
+ KubernetesConfig
+}
+import from jaclang.scale.deploy.target.kubernetes.target { KubernetesTarget }
+
+
+test "hpa engine renders the HPA manifest the apply path would build" {
+ target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
+ bundle = {"autoscalers": {"api": {"min": 2, "max": 7, "cpu_target": 60}}};
+ manifests = target.render_autoscaler_manifests(bundle);
+ assert len(manifests) == 1;
+ m = manifests[0];
+ assert m["kind"] == "HorizontalPodAutoscaler";
+ assert m["metadata"]["namespace"] == "t-ns";
+ assert m["spec"]["scaleTargetRef"]["name"] == "api-deployment";
+ assert m["spec"]["minReplicas"] == 2;
+ assert m["spec"]["maxReplicas"] == 7;
+}
+
+
+test "keda engine renders the ScaledObject body, not the name/body wrapper" {
+ target = KubernetesTarget(
+ config=KubernetesConfig(
+ app_name="t", namespace="t-ns", autoscaler_engine="keda"
+ )
+ );
+ bundle = {"autoscalers": {"api": {"min": 1, "max": 4}}};
+ manifests = target.render_autoscaler_manifests(bundle);
+ assert len(manifests) == 1;
+ m = manifests[0];
+ assert m["kind"] == "ScaledObject";
+ assert m["spec"]["scaleTargetRef"]["name"] == "api-deployment";
+ assert m["spec"]["minReplicaCount"] == 1;
+ assert m["spec"]["maxReplicaCount"] == 4;
+}
+
+
+test "a bundle with no autoscalers renders nothing" {
+ target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
+ assert target.render_autoscaler_manifests({"deployments": {}}) == [];
+}
diff --git a/jac/jaclang/scale/tests/microservices/test_plan.jac b/jac/jaclang/scale/tests/microservices/test_plan.jac
index a5c1b93ef16..5949e2cc99d 100644
--- a/jac/jaclang/scale/tests/microservices/test_plan.jac
+++ b/jac/jaclang/scale/tests/microservices/test_plan.jac
@@ -139,6 +139,52 @@ test "--show-yaml gate: YAML hidden by default, dumped on opt-in" {
}
+test "--show-yaml dumps rendered autoscaler manifests alongside the rest" {
+ bundle: dict[str, Any] = {
+ "deployments": {"api": _dep()},
+ "autoscaler_manifests": [
+ {
+ "apiVersion": "autoscaling/v2",
+ "kind": "HorizontalPodAutoscaler",
+ "metadata": {"name": "api-hpa"},
+ "spec": {"minReplicas": 2}
+ }
+ ]
+ };
+ (on, _) = _run_plan(bundle, show_yaml=True);
+ assert "kind: HorizontalPodAutoscaler" in on;
+ assert "api-hpa" in on;
+}
+
+
+test "every manifest-bearing bundle key reaches --show-yaml" {
+ # The summary counts these resources; the reviewed YAML must carry them
+ # all. Excluded by design: pod_specs and injector (not manifests) and
+ # autoscalers (configs, rendered into autoscaler_manifests).
+ bundle: dict[str, Any] = {
+ "user_secret": {"metadata": {"name": "sentinel-user-secret"}},
+ "pvcs": {"a": {"metadata": {"name": "sentinel-pvc"}}},
+ "deployments": {"a": {"metadata": {"name": "sentinel-deployment"}}},
+ "services": {"a": {"metadata": {"name": "sentinel-service"}}},
+ "pdbs": {"a": {"metadata": {"name": "sentinel-pdb"}}},
+ "autoscaler_manifests": [{"metadata": {"name": "sentinel-autoscaler"}}],
+ "ingress": {"metadata": {"name": "sentinel-ingress"}}
+ };
+ (on, _) = _run_plan(bundle, show_yaml=True);
+ for sentinel in [
+ "sentinel-user-secret",
+ "sentinel-pvc",
+ "sentinel-deployment",
+ "sentinel-service",
+ "sentinel-pdb",
+ "sentinel-autoscaler",
+ "sentinel-ingress"
+ ] {
+ assert sentinel in on , f"{sentinel} missing from --show-yaml output";
+ }
+}
+
+
test "render smoke: realistic bundle prints every section" {
bundle: dict[str, Any] = {
"deployments": {
diff --git a/jac/jaclang/vendor/typeshed/PROVENANCE.md b/jac/jaclang/vendor/typeshed/PROVENANCE.md
index eaaea1e8154..4b80c4bb095 100644
--- a/jac/jaclang/vendor/typeshed/PROVENANCE.md
+++ b/jac/jaclang/vendor/typeshed/PROVENANCE.md
@@ -1,13 +1,17 @@
# Vendored typeshed (stdlib stubs only)
The Python standard-library type stubs from typeshed. They are NOT committed:
-`stdlib/` is gitignored and rebuilt at the pinned commit by the `fetch-typeshed`
-subcommand of `launcher/payload.zig` (which `build.zig` runs so the `jac` binary
-bundles the stubs). Only this file, `PIN`, `TARBALL_SHA256`, and `LICENSE` are
-tracked.
+`stdlib/` is gitignored and rebuilt at the pinned commit by the Zig bootstrap
+seed `bootstrap/fetch_typeshed.zig`, which `build.zig` runs as its
+`fetch-typeshed` step so the `jac` binary bundles the stubs. It is a Zig seed
+and not the Jac payload tool because these stubs are what every compilation
+type-checks against, so they have to exist before the payload tool itself can
+be compiled (#8785). The payload tool keeps its own `fetch-typeshed`
+subcommand, reading the same pin, for the already-built tool's use. Only this
+file, `PIN`, `TARBALL_SHA256`, and `LICENSE` are tracked.
-Integrity: `payload.zig` downloads the GitHub tarball for the pinned commit and
-verifies the **decompressed tar's** sha256 against `TARBALL_SHA256` (git's
+Integrity: the fetcher downloads the GitHub tarball for the pinned commit
+and verifies the **decompressed tar's** sha256 against `TARBALL_SHA256` (git's
`archive` output is content-stable for a commit), so a swapped tarball cannot
slip in -- the same guarantee git's content-addressing gave the old `git fetch`.
@@ -21,8 +25,8 @@ from the project venv.
To bump:
1. Put the new commit SHA in `PIN`.
-2. Get the new hash: `zig build` builds the tool, then
- `./.zig-cache/.../payload typeshed-sha ` (or build it directly with
- `zig build-exe launcher/payload.zig`) and write the printed value into
+2. Get the new hash with the payload tool's `typeshed-sha` subcommand (run
+ `jaclang.payload.cli` with `typeshed-sha `; `build.zig` drives the
+ same tool for its fetch steps) and write the printed value into
`TARBALL_SHA256`.
3. Update the Commit line above and commit `PIN`, `TARBALL_SHA256`, `PROVENANCE.md`.
diff --git a/release_notes/unreleased/jaclang/8008.bugfix.md b/release_notes/unreleased/jaclang/8008.bugfix.md
new file mode 100644
index 00000000000..ad9e4a3829d
--- /dev/null
+++ b/release_notes/unreleased/jaclang/8008.bugfix.md
@@ -0,0 +1 @@
+- **Fix: --show-yaml includes autoscalers**: the dry-run YAML now prints the HPA / KEDA ScaledObject a deploy would apply, built from the same spec as the apply path.
From a64403f4f5805da39a1822ecbe527e0456493b4e Mon Sep 17 00:00:00 2001
From: Linus Kipkemoi Langat
<142144579+Developer-Linus@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:51:20 +0300
Subject: [PATCH 04/13] fix(scale): render KEDA TriggerAuthentication manifests
into --show-yaml (#8424)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #8409.
`jac start --scale --show-yaml` (via `render_autoscaler_manifests`)
prints
the autoscaler objects a deploy would apply, but for KEDA it only ever
printed the `ScaledObject`. A trigger with `auth.secret_refs` also
causes
`apply()` to create a `TriggerAuthentication`, and that half of the
footprint was invisible to the dry-run preview. This PR unifies the
`_build_manifests` contract across both autoscaler engines so the render
path renders the same objects `apply()` creates, with nothing built as a
side effect of a live API call.
`_build_manifests` for KEDA only ever constructed the `ScaledObject`
body.
The `TriggerAuthentication` for an authenticated trigger was built and
written to the cluster exclusively inside `KEDAAutoscaler.apply` via
`_apply_trigger_auth`, a function that conflated *building* the manifest
with *reconciling* it against the cluster (get → patch-or-create) in one
step. Because the manifest never existed as a standalone, returnable
value,
`render_autoscaler_manifests` (the `--show-yaml` path) had no way to see
it.
A secondary wart compounded this: `_build_manifests` returned a
different
shape per engine (HPA a bare manifest dict, KEDA a `{"name", "body"}`
wrapper), so the render path added defensive shape-sniffing
(`built["body"] if "body" in built else built`) instead of a real
contract.
That sniff is what let the KEDA gap go unnoticed - it happily returned
"a manifest" without anyone checking whether it was the *only* manifest
KEDA needed to emit.
Net effect: a KEDA autoscaler config with an auth-bearing trigger
dry-ran
clean (N ScaledObjects, 0 TriggerAuthentications) and then `apply()`
silently created N ScaledObjects **and** M TriggerAuthentications the
preview never showed - exactly the class of surprise `--show-yaml`
exists
to prevent, and the same shape of gap #8008 already fixed once for
autoscalers generally.
`_build_manifests` now returns one `list[dict]` contract for both
engines:
- `HPAAutoscaler._build_manifests` returns `[hpa]`.
- `KEDAAutoscaler._build_manifests` returns
`[scaled_object, *trigger_authentications]`.
To make that possible without duplicating logic between the render and
apply paths:
- The `TriggerAuthentication` body construction is extracted out of the
old
apply-only `_apply_trigger_auth` into a pure builder,
`_build_trigger_auth_manifest`, which returns the manifest (or `None`
when a trigger has no auth) and makes no API calls.
- A shared `_apply_custom_object` reconciler (get → patch-or-create)
replaces the two nearly-identical get/patch/create blocks that
previously existed separately for `TriggerAuthentication` and
`ScaledObject` writes in `KEDAAutoscaler.apply`.
- `render_autoscaler_manifests` (`target.jac`) now does
`out.extend(autoscaler._build_manifests(...))` instead of shape-sniffing
a single object out of the return value, so it renders every manifest an
engine produces, not just the first one.
Apply-time ordering is unchanged: `TriggerAuthentication` objects are
still
written before the `ScaledObject` that references them via
`authenticationRef`, since KEDA's admission webhook needs the referenced
auth resource to exist first.
| File | Change |
|---|---|
| `jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac` | Interface
updates: `_build_manifests` returns `list[dict[str, any]]`; new
`_build_trigger_auth_manifest` and `_apply_custom_object` declarations
replace `_apply_trigger_auth`. |
| `jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac` |
`_build_manifests` assembles `[scaled_object, *trigger_auths]`;
trigger-auth body construction moved into pure
`_build_trigger_auth_manifest`; new `_apply_custom_object` reconciler;
`apply()` applies trigger-auth manifests before the ScaledObject via the
shared reconciler. |
| `jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac` |
`_build_manifests` interface updated to `list[dict[str, any]]`. |
| `jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac` |
`_build_manifests` wraps its single manifest in a list; `apply()`
unwraps `[0]`. |
| `jac/jaclang/scale/deploy/target/kubernetes/target.jac` |
`render_autoscaler_manifests` flattens the manifest list from each
engine instead of shape-sniffing a single object. |
| `jac/jaclang/scale/tests/deploy/test_factories.jac` | HPA
`_build_manifests` assertions updated to the list contract. |
| `jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac` | KEDA
`_build_manifests` assertions updated to the list contract (ScaledObject
at index 0, no more `name`/`body` wrapper). |
| `jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac` | New
regression test: an authenticated KEDA trigger renders both the
`ScaledObject` and its matching `TriggerAuthentication` through
`render_autoscaler_manifests`. |
Ran the full `deploy` test group locally with `jac test` (one process
per
file, matching how CI's `test-scale (deploy)` matrix leg runs it): all
27
files, 293 tests pass, both before-vs-after on the three files this PR
touches and as a full-group run afterward to catch cross-file fallout.
- `test_show_yaml_autoscalers` (4, 1 new) - the regression test for this
issue. Builds a bundle with one KEDA-engine service carrying an
authenticated `redis` trigger (`auth.secret_refs`) and asserts
`render_autoscaler_manifests` returns exactly two manifests: the
`ScaledObject` at index 0 and a `TriggerAuthentication` at index 1 whose
`metadata.name` matches the `authenticationRef.name` KEDA embedded in
the
ScaledObject's trigger, with the `secretTargetRef` mapped correctly from
config. Confirmed this test fails against the pre-fix code (dropping the
`_build_trigger_auth_manifest` loop reproduces the original bug -
`len(manifests) == 1`) and passes with the fix. The other two existing
tests in this file (HPA render, empty-bundle render) are unchanged and
still pass, guarding against a regression in the ordinary case.
- `test_keda_autoscaler` (34) - full suite for the engine this PR
changes
most: `_build_manifests` shape and field mapping (9 tests, now indexing
`[0]` into the list), trigger-authentication naming/collision rules,
`preflight`, and - the part most likely to break silently on a
build/apply split - `apply()`'s behavior end to end against a mocked
`CustomObjectsApi`: that a `TriggerAuthentication` is still created
before its `ScaledObject`, that the name written to the cluster still
matches the `authenticationRef` the ScaledObject carries, and that a
KEDA-CRDs-not-installed cluster still short-circuits before either
write.
All pass unmodified in assertion logic (only the `_build_manifests`
indexing changed), which is the signal that `apply()`'s observable
behavior toward the cluster is unchanged by the refactor.
- `test_factories` (32) - HPA `_build_manifests` output (exact-dict
equality against the expected manifest, and the `behavior_overlay`
merge) re-verified against the new `[hpa]` list contract.
Also validated end to end against a real cluster, not just mocks:
- **Cluster**: local `kind` (v0.32.0) with KEDA core installed via the
standard Helm chart (`kedacore/keda`, namespace `keda`);
`scaledobjects.keda.sh`
and `triggerauthentications.keda.sh` CRDs confirmed present. A static
`hostPath` PV under a `no-provisioner` StorageClass (`jac-rwx`) stands
in
for the bundle PVC's `ReadWriteMany` requirement, the same technique
`jac/jaclang/scale/scripts/e2e_lib.sh`'s `provision_kind_rwx_storage`
uses for the kind-based CI e2e leg (kind's `local-path` StorageClass is
RWO-only; a static hostPath PV is safe on a single-node cluster).
- **Project**: a minimal scratch app (`main.jac`, one walker, no client)
with
`[dev] jaclang_source` pointing at this checkout, so both the CLI and
the
deployed pods run the fixed source, not a published release binary.
`[scale.kubernetes] autoscaler_engine = "keda"` plus one
`[[scale.microservices.services..triggers]]` entry with
`type = "redis"` and `auth.secret_refs` - the exact shape #8409
described.
- **Dry-run** (`jac start --scale --dry-run --show-yaml`, no cluster
contact): the rendered YAML stream includes both the `ScaledObject` and
a
`TriggerAuthentication` object, with the `ScaledObject`'s
`authenticationRef.name` matching the `TriggerAuthentication`'s
`metadata.name` exactly (`kedaval-7dcd81a6-ta`) - this is the bug fixed,
observed through the real CLI rather than only through the test suite.
- **Live apply** (`jac start --scale`): the same `TriggerAuthentication`
(`kedaval-7dcd81a6-ta`, `SECRET: redis-secret`) is present on the
cluster
via `kubectl get triggerauthentications`, and the `ScaledObject`'s
`AUTHENTICATIONS` column names it - dry-run and `apply()` now agree,
which is the whole point of the fix. `kedaval-scaledobject` reports
`READY=False`/`ACTIVE=Unknown` only because no real Redis exists at the
configured address; that's expected and irrelevant to what's being
validated here (the auth object's existence and wiring, not live
scaling).
- **App health**: both `gateway-deployment` and `kedaval-deployment`
reached
`1/1 Running`, the fleet rollout reported `2/2 deployments serving`, and
`/health` on the gateway reported both services `"status": "healthy"` -
confirming the fix doesn't regress the ordinary deploy path.
Configure a KEDA autoscaler with an auth-bearing trigger and run
`jac start --scale --show-yaml`: both the `ScaledObject` and its
`TriggerAuthentication` now render, matching what `apply()` creates.
---------
Co-authored-by: Kugesan Sivasothynathan
Co-authored-by: Kugesan Sivasothynathan <153247429+kugesan1105@users.noreply.github.com>
(cherry picked from commit b908596de5469402f2b8ae427b2859b38edd204f)
---
.../scale/deploy/autoscale/autoscaler.jac | 8 +
.../deploy/autoscale/hpa_autoscaler.impl.jac | 43 +++---
.../scale/deploy/autoscale/hpa_autoscaler.jac | 2 +-
.../deploy/autoscale/keda_autoscaler.impl.jac | 130 ++++++++--------
.../deploy/autoscale/keda_autoscaler.jac | 22 ++-
.../scale/deploy/target/kubernetes/target.jac | 10 +-
.../scale/tests/deploy/test_factories.jac | 70 +++++----
.../tests/deploy/test_keda_autoscaler.jac | 144 +++++++++++++-----
.../deploy/test_show_yaml_autoscalers.jac | 40 +++++
.../unreleased/jaclang/8424.bugfix.md | 2 +
10 files changed, 309 insertions(+), 162 deletions(-)
create mode 100644 release_notes/unreleased/jaclang/8424.bugfix.md
diff --git a/jac/jaclang/scale/deploy/autoscale/autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
index 9aaa5d38cd5..cfd153986ac 100644
--- a/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
@@ -64,6 +64,14 @@ class Autoscaler {
return merge_manifest_overlay(default, spec.behavior_overlay);
}
+ def _build_manifests(
+ self: Autoscaler, spec: AutoscalerSpec
+ ) -> list[dict[str, any]] {
+ raise NotImplementedError(
+ "_build_manifests() must be implemented by a concrete Autoscaler subclass"
+ );
+ }
+
def apply(self: Autoscaler, spec: AutoscalerSpec) -> bool {
raise NotImplementedError(
"apply() must be implemented by a concrete Autoscaler subclass"
diff --git a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac
index 1fc12f1c43b..a3445f8dbf6 100644
--- a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac
+++ b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac
@@ -39,26 +39,31 @@ impl HPAAutoscaler._build_manifests{
}
);
}
- return {
- "apiVersion": "autoscaling/v2",
- "kind": "HorizontalPodAutoscaler",
- "metadata": {
- "name": hpa_name,
- "namespace": spec.namespace,
- "labels": {"app": spec.scale_target_name, ** Autoscaler._managed_labels()}
- },
- "spec": {
- "scaleTargetRef": {
- "apiVersion": "apps/v1",
- "kind": "Deployment",
- "name": spec.scale_target_name
+ return [
+ {
+ "apiVersion": "autoscaling/v2",
+ "kind": "HorizontalPodAutoscaler",
+ "metadata": {
+ "name": hpa_name,
+ "namespace": spec.namespace,
+ "labels": {
+ "app": spec.scale_target_name,
+ ** Autoscaler._managed_labels()
+ }
},
- "minReplicas": spec.min_replicas,
- "maxReplicas": spec.max_replicas,
- "metrics": metrics,
- "behavior": Autoscaler._build_behavior(spec)
+ "spec": {
+ "scaleTargetRef": {
+ "apiVersion": "apps/v1",
+ "kind": "Deployment",
+ "name": spec.scale_target_name
+ },
+ "minReplicas": spec.min_replicas,
+ "maxReplicas": spec.max_replicas,
+ "metrics": metrics,
+ "behavior": Autoscaler._build_behavior(spec)
+ }
}
- };
+ ];
}
impl HPAAutoscaler.apply{
@@ -70,7 +75,7 @@ impl HPAAutoscaler.apply{
);
}
}
- manifest = self._build_manifests(spec);
+ manifest = self._build_manifests(spec)[0];
name = manifest["metadata"]["name"];
namespace = manifest["metadata"]["namespace"];
diff --git a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac
index 946441fdfd4..1e34be57f14 100644
--- a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac
@@ -13,7 +13,7 @@ import from jaclang.scale.deploy.target.kubernetes.utils.kubernetes_utils {
obj HPAAutoscaler(Autoscaler) {
has logger: (Logger | None) = None;
- def _build_manifests(spec: AutoscalerSpec) -> dict[str, any];
+ def _build_manifests(spec: AutoscalerSpec) -> list[dict[str, any]];
def apply(spec: AutoscalerSpec) -> bool;
def destroy(autoscaler_name: str, namespace: str) -> None;
def destroy_collection(namespace: str, label_selector: str) -> None;
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
index add6fbe3ce5..ab1356eec13 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
@@ -109,6 +109,7 @@ impl KEDAAutoscaler._build_manifests{
scaled_obj_name = spec.autoscaler_name or f"{spec.scale_target_name}-scaledobject";
keda_triggers: list[dict[str, any]] = [];
app_name = spec.app_name or spec.scale_target_name;
+ self._validate_triggers(spec, app_name);
for (i, trigger) in enumerate(spec.triggers) {
keda_triggers.append(self._build_trigger(trigger, app_name, i));
}
@@ -147,30 +148,36 @@ impl KEDAAutoscaler._build_manifests{
if spec.initial_cooldown_period > 0 {
scaled_obj_spec["initialCooldownPeriod"] = spec.initial_cooldown_period;
}
- return {
- "name": scaled_obj_name,
- "body": {
- "apiVersion": "keda.sh/v1alpha1",
- "kind": "ScaledObject",
- "metadata": {
- "name": scaled_obj_name,
- "namespace": spec.namespace,
- "labels": {
- "app": spec.scale_target_name,
- ** Autoscaler._managed_labels()
- }
- },
- "spec": scaled_obj_spec
- }
+
+ scaled_object = {
+ "apiVersion": "keda.sh/v1alpha1",
+ "kind": "ScaledObject",
+ "metadata": {
+ "name": scaled_obj_name,
+ "namespace": spec.namespace,
+ "labels": {"app": spec.scale_target_name, ** Autoscaler._managed_labels()}
+ },
+ "spec": scaled_obj_spec
};
+
+ manifests: list[dict[str, any]] = [scaled_object];
+ for (i, trigger) in enumerate(spec.triggers) {
+ auth_manifest = self._build_trigger_auth_manifest(
+ trigger, spec.namespace, app_name, i
+ );
+ if auth_manifest is not None {
+ manifests.append(auth_manifest);
+ }
+ }
+ return manifests;
}
-impl KEDAAutoscaler._apply_trigger_auth{
+impl KEDAAutoscaler._build_trigger_auth_manifest{
if not trigger.auth {
- return;
+ return None;
}
if not trigger.auth.secret_refs {
- return;
+ return None;
}
auth_name = self._trigger_auth_name(app_name, trigger, trigger_index);
secret_target_refs: list[dict[str, any]] = [];
@@ -186,7 +193,7 @@ impl KEDAAutoscaler._apply_trigger_auth{
{"parameter": param, "name": ref.get("name", ""), "key": ref.get("key", "")}
);
}
- auth_body = {
+ return {
"apiVersion": "keda.sh/v1alpha1",
"kind": "TriggerAuthentication",
"metadata": {
@@ -196,30 +203,29 @@ impl KEDAAutoscaler._apply_trigger_auth{
},
"spec": {"secretTargetRef": secret_target_refs}
};
+}
+
+impl KEDAAutoscaler._apply_custom_object{
try {
api.get_namespaced_custom_object(
- group="keda.sh",
- version="v1alpha1",
- namespace=namespace,
- plural="triggerauthentications",
- name=auth_name
+ group=group, version=version, namespace=namespace, plural=plural, name=name
);
api.patch_namespaced_custom_object(
- group="keda.sh",
- version="v1alpha1",
+ group=group,
+ version=version,
namespace=namespace,
- plural="triggerauthentications",
- name=auth_name,
- body=auth_body
+ plural=plural,
+ name=name,
+ body=body
);
} except ApiException as e {
if e.status == 404 {
api.create_namespaced_custom_object(
- group="keda.sh",
- version="v1alpha1",
+ group=group,
+ version=version,
namespace=namespace,
- plural="triggerauthentications",
- body=auth_body
+ plural=plural,
+ body=body
);
} else {
raise;
@@ -229,7 +235,7 @@ impl KEDAAutoscaler._apply_trigger_auth{
impl KEDAAutoscaler.apply{
app_name = spec.app_name or spec.scale_target_name;
- self._validate_triggers(spec, app_name);
+ manifests = self._build_manifests(spec);
warnings = self.preflight();
if warnings {
if self.logger {
@@ -251,39 +257,31 @@ impl KEDAAutoscaler.apply{
}
}
- for (i, trigger) in enumerate(spec.triggers) {
- self._apply_trigger_auth(api, trigger, spec.namespace, app_name, i);
- }
- manifests = self._build_manifests(spec);
- name = manifests["name"];
- body = manifests["body"];
- try {
- api.get_namespaced_custom_object(
- group="keda.sh",
- version="v1alpha1",
- namespace=spec.namespace,
- plural="scaledobjects",
- name=name
- );
- api.patch_namespaced_custom_object(
- group="keda.sh",
- version="v1alpha1",
- namespace=spec.namespace,
- plural="scaledobjects",
- name=name,
- body=body
+ known_kinds = [kind for (kind, _) in KEDAAutoscaler._APPLY_ORDER];
+ unknown_kinds = [
+ manifest["kind"]
+ for manifest in manifests
+ if manifest["kind"] not in known_kinds
+ ];
+ if unknown_kinds {
+ raise ValueError(
+ f"_build_manifests returned unhandled kind(s) {unknown_kinds}; "
+ "add them to KEDAAutoscaler._APPLY_ORDER."
);
- } except ApiException as e {
- if e.status == 404 {
- api.create_namespaced_custom_object(
- group="keda.sh",
- version="v1alpha1",
- namespace=spec.namespace,
- plural="scaledobjects",
- body=body
- );
- } else {
- raise;
+ }
+ for (kind, plural) in KEDAAutoscaler._APPLY_ORDER {
+ for manifest in manifests {
+ if manifest["kind"] == kind {
+ self._apply_custom_object(
+ api,
+ "keda.sh",
+ "v1alpha1",
+ spec.namespace,
+ plural,
+ manifest["metadata"]["name"],
+ manifest
+ );
+ }
}
}
return True;
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
index 6c601486c09..2e0d8a216ed 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
@@ -28,7 +28,11 @@ obj KEDAAutoscaler(Autoscaler) {
logger: (Logger | None) = None,
_capabilities_cache: dict[str, any] = {};
- static has _preflight_cache: dict[str, any] = {};
+ static has _preflight_cache: dict[str, any] = {},
+ _APPLY_ORDER: list[tuple[str, str]] = [
+ ("TriggerAuthentication", "triggerauthentications"),
+ ("ScaledObject", "scaledobjects")
+ ];
def _get_cluster_key -> str;
def _trigger_key(trigger: Trigger, trigger_index: int) -> str;
@@ -38,11 +42,21 @@ obj KEDAAutoscaler(Autoscaler) {
trigger: Trigger, app_name: str, trigger_index: int
) -> dict[str, any];
- def _apply_trigger_auth(
- api: any, trigger: Trigger, namespace: str, app_name: str, trigger_index: int
+ def _build_trigger_auth_manifest(
+ trigger: Trigger, namespace: str, app_name: str, trigger_index: int
+ ) -> (dict[str, any] | None);
+
+ def _apply_custom_object(
+ api: any,
+ group: str,
+ version: str,
+ namespace: str,
+ plural: str,
+ name: str,
+ body: dict[str, any]
) -> None;
- def _build_manifests(spec: AutoscalerSpec) -> dict[str, any];
+ def _build_manifests(spec: AutoscalerSpec) -> list[dict[str, any]];
def preflight -> list[str];
def apply(spec: AutoscalerSpec) -> bool;
def destroy(autoscaler_name: str, namespace: str) -> None;
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/target.jac b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
index 6ff71875c61..0375ea1a660 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
@@ -1014,13 +1014,13 @@ obj KubernetesTarget(KubernetesTargetBase) {
autoscaler = AutoscalerFactory.create(
self.k8s_config.autoscaler_engine, self.k8s_config.to_dict(), None
);
- built = autoscaler._build_manifests(
- self._autoscaler_spec(
- svc_name, cfg, autoscaler, self.k8s_config.namespace
+ out.extend(
+ autoscaler._build_manifests(
+ self._autoscaler_spec(
+ svc_name, cfg, autoscaler, self.k8s_config.namespace
+ )
)
);
-
- out.append(built["body"] if "body" in built else built);
}
return out;
}
diff --git a/jac/jaclang/scale/tests/deploy/test_factories.jac b/jac/jaclang/scale/tests/deploy/test_factories.jac
index dba68043dbe..286d38e765a 100644
--- a/jac/jaclang/scale/tests/deploy/test_factories.jac
+++ b/jac/jaclang/scale/tests/deploy/test_factories.jac
@@ -267,43 +267,47 @@ test "hpa autoscaler build manifests produces correct dict" {
triggers=[Trigger(type="cpu", metadata={"averageUtilization": "70"})]
);
result = HPAAutoscaler()._build_manifests(spec);
- expected = {
- "apiVersion": "autoscaling/v2",
- "kind": "HorizontalPodAutoscaler",
- "metadata": {
- "name": "my-service-hpa",
- "namespace": "default",
- "labels": {"app": "my-service-deployment", "managed": "jac-scale"}
- },
- "spec": {
- "scaleTargetRef": {
- "apiVersion": "apps/v1",
- "kind": "Deployment",
- "name": "my-service-deployment"
+ expected = [
+ {
+ "apiVersion": "autoscaling/v2",
+ "kind": "HorizontalPodAutoscaler",
+ "metadata": {
+ "name": "my-service-hpa",
+ "namespace": "default",
+ "labels": {"app": "my-service-deployment", "managed": "jac-scale"}
},
- "minReplicas": 1,
- "maxReplicas": 3,
- "metrics": [
- {
- "type": "Resource",
- "resource": {
- "name": "cpu",
- "target": {"type": "Utilization", "averageUtilization": 70}
- }
- }
- ],
- "behavior": {
- "scaleUp": {
- "stabilizationWindowSeconds": 60,
- "policies": [{"type": "Pods", "value": 2, "periodSeconds": 60}]
+ "spec": {
+ "scaleTargetRef": {
+ "apiVersion": "apps/v1",
+ "kind": "Deployment",
+ "name": "my-service-deployment"
},
- "scaleDown": {
- "stabilizationWindowSeconds": 300,
- "policies": [{"type": "Percent", "value": 50, "periodSeconds": 60}]
+ "minReplicas": 1,
+ "maxReplicas": 3,
+ "metrics": [
+ {
+ "type": "Resource",
+ "resource": {
+ "name": "cpu",
+ "target": {"type": "Utilization", "averageUtilization": 70}
+ }
+ }
+ ],
+ "behavior": {
+ "scaleUp": {
+ "stabilizationWindowSeconds": 60,
+ "policies": [{"type": "Pods", "value": 2, "periodSeconds": 60}]
+ },
+ "scaleDown": {
+ "stabilizationWindowSeconds": 300,
+ "policies": [
+ {"type": "Percent", "value": 50, "periodSeconds": 60}
+ ]
+ }
}
}
}
- };
+ ];
assert result == expected;
}
@@ -323,7 +327,7 @@ test "hpa autoscaler build manifests applies behavior_overlay onto the default s
}
}
);
- result = HPAAutoscaler()._build_manifests(spec);
+ result = HPAAutoscaler()._build_manifests(spec)[0];
behavior = result["spec"]["behavior"];
assert behavior["scaleDown"]["stabilizationWindowSeconds"] == 600;
assert behavior["scaleDown"]["selectPolicy"] == "Min";
diff --git a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
index de17cf29258..bf16d8beeda 100644
--- a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
+++ b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
@@ -133,6 +133,83 @@ test "duplicate trigger identities raise ValueError before any cluster write" {
}
+test "build manifests rejects duplicate trigger identities so the dry run refuses what apply refuses" {
+ # The rendered YAML is documented as pipeable into kubectl apply; if only
+ # apply() validated, the dry run would emit colliding TriggerAuthentications
+ # that silently resolve to whichever secret kubectl applied last.
+ keda = KEDAAutoscaler();
+ spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ app_name="order-service",
+ namespace="default",
+ triggers=[
+ Trigger(
+ type="redis",
+ metadata={},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
+ )
+ ),
+ Trigger(
+ type="rabbitmq",
+ metadata={},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "rmq-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ raised = False;
+ try {
+ keda._build_manifests(spec);
+ } except ValueError as e {
+ raised = True;
+ assert "cache" in str(e);
+ }
+ assert raised;
+}
+
+
+test "build manifests rejects an unnamed trigger colliding with an explicitly named one" {
+ # An unnamed trigger auto-keys to "-". KEDA's admission webhook
+ # cannot see this collision because only one trigger carries a name, so the
+ # builder is the last line of defense for both render and apply.
+ keda = KEDAAutoscaler();
+ spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ app_name="order-service",
+ namespace="default",
+ triggers=[
+ Trigger(
+ type="redis",
+ metadata={},
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "right-secret", "key": "pw"}}
+ )
+ ),
+ Trigger(
+ type="rabbitmq",
+ metadata={},
+ name="redis-0",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "wrong-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ raised = False;
+ try {
+ keda._build_manifests(spec);
+ } except ValueError as e {
+ raised = True;
+ assert "redis-0" in str(e);
+ }
+ assert raised;
+}
+
+
test "build manifests produces a complete and valid ScaledObject body" {
keda = KEDAAutoscaler();
spec = AutoscalerSpec(
@@ -142,15 +219,15 @@ test "build manifests produces a complete and valid ScaledObject body" {
max_replicas=10,
triggers=[Trigger(type="cpu", metadata={"averageUtilization": "60"})]
);
- m = keda._build_manifests(spec);
- assert m["name"] == "orders-deployment-scaledobject";
- assert m["body"]["apiVersion"] == "keda.sh/v1alpha1";
- assert m["body"]["kind"] == "ScaledObject";
- assert m["body"]["metadata"]["labels"]["managed"] == "jac-scale";
- assert m["body"]["spec"]["scaleTargetRef"]["name"] == "orders-deployment";
- assert m["body"]["spec"]["minReplicaCount"] == 2;
- assert m["body"]["spec"]["maxReplicaCount"] == 10;
- assert m["body"]["spec"]["triggers"][0]["metadata"]["value"] == "60";
+ m = keda._build_manifests(spec)[0];
+ assert m["metadata"]["name"] == "orders-deployment-scaledobject";
+ assert m["apiVersion"] == "keda.sh/v1alpha1";
+ assert m["kind"] == "ScaledObject";
+ assert m["metadata"]["labels"]["managed"] == "jac-scale";
+ assert m["spec"]["scaleTargetRef"]["name"] == "orders-deployment";
+ assert m["spec"]["minReplicaCount"] == 2;
+ assert m["spec"]["maxReplicaCount"] == 10;
+ assert m["spec"]["triggers"][0]["metadata"]["value"] == "60";
}
test "build manifests applies behavior_overlay through advanced.horizontalPodAutoscalerConfig" {
@@ -169,10 +246,8 @@ test "build manifests applies behavior_overlay through advanced.horizontalPodAut
}
}
);
- m = keda._build_manifests(spec);
- behavior = m["body"]["spec"]["advanced"]["horizontalPodAutoscalerConfig"][
- "behavior"
- ];
+ m = keda._build_manifests(spec)[0];
+ behavior = m["spec"]["advanced"]["horizontalPodAutoscalerConfig"]["behavior"];
assert behavior["scaleDown"]["stabilizationWindowSeconds"] == 600;
assert behavior["scaleDown"]["selectPolicy"] == "Min";
assert behavior["scaleDown"]["policies"]
@@ -185,8 +260,8 @@ test "build manifests adds idleReplicaCount zero for scale-to-zero" {
spec = AutoscalerSpec(
scale_target_name="svc-deployment", namespace="default", idle_replicas=0
);
- m = keda._build_manifests(spec);
- assert m["body"]["spec"]["idleReplicaCount"] == 0;
+ m = keda._build_manifests(spec)[0];
+ assert m["spec"]["idleReplicaCount"] == 0;
}
test "build manifests omits idleReplicaCount when not configured" {
@@ -194,8 +269,8 @@ test "build manifests omits idleReplicaCount when not configured" {
spec = AutoscalerSpec(
scale_target_name="svc-deployment", namespace="default", idle_replicas=None
);
- m = keda._build_manifests(spec);
- assert "idleReplicaCount" not in m["body"]["spec"];
+ m = keda._build_manifests(spec)[0];
+ assert "idleReplicaCount" not in m["spec"];
}
test "build manifests adds initialCooldownPeriod when set above zero" {
@@ -205,8 +280,8 @@ test "build manifests adds initialCooldownPeriod when set above zero" {
namespace="default",
initial_cooldown_period=120
);
- m = keda._build_manifests(spec);
- assert m["body"]["spec"]["initialCooldownPeriod"] == 120;
+ m = keda._build_manifests(spec)[0];
+ assert m["spec"]["initialCooldownPeriod"] == 120;
}
test "build manifests omits initialCooldownPeriod when zero" {
@@ -216,8 +291,8 @@ test "build manifests omits initialCooldownPeriod when zero" {
namespace="default",
initial_cooldown_period=0
);
- m = keda._build_manifests(spec);
- assert "initialCooldownPeriod" not in m["body"]["spec"];
+ m = keda._build_manifests(spec)[0];
+ assert "initialCooldownPeriod" not in m["spec"];
}
test "build manifests falls back to default cpu trigger when triggers list is empty" {
@@ -225,9 +300,9 @@ test "build manifests falls back to default cpu trigger when triggers list is em
spec = AutoscalerSpec(
scale_target_name="svc-deployment", namespace="default", triggers=[]
);
- m = keda._build_manifests(spec);
- assert len(m["body"]["spec"]["triggers"]) == 1;
- assert m["body"]["spec"]["triggers"][0]["type"] == "cpu";
+ m = keda._build_manifests(spec)[0];
+ assert len(m["spec"]["triggers"]) == 1;
+ assert m["spec"]["triggers"][0]["type"] == "cpu";
}
test "build manifests includes every trigger in the ScaledObject spec" {
@@ -243,10 +318,10 @@ test "build manifests includes every trigger in the ScaledObject spec" {
)
]
);
- m = keda._build_manifests(spec);
- assert len(m["body"]["spec"]["triggers"]) == 2;
- assert m["body"]["spec"]["triggers"][0]["type"] == "cpu";
- assert m["body"]["spec"]["triggers"][1]["type"] == "prometheus";
+ m = keda._build_manifests(spec)[0];
+ assert len(m["spec"]["triggers"]) == 2;
+ assert m["spec"]["triggers"][0]["type"] == "cpu";
+ assert m["spec"]["triggers"][1]["type"] == "prometheus";
}
test "build manifests with hpa bounds and non-cpu trigger produces ScaledObject with both triggers" {
@@ -269,10 +344,10 @@ test "build manifests with hpa bounds and non-cpu trigger produces ScaledObject
)
]
);
- m = keda._build_manifests(spec);
- assert m["body"]["spec"]["minReplicaCount"] == 2;
- assert m["body"]["spec"]["maxReplicaCount"] == 10;
- trigger_types = [t["type"] for t in m["body"]["spec"]["triggers"]];
+ m = keda._build_manifests(spec)[0];
+ assert m["spec"]["minReplicaCount"] == 2;
+ assert m["spec"]["maxReplicaCount"] == 10;
+ trigger_types = [t["type"] for t in m["spec"]["triggers"]];
assert "cpu" in trigger_types;
assert "redis" in trigger_types;
assert len(trigger_types) == 2;
@@ -354,8 +429,9 @@ test "apply logs the install docs link when KEDA CRDs are not installed" {
}
test "TriggerAuthentication name written to cluster matches authenticationRef name in ScaledObject" {
- # _build_trigger (authenticationRef) and _apply_trigger_auth (the resource) must
- # agree on the name end to end, or KEDA cannot resolve the secret at admission time.
+ # _build_trigger (authenticationRef) and _build_trigger_auth_manifest (the
+ # resource) must agree on the name end to end, or KEDA cannot resolve the
+ # secret at admission time.
KEDAAutoscaler._preflight_cache.clear();
mock_api = unittest.mock.MagicMock();
mock_api.list_cluster_custom_object.return_value = {};
diff --git a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
index 72f7493ae61..95474915013 100644
--- a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
+++ b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
@@ -43,3 +43,43 @@ test "a bundle with no autoscalers renders nothing" {
target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
assert target.render_autoscaler_manifests({"deployments": {}}) == [];
}
+
+
+test "keda engine renders the TriggerAuthentication apply() would create for an authenticated trigger" {
+ # --show-yaml only rendered the ScaledObject, so a config with an
+ # auth-bearing trigger dry-ran clean and then apply() created a
+ # TriggerAuthentication the preview never showed.
+ target = KubernetesTarget(
+ config=KubernetesConfig(
+ app_name="t", namespace="t-ns", autoscaler_engine="keda"
+ )
+ );
+ bundle = {
+ "autoscalers": {
+ "api": {
+ "min": 1,
+ "max": 4,
+ "triggers": [
+ {
+ "type": "redis",
+ "name": "cache",
+ "metadata": {"address": "redis:6379"},
+ "auth": {
+ "secret_refs": {
+ "password": {"name": "redis-secret", "key": "pw"}
+ }
+ }
+ }
+ ]
+ }
+ }
+ };
+ manifests = target.render_autoscaler_manifests(bundle);
+ assert len(manifests) == 2;
+ assert manifests[0]["kind"] == "ScaledObject";
+ auth_name = manifests[0]["spec"]["triggers"][0]["authenticationRef"]["name"];
+ assert manifests[1]["kind"] == "TriggerAuthentication";
+ assert manifests[1]["metadata"]["name"] == auth_name;
+ assert manifests[1]["spec"]["secretTargetRef"]
+ == [{"parameter": "password", "name": "redis-secret", "key": "pw"}];
+}
diff --git a/release_notes/unreleased/jaclang/8424.bugfix.md b/release_notes/unreleased/jaclang/8424.bugfix.md
new file mode 100644
index 00000000000..37272c5b913
--- /dev/null
+++ b/release_notes/unreleased/jaclang/8424.bugfix.md
@@ -0,0 +1,2 @@
+- **Scale: `--show-yaml` now renders the KEDA `TriggerAuthentication` an authenticated trigger creates**: `render_autoscaler_manifests` built its preview from each engine's `_build_manifests`, which for KEDA only ever returned the `ScaledObject`; the `TriggerAuthentication` for a trigger with `auth.secret_refs` was built and written to the cluster exclusively inside `KEDAAutoscaler.apply`, so it never existed as a value the render path could see. A config with an authenticated trigger therefore dry-ran clean and then `apply()` silently created a `TriggerAuthentication` the preview never showed, referencing the very Secret the preview was supposed to let a user check before it landed. `_build_manifests` now returns one `list[dict]` contract for both engines: HPA still returns a single-element list, KEDA returns the `ScaledObject` followed by every `TriggerAuthentication` its triggers need, built by a pure function extracted out of the old apply-only helper so `apply()` and the render path share one source instead of the render path shape-sniffing whatever `_build_manifests` happened to return. Apply-time behavior is unchanged: `TriggerAuthentication` objects are still written to the cluster before the `ScaledObject` that references them.
+ Trigger validation also moved from `apply()` into `_build_manifests`: a config whose triggers resolve to colliding identities now fails the dry run with the same `ValueError` that `apply()` raises, instead of rendering `TriggerAuthentication` manifests that overwrite one another when the YAML stream is piped into `kubectl apply -f -`.
From 56a604270c215b0ffbc29a12dc5e67136ffdef02 Mon Sep 17 00:00:00 2001
From: Linus Kipkemoi Langat
<142144579+Developer-Linus@users.noreply.github.com>
Date: Fri, 28 Aug 2026 00:19:31 +0300
Subject: [PATCH 05/13] fix(jac-scale): reconcile KEDA TriggerAuthentications
by ownership label (part 2 - final - of issue #6535) (#6908)
## Problem
`KEDAAutoscaler` orphaned `TriggerAuthentication` resources in two
distinct ways:
1. **Redeploy with a changed trigger set.** TA names are derived from
the trigger identity (`app + trigger-key`, hashed). When a redeploy
renames, removes, or reorders triggers, `apply()` creates new TAs under
new names and re-points the ScaledObject at them, but never deletes the
TAs from the previous config. `apply()` had no prune step, so every such
redeploy left a stale TA behind, unreferenced by the ScaledObject.
2. **destroy().** `destroy()` deleted only the ScaledObject, leaving
every TA its `apply()` created behind.
The earlier approach tried to fix (2) by reading
`authenticationRef.name` off the live ScaledObject. That could not fix
(1) at all (the stale TA is no longer referenced by the ScaledObject, so
it is never found), and it left a hole in (2): if the ScaledObject was
already gone, the GET returned 404 and `destroy()` returned early,
stranding any TAs permanently.
## Root cause
There was no ownership-based reconciliation. TAs are children whose
lifecycle should be bound to the autoscaler, but the code bound them
only by name derivation plus an imperative "read the ScaledObject,
delete what it currently points at". Nothing ever reconciled the actual
set of managed TAs in the namespace against the desired set. Compounding
this, TAs were stamped only with `managed=jac-scale` and no owner
scoping, so they could not even be listed per-autoscaler.
## Fix
Reconcile TAs by an ownership label instead of by parsing the
ScaledObject.
- `_build_trigger_auth_manifest()` stamps every managed TA with
`jac-scale/owner=` (plus `app` and `managed`). The
owner value is the ScaledObject name, hashed if it exceeds the 63-char
label limit, via a shared `_owner_label_value()` helper so the apply and
destroy selectors always agree. Because the labels live in the manifest
builder, `--show-yaml` renders exactly what `apply()` writes.
- `apply()` collects the desired TA names, then
`_prune_orphan_trigger_auths()` lists this owner's managed TAs by label
and deletes any not in the freshly-applied set. This closes orphan
pathway (1).
- `destroy()` lists TAs by `jac-scale/owner=,managed=jac-scale`
and deletes them (404/422-safe), then deletes the ScaledObject. It no
longer reads the ScaledObject, so cleanup works even when the
ScaledObject was already deleted out of band. This closes orphan pathway
(2) with no early-return hole.
- `destroy()` (base `Autoscaler`, `HPAAutoscaler`, `KEDAAutoscaler`) now
takes `app_name` instead of a raw resource name and resolves the
concrete name internally via `resource_name_for()`, matching how the
owner label selector is computed. A single `_scaled_object_name(spec,
app_name)` helper is the one definition of the ScaledObject name, used
by `apply()` and `_build_manifests()`; `destroy()`'s formula is exactly
its fallback branch, so the two cannot disagree.
```
apply(spec):
build manifests (validation runs inside the builder)
for each manifest in _APPLY_ORDER: create/patch (TAs labelled owner=, then the ScaledObject)
prune: list TA by owner label -> delete any name not in desired set
destroy(app_name, namespace):
scaled_object_name = resource_name_for(app_name)
list TA by owner label=scaled_object_name -> delete each (404-safe)
delete ScaledObject named scaled_object_name (404-safe)
```
## Migration for TAs that predate the owner label
There is deliberately no legacy name-pattern sweep (an earlier revision
had one; review showed its truncated-prefix match could cross-delete
another app's TAs in a shared namespace, so it was removed rather than
narrowed). Instead:
- A pre-label TA whose trigger still exists is adopted implicitly:
`apply()` patches it by name and the merge patch adds the owner label.
From then on it is fully reconciled.
- A pre-label TA whose trigger was already removed cannot be named from
`spec.triggers`, so nothing touches it; the namespace-wide
`destroy_collection()` sweep (`managed=jac-scale`) remains the catch-all
for those.
## Tests
`test_keda_autoscaler.jac`, all mocked, 42 tests including:
- TriggerAuthentication manifest carries app and owner labels for
reconciliation
- apply prunes a TriggerAuthentication the redeployed trigger set no
longer references, and keeps one still referenced
- destroy resolves the ScaledObject name from app_name and deletes it
- destroy sweeps TriggerAuthentications owned by this ScaledObject
before deleting it (works with the ScaledObject already gone, the case
the earlier patch could not handle)
- destroy tolerates an already-deleted TA (404) and an absent auth CRD
(404 on list)
- duplicate trigger identities are rejected by the manifest builder, so
`--show-yaml` refuses what `apply()` refuses
`test_show_yaml_autoscalers.jac` covers the rendered TA manifests
carrying the same labels `apply()` writes. `test_factories.jac` green.
## Live cluster validation
Verified against a real cluster with real KEDA CRDs, driving this
branch's `KEDAAutoscaler.apply()`/`destroy()` directly:
| Step | Action | Result |
| --- | --- | --- |
| apply | trigger `cache` + auth | TA created with `jac-scale/owner`,
`app`, `managed` labels |
| apply prune | rename trigger `cache` to `queue` | old TA pruned, only
the new TA remains |
| destroy | destroy with ScaledObject present | 0 TAs, 0 ScaledObjects |
| destroy (SO gone) | delete ScaledObject out of band, then destroy | TA
still swept by label |
| migration | pre-label TA, trigger still live | adopted by apply's
patch, swept by destroy |
Both orphan pathways are provably closed on a real cluster.
## Notes
Rebased on the merged #8424: trigger validation lives in
`_build_manifests`, so the render and apply paths accept and refuse
identical specs, and the manifest list is the single desired-state
description that both paths share. This PR supersedes the earlier
ScaledObject-parsing approach and resolves the partial-apply /
already-gone-ScaledObject limitation tracked in #6909 directly: cleanup
is now independent of the ScaledObject.
Closes #6535.
Closes #6909.
---------
Co-authored-by: Kugesan Sivasothynathan
Co-authored-by: Kugesan Sivasothynathan <153247429+kugesan1105@users.noreply.github.com>
(cherry picked from commit e092aaa4182b672147b4863ace77890b441c8141)
---
.../scale/deploy/autoscale/autoscaler.jac | 2 +-
.../deploy/autoscale/hpa_autoscaler.impl.jac | 2 +-
.../scale/deploy/autoscale/hpa_autoscaler.jac | 2 +-
.../deploy/autoscale/keda_autoscaler.impl.jac | 108 ++++++++++-
.../deploy/autoscale/keda_autoscaler.jac | 10 +-
.../target/kubernetes/kubernetes_target.jac | 2 +-
.../tests/deploy/test_keda_autoscaler.jac | 179 +++++++++++++++++-
.../unreleased/jaclang/6908.bugfix.md | 1 +
8 files changed, 287 insertions(+), 19 deletions(-)
create mode 100644 release_notes/unreleased/jaclang/6908.bugfix.md
diff --git a/jac/jaclang/scale/deploy/autoscale/autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
index cfd153986ac..c1022fb0dac 100644
--- a/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/autoscaler.jac
@@ -78,7 +78,7 @@ class Autoscaler {
);
}
- def destroy(self: Autoscaler, autoscaler_name: str, namespace: str) {
+ def destroy(self: Autoscaler, app_name: str, namespace: str) {
raise NotImplementedError(
"destroy() must be implemented by a concrete Autoscaler subclass"
);
diff --git a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac
index a3445f8dbf6..70750dc1d76 100644
--- a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac
+++ b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.impl.jac
@@ -116,7 +116,7 @@ impl HPAAutoscaler.destroy{
autoscaling_v2 = AutoscalingV2Api();
delete_if_exists(
autoscaling_v2.delete_namespaced_horizontal_pod_autoscaler,
- autoscaler_name,
+ self.resource_name_for(app_name),
namespace,
'HorizontalPodAutoscaler'
);
diff --git a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac
index 1e34be57f14..47866e25700 100644
--- a/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/hpa_autoscaler.jac
@@ -15,7 +15,7 @@ obj HPAAutoscaler(Autoscaler) {
def _build_manifests(spec: AutoscalerSpec) -> list[dict[str, any]];
def apply(spec: AutoscalerSpec) -> bool;
- def destroy(autoscaler_name: str, namespace: str) -> None;
+ def destroy(app_name: str, namespace: str) -> None;
def destroy_collection(namespace: str, label_selector: str) -> None;
def resource_name_for(target: str) -> str;
}
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
index ab1356eec13..82343d1fa8d 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
@@ -105,11 +105,22 @@ impl KEDAAutoscaler._build_trigger{
return keda_trigger;
}
+impl KEDAAutoscaler._owner_label_value{
+ if len(name) <= 63 {
+ return name;
+ }
+ return f"so-{hashlib.sha256(name.encode()).hexdigest()[:16]}";
+}
+
+impl KEDAAutoscaler._scaled_object_name{
+ return spec.autoscaler_name or self.resource_name_for(app_name);
+}
+
impl KEDAAutoscaler._build_manifests{
- scaled_obj_name = spec.autoscaler_name or f"{spec.scale_target_name}-scaledobject";
- keda_triggers: list[dict[str, any]] = [];
app_name = spec.app_name or spec.scale_target_name;
self._validate_triggers(spec, app_name);
+ scaled_obj_name = self._scaled_object_name(spec, app_name);
+ keda_triggers: list[dict[str, any]] = [];
for (i, trigger) in enumerate(spec.triggers) {
keda_triggers.append(self._build_trigger(trigger, app_name, i));
}
@@ -163,7 +174,7 @@ impl KEDAAutoscaler._build_manifests{
manifests: list[dict[str, any]] = [scaled_object];
for (i, trigger) in enumerate(spec.triggers) {
auth_manifest = self._build_trigger_auth_manifest(
- trigger, spec.namespace, app_name, i
+ trigger, spec.namespace, app_name, i, scaled_obj_name
);
if auth_manifest is not None {
manifests.append(auth_manifest);
@@ -199,7 +210,11 @@ impl KEDAAutoscaler._build_trigger_auth_manifest{
"metadata": {
"name": auth_name,
"namespace": namespace,
- "labels": Autoscaler._managed_labels()
+ "labels": {
+ "app": app_name,
+ "jac-scale/owner": self._owner_label_value(owner),
+ ** Autoscaler._managed_labels()
+ }
},
"spec": {"secretTargetRef": secret_target_refs}
};
@@ -284,18 +299,101 @@ impl KEDAAutoscaler.apply{
}
}
}
+
+ owner = self._scaled_object_name(spec, app_name);
+ desired_auth_names: set[str] = set();
+ for manifest in manifests {
+ if manifest["kind"] == "TriggerAuthentication" {
+ desired_auth_names.add(manifest["metadata"]["name"]);
+ }
+ }
+ self._prune_orphan_trigger_auths(api, spec.namespace, owner, desired_auth_names);
return True;
}
+impl KEDAAutoscaler._prune_orphan_trigger_auths{
+ selector = f"jac-scale/owner={self._owner_label_value(owner)},managed=jac-scale";
+ try {
+ existing = api.list_namespaced_custom_object(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace=namespace,
+ plural="triggerauthentications",
+ label_selector=selector
+ );
+ } except ApiException as e {
+ if e.status not in [404, 422] {
+ raise;
+ }
+ return;
+ }
+ for item in existing.get("items", []) {
+ item_name = item["metadata"]["name"];
+ if item_name in desired_auth_names {
+ continue;
+ }
+ try {
+ api.delete_namespaced_custom_object(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace=namespace,
+ plural="triggerauthentications",
+ name=item_name
+ );
+ } except ApiException as e {
+ if e.status not in [404, 422] {
+ raise;
+ }
+ }
+ }
+}
+
impl KEDAAutoscaler.destroy{
api = self._custom_api or client.CustomObjectsApi();
+ scaled_object_name = self.resource_name_for(app_name);
+ selector = f"jac-scale/owner={self._owner_label_value(
+ scaled_object_name
+ )},managed=jac-scale";
+ try {
+ existing = api.list_namespaced_custom_object(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace=namespace,
+ plural="triggerauthentications",
+ label_selector=selector
+ );
+ listing: dict[str, list[dict[str, any]]] = existing
+ if isinstance(existing, dict)
+ else {};
+ for item in listing.get("items", []) {
+ item_name = item["metadata"]["name"];
+ try {
+ api.delete_namespaced_custom_object(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace=namespace,
+ plural="triggerauthentications",
+ name=item_name
+ );
+ } except ApiException as e {
+ if e.status not in [404, 422] {
+ raise;
+ }
+ }
+ }
+ } except ApiException as e {
+ if e.status not in [404, 422] {
+ raise;
+ }
+ }
+
try {
api.delete_namespaced_custom_object(
group="keda.sh",
version="v1alpha1",
namespace=namespace,
plural="scaledobjects",
- name=autoscaler_name
+ name=scaled_object_name
);
} except ApiException as e {
if e.status not in [404, 422] {
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
index 2e0d8a216ed..cd8b1dee5d5 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
@@ -42,8 +42,10 @@ obj KEDAAutoscaler(Autoscaler) {
trigger: Trigger, app_name: str, trigger_index: int
) -> dict[str, any];
+ static def _owner_label_value(name: str) -> str;
+ def _scaled_object_name(spec: AutoscalerSpec, app_name: str) -> str;
def _build_trigger_auth_manifest(
- trigger: Trigger, namespace: str, app_name: str, trigger_index: int
+ trigger: Trigger, namespace: str, app_name: str, trigger_index: int, owner: str
) -> (dict[str, any] | None);
def _apply_custom_object(
@@ -56,10 +58,14 @@ obj KEDAAutoscaler(Autoscaler) {
body: dict[str, any]
) -> None;
+ def _prune_orphan_trigger_auths(
+ api: any, namespace: str, owner: str, desired_auth_names: set[str]
+ ) -> None;
+
def _build_manifests(spec: AutoscalerSpec) -> list[dict[str, any]];
def preflight -> list[str];
def apply(spec: AutoscalerSpec) -> bool;
- def destroy(autoscaler_name: str, namespace: str) -> None;
+ def destroy(app_name: str, namespace: str) -> None;
def destroy_collection(namespace: str, label_selector: str) -> None;
def resource_name_for(target: str) -> str;
def _validate_http_activation_spec(spec: HTTPActivationSpec) -> None;
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac b/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac
index 475801fa6aa..56c4a172994 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac
@@ -645,7 +645,7 @@ obj KubernetesTargetBase(DeploymentTarget) {
autoscaler = AutoscalerFactory.create(
self.k8s_config.autoscaler_engine, self.k8s_config.to_dict(), self.logger
);
- autoscaler.destroy(autoscaler.resource_name_for(app_name), namespace);
+ autoscaler.destroy(app_name, namespace);
delete_if_exists(
apps_v1.delete_namespaced_deployment, app_name, namespace, 'Deployment'
);
diff --git a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
index bf16d8beeda..e932b15ae81 100644
--- a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
+++ b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
@@ -95,6 +95,37 @@ test "TriggerAuthentication names are scoped per service and per trigger positio
assert order != order_second;
}
+test "TriggerAuthentication manifest carries app and owner labels for reconciliation" {
+ # apply()'s prune step and destroy() both find TriggerAuthentications by
+ # this label, not by parsing the ScaledObject's authenticationRef.
+ keda = KEDAAutoscaler();
+ spec = AutoscalerSpec(
+ scale_target_name="order-service-deployment",
+ app_name="order-service",
+ namespace="default",
+ triggers=[
+ Trigger(
+ type="redis",
+ metadata={"address": "redis:6379"},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ manifests = keda._build_manifests(spec);
+ ta = [
+ m
+ for m in manifests
+ if m["kind"] == "TriggerAuthentication"
+ ][0];
+ labels = ta["metadata"]["labels"];
+ assert labels["app"] == "order-service";
+ assert labels["jac-scale/owner"] == "order-service-scaledobject";
+ assert labels["managed"] == "jac-scale";
+}
+
test "duplicate trigger identities raise ValueError before any cluster write" {
# Two triggers resolving to the same identity would overwrite each other's
# TriggerAuthentication; apply() must reject the spec up front.
@@ -541,6 +572,74 @@ test "apply patches ScaledObject when it already exists on the cluster" {
assert len(patch_calls) == 1;
}
+test "apply prunes a TriggerAuthentication the redeployed trigger set no longer references" {
+ # A redeploy that renames or drops a trigger must not leave its old
+ # TriggerAuthentication behind, unreferenced by the ScaledObject.
+ KEDAAutoscaler._preflight_cache.clear();
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_cluster_custom_object.return_value = {};
+ mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": "svc-deployment-scaledobject-stale-ta"}}]
+ };
+ keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
+ spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ namespace="default",
+ triggers=[Trigger(type="cpu", metadata={})]
+ );
+ result = keda.apply(spec);
+ assert result == True;
+ mock_api.list_namespaced_custom_object.assert_called_once_with(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace="default",
+ plural="triggerauthentications",
+ label_selector="jac-scale/owner=svc-deployment-scaledobject,managed=jac-scale"
+ );
+ prune_calls = [
+ c
+ for c in mock_api.delete_namespaced_custom_object.call_args_list
+ if c[1]["plural"] == "triggerauthentications"
+ ];
+ assert len(prune_calls) == 1;
+ assert prune_calls[0][1]["name"] == "svc-deployment-scaledobject-stale-ta";
+}
+
+test "apply keeps a TriggerAuthentication that is still referenced by the current trigger set" {
+ KEDAAutoscaler._preflight_cache.clear();
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_cluster_custom_object.return_value = {};
+ mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ namespace="default",
+ triggers=[
+ Trigger(
+ type="redis",
+ metadata={"address": "redis:6379"},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
+ desired_name = keda._trigger_auth_name("svc-deployment", spec.triggers[0], 0);
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": desired_name}}]
+ };
+ result = keda.apply(spec);
+ assert result == True;
+ prune_calls = [
+ c
+ for c in mock_api.delete_namespaced_custom_object.call_args_list
+ if c[1]["plural"] == "triggerauthentications"
+ ];
+ assert len(prune_calls) == 0;
+}
+
test "apply deletes competing HPA when switching from hpa engine to keda" {
KEDAAutoscaler._preflight_cache.clear();
@@ -592,24 +691,88 @@ test "destroy collection also sweeps competing HPAs" {
);
}
-test "destroy calls delete with the correct plural and resource name" {
+test "destroy resolves the ScaledObject name from app_name and deletes it" {
mock_api = unittest.mock.MagicMock();
+ mock_api.list_namespaced_custom_object.return_value = {"items": []};
keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc-scaledobject", "staging");
- mock_api.delete_namespaced_custom_object.assert_called_once_with(
+ keda.destroy("my-svc", "staging");
+ delete_calls = [
+ c
+ for c in mock_api.delete_namespaced_custom_object.call_args_list
+ if c[1]["plural"] == "scaledobjects"
+ ];
+ assert len(delete_calls) == 1;
+ assert delete_calls[0][1]
+ == {
+ "group": "keda.sh",
+ "version": "v1alpha1",
+ "namespace": "staging",
+ "plural": "scaledobjects",
+ "name": "my-svc-scaledobject"
+ };
+}
+
+test "destroy is a no-op when the ScaledObject is already gone" {
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_namespaced_custom_object.return_value = {"items": []};
+ mock_api.delete_namespaced_custom_object.side_effect = ApiException(status=404);
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ keda.destroy("my-svc", "staging");
+}
+
+test "destroy sweeps TriggerAuthentications owned by this ScaledObject before deleting it" {
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": "my-svc-scaledobject-89abcdef-ta"}}]
+ };
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ keda.destroy("my-svc", "staging");
+ mock_api.list_namespaced_custom_object.assert_called_once_with(
group="keda.sh",
version="v1alpha1",
namespace="staging",
- plural="scaledobjects",
- name="my-svc-scaledobject"
+ plural="triggerauthentications",
+ label_selector="jac-scale/owner=my-svc-scaledobject,managed=jac-scale"
);
+ delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
+ assert len(delete_calls) == 2;
+ assert delete_calls[0][1]
+ == {
+ "group": "keda.sh",
+ "version": "v1alpha1",
+ "namespace": "staging",
+ "plural": "triggerauthentications",
+ "name": "my-svc-scaledobject-89abcdef-ta"
+ };
+ assert delete_calls[1][1]["plural"] == "scaledobjects";
+}
+
+test "destroy tolerates a TriggerAuthentication that is already deleted" {
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": "my-svc-scaledobject-89abcdef-ta"}}]
+ };
+ mock_api.delete_namespaced_custom_object.side_effect = [
+ ApiException(status=404), # the TriggerAuthentication
+ None # the ScaledObject
+ ];
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ keda.destroy("my-svc", "staging");
+ assert len(mock_api.delete_namespaced_custom_object.call_args_list) == 2;
}
-test "destroy is a no-op when the ScaledObject is already gone" {
+test "destroy still deletes the ScaledObject when the TriggerAuthentication CRD is absent" {
mock_api = unittest.mock.MagicMock();
- mock_api.delete_namespaced_custom_object.side_effect = ApiException(status=404);
+ mock_api.list_namespaced_custom_object.side_effect = ApiException(status=404);
keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc-scaledobject", "staging");
+ keda.destroy("my-svc", "staging");
+ mock_api.delete_namespaced_custom_object.assert_called_once_with(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace="staging",
+ plural="scaledobjects",
+ name="my-svc-scaledobject"
+ );
}
diff --git a/release_notes/unreleased/jaclang/6908.bugfix.md b/release_notes/unreleased/jaclang/6908.bugfix.md
new file mode 100644
index 00000000000..3d07cb421e6
--- /dev/null
+++ b/release_notes/unreleased/jaclang/6908.bugfix.md
@@ -0,0 +1 @@
+- **Fix: KEDA `TriggerAuthentication` resources are now reconciled by ownership label**: `KEDAAutoscaler` previously orphaned `TriggerAuthentication` resources in two ways. A redeploy with a changed trigger set created new auths but never removed the ones the `ScaledObject` no longer referenced, and `destroy()` deleted only the `ScaledObject`, leaving every auth it created behind. `_build_trigger_auth_manifest` now stamps every `TriggerAuthentication` it builds with `app` and `jac-scale/owner` labels (the owner is the `ScaledObject` name, computed by one shared `_scaled_object_name()` helper instead of being derived separately, and divergently once `spec.autoscaler_name` is unset, in `apply()` and `_build_manifests()`), so `--show-yaml` and `apply()` write the same labels. `apply()` prunes any owned auth not in the freshly-applied set after writing the current ones, and `destroy(app_name, namespace)` deletes owned auths by that label (404-safe) before deleting the `ScaledObject`, instead of parsing the live `ScaledObject` to find them, so cleanup works even when the `ScaledObject` was already deleted out of band.
From 8eb3b11b2721b51eec7fbbb9aef180ce5ad4b930 Mon Sep 17 00:00:00 2001
From: Linus Kipkemoi Langat
<142144579+Developer-Linus@users.noreply.github.com>
Date: Fri, 28 Aug 2026 19:45:10 +0300
Subject: [PATCH 06/13] feat(jac-scale): support KEDA HTTP Add-on activation
via declarative jac.toml config (#7709)
Closes #7475
PR #7421 added a programmatic API for KEDA HTTP Add-on scale-to-zero
activation (`HTTPActivationSpec`, `KEDAAutoscaler.apply_http_activation`
/ `destroy_http_activation`), but left it reachable only by writing a
Jac driver script. Every other autoscaler knob in jac-scale is
configured declaratively through `jac.toml`, so this PR adds that same
entry point for HTTP activation, for both the monolith deploy path and
per-service microservice deploys.
The programmatic API from #7421 is unchanged and remains the right tool
for callers with a dynamic, create/destroy-on-demand lifecycle (for
example, an IDE preview orchestrator). The two entry points are
additive, not a replacement of one by the other: `jac.toml` covers a
known, standing service; the programmatic API covers a workload created
and torn down at runtime.
**One mutually-exclusive scaling decision per service, resolved at build
time.** `manifest_builder` resolves each service (the gateway included)
to exactly one scaling mode - `none`, `metric` (HPA/KEDA), or
`http_activation` - computed once and carried through the bundle as a
single `scaling` map, replacing two independent resolvers (autoscaler
config and http_activation config) that could both fire on the same
Deployment. Three consequences, each of which was a real apply-side
defect before:
- **Correct scale target.** The `ScaledObject`'s `scaleTargetRef`, and
the `InterceptorRoute`/`ScaledObject` resource names derived from it,
come from the built Deployment manifest (`-deployment`), never
re-derived from the bare service name. A bare-name target made
`apply_http_activation`'s existence check 404 on a real cluster, killing
the deploy.
- **No dual scalers.** A service gets a metric autoscaler or an HTTP
`ScaledObject`, never both; the modes are mutually exclusive by
construction, so KEDA's admission webhook can no longer reject a second
`ScaledObject` on an HPA-managed target.
- **The gateway never scales to zero.** It is the ingress entry point
and must stay warm, so it is structurally excluded from HTTP activation
and never inherits a shared `enabled = true`; it falls through to a
normal HPA.
**Teardown sweeps KEDA resources regardless of the base engine.**
`destroy()`'s KEDA sweep previously went through
`AutoscalerFactory.create(self.k8s_config.autoscaler_engine, ...)`, so
when `autoscaler_engine = "hpa"` it resolved to
`HPAAutoscaler.destroy_collection`, which does not know about
`InterceptorRoute` or HTTP-activation `ScaledObject` resources,
orphaning them on teardown. `destroy()` now also runs a
`KEDAAutoscaler(...).destroy_collection(...)` sweep whenever
`autoscaler_engine != "keda"`, since HTTP activation needs the KEDA HTTP
Add-on regardless of the base engine.
**Teardown tolerates the HTTP Add-on's separate RBAC surface, without
masking other failures.** The HTTP Add-on's CRD group (`http.keda.sh`,
covering `InterceptorRoute`) installs and is permissioned separately
from core KEDA's `keda.sh` group (`ScaledObject`,
`TriggerAuthentication`), which the base autoscaler already needs. A
cluster's RBAC can easily cover one without the other. Both
`destroy_http_activation` and `destroy_collection` were changed so a 403
on the `http.keda.sh` side never aborts cleanup of the `keda.sh` side
(or vice versa for `destroy_collection`, which still requires 404/422
there), both resources are always attempted regardless of the other's
outcome, and if one side hits a genuinely unexpected status (500, for
example) that is what propagates, not a tolerable 403 that happened to
be checked second. This closes a class of gaps found in review: a single
unhandled 403 previously could abort the rest of monolith or
microservice teardown (Deployment, Service, PVC, ingress, monitoring,
database cleanup) before it ran.
**Per-service config inherits from the shared default, except the switch
itself and the gateway.** A per-service
`[scale.microservices.services.NAME.http_activation]` block merges over
`[scale.kubernetes.http_activation]` as a base, so a service that only
sets `target_port` still picks up a shared `concurrency_target` from the
top level, and a service with no local block at all still inherits a
top-level `enabled = true`. An explicit per-service `enabled = false`
still overrides that default, so a shared "on" switch can be selectively
opted out of per service. The gateway is the one service that never
inherits the shared switch (see the scaling-decision point above).
**`jac plan` surfaces HTTP-activated services.** The dry-run plan reads
the same `scaling` map, so an http_activation service renders its `HTTP
activation: 0 -> N` line and appears in the Totals, instead of being
invisible in the plan.
**A redeploy no longer resets a scaled-to-zero target back to its
baseline.** The Deployment manifest's `spec.replicas` is always the
configured static default, and every apply PATCHes it onto the live
Deployment - with no exclusion for HTTP-activation-mode services, that
undid KEDA's own scale-to-zero on every redeploy. `_apply_or_replace`
now takes an optional `patch_body` distinct from the manifest used for a
fresh create, and the deployment-apply loop strips `replicas` from the
PATCH body (not the CREATE body) for `HTTP_ACTIVATION`-mode services, so
KEDA's current value is left alone.
**No PodDisruptionBudget for an HTTP-activated service, and `jac plan`
stops flagging it.** `_build_pdb_manifest` used to compute a replica
floor from `hpa.min` regardless of mode, so an HTTP-activated service
could still get a PDB with `min_available` set - a budget that's
permanently unsatisfiable once the target is actually at zero replicas.
It now takes the resolved mode and skips PDB generation entirely for
`HTTP_ACTIVATION` services (warning if the user had `pdb` settings
configured). `PlanValidator._check_hpa`/`_check_pdb` had the same blind
spot for `jac plan`'s diagnostics and now skip both checks for
`HTTP_ACTIVATION` services too.
**Two services can't silently collide on the same HTTP-activation
traffic.** `build_http_activation_spec` now rejects an empty `rules`
list at build time - the docs already said an empty list means no
traffic ever matches, so a service left at the default was silently
deploying unreachable. Separately, `generate_manifests` now runs
`_validate_http_activation_rules`, which rejects two services that
resolve to identical `InterceptorRoute` match criteria (for example, two
services that both inherit the shared top-level rules unchanged) - the
KEDA HTTP Add-on interceptor has no way to tell which target a matching
request belongs to, so this is now a build-time error naming the
colliding services, not a runtime routing ambiguity.
**Mode transitions reap the old resource before applying the new one.**
`apply_manifests` used to apply the new mode's scaling resource (metric
autoscaler, or the HTTP `ScaledObject`) before calling
`reap_stale_scaling_resources_for_bundle`, which tears down the previous
mode's leftover resource. A service switching modes on a given redeploy
collided with its own stale resource - KEDA's admission webhook rejects
a second `ScaledObject` on an already-managed target - and only got
cleaned up on the next deploy. The reap call now runs before both
applies.
**A deploy bundle that's entirely HTTP-activation-scaled no longer
crashes.** `_wait_for_fleet_rollout` called `self._load_kube_config()`,
a method that doesn't exist anywhere in this codebase - the real method
is `_load_cluster_config()`. This crashed with an `AttributeError` in
exactly the case the surrounding code path exists to handle: a fleet
with no metric-scaled services to wait on.
**Traffic routing to the interceptor is not wired yet (documented
limitation).** jac-scale creates the `InterceptorRoute` and
`ScaledObject`, but does not yet rewire the gateway or Ingress backend
to the KEDA HTTP interceptor proxy. With `min_replicas = 0`, inbound
traffic that reaches the app Service directly (instead of the
interceptor) will not wake the pod. The reference doc carries a warning
to route through the interceptor yourself for now; the routing-plane
rewire is tracked separately as #7959.
**Namespace-wide sweeps still lack application-identity scoping.** Every
resource-kind sweep in `destroy()` (Deployments, Services, PDBs,
Ingresses, ConfigMaps, and the KEDA resources above) uses a
namespace-wide `managed=jac-scale` label selector with no
application-identity component, so two jac-scale applications sharing
one namespace would delete each other's resources on teardown. This is a
pre-existing characteristic of the whole method, not something this PR
introduces; fixing it properly requires an app-identity label added at
manifest-build time across several files. Tracked separately as #7710
rather than patched partially here.
- `test_http_activation_config.jac` (12 tests): config-to-spec
translation (disabled/absent, minimal config defaults, one full-config
test covering every optional field, and the four jac.toml-relative
validation errors - `target_port`/`target_port_name` mutual exclusivity,
a missing `concurrency_target`/`request_rate_target`, empty `rules`, and
a rule header missing `name`), the injectable apply/destroy wiring
helpers, and that only a 403 from `destroy_http_activation` is caught (a
500 still propagates). Uses a shared `_assert_config_error(cfg,
*substrings)` helper instead of hand-rolled raise/catch/assert blocks
per test.
- `test_http_activation_microservices.jac` (17 tests): the per-service
config pass-through and top-level inheritance (including `enabled`
itself, and an explicit per-service opt-out); the single scaling
decision in `_resolve_scaling` - that an http_activation service
resolves to `HTTP_ACTIVATION` mode carrying the `-deployment`
scale target and emitting no metric autoscaler, that the gateway is
never HTTP-activated even under a shared default (exercised with a real
logger so a wrong log-method name is caught without a live deploy), and
that a plain service resolves to `METRIC`; the per-service apply loop
(only HTTP_ACTIVATION-mode services are applied, using the carried
scale-target and service names); the mode-transition reap tests (a
METRIC service has its stale HTTP activation resources reaped and its
autoscaler left alone, and vice versa, a NONE service has both reaped,
and an empty bundle is a no-op); and the cross-service rule-collision
validation (identical rules raise, distinct/empty/lone-peer rules don't,
and `generate_manifests` raises when two services inherit the shared
catch-all rule unchanged).
- `test_keda_http_activation.jac` (46 tests): imports the shared
fixtures instead of defining them locally, plus the
`destroy_http_activation`/`destroy_collection` error-handling coverage -
that `destroy_http_activation` still attempts the `ScaledObject` delete
after a 403 on the `InterceptorRoute` delete; that a 500 on one delete
is not masked by a 403 on the other; that `destroy_collection` tolerates
a 403 listing `InterceptorRoute`s without aborting the
`ScaledObject`/`TriggerAuthentication` sweep; and that a 403 listing
`ScaledObject`s still raises.
- `test_plan.jac` (23 tests): includes a case asserting an
http_activation service renders its scale-to-zero line and counts in
Totals, with no HPA line, and that the metric-autoscaler smoke test
reads the new `scaling` bundle key.
- `test_pdb_budget.jac` (11 tests): the existing PDB floor/emission
cases, plus that an HTTP-activated service gets no PDB even with
`pdb.min_available` set, and that the plan validator skips both the HPA
and PDB checks for an `HTTP_ACTIVATION` service.
- `test_show_yaml_autoscalers.jac` (5 tests): `jac plan --show-yaml`
renders the HPA/`ScaledObject` body the apply path would build, that the
scale target comes from the resolved plan rather than being re-derived
from the service name, that an http_activation-scaled service renders no
autoscaler manifest, and that a bundle with no scaling renders nothing.
- `http_activation_test_support.jac`: shared fixtures
(`default_http_activation_spec`, `default_http_activation_config`,
`mocked_keda_autoscaler`) extracted from `test_keda_http_activation.jac`
so both files use one source of truth instead of duplicating mock setup.
The HTTP activation, KEDA autoscaler, plan, memory-trigger-guard,
deployment-overlay, dry-run-purity, ms-config-isolation, and
superseded-deployment-reap suites all pass under `jac test`.
The microservices `destroy()` engine-gap wiring itself has no dedicated
test: like the rest of that method, it requires a live cluster
(`_load_cluster_config`, real client construction) with no existing
unit-test precedent in this codebase. The error-handling logic it
depends on (`destroy_collection`'s group-aware tolerance) is unit-tested
directly, as listed above. It was, however, exercised end to end on a
real cluster (see Notes to Reviewer).
| File | What Changed |
|---|---|
| `jac/jaclang/scale/deploy/autoscale/http_activation_config.jac` | New.
`build_http_activation_spec` translates a `[*.http_activation]` config
dict into an `HTTPActivationSpec`, with jac.toml-relative validation
errors, including an empty-`rules` check and a friendly error (instead
of a bare `KeyError`) for a rule header missing its required `name` key.
`apply_http_activation_for_target` /
`destroy_http_activation_for_target` wrap the `KEDAAutoscaler` calls
behind an injectable interface, shared by both deploy paths below.
`destroy_http_activation_for_target` is unconditional (no config gate,
so a resource created while enabled stays cleanable even if disabled
later) and swallows only a 403 from `destroy_http_activation` (via
null-safe `e?.status`, since the checker can't prove `ApiException`
isn't the `_optdeps` fallback `Exception`), re-raising anything else. |
| `jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac` |
`destroy_http_activation` now always attempts both the
`InterceptorRoute` and `ScaledObject` deletes regardless of the other's
outcome, and prefers surfacing a non-403 error over a 403 if both fail
differently. `destroy_collection` now tolerates 403 specifically for the
`http.keda.sh` group, leaving `keda.sh` groups at the existing strict
404/422 tolerance. |
| `jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac` |
`destroy_collection`'s interface gains a `best_effort: bool = False`
parameter for the group-aware 403 tolerance above. |
| `jac/jaclang/scale/deploy/autoscale/http_activation.jac` | Renames
`HTTPRequestRateMetric.window` to `rate_window` to match the config
schema and generated YAML field name. |
| `jac/jaclang/scale/config/plugin_config.jac` | New
`[scale.kubernetes.http_activation]` schema block (master switch,
replica bounds, target port, concurrency/request-rate metrics, routing
rules, cold start, timeouts, custom scale target). Documents the
per-service `[scale.microservices.services.NAME.http_activation]`
override, which falls back to the top-level block. The ~20-field nested
schema is now built once by `_http_activation_nested_schema()` and
shared by both call sites instead of being duplicated verbatim (the two
copies had already drifted: the `rules` description claimed "empty
matches all traffic," the opposite of the now-enforced behavior). |
| `jac/jaclang/scale/deploy/target/kubernetes/kubernetes_config.jac` |
New `http_activation: dict[str, any]` field on `KubernetesConfig`, wired
into `to_dict()` / `from_dict()`. |
| `jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac` |
Monolith teardown (`_destroy_application`) calls
`destroy_http_activation_for_target` next to the base-autoscaler
destroy, with the engine-gap sweep described above. Also removes
`KubernetesTargetBase.destroy`, `_destroy_component`,
`_destroy_application`, `_destroy_databases`/`_destroy_database`, and
`_wait_for_deletion` (about 360 lines): all were shadowed by
`KubernetesTarget`'s own overrides for every real deploy and unreachable
in practice. Note for future readers: `_destroy_component` was the
reference implementation #8403 and #7968 point at for full-teardown
component dispatch. After this merges, addressing either issue means
writing that dispatch logic fresh in `target.jac` rather than reusing
this block. |
| `jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac` |
New `ScalingMode` enum and `_resolve_scaling(svc_name, deployment,
service)`: the single scaling decision, reading the scale-target name
off the built Deployment manifest, enforcing the gateway exclusion and
metric-vs-activation exclusivity. `_get_http_activation_config` merges
`[scale.kubernetes.http_activation]` as a base with the per-service
block. The build loop emits one `bundle["scaling"]` map (replacing the
separate `autoscalers`/`http_activations` keys). `generate_manifests`
now also calls `_validate_http_activation_rules` to reject cross-service
rule collisions, and `_build_pdb_manifest` takes the resolved mode and
skips PDB generation for `HTTP_ACTIVATION` services. |
| `jac/jaclang/scale/deploy/target/kubernetes/target.jac` |
`apply_http_activations_for_bundle`, the autoscaler apply loop, and the
post-deploy reachability-skip set all read `bundle["scaling"]` by mode,
using the carried scale-target/service names with no name re-derivation.
`destroy()` engine-gap fix (see Notable design points).
`apply_manifests` now reaps stale scaling resources before applying the
new mode's resource, `_apply_or_replace` takes an optional `patch_body`
so a redeploy's PATCH strips `replicas` for `HTTP_ACTIVATION`-mode
services instead of resetting KEDA's scale-to-zero, and
`_wait_for_fleet_rollout`'s crash on an all-activated fleet is fixed
(`_load_cluster_config`, not the nonexistent `_load_kube_config`). |
| `jac/jaclang/scale/runtime/cli/plan.jac` | Reads the `scaling` bundle
key; new `HTTPActivationView` rendered beside `HPAView`, and Totals
counts autoscalers and HTTP activations by mode.
`PlanValidator._check_hpa`/`_check_pdb` now read the resolved mode and
skip both checks for `HTTP_ACTIVATION` services. |
| `jac/jaclang/scale/tests/deploy/http_activation_test_support.jac` |
New. Shared fixtures (`default_http_activation_spec`,
`default_http_activation_config`, `mocked_keda_autoscaler`) extracted
from `test_keda_http_activation.jac`. |
| `jac/jaclang/scale/tests/deploy/test_http_activation_config.jac` |
Config-to-spec translation tests plus the injectable apply/destroy
wiring-helper tests (12 tests), using a shared `_assert_config_error`
helper. |
|
`jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac`
| New. Per-service config inheritance, the `_resolve_scaling` decision
(scale-target name, gateway exclusion, metric-vs-activation
exclusivity), the mode-transition reap tests, the apply-loop tests, and
the cross-service rule-collision validation (17 tests). |
| `jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac` |
Imports shared fixtures instead of defining them locally, plus the
`destroy_http_activation`/`destroy_collection` error-handling tests (46
tests). |
| `jac/jaclang/scale/tests/microservices/test_plan.jac` | Reads the new
`scaling` bundle key; adds an http_activation render/Totals test (23
tests). |
| `jac/jaclang/scale/tests/deploy/test_pdb_budget.jac` | Adds coverage
that an `HTTP_ACTIVATION` service gets no PDB even with
`pdb.min_available` set, and that the plan validator skips both the HPA
and PDB checks for it (11 tests). |
| `jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac` | Adds
coverage that `jac plan --show-yaml`'s scale target comes from the
resolved plan (not re-derived from the service name) and that an
http_activation-scaled service renders no autoscaler manifest (5 tests).
|
| `jac/jaclang/scale/runtime/cli/diagnostics.jac` |
`_check_hpa`/`_check_pdb` read the resolved scaling mode and skip both
checks for `HTTP_ACTIVATION` services. |
| `jac/jaclang/cli/docs/reference/plugins/jac-scale-kubernetes.md` |
Restores the "HTTP Add-on Activation" reference section with the new
config keys, both deploy-path examples, a note on the still-available
programmatic API, and a warning that inbound traffic must be routed
through the interceptor for now (#7959). |
| `jac/jaclang/cli/docs/tutorials/production/kubernetes.md` | New "Scale
to zero on an HTTP request" subsection alongside the existing
autoscaling tutorial content, with a minimal `jac.toml` example and a
link to the full reference section. |
| `jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/` and
`jac/jaclang/scale/tests/deploy/keda_http_activation_real_e2e.sh` |
Real-cluster e2e fixture and driver, deployed through the `jac.toml`
wiring (replaces the old `fixture.yaml`-driven setup). The driver
resolves resource names from the unified deploy path's
`-deployment` scale target. `keda_http_activation_verify.jac`, the
old standalone verify script, is removed - superseded by the
`jac.toml`-driven driver. |
The full flow was validated end to end on a local `kind` cluster (not
just unit tests):
- **Runtime channel.** The fixture `jac.toml` carried a `[dev]` stanza
(dev binary channel) with `[dev] jaclang_source` pointing at this
checkout, so `jac start --scale` ran this branch's deploy code host-side
(all manifest generation, the `kubectl apply` calls, and the
`InterceptorRoute`/`ScaledObject` reconcile happen in that process). The
confirming `jac dev mode - using compiler source at ...` banner appeared
on every deploy.
- **Cluster.** A single-node `kind` cluster with KEDA core and the KEDA
HTTP Add-on installed via helm. Leader election was disabled on the
control-plane components for the single-node dev cluster so the pod's
first-boot compile could not starve them.
- **Result.** `keda_http_activation_real_e2e.sh` PASSED: deploy,
idempotent redeploy (get-then-patch, no duplication), both
`InterceptorRoute` and `ScaledObject` reconciled to Ready, a request
through the interceptor cold-started the target from `0 -> 1` and
returned HTTP 200 with the walker's response, then the target scaled
back to `0` after the cooldown.
- **The three design fixes confirmed on the live cluster:** the
`ScaledObject`'s `scaleTargetRef` was `echo-deployment` (not the bare
name), the activated service had no metric HPA, and the gateway had a
normal HPA rather than a `ScaledObject`.
(cherry picked from commit 6ded655268bd7947a9d4fd595e01b3b58d1d8c41)
---
.../reference/plugins/jac-scale-kubernetes.md | 92 ++
docs/docs/tutorials/production/kubernetes.md | 30 +
jac/jaclang/scale/config/plugin_config.jac | 141 +-
.../deploy/autoscale/http_activation.jac | 2 +-
.../autoscale/http_activation_config.jac | 188 +++
.../deploy/autoscale/keda_autoscaler.impl.jac | 29 +-
.../deploy/autoscale/keda_autoscaler.jac | 5 +-
.../target/kubernetes/kubernetes_config.jac | 3 +
.../target/kubernetes/kubernetes_target.jac | 470 ------
.../target/kubernetes/manifest_builder.jac | 172 +-
.../scale/deploy/target/kubernetes/target.jac | 171 +-
jac/jaclang/scale/runtime/cli/diagnostics.jac | 12 +-
jac/jaclang/scale/runtime/cli/plan.jac | 94 +-
.../deploy/http_activation_test_support.jac | 90 ++
.../deploy/keda_http_activation_real_e2e.sh | 170 +-
.../deploy/keda_http_activation_verify.jac | 86 -
.../deploy/test_http_activation_config.jac | 244 +++
.../test_http_activation_microservices.jac | 1386 +++++++++++++++++
.../tests/deploy/test_keda_autoscaler.jac | 806 ----------
.../deploy/test_keda_http_activation.jac | 789 ----------
.../deploy/test_show_yaml_autoscalers.jac | 79 +-
.../keda_http_activation_e2e/README.md | 131 +-
.../fixtures/keda_http_activation_e2e/app.jac | 5 +
.../keda_http_activation_e2e/fixture.yaml | 53 -
.../keda_http_activation_e2e/jac.toml | 24 +
.../scale/tests/microservices/test_plan.jac | 40 +-
.../unreleased/jaclang/7709.feature.md | 1 +
27 files changed, 2926 insertions(+), 2387 deletions(-)
create mode 100644 jac/jaclang/scale/deploy/autoscale/http_activation_config.jac
create mode 100644 jac/jaclang/scale/tests/deploy/http_activation_test_support.jac
delete mode 100644 jac/jaclang/scale/tests/deploy/keda_http_activation_verify.jac
create mode 100644 jac/jaclang/scale/tests/deploy/test_http_activation_config.jac
create mode 100644 jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac
delete mode 100644 jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
delete mode 100644 jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac
create mode 100644 jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/app.jac
delete mode 100644 jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/fixture.yaml
create mode 100644 jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/jac.toml
create mode 100644 release_notes/unreleased/jaclang/7709.feature.md
diff --git a/docs/docs/reference/plugins/jac-scale-kubernetes.md b/docs/docs/reference/plugins/jac-scale-kubernetes.md
index 40288944e70..ec55ac37857 100644
--- a/docs/docs/reference/plugins/jac-scale-kubernetes.md
+++ b/docs/docs/reference/plugins/jac-scale-kubernetes.md
@@ -472,6 +472,98 @@ metadata = { queueName = "orders", mode = "QueueLength", value = "50", protocol
host = { name = "rabbitmq-secret", key = "host" }
```
+#### HTTP Add-on Activation (Scale-to-Zero on Request)
+
+The KEDA engine above scales on CPU, memory, or any KEDA trigger, but none of those triggers can wake a workload from zero replicas in response to an incoming HTTP request itself. There is nothing listening on the Service to observe traffic when replicas are at zero. The [KEDA HTTP Add-on](https://keda.sh/http-add-on/0.15/) closes that gap: it intercepts HTTP traffic bound for the target, holds the request while a zero-replica workload starts, and forwards it only once the workload is ready.
+
+!!! note
+ The KEDA HTTP Add-on installs separately from KEDA core. It ships as its own Helm chart:
+ ```bash
+ helm repo add kedacore https://kedacore.github.io/charts
+ helm repo update
+ helm install keda kedacore/keda -n keda --create-namespace --wait
+ helm install http-add-on kedacore/keda-add-ons-http -n keda --wait
+ ```
+ If the HTTP Add-on's CRDs are missing at deploy time, jac-scale logs a warning and skips creating the `InterceptorRoute`/`ScaledObject` rather than failing the deploy, matching the `"keda"` engine's own preflight fallback above.
+
+**Prerequisites**
+
+- KEDA core is installed on the cluster, as described above for the `"keda"` engine.
+- The KEDA HTTP Add-on is installed, per the note above.
+- The scale target exposes Kubernetes' `/scale` subresource. A `Deployment` or `StatefulSet` works out of the box; a standalone `Pod` is rejected with an error explaining the `/scale` requirement. A custom resource such as a `Rollout` also works, but only once `scale_target_plural` is set, so jac-scale can confirm it exists before applying anything.
+- The target's Deployment or StatefulSet, and its Service, already exist. This feature manages the `InterceptorRoute` and `ScaledObject` around an existing workload; it does not create the workload or the Service.
+- Exactly one of `target_port` or `target_port_name` is set, and at least one of `concurrency_target` or `request_rate_target` is set. Both are validated up front with an error that names the offending `jac.toml` key.
+
+**Interaction with the base autoscaler:** a target with `http_activation.enabled = true` is scaled entirely by its `ScaledObject` (min/max replicas, scale-to-zero). `jac start --scale` skips creating the base HPA/KEDA autoscaler for that same target automatically -- KEDA's admission webhook rejects a `ScaledObject` for a workload already managed by an HPA (or another `ScaledObject`), so both can't coexist on one target. This also means the deploy's usual post-deploy HTTP reachability check is skipped for that target (it may legitimately be sitting at 0 replicas with nothing to reach); a crash-loop check on the pods runs instead.
+
+**HTTP activation configuration (`[scale.kubernetes.http_activation]`):**
+
+| TOML Key | Default | Description |
+|----------|---------|-------------|
+| `enabled` | `false` | Master switch. Off by default. |
+| `min_replicas` | `0` | Replica floor while inactive. `0` enables true scale-to-zero. |
+| `max_replicas` | `1` | Replica ceiling once activated. |
+| `polling_interval` | `30` | Seconds between HTTP metric evaluations. |
+| `cooldown_period` | `300` | Seconds of inactivity before scaling back to `min_replicas`. |
+| `target_port` / `target_port_name` | `null` | Container port on the app's Service. Set exactly one. |
+| `concurrency_target` | `null` | In-flight-request concurrency target. Set this or `request_rate_target`. |
+| `request_rate_target` | `null` | Requests-per-window target, as an alternative to `concurrency_target`. |
+| `request_rate_window` / `request_rate_granularity` | `"1m"` / `"1s"` | Window and sampling granularity for `request_rate_target`. |
+| `[[rules]]` | `[]` | Routing rules: `hosts` (list), `paths` (list), `headers` (list of `{name, value}`, `value` omitted matches any). Fields within one rule are AND'd; separate rules are OR'd. **Leaving this empty means no traffic matches** -- the interceptor never forwards anything and the target never wakes. At least one rule is required; use `hosts = ["*"]` for an explicit catch-all. |
+| `cold_start_status_code` / `cold_start_body` / `cold_start_headers` | `503` / `null` / `{}` | Static placeholder response served while the target cold-starts. |
+| `cold_start_fallback_service` / `cold_start_fallback_port` | `null` | Service to forward to while cold-starting, as an alternative to a static placeholder. |
+| `timeout_readiness` / `timeout_request` / `timeout_response_header` | `null` | Duration strings (e.g. `"30s"`) the interceptor waits at each stage. |
+| `scale_target_kind` / `scale_target_api_version` / `scale_target_plural` | `"Deployment"` / `"apps/v1"` / `null` | Only needed when activating a non-Deployment/StatefulSet target. |
+
+**To configure in `jac.toml` (monolith deploy):**
+
+```toml
+[scale.kubernetes.http_activation]
+enabled = true
+target_port = 8000
+concurrency_target = 10
+min_replicas = 0
+max_replicas = 3
+cooldown_period = 300
+
+[[scale.kubernetes.http_activation.rules]]
+hosts = ["app.example.com"]
+```
+
+**Per-service, in microservice mode:** the same keys apply under `[scale.microservices.services..http_activation]`. The target Service is always the service's own generated Service; it is never user-set. Any key left unset falls back to `[scale.kubernetes.http_activation]`'s value.
+
+```toml
+[scale.microservices.services.jac_coder_sv.http_activation]
+enabled = true
+target_port = 8000
+concurrency_target = 5
+min_replicas = 0
+
+[[scale.microservices.services.jac_coder_sv.http_activation.rules]]
+paths = ["/coder"]
+```
+
+**Traffic topology**
+
+```mermaid
+graph TD
+ Client["Client"] -->|"HTTP request"| Interceptor["HTTP Add-on Interceptor
(matches InterceptorRoute rules)"]
+ Interceptor -->|"pending request count"| Scaler["External Scaler"]
+ Scaler -->|"external-push metric"| Operator["KEDA Operator"]
+ Operator -->|"scale 0 to 1"| Target["Deployment (0 replicas)"]
+ Target -->|"pod Ready"| Interceptor
+ Interceptor -->|"forward held request"| Target
+ Target -->|"response"| Client
+```
+
+jac-scale always reconciles the `InterceptorRoute` before the `ScaledObject`, because the external scaler resolves the target Service and scaling metric from the route when KEDA evaluates the trigger. Reconciling in the other order would leave the `ScaledObject` unable to find its metric source.
+
+!!! warning "Route inbound traffic through the interceptor yourself"
+ jac-scale creates the `InterceptorRoute` and `ScaledObject`, but does **not** rewire the gateway or Ingress to the interceptor proxy -- they still resolve the app's own Service directly. With `min_replicas = 0`, a request that reaches the Service instead of the interceptor is refused and never wakes the pod. Only enable `http_activation` on a service whose inbound traffic you have already pointed at the KEDA HTTP interceptor proxy. The gateway is exempt and never inherits a shared `enabled = true` default.
+
+!!! note "Programmatic API for dynamic activation"
+ A control-plane process that creates and tears down workloads on demand (for example, an IDE-preview orchestrator spinning up a per-session preview) has no fixed target to put in `jac.toml`. For that case, `HTTPActivationSpec` (`jaclang.scale.deploy.autoscale.http_activation`) and `KEDAAutoscaler.apply_http_activation` / `destroy_http_activation` (`jaclang.scale.deploy.autoscale.keda_autoscaler`) remain available as a direct API, unchanged by the `jac.toml` surface above. Use whichever entry point matches your workload's lifecycle: `jac.toml` for a known, standing service; the programmatic API for one created and destroyed at runtime.
+
---
### Persistent Storage
diff --git a/docs/docs/tutorials/production/kubernetes.md b/docs/docs/tutorials/production/kubernetes.md
index 3a6912cccc5..9e015e5d944 100644
--- a/docs/docs/tutorials/production/kubernetes.md
+++ b/docs/docs/tutorials/production/kubernetes.md
@@ -221,6 +221,36 @@ autoscaler_initial_cooldown = 0 # default 0; seconds after deploy before scal
For the full list of autoscaling options (including event triggers, polling intervals, cooldown tuning, and authenticated triggers), see the [Scale Reference](../../reference/plugins/jac-scale-kubernetes.md#autoscaling).
+### Scale to zero on an HTTP request
+
+The KEDA engine above scales on metrics or events. To instead wake a
+zero-replica workload on an incoming HTTP request, enable the KEDA HTTP
+Add-on activation:
+
+```toml
+[scale.kubernetes.http_activation]
+enabled = true
+target_port = 8000
+concurrency_target = 10
+min_replicas = 0 # true scale-to-zero
+max_replicas = 3
+
+[[scale.kubernetes.http_activation.rules]]
+hosts = ["app.example.com"]
+```
+
+This reconciles a KEDA `InterceptorRoute` and `ScaledObject` for the target,
+and works for both single-app and per-service (`[scale.microservices.services..http_activation]`) deploys.
+
+!!! note
+ This needs the KEDA HTTP Add-on installed alongside KEDA core. With
+ `min_replicas = 0`, route inbound traffic through the KEDA HTTP interceptor
+ proxy: jac-scale does not yet rewire the gateway or Ingress to it, so a
+ request that reaches the app Service directly will not wake a scaled-to-zero
+ pod.
+
+See the [Scale Reference](../../reference/plugins/jac-scale-kubernetes.md#http-add-on-activation-scale-to-zero-on-request) for the full HTTP activation config (routing rules, cold-start response, timeouts).
+
---
## Local and Remote Clusters
diff --git a/jac/jaclang/scale/config/plugin_config.jac b/jac/jaclang/scale/config/plugin_config.jac
index f4fe86daa1f..e1f82247730 100644
--- a/jac/jaclang/scale/config/plugin_config.jac
+++ b/jac/jaclang/scale/config/plugin_config.jac
@@ -1,3 +1,135 @@
+def _http_activation_nested_schema -> dict[str, any] {
+ """The `nested` fields shared verbatim by [scale.kubernetes.http_activation]
+ and [scale.microservices.services.NAME.http_activation] -- factored out so
+ the two call sites (top-level default, per-service override) can't drift
+ out of sync on a field's type/default the way two independent copies
+ would.
+ """;
+ return {
+ "enabled": {
+ "type": "bool",
+ "default": False,
+ "description": "Master switch. Off by default."
+ },
+ "min_replicas": {
+ "type": "int",
+ "default": 0,
+ "description": "Replica floor while inactive. 0 enables true scale-to-zero."
+ },
+ "max_replicas": {
+ "type": "int",
+ "default": 1,
+ "description": "Replica ceiling once activated."
+ },
+ "polling_interval": {
+ "type": "int",
+ "default": 30,
+ "description": "How often KEDA evaluates the HTTP scaling metric, in seconds."
+ },
+ "cooldown_period": {
+ "type": "int",
+ "default": 300,
+ "description": "Seconds of inactivity before scaling back to min_replicas."
+ },
+ "target_port": {
+ "type": "int",
+ "default": None,
+ "description": "Container port on the target's own generated Service. Exactly one of target_port/target_port_name must be set when enabled."
+ },
+ "target_port_name": {
+ "type": "string",
+ "default": None,
+ "description": "Named container port on the target's own generated Service, as an alternative to target_port."
+ },
+ "concurrency_target": {
+ "type": "int",
+ "default": None,
+ "description": "In-flight-request concurrency target. Exactly one of concurrency_target/request_rate_target must be set when enabled."
+ },
+ "request_rate_target": {
+ "type": "int",
+ "default": None,
+ "description": "Requests-per-window target, as an alternative to concurrency_target."
+ },
+ "request_rate_window": {
+ "type": "string",
+ "default": "1m",
+ "description": "Window duration for request_rate_target."
+ },
+ "request_rate_granularity": {
+ "type": "string",
+ "default": "1s",
+ "description": "Sampling granularity for request_rate_target."
+ },
+ "cold_start_status_code": {
+ "type": "int",
+ "default": 503,
+ "description": "HTTP status the interceptor returns while the target cold-starts, if a placeholder response is configured."
+ },
+ "cold_start_body": {
+ "type": "string",
+ "default": None,
+ "description": "Static placeholder response body served while the target cold-starts."
+ },
+ "cold_start_body_config_map": {
+ "type": "dict",
+ "default": None,
+ "description": "ConfigMap reference to source the placeholder response body from, as an alternative to cold_start_body."
+ },
+ "cold_start_headers": {
+ "type": "dict",
+ "default": {},
+ "description": "Extra headers on the placeholder response."
+ },
+ "cold_start_fallback_service": {
+ "type": "string",
+ "default": None,
+ "description": "Service name to forward requests to while the target cold-starts, as an alternative to a static placeholder response."
+ },
+ "cold_start_fallback_port": {
+ "type": "int",
+ "default": None,
+ "description": "Port on cold_start_fallback_service."
+ },
+ "timeout_readiness": {
+ "type": "string",
+ "default": None,
+ "description": "Duration (e.g. \"30s\") the interceptor waits for the target to become ready after scaling up."
+ },
+ "timeout_request": {
+ "type": "string",
+ "default": None,
+ "description": "Duration the interceptor waits for the forwarded request to complete."
+ },
+ "timeout_response_header": {
+ "type": "string",
+ "default": None,
+ "description": "Duration the interceptor waits for response headers from the target."
+ },
+ "scale_target_kind": {
+ "type": "string",
+ "default": "Deployment",
+ "description": "Kind of the scale target. Deployment/StatefulSet work directly; other kinds require scale_target_plural."
+ },
+ "scale_target_api_version": {
+ "type": "string",
+ "default": "apps/v1",
+ "description": "apiVersion of the scale target, used when scale_target_plural is set for a custom-resource target."
+ },
+ "scale_target_plural": {
+ "type": "string",
+ "default": None,
+ "description": "Plural resource name for a custom-resource scale target (e.g. \"rollouts\"), enabling existence validation before writing anything."
+ },
+ "rules": {
+ "type": "list",
+ "default": [],
+ "description": "[[...rules]] array-of-tables. Each entry: hosts (list[str]), paths (list[str]), headers (list of {name, value?}). Fields within one rule are AND'd; separate rules are OR'd. At least one rule is required -- leaving this empty raises an error at build time, since no traffic would ever match and the target would never wake. Use hosts = [\"*\"] for an explicit catch-all."
+ }
+ };
+}
+
+
class JacScalePluginConfig {
static def get_plugin_metadata -> dict[str, any] {
return {
@@ -226,7 +358,12 @@ class JacScalePluginConfig {
"default": [],
"description": "(KEDA only) Additional KEDA trigger dicts applied to every service. Each entry: `type` (str), `metadata` (dict[str,str]), optional `name` (str), optional `auth.secret_refs` (dict). For per-service triggers use [[scale.microservices.services.NAME.triggers]] instead."
},
-
+ "http_activation": {
+ "type": "dict",
+ "default": {},
+ "description": "KEDA HTTP Add-on scale-to-zero activation for the monolith app (or shared defaults inherited by microservices unless overridden per-service). Requires the KEDA HTTP Add-on installed separately from core KEDA. For per-service activation use [scale.microservices.services.NAME.http_activation] instead.",
+ "nested": _http_activation_nested_schema()
+ }
}
},
"secrets": {
@@ -299,7 +436,7 @@ class JacScalePluginConfig {
"services": {
"type": "dict",
"default": {},
- "description": "Per-service overrides keyed by module name. Subkeys (all optional): `rpc_timeout` (float, default 10s, inter-service sv-import calls), `http_forward_timeout` (float, default 30s, gateway-to-service forward), `replicas` (int, default 1, K8s Deployment.spec.replicas), `cpu_request` / `cpu_limit` (str, e.g. \"100m\", K8s container resources), `memory_request` / `memory_limit` (str, e.g. \"128Mi\"), `env` (dict[str,str], extra container env vars merged with auto-set JAC_SV_NAME), `image_tag` (str, override global image tag for canary), and nested `hpa` / `pdb` sub-tables. `hpa` keys: `enabled` (bool, default true), `min` (int, default 1), `max` (int, default 3), `cpu_target` (int percent, default 70), `memory_target` (int percent of memory request, default 80), `behavior` (table, default `{}`): a raw HPA `behavior` fragment (`scaleUp`/`scaleDown`, each with `stabilizationWindowSeconds`/`policies`/`selectPolicy`) deep-merged over the generated scale-rate defaults - same merge semantics as `deployment_overlay` (maps merge recursively, the `policies` list has no `name` key so it replaces wholesale rather than merging by entry); applies to both \"hpa\" and \"keda\" engines since both route through the same `Autoscaler._build_behavior` (for keda it only governs scaling above zero replicas - the drop to zero remains controlled by `autoscaler_cooldown`, the ScaledObject's cooldownPeriod). Use it to slow scale-down for connection-heavy services (e.g. `[services.NAME.hpa.behavior.scaleDown] stabilizationWindowSeconds = 600, selectPolicy = 'Min', policies = [{ type = 'Pods', value = 2, periodSeconds = 300 }]`) without imposing the same rate limit on every service via the app-global `autoscaler_cooldown`. `pdb` keys: `enabled` (bool, default true), `max_unavailable` (int, default 1). `deployment_overlay` (table): a raw Deployment-manifest fragment merged into the generated manifest at build time - maps merge recursively, lists whose members all carry `name` (containers, volumes, env, initContainers, volumeMounts) merge by that name, anything else replaces; identity fields (metadata.name/namespace, selector, the app/managed template labels) are reasserted after the merge. The main container's `name` is the k8s-safe service name (e.g. `builder-sv`; gateway is `gateway`). Use it for nodeSelector, probe tuning, extra volumes/env/init containers - anything the schema has no first-class key for - so pods are right on the FIRST rollout instead of being kubectl-patched into a second one. Overlays add and override only: there is no delete directive (setting a key to null stores null rather than removing the generated field). Gateway uses the `__gateway__` key. `[[services.NAME.triggers]]` array (KEDA only): per-service event-driven triggers; each entry has `type` (str), `metadata` (dict[str,str]), optional `name` (str), optional `auth.secret_refs` (dict). Requires `autoscaler_engine = \"keda\"` in [scale.kubernetes]. Example: [scale.microservices.services.llm_app] rpc_timeout = 120.0, replicas = 2, cpu_limit = \"2000m\", memory_limit = \"4Gi\", env = { LOG_LEVEL = \"DEBUG\" }, [scale.microservices.services.llm_app.hpa] max = 20, cpu_target = 60"
+ "description": "Per-service overrides keyed by module name. Subkeys (all optional): `rpc_timeout` (float, default 10s, inter-service sv-import calls), `http_forward_timeout` (float, default 30s, gateway-to-service forward), `replicas` (int, default 1, K8s Deployment.spec.replicas), `cpu_request` / `cpu_limit` (str, e.g. \"100m\", K8s container resources), `memory_request` / `memory_limit` (str, e.g. \"128Mi\"), `env` (dict[str,str], extra container env vars merged with auto-set JAC_SV_NAME), `image_tag` (str, override global image tag for canary), and nested `hpa` / `pdb` sub-tables. `hpa` keys: `enabled` (bool, default true), `min` (int, default 1), `max` (int, default 3), `cpu_target` (int percent, default 70), `memory_target` (int percent of memory request, default 80), `behavior` (table, default `{}`): a raw HPA `behavior` fragment (`scaleUp`/`scaleDown`, each with `stabilizationWindowSeconds`/`policies`/`selectPolicy`) deep-merged over the generated scale-rate defaults - same merge semantics as `deployment_overlay` (maps merge recursively, the `policies` list has no `name` key so it replaces wholesale rather than merging by entry); applies to both \"hpa\" and \"keda\" engines since both route through the same `Autoscaler._build_behavior` (for keda it only governs scaling above zero replicas - the drop to zero remains controlled by `autoscaler_cooldown`, the ScaledObject's cooldownPeriod). Use it to slow scale-down for connection-heavy services (e.g. `[services.NAME.hpa.behavior.scaleDown] stabilizationWindowSeconds = 600, selectPolicy = 'Min', policies = [{ type = 'Pods', value = 2, periodSeconds = 300 }]`) without imposing the same rate limit on every service via the app-global `autoscaler_cooldown`. `pdb` keys: `enabled` (bool, default true), `max_unavailable` (int, default 1). `deployment_overlay` (table): a raw Deployment-manifest fragment merged into the generated manifest at build time - maps merge recursively, lists whose members all carry `name` (containers, volumes, env, initContainers, volumeMounts) merge by that name, anything else replaces; identity fields (metadata.name/namespace, selector, the app/managed template labels) are reasserted after the merge. The main container's `name` is the k8s-safe service name (e.g. `builder-sv`; gateway is `gateway`). Use it for nodeSelector, probe tuning, extra volumes/env/init containers - anything the schema has no first-class key for - so pods are right on the FIRST rollout instead of being kubectl-patched into a second one. Overlays add and override only: there is no delete directive (setting a key to null stores null rather than removing the generated field). Gateway uses the `__gateway__` key. `[[services.NAME.triggers]]` array (KEDA only): per-service event-driven triggers; each entry has `type` (str), `metadata` (dict[str,str]), optional `name` (str), optional `auth.secret_refs` (dict). Requires `autoscaler_engine = \"keda\"` in [scale.kubernetes]. `http_activation` sub-table (KEDA HTTP Add-on only): per-service HTTP scale-to-zero activation, same keys as [scale.kubernetes.http_activation] (`enabled`, `min_replicas`, `max_replicas`, `target_port`/`target_port_name`, `concurrency_target`/`request_rate_target`, `[[rules]]`, cold-start and timeout keys, `scale_target_kind`/`scale_target_api_version`/`scale_target_plural`); the target Service is always the service's own generated Service, never user-set. Falls back to [scale.kubernetes.http_activation]'s values for any key left unset. Example: [scale.microservices.services.llm_app] rpc_timeout = 120.0, replicas = 2, cpu_limit = \"2000m\", memory_limit = \"4Gi\", env = { LOG_LEVEL = \"DEBUG\" }, [scale.microservices.services.llm_app.hpa] max = 20, cpu_target = 60"
},
"rate_limit": {
"type": "dict",
diff --git a/jac/jaclang/scale/deploy/autoscale/http_activation.jac b/jac/jaclang/scale/deploy/autoscale/http_activation.jac
index 2c7f136771c..42725e2c29c 100644
--- a/jac/jaclang/scale/deploy/autoscale/http_activation.jac
+++ b/jac/jaclang/scale/deploy/autoscale/http_activation.jac
@@ -25,7 +25,7 @@ obj HTTPConcurrencyMetric {
obj HTTPRequestRateMetric {
has target_value: int,
- window: str = "1m",
+ rate_window: str = "1m",
granularity: str = "1s";
}
diff --git a/jac/jaclang/scale/deploy/autoscale/http_activation_config.jac b/jac/jaclang/scale/deploy/autoscale/http_activation_config.jac
new file mode 100644
index 00000000000..ecf25471077
--- /dev/null
+++ b/jac/jaclang/scale/deploy/autoscale/http_activation_config.jac
@@ -0,0 +1,188 @@
+import from jaclang.scale.deploy.autoscale.http_activation {
+ HTTPActivationSpec,
+ HTTPServiceTarget,
+ HTTPConcurrencyMetric,
+ HTTPRequestRateMetric,
+ HTTPStaticResponse,
+ HTTPColdStartSpec,
+ HTTPTimeoutSpec,
+ HTTPRoutingRule,
+ HTTPPathMatch,
+ HTTPHeaderMatch
+}
+import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
+import from jaclang.scale._optdeps.kubernetes { ApiException }
+
+def build_http_activation_spec(
+ cfg: dict[str, any], scale_target_name: str, namespace: str, service_name: str
+) -> HTTPActivationSpec | None {
+ if not cfg.get("enabled", False) {
+ return None;
+ }
+
+ target_port = cfg.get("target_port");
+ target_port_name = cfg.get("target_port_name");
+ if target_port is not None and target_port_name is not None {
+ raise ValueError(
+ "[*.http_activation]: set exactly one of target_port or "
+ "target_port_name, not both."
+ );
+ }
+ if target_port is None and target_port_name is None {
+ raise ValueError(
+ "[*.http_activation]: set exactly one of target_port or "
+ "target_port_name."
+ );
+ }
+
+ concurrency_target = cfg.get("concurrency_target");
+ request_rate_target = cfg.get("request_rate_target");
+ if concurrency_target is None and request_rate_target is None {
+ raise ValueError(
+ "[*.http_activation]: set at least one of concurrency_target or "
+ "request_rate_target."
+ );
+ }
+
+ concurrency = None;
+ if concurrency_target is not None {
+ concurrency = HTTPConcurrencyMetric(target_value=concurrency_target);
+ }
+ request_rate = None;
+ if request_rate_target is not None {
+ request_rate = HTTPRequestRateMetric(
+ target_value=request_rate_target,
+ rate_window=cfg.get("request_rate_window", "1m"),
+ granularity=cfg.get("request_rate_granularity", "1s")
+ );
+ }
+
+ raw_rules: list[dict[str, any]] = cfg.get("rules", []);
+ if not raw_rules {
+ raise ValueError(
+ "[*.http_activation]: at least one [[rules]] entry is required. "
+ "Leaving rules empty means no traffic ever matches, so the "
+ "interceptor never forwards anything and the target never wakes; "
+ "use hosts = [\"*\"] for an explicit catch-all."
+ );
+ }
+
+ rules: list[HTTPRoutingRule] = [];
+ for rule in raw_rules {
+ headers: list[HTTPHeaderMatch] = [];
+ for h in rule.get("headers", []) {
+ if "name" not in h {
+ raise ValueError(
+ "[*.http_activation]: a [[rules.headers]] entry is "
+ f"missing its required 'name' key: {h}."
+ );
+ }
+ headers.append(HTTPHeaderMatch(name=h["name"], value=h.get("value")));
+ }
+ rules.append(
+ HTTPRoutingRule(
+ hosts=list(rule.get("hosts", [])),
+ paths=[HTTPPathMatch(value=p) for p in rule.get("paths", [])],
+ headers=headers
+ )
+ );
+ }
+
+ placeholder = None;
+ has_placeholder = (
+ "cold_start_body" in cfg
+ or "cold_start_body_config_map" in cfg
+ or "cold_start_headers" in cfg
+ or "cold_start_status_code" in cfg
+ );
+ if has_placeholder {
+ placeholder = HTTPStaticResponse(
+ status_code=cfg.get("cold_start_status_code", 503),
+ body=cfg.get("cold_start_body"),
+ body_from_config_map=cfg.get("cold_start_body_config_map"),
+ headers=cfg.get("cold_start_headers", {})
+ );
+ }
+ fallback_service = None;
+ if "cold_start_fallback_service" in cfg {
+ fallback_service = HTTPServiceTarget(
+ service=str(cfg.get("cold_start_fallback_service", "")),
+ port=cfg.get("cold_start_fallback_port")
+ );
+ }
+ cold_start = None;
+ if placeholder is not None or fallback_service is not None {
+ cold_start = HTTPColdStartSpec(
+ placeholder=placeholder, fallback_service=fallback_service
+ );
+ }
+
+ timeouts = None;
+ has_timeouts = (
+ "timeout_readiness" in cfg
+ or "timeout_request" in cfg
+ or "timeout_response_header" in cfg
+ );
+ if has_timeouts {
+ timeouts = HTTPTimeoutSpec(
+ readiness=cfg.get("timeout_readiness"),
+ request=cfg.get("timeout_request"),
+ response_header=cfg.get("timeout_response_header")
+ );
+ }
+
+ return HTTPActivationSpec(
+ name=scale_target_name,
+ namespace=namespace,
+ scale_target_name=scale_target_name,
+ target=HTTPServiceTarget(
+ service=service_name, port=target_port, port_name=target_port_name
+ ),
+ scale_target_api_version=cfg.get("scale_target_api_version", "apps/v1"),
+ scale_target_kind=cfg.get("scale_target_kind", "Deployment"),
+ scale_target_plural=cfg.get("scale_target_plural"),
+ rules=rules,
+ min_replicas=cfg.get("min_replicas", 0),
+ max_replicas=cfg.get("max_replicas", 1),
+ polling_interval=cfg.get("polling_interval", 30),
+ cooldown_period=cfg.get("cooldown_period", 300),
+ concurrency=concurrency,
+ request_rate=request_rate,
+ cold_start=cold_start,
+ timeouts=timeouts
+ );
+}
+
+def apply_http_activation_for_target(
+ cfg: dict[str, any],
+ scale_target_name: str,
+ namespace: str,
+ service_name: str,
+ keda: KEDAAutoscaler
+) -> bool | None {
+ spec = build_http_activation_spec(cfg, scale_target_name, namespace, service_name);
+ if spec is None {
+ return None;
+ }
+ return keda.apply_http_activation(spec);
+}
+
+def destroy_http_activation_for_target(
+ scale_target_name: str, namespace: str, keda: KEDAAutoscaler
+) {
+ try {
+ keda.destroy_http_activation(scale_target_name, namespace);
+ } except ApiException as e {
+ if e?.status != 403 {
+ raise;
+ }
+ if keda.logger {
+ keda.logger.warn(
+ "Failed to clean up HTTP activation resources for "
+ f"'{scale_target_name}' in '{namespace}': {e}. Continuing "
+ "with the rest of teardown; manual cleanup of the "
+ "InterceptorRoute/ScaledObject may be required."
+ );
+ }
+ }
+}
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
index 82343d1fa8d..350cfe8abed 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.impl.jac
@@ -409,6 +409,9 @@ impl KEDAAutoscaler.destroy_collection{
("keda.sh", "v1alpha1", "triggerauthentications"),
("http.keda.sh", "v1beta1", "interceptorroutes")
] {
+ tolerated = [404, 422, 403]
+ if (group == "http.keda.sh" or best_effort)
+ else [404, 422];
try {
items = api.list_namespaced_custom_object(
group=group,
@@ -428,13 +431,13 @@ impl KEDAAutoscaler.destroy_collection{
name=item_name
);
} except ApiException as e {
- if e.status not in [404, 422] {
+ if e.status not in tolerated {
raise;
}
}
}
} except ApiException as e {
- if e.status not in [404, 422] {
+ if e.status not in tolerated {
raise;
}
}
@@ -612,6 +615,9 @@ impl KEDAAutoscaler.apply_http_activation{
impl KEDAAutoscaler.destroy_http_activation{
api = self._custom_api or client.CustomObjectsApi();
+
+ interceptor_error: (ApiException | None) = None;
+ scaled_object_error: (ApiException | None) = None;
try {
api.delete_namespaced_custom_object(
group="http.keda.sh",
@@ -622,7 +628,7 @@ impl KEDAAutoscaler.destroy_http_activation{
);
} except ApiException as e {
if e.status not in [404, 422] {
- raise;
+ interceptor_error = e;
}
}
try {
@@ -635,9 +641,20 @@ impl KEDAAutoscaler.destroy_http_activation{
);
} except ApiException as e {
if e.status not in [404, 422] {
- raise;
+ scaled_object_error = e;
}
}
+
+ if interceptor_error is not None and interceptor_error.status != 403 {
+ raise interceptor_error;
+ }
+ if scaled_object_error is not None and scaled_object_error.status != 403 {
+ raise scaled_object_error;
+ }
+ pending_error = interceptor_error or scaled_object_error;
+ if pending_error is not None {
+ raise pending_error;
+ }
}
impl KEDAAutoscaler._build_routing_rules{
@@ -686,7 +703,7 @@ impl KEDAAutoscaler._build_interceptor_route{
} elif spec.request_rate is not None {
scaling_metric["requestRate"] = {
"targetValue": spec.request_rate.target_value,
- "window": spec.request_rate.window,
+ "window": spec.request_rate.rate_window,
"granularity": spec.request_rate.granularity
};
}
@@ -773,7 +790,7 @@ impl KEDAAutoscaler._build_http_scaled_object{
trigger_metadata["targetValue"] = str(spec.concurrency.target_value);
} elif spec.request_rate is not None {
trigger_metadata["targetValue"] = str(spec.request_rate.target_value);
- trigger_metadata["window"] = spec.request_rate.window;
+ trigger_metadata["window"] = spec.request_rate.rate_window;
trigger_metadata["granularity"] = spec.request_rate.granularity;
}
diff --git a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
index cd8b1dee5d5..8c8ec24a1dc 100644
--- a/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
+++ b/jac/jaclang/scale/deploy/autoscale/keda_autoscaler.jac
@@ -66,7 +66,10 @@ obj KEDAAutoscaler(Autoscaler) {
def preflight -> list[str];
def apply(spec: AutoscalerSpec) -> bool;
def destroy(app_name: str, namespace: str) -> None;
- def destroy_collection(namespace: str, label_selector: str) -> None;
+ def destroy_collection(
+ namespace: str, label_selector: str, best_effort: bool = False
+ ) -> None;
+
def resource_name_for(target: str) -> str;
def _validate_http_activation_spec(spec: HTTPActivationSpec) -> None;
def _build_routing_rules(rules: list[HTTPRoutingRule]) -> list[dict[str, any]];
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_config.jac b/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_config.jac
index 0ce03904c80..74dbefe17cc 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_config.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_config.jac
@@ -35,6 +35,7 @@ obj KubernetesConfig(BaseConfig) {
autoscaler_cooldown: int = 300,
autoscaler_initial_cooldown: int = 0,
extra_triggers: list[dict] = [],
+ http_activation: dict[str, any] = {},
health_check_path: str = '/docs',
python_image: str = '',
busybox_image: str = 'busybox:1.36',
@@ -118,6 +119,7 @@ obj KubernetesConfig(BaseConfig) {
'autoscaler_cooldown': self.autoscaler_cooldown,
'autoscaler_initial_cooldown': self.autoscaler_initial_cooldown,
'extra_triggers': self.extra_triggers,
+ 'http_activation': self.http_activation,
'health_check_path': self.health_check_path,
'python_image': self.python_image,
'busybox_image': self.busybox_image,
@@ -207,6 +209,7 @@ obj KubernetesConfig(BaseConfig) {
autoscaler_cooldown=config.get('autoscaler_cooldown', 300),
autoscaler_initial_cooldown=config.get('autoscaler_initial_cooldown', 0),
extra_triggers=config.get('extra_triggers', []),
+ http_activation=config.get('http_activation', {}),
health_check_path=config.get('health_check_path', '/docs'),
python_image=config.get('python_image', ''),
busybox_image=config.get('busybox_image', 'busybox:1.36'),
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac b/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac
index 56c4a172994..8008a1bd7a8 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/kubernetes_target.jac
@@ -6,14 +6,9 @@ import from jaclang.scale.observability.logger { Logger }
import from jaclang.scale.deploy.target.kubernetes.kubernetes_config {
KubernetesConfig
}
-import from jaclang.scale.injector.pvc_injector { bundle_pvc_name }
import from jaclang.scale.deploy.target.kubernetes.utils.kubernetes_utils {
COMPANION_DEFS,
create_k8s_secret,
- delete_k8s_secret,
- delete_if_exists,
- delete_namespace,
- check_K8s_status,
ensure_namespace_exists,
get_cluster_provider,
resize_pvc_if_needed
@@ -29,8 +24,6 @@ import from jaclang.scale._optdeps.kubernetes {
import from jaclang.scale.deploy.database.factory { DatabaseProviderFactory }
import from jaclang.scale.deploy.target.kubernetes.monitoring { MonitoringDeployer }
import from jaclang.scale.deploy.target.kubernetes.ingress { IngressDeployer }
-import from jaclang.scale.deploy.autoscale.factory { AutoscalerFactory }
-import time;
obj KubernetesTargetBase(DeploymentTarget) {
has config: KubernetesConfig,
@@ -462,469 +455,6 @@ obj KubernetesTargetBase(DeploymentTarget) {
}
}
- def _wait_for_deletion(
- app_name: str,
- namespace: str,
- apps_v1: any,
- core_v1: any,
- max_wait: int = 60,
- poll_interval: float = 1.0
- ) -> None {
- elapsed = 0.0;
- while elapsed < max_wait {
- resources_exist = False;
-
- try {
- apps_v1.read_namespaced_deployment(name=app_name, namespace=namespace);
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
-
- try {
- core_v1.read_namespaced_service(
- name=f"{app_name}-service", namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
-
- if self.k8s_config.mongodb_enabled {
- mongodb_name = f"{app_name}-mongodb";
- try {
- apps_v1.read_namespaced_stateful_set(
- name=mongodb_name, namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- try {
- core_v1.read_namespaced_service(
- name=f"{mongodb_name}-service", namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- }
-
- if self.k8s_config.redis_enabled {
- redis_name = f"{app_name}-redis";
- try {
- apps_v1.read_namespaced_deployment(
- name=redis_name, namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- try {
- core_v1.read_namespaced_service(
- name=f"{redis_name}-service", namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- }
-
- if self.k8s_config.monitoring_enabled {
- prometheus_name = f"{app_name}-prometheus";
- try {
- apps_v1.read_namespaced_deployment(
- name=prometheus_name, namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- try {
- core_v1.read_namespaced_service(
- name=f"{prometheus_name}-service", namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- grafana_name = f"{app_name}-grafana";
- try {
- apps_v1.read_namespaced_deployment(
- name=grafana_name, namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- try {
- core_v1.read_namespaced_service(
- name=f"{grafana_name}-service", namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
- }
-
- try {
- pvcs = core_v1.list_namespaced_persistent_volume_claim(namespace);
- for pvc in pvcs.items {
- if pvc.metadata.name.startswith(app_name) {
- resources_exist = True;
- break;
- }
- }
- } except Exception as e {
- if self.logger {
- self.logger.warning(
- f"Failed to list PVCs while checking resources for '{app_name}': {e}"
- );
- }
- }
-
- try {
- core_v1.read_namespaced_pod(
- name=f"{app_name}-code-sync", namespace=namespace
- );
- resources_exist = True;
- } except ApiException as e {
- if e.status != 404 {
- raise;
- }
- }
-
- if not resources_exist {
- if self.logger {
- self.logger.info(
- f"All resources for '{app_name}' have been deleted"
- );
- }
- return;
- }
- time.sleep(poll_interval);
- elapsed = elapsed + poll_interval;
- }
-
- if self.logger {
- self.logger.warn(
- f"Timeout waiting for resources to be deleted after {max_wait} seconds"
- );
- }
- }
-
- def _destroy_databases(app_name: str, namespace: str, apps_v1: any, core_v1: any) {
- self._destroy_database(app_name, namespace, apps_v1, core_v1);
- self._destroy_cache(app_name, namespace, apps_v1, core_v1);
- self._destroy_dashboard(app_name, namespace, apps_v1, core_v1);
- }
-
- def _destroy_application(
- app_name: str, namespace: str, apps_v1: any, core_v1: any
- ) {
- autoscaler = AutoscalerFactory.create(
- self.k8s_config.autoscaler_engine, self.k8s_config.to_dict(), self.logger
- );
- autoscaler.destroy(app_name, namespace);
- delete_if_exists(
- apps_v1.delete_namespaced_deployment, app_name, namespace, 'Deployment'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service,
- f"{app_name}-service",
- namespace,
- 'Service'
- );
- delete_k8s_secret(core_v1, namespace, f"{app_name}-secrets");
-
- delete_if_exists(
- core_v1.delete_namespaced_pod, f"{app_name}-bundle-loader", namespace, 'Pod'
- );
- delete_if_exists(
- core_v1.delete_namespaced_persistent_volume_claim,
- bundle_pvc_name(app_name),
- namespace,
- 'PersistentVolumeClaim'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service_account,
- f"{app_name}-sa",
- namespace,
- 'ServiceAccount'
- );
- networking_v1 = client.NetworkingV1Api();
-
- network_policy_names = [
- f"{app_name}-network-policy",
- f"{app_name}-mongodb-network-policy",
- f"{app_name}-redis-network-policy",
- f"{app_name}-prometheus-network-policy",
- f"{app_name}-grafana-network-policy",
- f"{app_name}-kube-state-metrics-network-policy",
- f"{app_name}-node-exporter-network-policy",
- f"{app_name}-mongo-express-network-policy",
- f"{app_name}-redis-insight-network-policy"
- ];
- for policy_name in network_policy_names {
- delete_if_exists(
- networking_v1.delete_namespaced_network_policy,
- policy_name,
- namespace,
- 'NetworkPolicy'
- );
- }
- }
-
- def _destroy_database(app_name: str, namespace: str, apps_v1: any, core_v1: any) {
- mongodb_name = f"{app_name}-mongodb";
- delete_if_exists(
- apps_v1.delete_namespaced_stateful_set,
- mongodb_name,
- namespace,
- 'StatefulSet'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service,
- f"{mongodb_name}-service",
- namespace,
- 'Service'
- );
- delete_k8s_secret(core_v1, namespace, f"{app_name}-mongodb-secret");
- delete_if_exists(
- core_v1.delete_namespaced_service_account,
- f"{app_name}-mongodb-sa",
- namespace,
- 'ServiceAccount'
- );
- }
-
- def _destroy_cache(app_name: str, namespace: str, apps_v1: any, core_v1: any) {
- redis_name = f"{app_name}-redis";
- delete_if_exists(
- apps_v1.delete_namespaced_deployment, redis_name, namespace, 'Deployment'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service,
- f"{redis_name}-service",
- namespace,
- 'Service'
- );
- delete_if_exists(
- core_v1.delete_namespaced_config_map,
- f"{redis_name}-config",
- namespace,
- 'ConfigMap'
- );
- delete_k8s_secret(core_v1, namespace, f"{app_name}-redis-secret");
- delete_if_exists(
- core_v1.delete_namespaced_service_account,
- f"{app_name}-redis-sa",
- namespace,
- 'ServiceAccount'
- );
- }
-
- def _destroy_dashboard(app_name: str, namespace: str, apps_v1: any, core_v1: any) {
- express_name = f"{app_name}-mongo-express";
- delete_if_exists(
- apps_v1.delete_namespaced_deployment, express_name, namespace, 'Deployment'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service,
- f"{express_name}-service",
- namespace,
- 'Service'
- );
- insight_name = f"{app_name}-redis-insight";
- delete_if_exists(
- apps_v1.delete_namespaced_deployment, insight_name, namespace, 'Deployment'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service,
- f"{insight_name}-service",
- namespace,
- 'Service'
- );
- delete_if_exists(
- core_v1.delete_namespaced_config_map,
- f"{insight_name}-nginx-config",
- namespace,
- 'ConfigMap'
- );
- delete_if_exists(
- core_v1.delete_namespaced_secret,
- f"{insight_name}-auth",
- namespace,
- 'Secret'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service_account,
- f"{app_name}-mongo-express-sa",
- namespace,
- 'ServiceAccount'
- );
- delete_if_exists(
- core_v1.delete_namespaced_service_account,
- f"{app_name}-redis-insight-sa",
- namespace,
- 'ServiceAccount'
- );
- }
-
- def _destroy_component(
- app_name: str, namespace: str, component: str, apps_v1: any, core_v1: any
- ) {
- if component == 'application' {
- self._destroy_application(app_name, namespace, apps_v1, core_v1);
- } elif component == 'database' {
- self._destroy_database(app_name, namespace, apps_v1, core_v1);
- } elif component == 'cache' {
- self._destroy_cache(app_name, namespace, apps_v1, core_v1);
- } elif component == 'monitoring' {
- monitoring = MonitoringDeployer(self.k8s_config, self.logger);
- monitoring.destroy(app_name, namespace, apps_v1, core_v1);
- } elif component == 'dashboard' {
- self._destroy_dashboard(app_name, namespace, apps_v1, core_v1);
- } else {
- raise ValueError(
- f"Unknown component: '{component}'. Valid choices: application, database, cache, monitoring, dashboard"
- );
- }
- if self.logger {
- self.logger.info(f"Component '{component}' of '{app_name}' destroyed");
- }
- }
-
- def destroy(app_name: str, component: str = "") -> None {
- if self.logger {
- if component {
- self.logger.info(
- f"Destroying component '{component}' of '{app_name}' from Kubernetes"
- );
- } else {
- self.logger.info(
- f"Destroying application '{app_name}' from Kubernetes"
- );
- }
- }
-
- try {
- self._load_cluster_config();
- apps_v1 = client.AppsV1Api();
- core_v1 = client.CoreV1Api();
- namespace = self.k8s_config.namespace;
-
- if component {
- self._destroy_component(
- app_name, namespace, component, apps_v1, core_v1
- );
- return;
- }
-
- self._destroy_application(app_name, namespace, apps_v1, core_v1);
-
- rbac_v1 = client.RbacAuthorizationV1Api();
- networking_v1 = client.NetworkingV1Api();
- ingress_deployer = IngressDeployer(self.k8s_config, self.logger);
- ingress_deployer.destroy(
- app_name, namespace, apps_v1, core_v1, rbac_v1, networking_v1
- );
-
- monitoring = MonitoringDeployer(self.k8s_config, self.logger);
- monitoring.destroy(app_name, namespace, apps_v1, core_v1);
-
- self._destroy_databases(app_name, namespace, apps_v1, core_v1);
-
- pvcs = core_v1.list_namespaced_persistent_volume_claim(namespace);
- for pvc in pvcs.items {
- if pvc.metadata.name.startswith(app_name) {
- try {
- core_v1.delete_namespaced_persistent_volume_claim(
- name=pvc.metadata.name, namespace=namespace
- );
- } except Exception as e {
- if self.logger {
- self.logger.warning(
- f"Failed to delete PVC '{pvc.metadata.name}': {e}"
- );
- }
- }
- }
- }
-
- if self.logger {
- self.logger.info(
- f"Waiting for all resources to be deleted for '{app_name}'..."
- );
- }
- self._wait_for_deletion(app_name, namespace, apps_v1, core_v1);
-
- if namespace != 'default' {
- delete_namespace(namespace);
- if self.logger {
- self.logger.info(
- f"Waiting for namespace '{namespace}' to be deleted..."
- );
- }
- ns_deleted = False;
- ns_elapsed = 0.0;
- ns_max_wait = 120;
- ns_poll = 2.0;
- while ns_elapsed < ns_max_wait {
- try {
- core_v1.read_namespace(name=namespace);
- time.sleep(ns_poll);
- ns_elapsed = ns_elapsed + ns_poll;
- } except ApiException as e {
- if e.status == 404 {
- ns_deleted = True;
- break;
- }
- raise;
- }
- }
- if self.logger {
- if ns_deleted {
- self.logger.info(f"Namespace '{namespace}' deleted");
- } else {
- self.logger.warning(
- f"Timed out waiting for namespace '{namespace}' to be deleted"
- );
- }
- }
- }
-
- if self.logger {
- self.logger.info(f"Application '{app_name}' destroyed successfully");
- }
- } except Exception as e {
- if self.logger {
- self.logger.error(f"Error destroying application '{app_name}': {e}");
- }
- raise;
- }
- }
-
def get_status(app_name: str) -> ResourceStatusInfo {
try {
self._load_cluster_config();
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac b/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac
index d9bad72d15a..4384eee4574 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/manifest_builder.jac
@@ -11,6 +11,10 @@ import from jaclang.scale.deploy.autoscale.autoscaler {
DEFAULT_CPU_UTILIZATION_TARGET,
DEFAULT_MEMORY_UTILIZATION_TARGET
}
+import from jaclang.scale.deploy.autoscale.http_activation_config {
+ build_http_activation_spec
+}
+import from enum { StrEnum }
glob GATEWAY_NAME: str = "__gateway__",
@@ -53,6 +57,12 @@ def _is_transient_client_build_failure(output: str) -> bool {
}
+enum ScalingMode(StrEnum) {
+ NONE = "none",
+ METRIC = "metric",
+ HTTP_ACTIVATION = "http_activation"
+}
+
def k8s_safe_name(svc_name: str) -> str {
if svc_name == GATEWAY_NAME {
return "gateway";
@@ -336,26 +346,26 @@ obj ManifestBuilder {
pod_specs = self.generate_service_pod_specs(app_config, image);
deployments: dict[str, dict[str, any]] = {};
services: dict[str, dict[str, any]] = {};
- autoscalers: dict[str, dict[str, any]] = {};
+ scaling: dict[str, dict[str, any]] = {};
pdbs: dict[str, dict[str, any]] = {};
for (svc_name, pod_spec) in pod_specs.items() {
deployments[svc_name] = self._build_deployment_manifest(svc_name, pod_spec);
services[svc_name] = self._build_service_manifest(svc_name);
- autoscaler_cfg = self._get_autoscaler_config(svc_name);
- if autoscaler_cfg is not None {
- autoscalers[svc_name] = autoscaler_cfg;
- }
- pdb = self._build_pdb_manifest(svc_name);
+ scaling[svc_name] = self._resolve_scaling(
+ svc_name, deployments[svc_name], services[svc_name]
+ );
+ pdb = self._build_pdb_manifest(svc_name, scaling[svc_name].get("mode"));
if pdb is not None {
pdbs[svc_name] = pdb;
}
}
+ self._validate_http_activation_rules(scaling);
bundle: dict[str, any] = {
"deployments": deployments,
"services": services,
- "autoscalers": autoscalers,
+ "scaling": scaling,
"pdbs": pdbs,
"pod_specs": pod_specs
};
@@ -935,6 +945,127 @@ obj ManifestBuilder {
};
}
+ def _resolve_scaling(
+ svc_name: str, deployment: dict[str, any], service: dict[str, any]
+ ) -> dict[str, any] {
+ """Resolve a service's single, mutually-exclusive scaling decision.
+
+ The one place a service's scaling mode is decided. The scale-target
+ name is read from the Deployment manifest just built (never rebuilt
+ from svc_name), so build, apply, and `jac plan` can never disagree on
+ it. The gateway is forced out of HTTP_ACTIVATION here: it is the
+ ingress entry point and must stay warm, so it must never inherit the
+ shared scale-to-zero default.
+ """;
+ scale_target: str = deployment["metadata"]["name"];
+ service_name: str = service["metadata"]["name"];
+ http: dict[str, any] | None = self._get_http_activation_config(svc_name);
+ if http is not None and svc_name == GATEWAY_NAME {
+ if self.logger {
+ self.logger.warn(
+ "Ignoring http_activation for the gateway: it is the ingress "
+ "entry point and must stay warm. Set http_activation "
+ "per-service instead of relying on the shared default."
+ );
+ }
+ http = None;
+ }
+ if http is not None {
+ try {
+ build_http_activation_spec(
+ http, scale_target, self.k8s_config.namespace, service_name
+ );
+ } except ValueError as e {
+ raise ValueError(f"[service '{svc_name}'] {e}") from e;
+ }
+ return {
+ "mode": ScalingMode.HTTP_ACTIVATION,
+ "scale_target_name": scale_target,
+ "service_name": service_name,
+ "autoscaler": {},
+ "http_activation": http
+ };
+ }
+ metric: dict[str, any] | None = self._get_autoscaler_config(svc_name);
+ if metric is not None {
+ return {
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": scale_target,
+ "service_name": service_name,
+ "autoscaler": metric,
+ "http_activation": {}
+ };
+ }
+ return {
+ "mode": ScalingMode.NONE,
+ "scale_target_name": scale_target,
+ "service_name": service_name,
+ "autoscaler": {},
+ "http_activation": {}
+ };
+ }
+
+ def _canonical_rules_fingerprint(rules: list[dict[str, any]]) -> str {
+ """A rules fingerprint that ignores list ordering, so two services
+ with the same match criteria collide in _validate_http_activation_rules
+ even when `hosts`/`paths`/`headers`, or the rules themselves, are
+ written in a different order -- fields within a rule are AND'd and
+ separate rules are OR'd, so reordering never changes what a rule set
+ actually matches.
+ """;
+ import json;
+ canonical_rules = [];
+ for rule in rules {
+ headers = [
+ (str(h.get("name", "")), str(h.get("value", "")))
+ for h in rule.get("headers", [])
+ ];
+ canonical_rules.append(
+ {
+ "hosts": sorted(str(h) for h in rule.get("hosts", [])),
+ "paths": sorted(str(p) for p in rule.get("paths", [])),
+ "headers": sorted(headers)
+ }
+ );
+ }
+ canonical_rules.sort(key=lambda (r) { json.dumps(r, sort_keys=True); });
+ return json.dumps(canonical_rules, sort_keys=True);
+ }
+
+ def _validate_http_activation_rules(scaling: dict[str, dict[str, any]]) {
+ """Two services sharing the top-level http_activation default with no
+ per-service override resolve to identical routing rules; the interceptor
+ then has no way to tell which target a matching request belongs to.
+ """;
+ by_rules: dict[str, list[str]] = {};
+ for (svc_name, plan) in scaling.items() {
+ if plan.get("mode") != ScalingMode.HTTP_ACTIVATION {
+ continue;
+ }
+ rules = plan.get("http_activation", {}).get("rules", []);
+ if not rules {
+ continue;
+ }
+ fingerprint = self._canonical_rules_fingerprint(rules);
+ by_rules.setdefault(fingerprint, []).append(svc_name);
+ }
+ for svc_names in by_rules.values() {
+ if len(svc_names) > 1 {
+ names = ", ".join(sorted(svc_names));
+ raise ValueError(
+ f"[http_activation] {names} resolve to identical routing "
+ f"rules, most likely all inheriting "
+ f"[scale.kubernetes.http_activation]'s rules unchanged. The "
+ f"KEDA HTTP Add-on interceptor can't tell which target a "
+ f"matching request belongs to when two InterceptorRoutes "
+ f"share the same match criteria. Set a distinct "
+ f"[services.NAME.http_activation.rules] for at least all "
+ f"but one of these services."
+ );
+ }
+ }
+ }
+
def _get_autoscaler_config(svc_name: str) -> dict[str, any] | None {
svc: dict[str, any] = self._service_config(svc_name);
@@ -970,6 +1101,17 @@ obj ManifestBuilder {
};
}
+ def _get_http_activation_config(svc_name: str) -> dict[str, any] | None {
+ svc: dict[str, any] = self._service_config(svc_name);
+ per_service: dict[str, any] = svc.get("http_activation", {});
+ merged: dict[str, any] = dict(self.k8s_config.http_activation);
+ merged.update(per_service);
+ if not merged.get("enabled", False) {
+ return None;
+ }
+ return merged;
+ }
+
def _build_ingress_manifest(ingress_cfg: dict[str, any]) -> dict[str, any] | None {
cfg = self.k8s_config;
enabled: bool = bool(ingress_cfg.get("enabled", False));
@@ -1050,11 +1192,25 @@ obj ManifestBuilder {
};
}
- def _build_pdb_manifest(svc_name: str) -> dict[str, any] | None {
+ def _build_pdb_manifest(
+ svc_name: str, mode: (ScalingMode | None) = None
+ ) -> dict[str, any] | None {
cfg: dict[str, any] = self._service_config(svc_name).get("pdb", {});
if not cfg.get("enabled", True) {
return None;
}
+ if mode == ScalingMode.HTTP_ACTIVATION {
+ if self.logger and cfg {
+ self.logger.warn(
+ f"No PodDisruptionBudget emitted for '{svc_name}': it is "
+ f"HTTP-activated and legitimately scales to zero replicas, "
+ f"so a floor-based budget (pdb.max_unavailable / "
+ f"min_available) doesn't apply. Disable it explicitly with "
+ f"pdb.enabled = false to silence this warning."
+ );
+ }
+ return None;
+ }
k8s_name = k8s_safe_name(svc_name);
max_u: int = int(cfg.get("max_unavailable", 1));
return {
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/target.jac b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
index 0375ea1a660..7348ba8f967 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
@@ -22,7 +22,8 @@ import from jaclang.scale.deploy.target.kubernetes.manifest_builder {
CONFIG_REVISION_ANNOTATION,
ManifestBuilder,
GATEWAY_NAME,
- k8s_safe_name
+ k8s_safe_name,
+ ScalingMode
}
import from jaclang.scale.deploy.target.kubernetes.database_provisioner {
DatabaseProvisioner
@@ -35,6 +36,11 @@ import from jaclang.scale.deploy.autoscale.autoscaler {
DEFAULT_CPU_UTILIZATION_TARGET,
DEFAULT_MEMORY_UTILIZATION_TARGET
}
+import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
+import from jaclang.scale.deploy.autoscale.http_activation_config {
+ apply_http_activation_for_target,
+ destroy_http_activation_for_target
+}
import from jaclang.scale.deploy.target.kubernetes.monitoring { MonitoringDeployer }
import from jaclang.scale.config.config_loader { get_scale_config }
import from jaclang.scale.injector.pvc_injector { bundle_keys_from_pod_spec }
@@ -165,6 +171,43 @@ def restorable_pod_template(template: dict[str, any]) -> dict[str, any] {
return cleaned;
}
+def apply_http_activations_for_bundle(
+ bundle: dict[str, any], namespace: str, keda: KEDAAutoscaler
+) {
+ scaling: dict[str, any] = bundle.get("scaling", {});
+ for svc_name in scaling.keys() {
+ plan: dict[str, any] = dict(scaling.get(svc_name, {}));
+ if plan.get("mode") != ScalingMode.HTTP_ACTIVATION {
+ continue;
+ }
+ apply_http_activation_for_target(
+ dict(plan.get("http_activation", {})),
+ str(plan.get("scale_target_name", "")),
+ namespace,
+ str(plan.get("service_name", "")),
+ keda
+ );
+ }
+}
+
+
+def reap_stale_scaling_resources_for_bundle(
+ bundle: dict[str, any], namespace: str, autoscaler: any, keda: KEDAAutoscaler
+) {
+ scaling: dict[str, any] = bundle.get("scaling", {});
+ for svc_name in scaling.keys() {
+ plan: dict[str, any] = dict(scaling.get(svc_name, {}));
+ mode = plan.get("mode");
+ scale_target_name = str(plan.get("scale_target_name", ""));
+ if mode != ScalingMode.METRIC {
+ autoscaler.destroy(k8s_safe_name(svc_name), namespace);
+ }
+ if mode != ScalingMode.HTTP_ACTIVATION {
+ destroy_http_activation_for_target(scale_target_name, namespace, keda);
+ }
+ }
+}
+
obj KubernetesTarget(KubernetesTargetBase) {
has _deploy_context: DeployContext = DeployContext(),
@@ -498,10 +541,17 @@ obj KubernetesTarget(KubernetesTargetBase) {
import sys;
import from kubernetes { client }
import from jaclang.cli.console { console }
+
+ scaling: dict[str, any] = bundle.get("scaling", {});
+ http_activation_svc_names: set = {
+ str(svc_name)
+ for svc_name in scaling.keys()
+ if dict(scaling.get(svc_name, {})).get("mode")
+ == ScalingMode.HTTP_ACTIVATION
+ };
names: list[str] = [];
app_labels: list[str] = [];
- for dep in bundle.get("deployments", {}).values() {
- names.append(dep["metadata"]["name"]);
+ for (svc_name, dep) in bundle.get("deployments", {}).items() {
app: str = "";
try {
app = str(dep["spec"]["selector"]["matchLabels"]["app"]);
@@ -514,8 +564,29 @@ obj KubernetesTarget(KubernetesTargetBase) {
if app {
app_labels.append(app);
}
+ if svc_name not in http_activation_svc_names {
+ names.append(str(dep["metadata"]["name"]));
+ }
}
if not names {
+ self._load_cluster_config();
+ core_v1 = client.CoreV1Api();
+ ns = self.k8s_config.namespace;
+ crash = self._scan_for_crash_loop(core_v1, ns, set(), set(app_labels))[
+ "crash"
+ ];
+ if crash {
+ raise Exception(
+ "Deployment failed: application pods are restarting "
+ "(crash loop / bad image); not marking the fleet ready."
+ );
+ }
+ if self.logger {
+ self.logger.info(
+ "Fleet ready: all services use HTTP activation "
+ "(scale to zero until first request); pods healthy."
+ );
+ }
return;
}
self._load_cluster_config();
@@ -986,11 +1057,18 @@ obj KubernetesTarget(KubernetesTargetBase) {
}
def _autoscaler_spec(
- svc_name: str, cfg: dict[str, any], autoscaler: any, namespace: str
+ svc_name: str, plan: dict[str, any], autoscaler: any, namespace: str
) -> AutoscalerSpec {
+ """Build the AutoscalerSpec for a METRIC-mode service from its already-
+ resolved scaling plan, so the scale target here is the same Deployment
+ name apply_manifests actually applied -- never re-derived from svc_name,
+ which is exactly the drift that caused apply_http_activation to 404 on
+ a real cluster before _resolve_scaling existed.
+ """;
k8s_name = k8s_safe_name(svc_name);
+ cfg: dict[str, any] = dict(plan.get("autoscaler", {}));
return AutoscalerSpec(
- scale_target_name=f"{k8s_name}-deployment",
+ scale_target_name=str(plan.get("scale_target_name", "")),
autoscaler_name=autoscaler.resource_name_for(k8s_name),
app_name=k8s_name,
namespace=namespace,
@@ -1009,15 +1087,19 @@ obj KubernetesTarget(KubernetesTargetBase) {
def render_autoscaler_manifests(bundle: dict[str, any]) -> list[dict[str, any]] {
out: list[dict[str, any]] = [];
- for (svc_name, cfg_raw) in bundle.get("autoscalers", {}).items() {
- cfg = dict(cfg_raw or {});
+ scaling: dict[str, any] = bundle.get("scaling", {});
+ for svc_name in scaling.keys() {
+ plan: dict[str, any] = dict(scaling.get(svc_name, {}));
+ if plan.get("mode") != ScalingMode.METRIC {
+ continue;
+ }
autoscaler = AutoscalerFactory.create(
self.k8s_config.autoscaler_engine, self.k8s_config.to_dict(), None
);
out.extend(
autoscaler._build_manifests(
self._autoscaler_spec(
- svc_name, cfg, autoscaler, self.k8s_config.namespace
+ svc_name, plan, autoscaler, self.k8s_config.namespace
)
)
);
@@ -1119,15 +1201,29 @@ obj KubernetesTarget(KubernetesTargetBase) {
}
}
- for (svc_name, raw_manifest) in bundle["deployments"].items() {
+ scaling: dict[str, any] = bundle.get("scaling", {});
+
+ deployments: dict[str, dict[str, any]] = bundle["deployments"];
+ for (svc_name, raw_manifest) in deployments.items() {
dep_manifest = with_config_revision(raw_manifest, config_revision);
+ patch_body = None;
+ if dict(scaling.get(svc_name, {})).get("mode")
+ == ScalingMode.HTTP_ACTIVATION {
+ patch_body = dict(dep_manifest);
+ patch_body["spec"] = {
+ k: v
+ for (k, v) in dep_manifest["spec"].items()
+ if k != "replicas"
+ };
+ }
self._apply_or_replace(
apps_v1,
"read_namespaced_deployment",
"patch_namespaced_deployment",
"create_namespaced_deployment",
namespace,
- dep_manifest
+ dep_manifest,
+ patch_body
);
if self.logger {
self.logger.info(
@@ -1139,14 +1235,28 @@ obj KubernetesTarget(KubernetesTargetBase) {
self.reap_superseded_deployments(apps_v1, namespace, bundle);
- for svc_name in bundle.get("autoscalers", {}).keys() {
- cfg = dict(bundle.get("autoscalers", {}).get(svc_name, {}));
+ reap_stale_scaling_resources_for_bundle(
+ bundle,
+ namespace,
+ AutoscalerFactory.create(
+ self.k8s_config.autoscaler_engine,
+ self.k8s_config.to_dict(),
+ self.logger
+ ),
+ KEDAAutoscaler(logger=self.logger)
+ );
+
+ for svc_name in scaling.keys() {
+ plan: dict[str, any] = dict(scaling.get(svc_name, {}));
+ if plan.get("mode") != ScalingMode.METRIC {
+ continue;
+ }
autoscaler = AutoscalerFactory.create(
self.k8s_config.autoscaler_engine,
self.k8s_config.to_dict(),
self.logger
);
- spec = self._autoscaler_spec(svc_name, cfg, autoscaler, namespace);
+ spec = self._autoscaler_spec(svc_name, plan, autoscaler, namespace);
autoscaler_applied = autoscaler.apply(spec);
if self.logger and autoscaler_applied {
@@ -1157,7 +1267,12 @@ obj KubernetesTarget(KubernetesTargetBase) {
}
}
- for (svc_name, pdb_manifest) in bundle.get("pdbs", {}).items() {
+ apply_http_activations_for_bundle(
+ bundle, namespace, KEDAAutoscaler(logger=self.logger)
+ );
+
+ pdbs: dict[str, dict[str, any]] = bundle.get("pdbs", {}) or {};
+ for (svc_name, pdb_manifest) in pdbs.items() {
self._apply_or_replace(
policy_v1,
"read_namespaced_pod_disruption_budget",
@@ -1592,12 +1707,24 @@ obj KubernetesTarget(KubernetesTargetBase) {
update: str,
create: str,
namespace: str,
- manifest: dict[str, any]
+ manifest: dict[str, any],
+ patch_body: (dict[str, any] | None) = None
) {
+ """patch_body, when given, is sent to `update` instead of `manifest` --
+ e.g. a copy with `spec.replicas` stripped so a strategic-merge PATCH
+ leaves an externally-scaled field (KEDA's ScaledObject) alone, while a
+ fresh `create` still gets the full manifest including that field.
+ HTTP activation relies on exactly this: KEDA owns `replicas` once a
+ Deployment scales via HTTP_ACTIVATION, and the strategic-merge PATCH
+ this omission produces is what keeps this apply from resetting the
+ live value KEDA set.
+ """;
name: str = manifest["metadata"]["name"];
try {
getattr(api, read)(name=name, namespace=namespace);
- getattr(api, update)(name=name, namespace=namespace, body=manifest);
+ getattr(api, update)(
+ name=name, namespace=namespace, body=patch_body or manifest
+ );
} except ApiException as e {
if e.status == 404 {
getattr(api, create)(namespace=namespace, body=manifest);
@@ -1674,6 +1801,18 @@ obj KubernetesTarget(KubernetesTargetBase) {
}
}
+ if self.k8s_config.autoscaler_engine != "keda" {
+ try {
+ KEDAAutoscaler(logger=self.logger).destroy_collection(
+ namespace, ms_selector, best_effort=True
+ );
+ } except ApiException as e {
+ if e.status != 404 {
+ raise;
+ }
+ }
+ }
+
try {
policy_v1.delete_collection_namespaced_pod_disruption_budget(
namespace=namespace, label_selector=ms_selector
diff --git a/jac/jaclang/scale/runtime/cli/diagnostics.jac b/jac/jaclang/scale/runtime/cli/diagnostics.jac
index d63fca81de8..6502a93c151 100644
--- a/jac/jaclang/scale/runtime/cli/diagnostics.jac
+++ b/jac/jaclang/scale/runtime/cli/diagnostics.jac
@@ -1,5 +1,8 @@
import from typing { Any }
-import from jaclang.scale.deploy.target.kubernetes.manifest_builder { GATEWAY_NAME }
+import from jaclang.scale.deploy.target.kubernetes.manifest_builder {
+ GATEWAY_NAME,
+ ScalingMode
+}
import from jaclang.scale.runtime.cli._narrow {
bget,
dget,
@@ -81,6 +84,7 @@ obj PlanValidator {
out: list[Diagnostic] = [];
routes = dget(self.ms_cfg, "routes");
deployments = dget(self.bundle, "deployments");
+ scaling = dget(self.bundle, "scaling");
self._check_routes(routes, out);
@@ -106,6 +110,12 @@ obj PlanValidator {
);
continue;
}
+ mode = dget(scaling, svc_name).get("mode");
+ if mode == ScalingMode.HTTP_ACTIVATION {
+ self._check_resources(svc_name, deployment, out);
+ self._check_image(svc_name, deployment, out);
+ continue;
+ }
self._check_hpa(svc_name, svc_cfg, deployment, out);
self._check_pdb(svc_name, svc_cfg, deployment, out);
self._check_resources(svc_name, deployment, out);
diff --git a/jac/jaclang/scale/runtime/cli/plan.jac b/jac/jaclang/scale/runtime/cli/plan.jac
index 0763948f689..cd7c9069d5e 100644
--- a/jac/jaclang/scale/runtime/cli/plan.jac
+++ b/jac/jaclang/scale/runtime/cli/plan.jac
@@ -2,7 +2,10 @@ import yaml;
import from typing { Any }
import from jaclang.cli.console { console }
import from jaclang.scale.config.app_config { AppConfig }
-import from jaclang.scale.deploy.target.kubernetes.manifest_builder { GATEWAY_NAME }
+import from jaclang.scale.deploy.target.kubernetes.manifest_builder {
+ GATEWAY_NAME,
+ ScalingMode
+}
import from jaclang.scale.runtime.cli.diagnostics {
Diagnostic,
PlanValidator,
@@ -59,6 +62,31 @@ obj HPAView {
}
+obj HTTPActivationView {
+ has min_replicas: int,
+ max_replicas: int,
+ metric: str;
+
+ static def from_manifest(http: Any) -> HTTPActivationView | None {
+ if not isinstance(http, dict) or not http {
+ return None;
+ }
+ if http.get("concurrency_target") is not None {
+ metric = f"concurrency={http.get('concurrency_target')}";
+ } elif http.get("request_rate_target") is not None {
+ metric = f"request_rate={http.get('request_rate_target')}";
+ } else {
+ metric = "(metric unset)";
+ }
+ return HTTPActivationView(
+ min_replicas=iget(http, "min_replicas", 0),
+ max_replicas=iget(http, "max_replicas", 1),
+ metric=metric
+ );
+ }
+}
+
+
obj PDBView {
has max_unavailable: int | None;
@@ -92,11 +120,17 @@ obj ServiceView {
env_keys: list[str] = [],
secret_ref_count: int = 0,
hpa: HPAView | None = None,
+ http_activation: HTTPActivationView | None = None,
pdb: PDBView | None = None,
mounts: list[tuple[str, str]] = [];
static def from_manifests(
- name: str, deployment: Any, hpa: Any, pdb: Any, route_path: str | None
+ name: str,
+ deployment: any,
+ hpa: any,
+ http_activation: any,
+ pdb: any,
+ route_path: str | None
) -> ServiceView {
containers = lget(pod_spec(deployment), "containers");
container: dict = {};
@@ -158,6 +192,7 @@ obj ServiceView {
env_keys=env_keys,
secret_ref_count=secret_refs,
hpa=HPAView.from_manifest(hpa),
+ http_activation=HTTPActivationView.from_manifest(http_activation),
pdb=PDBView.from_manifest(pdb),
mounts=mounts
);
@@ -174,6 +209,13 @@ obj ServiceView {
f"{self.replicas} "
f"(HPA: {h.min_replicas} -> {h.max_replicas} {target_str})"
);
+ } elif self.http_activation is not None {
+ a = self.http_activation;
+ replicas_line = (
+ f"{self.replicas} "
+ f"(HTTP activation: {a.min_replicas} -> {a.max_replicas} "
+ f"on {a.metric}, scale-to-zero via KEDA HTTP Add-on)"
+ );
} else {
replicas_line = str(self.replicas);
}
@@ -308,19 +350,33 @@ def _render_totals(bundle: Any) {
n = len(dget(bundle, key));
return f"{n} {label}{'s' if n != 1 else ''}" if n else None;
}
+ def _n_mode(mode: ScalingMode, label: str) -> str | None {
+ n = len(
+ [
+ p
+ for p in dget(bundle, "scaling").values()
+ if isinstance(p, dict) and p.get("mode") == mode
+ ]
+ );
+ return f"{n} {label}{'s' if n != 1 else ''}" if n else None;
+ }
parts: list[str] = [];
n_dep = len(dget(bundle, "deployments"));
n_svc = len(dget(bundle, "services"));
parts.append(f"{n_dep} deployment{'s' if n_dep != 1 else ''}");
parts.append(f"{n_svc} service{'s' if n_svc != 1 else ''}");
- for (key, label) in [
- ("autoscalers", "Autoscaler"),
- ("pdbs", "PDB"),
- ("pvcs", "PVC")
- ] {
- entry = _n(key, label);
- if entry {
- parts.append(entry);
+ autoscaler_entry = _n_mode(ScalingMode.METRIC, "Autoscaler");
+ if autoscaler_entry {
+ parts.append(autoscaler_entry);
+ }
+ http_entry = _n_mode(ScalingMode.HTTP_ACTIVATION, "HTTP activation");
+ if http_entry {
+ parts.append(http_entry);
+ }
+ for (key, label) in [("pdbs", "PDB"), ("pvcs", "PVC")] {
+ `entry = _n(key, label);
+ if `entry {
+ parts.append(`entry);
}
}
if isinstance(bundle, dict) and bundle.get("ingress") {
@@ -356,7 +412,7 @@ obj Plan {
routes = routes_any;
}
deployments = dget(bundle, "deployments");
- autoscalers = dget(bundle, "autoscalers");
+ scaling = dget(bundle, "scaling");
pdbs = dget(bundle, "pdbs");
user_routes: list[str] = [
@@ -382,11 +438,25 @@ obj Plan {
services: list[ServiceView] = [];
for svc in ordered {
route_path: str | None = str(routes[svc]) if svc in routes else None;
+ plan_svc: Any = scaling.get(svc);
+ mode: Any = plan_svc.get("mode") if isinstance(plan_svc, dict) else None;
+ hpa: Any = (
+ plan_svc.get("autoscaler")
+ if isinstance(plan_svc, dict) and mode == ScalingMode.METRIC
+ else None
+ );
+ http: Any = (
+ plan_svc.get("http_activation")
+ if isinstance(plan_svc, dict)
+ and mode == ScalingMode.HTTP_ACTIVATION
+ else None
+ );
services.append(
ServiceView.from_manifests(
name=svc,
deployment=deployments.get(svc),
- hpa=autoscalers.get(svc),
+ hpa=hpa,
+ http_activation=http,
pdb=pdbs.get(svc),
route_path=route_path
)
diff --git a/jac/jaclang/scale/tests/deploy/http_activation_test_support.jac b/jac/jaclang/scale/tests/deploy/http_activation_test_support.jac
new file mode 100644
index 00000000000..05acd7fc3d0
--- /dev/null
+++ b/jac/jaclang/scale/tests/deploy/http_activation_test_support.jac
@@ -0,0 +1,90 @@
+"""Shared test helpers for jac-scale's KEDA HTTP activation tests.
+
+Canonical, copy-paste-free fixtures used by both the integration tests in
+test_http_activation_microservices.jac and the jac.toml config-translation/
+wiring tests (test_http_activation_config.jac): a defaulted
+HTTPActivationSpec, a defaulted jac.toml-shaped config dict, and a
+KEDAAutoscaler wired to MagicMock Kubernetes clients.
+
+Example:
+ import from jaclang.scale.tests.deploy.http_activation_test_support {
+ default_http_activation_spec as _spec,
+ mocked_keda_autoscaler as _mocked_keda
+ }
+ spec = _spec(namespace="staging");
+"""
+
+import unittest.mock;
+import from kubernetes.client.exceptions { ApiException }
+import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
+import from jaclang.scale.deploy.autoscale.http_activation {
+ HTTPActivationSpec,
+ HTTPServiceTarget,
+ HTTPConcurrencyMetric
+}
+
+# Base HTTPActivationSpec shared by most tests: a Deployment target on port
+# 8080 with a concurrency=10 trigger. Override only what a given test cares
+# about; pass concurrency=None when testing the metric-required check.
+def default_http_activation_spec(**overrides: any) -> HTTPActivationSpec {
+ defaults = {
+ "name": "preview",
+ "namespace": "default",
+ "scale_target_name": "preview-deployment",
+ "target": HTTPServiceTarget(service="preview-svc", port=8080),
+ "concurrency": HTTPConcurrencyMetric(target_value=10)
+ };
+ if "request_rate" in overrides {
+ defaults.pop("concurrency");
+ }
+ defaults.update(overrides);
+ return HTTPActivationSpec(**defaults);
+}
+
+# Base jac.toml [*.http_activation] config dict, the dict-side counterpart to
+# default_http_activation_spec above: enabled, target_port=8080,
+# concurrency_target=10, rules=[{hosts=["*"]}]. Override only what a given
+# test cares about; pass request_rate_target=... to switch to the
+# request-rate metric (drops concurrency_target the same way
+# default_http_activation_spec drops concurrency when request_rate is
+# supplied); pass rules=[] to exercise the empty-rules rejection itself.
+def default_http_activation_config(**overrides: any) -> dict[str, any] {
+ defaults: dict[str, any] = {
+ "enabled": True,
+ "target_port": 8080,
+ "concurrency_target": 10,
+ "rules": [{"hosts": ["*"]}]
+ };
+ if "request_rate_target" in overrides {
+ defaults.pop("concurrency_target");
+ }
+ defaults.update(overrides);
+ return defaults;
+}
+
+# KEDAAutoscaler wired to MagicMock clients, with the HTTP Add-on preflight
+# check (list_cluster_custom_object) pre-configured as installed by default.
+# Pass a pre-configured apps_api/core_api/custom_api to control target,
+# Service, or route/ScaledObject existence for a specific test. Returns the
+# mocks alongside the instance since KEDAAutoscaler's api fields are typed
+# `any | None`, which the checker won't let a caller re-narrow after the fact.
+def mocked_keda_autoscaler(
+ http_addon_installed: bool = True,
+ custom_api: any = None,
+ apps_api: any = None,
+ core_api: any = None
+) -> tuple[KEDAAutoscaler, any, any, any] {
+ KEDAAutoscaler._preflight_cache.clear();
+ custom_api = custom_api or unittest.mock.MagicMock();
+ if http_addon_installed {
+ custom_api.list_cluster_custom_object.return_value = {};
+ } else {
+ custom_api.list_cluster_custom_object.side_effect = ApiException(status=404);
+ }
+ apps_api = apps_api or unittest.mock.MagicMock();
+ core_api = core_api or unittest.mock.MagicMock();
+ keda = KEDAAutoscaler(
+ _custom_api=custom_api, _apps_api=apps_api, _core_api=core_api
+ );
+ return (keda, custom_api, apps_api, core_api);
+}
diff --git a/jac/jaclang/scale/tests/deploy/keda_http_activation_real_e2e.sh b/jac/jaclang/scale/tests/deploy/keda_http_activation_real_e2e.sh
index 19e6b1a4e1f..8b4d974cdaf 100755
--- a/jac/jaclang/scale/tests/deploy/keda_http_activation_real_e2e.sh
+++ b/jac/jaclang/scale/tests/deploy/keda_http_activation_real_e2e.sh
@@ -1,50 +1,97 @@
#!/usr/bin/env bash
-# Real-cluster e2e for jac-scale KEDA HTTP Add-on activation (#7403/#7421).
+# Real-cluster e2e for jac-scale KEDA HTTP Add-on activation (#7403/#7421),
+# deployed through the jac.toml [scale.kubernetes.http_activation] wiring
+# (#7475) instead of calling KEDAAutoscaler.apply_http_activation directly.
#
-# Starts from a zero-replica Deployment, applies HTTP activation twice (create
-# then patch, to exercise both branches of apply_http_activation against a
-# real API server), sends an HTTP request through the KEDA HTTP Add-on
-# interceptor, waits for the target to become Ready, then confirms it scales
-# back to zero after cooldown. Requires KEDA core + the HTTP Add-on already
-# installed on the target cluster (this script does not install them -- see
-# README.md in the fixture dir / the CI step that calls this script for the
-# `helm install` invocations).
+# Deploys the fixture app via `jac scale deploy`, confirms the resulting
+# InterceptorRoute + ScaledObject reconcile to Ready, sends an HTTP request
+# through the KEDA HTTP Add-on interceptor (should block on cold start, then
+# respond), waits for the target to scale 0 -> 1 and become Available, then
+# confirms it scales back to zero after cooldown. Requires KEDA core + the
+# HTTP Add-on already installed on the target cluster (this script does not
+# install them -- see README.md in the fixture dir for the `helm install`
+# invocations).
set -euo pipefail
+# shellcheck source=../../scripts/e2e_lib.sh
+source "$(cd "$(dirname "$0")/../../scripts" && pwd)/e2e_lib.sh"
+e2e_timing_init
+
FIXTURE_DIR="${1:-$(cd "$(dirname "$0")/../fixtures/keda_http_activation_e2e" && pwd)}"
-if [ ! -f "${FIXTURE_DIR}/fixture.yaml" ]; then
- echo "FAIL: ${FIXTURE_DIR}/fixture.yaml not found" >&2
+if [ ! -f "${FIXTURE_DIR}/jac.toml" ]; then
+ echo "FAIL: ${FIXTURE_DIR}/jac.toml not found" >&2
echo "Usage: $0 [FIXTURE_DIR]" >&2
exit 1
fi
-# This script lives at jac/jaclang/scale/tests/deploy/, so the repo root is
-# five levels up.
-REPO_ROOT="$(cd "$(dirname "$0")/../../../../.." && pwd)"
-DRIVER="${REPO_ROOT}/jac/jaclang/scale/tests/deploy/keda_http_activation_verify.jac"
-if [ ! -f "${DRIVER}" ]; then
- echo "FAIL: driver script not found at ${DRIVER}" >&2
- exit 1
+# The manifest builder refuses to guess a RWX-capable StorageClass for the
+# bundle PVC (see manifest_builder.jac): most cloud defaults are
+# ReadWriteOnce-only, so it now requires bundle_storage_class set explicitly
+# rather than trusting the cluster default. Local/kind runs edit jac.toml by
+# hand per the fixture README; a CI lane on a different cluster type sets
+# BUNDLE_STORAGE_CLASS instead, applied here so the file itself stays
+# cluster-agnostic.
+if [ -n "${BUNDLE_STORAGE_CLASS:-}" ]; then
+ python3 - "${FIXTURE_DIR}/jac.toml" "${BUNDLE_STORAGE_CLASS}" <<'PYEOF'
+import sys
+
+path, storage_class = sys.argv[1], sys.argv[2]
+with open(path) as f:
+ lines = f.readlines()
+out, in_k8s, done = [], False, False
+for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("["):
+ in_k8s = stripped == "[scale.kubernetes]"
+ if in_k8s and stripped.startswith("bundle_storage_class"):
+ continue
+ out.append(line)
+ if in_k8s and not done and stripped == "[scale.kubernetes]":
+ out.append(f'bundle_storage_class = "{storage_class}"\n')
+ done = True
+with open(path, "w") as f:
+ f.writelines(out)
+PYEOF
fi
-export KEDA_HTTP_E2E_NAMESPACE="${KEDA_HTTP_E2E_NAMESPACE:-jac-http-e2e}"
-export KEDA_HTTP_E2E_DEPLOYMENT="${KEDA_HTTP_E2E_DEPLOYMENT:-echo}"
-export KEDA_HTTP_E2E_SERVICE="${KEDA_HTTP_E2E_SERVICE:-echo-svc}"
-export KEDA_HTTP_E2E_ROUTE_HOST="${KEDA_HTTP_E2E_ROUTE_HOST:-echo.jac-http-e2e.local}"
-export KEDA_HTTP_E2E_POLLING_INTERVAL="${KEDA_HTTP_E2E_POLLING_INTERVAL:-10}"
-export KEDA_HTTP_E2E_COOLDOWN_PERIOD="${KEDA_HTTP_E2E_COOLDOWN_PERIOD:-60}"
-NAMESPACE="${KEDA_HTTP_E2E_NAMESPACE}"
-DEPLOYMENT="${KEDA_HTTP_E2E_DEPLOYMENT}"
+CFG=$(cd "${FIXTURE_DIR}" && jac -c "
+import tomllib
+with open('jac.toml', 'rb') as f:
+ cfg = tomllib.load(f)
+proj = cfg['project']
+k8s = cfg['scale']['kubernetes']
+act = k8s['http_activation']
+print(proj['name'])
+print(k8s.get('namespace', 'default'))
+print(act['rules'][0]['hosts'][0])
+print(act.get('polling_interval', 30))
+print(act.get('cooldown_period', 300))
+")
+APP_NAME=$(echo "${CFG}" | sed -n '1p')
+# The unified deploy path names every workload "-deployment" (even a solo
+# app behind its gateway), and the KEDA InterceptorRoute/ScaledObject are named
+# after that scale target, so they are "-deployment-http-{route,scaledobject}".
+SCALE_TARGET="${APP_NAME}-deployment"
+NAMESPACE=$(echo "${CFG}" | sed -n '2p')
+ROUTE_HOST=$(echo "${CFG}" | sed -n '3p')
+POLLING_INTERVAL=$(echo "${CFG}" | sed -n '4p')
+COOLDOWN_PERIOD=$(echo "${CFG}" | sed -n '5p')
+
# Bare seconds, like every other *_TIMEOUT var here -- "s" is appended at the
# call site. kubectl's --timeout requires a unit suffix (e.g. "120s"); baking
# it into the default here would make DELETE_TIMEOUT the only timeout var
# that breaks if overridden with a bare integer like the rest.
DELETE_TIMEOUT="${DELETE_TIMEOUT:-120}"
-READY_TIMEOUT="${READY_TIMEOUT:-90}"
-# Bound the scale-down wait comfortably above cooldown + one poll tick so a
+# A real jac-scale pod's cold start runs jac-pvc-bootstrap + jac-bootstrap
+# init containers (installing deps, first-run compile) before it's Ready -
+# tens of seconds, not the near-instant start of a bare container image. 90s
+# (right for the old fixture.yaml's http-echo image) was too tight for that;
+# 180s gives real headroom while still failing fast on an actual hang.
+READY_TIMEOUT="${READY_TIMEOUT:-180}"
+# Bound the scale-down wait comfortably above cooldown + three poll ticks so a
# real hang fails loudly instead of the script exiting early on a fluke.
-SCALE_DOWN_TIMEOUT="${SCALE_DOWN_TIMEOUT:-$(( KEDA_HTTP_E2E_COOLDOWN_PERIOD + KEDA_HTTP_E2E_POLLING_INTERVAL * 3 + 30 ))}"
+SCALE_DOWN_TIMEOUT="${SCALE_DOWN_TIMEOUT:-$(( COOLDOWN_PERIOD + POLLING_INTERVAL * 3 + 30 ))}"
echo "=== preflight: KEDA HTTP Add-on CRDs ==="
if ! kubectl get crd interceptorroutes.http.keda.sh >/dev/null 2>&1; then
@@ -62,9 +109,9 @@ dump_state() {
kubectl get pods -n "${NAMESPACE}" -o wide || true
kubectl describe pods -n "${NAMESPACE}" || true
kubectl get events -n "${NAMESPACE}" --sort-by=.lastTimestamp || true
- kubectl logs -n "${NAMESPACE}" -l "app=${DEPLOYMENT}" --tail=200 --all-containers=true || true
- kubectl describe interceptorroute "${DEPLOYMENT}-http-route" -n "${NAMESPACE}" || true
- kubectl describe scaledobject "${DEPLOYMENT}-http-scaledobject" -n "${NAMESPACE}" || true
+ kubectl logs -n "${NAMESPACE}" -l "app=${APP_NAME}" --tail=200 --all-containers=true || true
+ kubectl describe interceptorroute "${SCALE_TARGET}-http-route" -n "${NAMESPACE}" || true
+ kubectl describe scaledobject "${SCALE_TARGET}-http-scaledobject" -n "${NAMESPACE}" || true
echo "--- HTTP Add-on component logs (namespace=keda) ---"
kubectl logs -n keda -l app=keda-add-ons-http-interceptor --tail=100 || true
kubectl logs -n keda -l app=keda-add-ons-http-external-scaler --tail=100 || true
@@ -88,14 +135,12 @@ cleanup() {
echo "=== e2e failed (rc=${rc}); KEEPING namespace '${NAMESPACE}' for inspection (set E2E_KEEP_NS_ON_FAIL=0 to force cleanup) ==="
return
fi
- (cd "${REPO_ROOT}/jac" && jac run "${DRIVER}" destroy) || true
+ # Deleting the namespace sweeps the Deployment/Service and the namespaced
+ # InterceptorRoute/ScaledObject together; no separate destroy call needed.
kubectl delete namespace "${NAMESPACE}" --ignore-not-found --timeout="${DELETE_TIMEOUT}s" || true
}
trap 'cleanup "$?"' EXIT
-_T0=$(date +%s)
-_t() { echo "[TIMING +$(( $(date +%s) - _T0 ))s] $1"; }
-
# Polls a resource's status.conditions[type=Ready].status, per the HTTP
# Add-on's own "Autoscale an App" verify step (kubectl get and
# check the READY column) -- done here as a jsonpath poll instead so the
@@ -120,39 +165,29 @@ wait_for_ready() {
return 1
}
-_t "fixture apply start"
-echo "=== apply zero-replica Deployment + Service fixture ==="
-kubectl apply -f "${FIXTURE_DIR}/fixture.yaml"
-REPLICAS=$(kubectl get deployment "${DEPLOYMENT}" -n "${NAMESPACE}" -o jsonpath='{.spec.replicas}')
-if [ "${REPLICAS}" != "0" ]; then
- echo "FAIL: fixture Deployment '${DEPLOYMENT}' did not start at 0 replicas (got ${REPLICAS})" >&2
- exit 1
-fi
-echo " ${DEPLOYMENT} starts at 0 replicas"
-
-_t "apply_http_activation (create path)"
-echo "=== apply_http_activation: reconcile InterceptorRoute + ScaledObject (create) ==="
-if ! (cd "${REPO_ROOT}/jac" && jac run "${DRIVER}" apply); then
- echo "FAIL: first apply_http_activation call (create path) errored" >&2
+_t "deploy start"
+echo "=== deploy via jac scale deploy (jac.toml [scale.kubernetes.http_activation] wiring) ==="
+if ! (cd "${FIXTURE_DIR}" && jac scale deploy app.jac); then
+ echo "FAIL: deploy failed" >&2
dump_state
exit 1
fi
-_t "apply_http_activation (patch path)"
-echo "=== re-apply: both resources already exist, so this exercises the get-then-patch branch against the real API server (required behavior: idempotent create-or-patch) ==="
-if ! (cd "${REPO_ROOT}/jac" && jac run "${DRIVER}" apply); then
- echo "FAIL: second apply_http_activation call (patch path) errored" >&2
+_t "redeploy (idempotency check)"
+echo "=== redeploy: confirms InterceptorRoute + ScaledObject reconcile is idempotent (get-then-patch, not create-or-duplicate) ==="
+if ! (cd "${FIXTURE_DIR}" && jac scale deploy app.jac); then
+ echo "FAIL: redeploy failed" >&2
dump_state
exit 1
fi
_t "wait for InterceptorRoute + ScaledObject Ready"
echo "=== confirm InterceptorRoute and ScaledObject reconciled to Ready ==="
-if ! wait_for_ready interceptorroute "${DEPLOYMENT}-http-route"; then
+if ! wait_for_ready interceptorroute "${SCALE_TARGET}-http-route"; then
dump_state
exit 1
fi
-if ! wait_for_ready scaledobject "${DEPLOYMENT}-http-scaledobject"; then
+if ! wait_for_ready scaledobject "${SCALE_TARGET}-http-scaledobject"; then
dump_state
exit 1
fi
@@ -177,8 +212,9 @@ echo "=== send HTTP request through the interceptor (should block on cold start,
RESP_BODY_FILE="$(mktemp)"
RESP_CODE=$(curl -s -o "${RESP_BODY_FILE}" -w "%{http_code}" \
--max-time "${READY_TIMEOUT}" \
- -H "Host: ${KEDA_HTTP_E2E_ROUTE_HOST}" \
- "http://localhost:${INTERCEPTOR_LOCAL_PORT}/" || echo "000")
+ -X POST \
+ -H "Host: ${ROUTE_HOST}" \
+ "http://localhost:${INTERCEPTOR_LOCAL_PORT}/walker/echo" || echo "000")
if [ "${RESP_CODE}" != "200" ]; then
echo "FAIL: interceptor request returned '${RESP_CODE}' (expected 200) within ${READY_TIMEOUT}s" >&2
dump_state
@@ -190,27 +226,27 @@ rm -f "${RESP_BODY_FILE}"
_t "wait for readiness"
echo "=== confirm the target scaled 0 -> 1 and became Ready ==="
-if ! kubectl wait --for=condition=Available "deployment/${DEPLOYMENT}" \
+if ! kubectl wait --for=condition=Available "deployment/${SCALE_TARGET}" \
-n "${NAMESPACE}" --timeout="${READY_TIMEOUT}s"; then
- echo "FAIL: ${DEPLOYMENT} did not become Available within ${READY_TIMEOUT}s" >&2
+ echo "FAIL: ${APP_NAME} did not become Available within ${READY_TIMEOUT}s" >&2
dump_state
exit 1
fi
-READY_REPLICAS=$(kubectl get deployment "${DEPLOYMENT}" -n "${NAMESPACE}" \
+READY_REPLICAS=$(kubectl get deployment "${SCALE_TARGET}" -n "${NAMESPACE}" \
-o jsonpath='{.status.readyReplicas}')
-echo " ${DEPLOYMENT} readyReplicas=${READY_REPLICAS}"
+echo " ${APP_NAME} readyReplicas=${READY_REPLICAS}"
kill "${PORT_FORWARD_PID}" 2>/dev/null || true
PORT_FORWARD_PID=""
_t "wait for scale-down after cooldown"
-echo "=== stop traffic; wait up to ${SCALE_DOWN_TIMEOUT}s for scale-down after ${KEDA_HTTP_E2E_COOLDOWN_PERIOD}s cooldown ==="
+echo "=== stop traffic; wait up to ${SCALE_DOWN_TIMEOUT}s for scale-down after ${COOLDOWN_PERIOD}s cooldown ==="
SCALED_DOWN=0
ELAPSED=0
while [ "${ELAPSED}" -lt "${SCALE_DOWN_TIMEOUT}" ]; do
- sleep "${KEDA_HTTP_E2E_POLLING_INTERVAL}"
- ELAPSED=$(( ELAPSED + KEDA_HTTP_E2E_POLLING_INTERVAL ))
- CURRENT_REPLICAS=$(kubectl get deployment "${DEPLOYMENT}" -n "${NAMESPACE}" \
+ sleep "${POLLING_INTERVAL}"
+ ELAPSED=$(( ELAPSED + POLLING_INTERVAL ))
+ CURRENT_REPLICAS=$(kubectl get deployment "${SCALE_TARGET}" -n "${NAMESPACE}" \
-o jsonpath='{.spec.replicas}')
echo " +${ELAPSED}s replicas=${CURRENT_REPLICAS}"
if [ "${CURRENT_REPLICAS}" = "0" ]; then
@@ -219,7 +255,7 @@ while [ "${ELAPSED}" -lt "${SCALE_DOWN_TIMEOUT}" ]; do
fi
done
if [ "${SCALED_DOWN}" != "1" ]; then
- echo "FAIL: ${DEPLOYMENT} did not scale back to 0 within ${SCALE_DOWN_TIMEOUT}s of cooldown" >&2
+ echo "FAIL: ${APP_NAME} did not scale back to 0 within ${SCALE_DOWN_TIMEOUT}s of cooldown" >&2
dump_state
exit 1
fi
diff --git a/jac/jaclang/scale/tests/deploy/keda_http_activation_verify.jac b/jac/jaclang/scale/tests/deploy/keda_http_activation_verify.jac
deleted file mode 100644
index d99517f7630..00000000000
--- a/jac/jaclang/scale/tests/deploy/keda_http_activation_verify.jac
+++ /dev/null
@@ -1,86 +0,0 @@
-"""Apply/destroy driver for the KEDA HTTP Add-on activation e2e
-(keda_http_activation_real_e2e.sh). Talks to whatever cluster the current
-kubeconfig/in-cluster context points at -- no mocking, this exercises
-KEDAAutoscaler.apply_http_activation / destroy_http_activation for real.
-
- jac run keda_http_activation_verify.jac apply
- jac run keda_http_activation_verify.jac destroy
-
-Target namespace/name/host are read from env vars (defaults match
-../fixtures/keda_http_activation_e2e/fixture.yaml) so the orchestrating
-shell script controls them without editing this file.
-"""
-import os;
-import sys;
-import from jaclang.scale._optdeps.kubernetes { config as k8s_config, ConfigException }
-import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
-import from jaclang.scale.deploy.autoscale.http_activation {
- HTTPActivationSpec,
- HTTPServiceTarget,
- HTTPRoutingRule,
- HTTPConcurrencyMetric
-}
-import from jaclang.scale.observability.standard_logger { StandardLogger }
-
-glob NAMESPACE: str = os.environ.get("KEDA_HTTP_E2E_NAMESPACE", "jac-http-e2e"),
- DEPLOYMENT: str = os.environ.get("KEDA_HTTP_E2E_DEPLOYMENT", "echo"),
- SERVICE: str = os.environ.get("KEDA_HTTP_E2E_SERVICE", "echo-svc"),
- ROUTE_HOST: str = os.environ.get(
- "KEDA_HTTP_E2E_ROUTE_HOST", "echo.jac-http-e2e.local"
- ),
- POLLING_INTERVAL: int = int(
- os.environ.get("KEDA_HTTP_E2E_POLLING_INTERVAL", "10")
- ),
- COOLDOWN_PERIOD: int = int(os.environ.get("KEDA_HTTP_E2E_COOLDOWN_PERIOD", "60"));
-
-def load_config {
- # any: sidesteps a checker false positive on _optdeps' guarded import (NoneType inferred from its ImportError fallback branch).
- kube_config: any = k8s_config;
- try {
- kube_config.load_kube_config();
- } except ConfigException {
- kube_config.load_incluster_config();
- }
-}
-
-def build_spec -> HTTPActivationSpec {
- return HTTPActivationSpec(
- name="e2e",
- namespace=NAMESPACE,
- scale_target_name=DEPLOYMENT,
- target=HTTPServiceTarget(service=SERVICE, port=80),
- rules=[HTTPRoutingRule(hosts=[ROUTE_HOST])],
- min_replicas=0,
- max_replicas=1,
- polling_interval=POLLING_INTERVAL,
- cooldown_period=COOLDOWN_PERIOD,
- concurrency=HTTPConcurrencyMetric(target_value=1)
- );
-}
-
-with entry {
- if len(sys.argv) < 2 or sys.argv[1] not in ("apply", "destroy") {
- print("usage: jac run keda_http_activation_verify.jac [apply|destroy]");
- sys.exit(1);
- }
-
- load_config();
-}
-
-glob keda = KEDAAutoscaler(logger=StandardLogger());
-
-with entry {
- if sys.argv[1] == "apply" {
- ok = keda.apply_http_activation(build_spec());
- if not ok {
- print("apply_http_activation returned False (HTTP Add-on CRDs missing?)");
- sys.exit(1);
- }
- print(f"apply_http_activation -> {ok}");
- print(f"InterceptorRoute: {keda.interceptor_route_name_for(DEPLOYMENT)}");
- print(f"ScaledObject: {keda.http_scaled_object_name_for(DEPLOYMENT)}");
- } else {
- keda.destroy_http_activation(DEPLOYMENT, NAMESPACE);
- print("destroy_http_activation -> done");
- }
-}
diff --git a/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac b/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac
new file mode 100644
index 00000000000..09b0099fca1
--- /dev/null
+++ b/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac
@@ -0,0 +1,244 @@
+import unittest.mock;
+import from kubernetes.client.exceptions { ApiException }
+import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
+import from jaclang.scale.deploy.autoscale.http_activation_config {
+ build_http_activation_spec,
+ apply_http_activation_for_target,
+ destroy_http_activation_for_target
+}
+import from jaclang.scale.tests.deploy.http_activation_test_support {
+ default_http_activation_config as _cfg,
+ mocked_keda_autoscaler as _mocked_keda
+}
+
+
+# --- Section A: enabled gating -----------------------------------------------
+test "disabled or missing enabled key returns None instead of a spec" {
+ assert build_http_activation_spec(
+ _cfg(enabled=False), "preview-deployment", "staging", "preview-svc"
+ )
+ is None;
+ assert build_http_activation_spec(
+ {}, "preview-deployment", "staging", "preview-svc"
+ )
+ is None;
+}
+
+
+# --- Section B: minimal config -----------------------------------------------
+test "minimal config (enabled, target_port, concurrency_target) builds a spec with correct defaults everywhere else" {
+ spec = build_http_activation_spec(
+ _cfg(), "preview-deployment", "staging", "preview-svc"
+ );
+ assert spec is not None;
+ assert spec.scale_target_name == "preview-deployment";
+ assert spec.namespace == "staging";
+ assert spec.target.service == "preview-svc";
+ assert spec.target.port == 8080;
+ assert spec.target.port_name is None;
+ assert spec.concurrency.target_value == 10;
+ assert spec.request_rate is None;
+ assert spec.min_replicas == 0;
+ assert spec.max_replicas == 1;
+ assert spec.polling_interval == 30;
+ assert spec.cooldown_period == 300;
+ assert len(spec.rules) == 1;
+ assert spec.rules[0].hosts == ["*"];
+ assert spec.cold_start is None;
+ assert spec.timeouts is None;
+ assert spec.scale_target_kind == "Deployment";
+ assert spec.scale_target_api_version == "apps/v1";
+ assert spec.scale_target_plural is None;
+}
+
+
+# --- Section C: full config (every optional field, exercised once) ----------
+test "full config translates rules, cold_start, timeouts, request_rate, and custom target kind" {
+ cfg = _cfg(
+ min_replicas=1,
+ max_replicas=5,
+ polling_interval=15,
+ cooldown_period=120,
+ target_port=None,
+ target_port_name="http",
+ request_rate_target=50,
+ request_rate_window="5m",
+ request_rate_granularity="10s",
+ rules=[
+ {
+ "hosts": ["preview.jac.dev"],
+ "paths": ["/app"],
+ "headers": [{"name": "x-preview-id", "value": "123"}]
+ }
+ ],
+ cold_start_status_code=503,
+ cold_start_body="warming up",
+ cold_start_headers={"Retry-After": "2"},
+ cold_start_fallback_service="fallback-svc",
+ cold_start_fallback_port=8080,
+ timeout_readiness="30s",
+ timeout_request="60s",
+ timeout_response_header="5s",
+ scale_target_kind="StatefulSet"
+ );
+ spec = build_http_activation_spec(
+ cfg, "preview-deployment", "staging", "preview-svc"
+ );
+ assert spec.min_replicas == 1;
+ assert spec.max_replicas == 5;
+ assert spec.polling_interval == 15;
+ assert spec.cooldown_period == 120;
+ assert spec.target.port is None;
+ assert spec.target.port_name == "http";
+ assert spec.concurrency is None;
+ assert spec.request_rate.target_value == 50;
+ assert spec.request_rate.rate_window == "5m";
+ assert spec.request_rate.granularity == "10s";
+ assert len(spec.rules) == 1;
+ assert spec.rules[0].hosts == ["preview.jac.dev"];
+ assert spec.rules[0].paths[0].value == "/app";
+ assert spec.rules[0].headers[0].name == "x-preview-id";
+ assert spec.rules[0].headers[0].value == "123";
+ assert spec.cold_start.placeholder.status_code == 503;
+ assert spec.cold_start.placeholder.body == "warming up";
+ assert spec.cold_start.placeholder.headers == {"Retry-After": "2"};
+ assert spec.cold_start.fallback_service.service == "fallback-svc";
+ assert spec.cold_start.fallback_service.port == 8080;
+ assert spec.timeouts.readiness == "30s";
+ assert spec.timeouts.request == "60s";
+ assert spec.timeouts.response_header == "5s";
+ assert spec.scale_target_kind == "StatefulSet";
+}
+
+
+# --- Section D: jac.toml-relative validation errors --------------------------
+# One shape, four inputs: each bad cfg must raise ValueError (never a bare
+# KeyError or anything else -- left uncaught here, so it fails the test loudly
+# instead of silently) naming the jac.toml key(s) at fault.
+def _assert_config_error(cfg: dict[str, any], *substrings: str) -> None {
+ try {
+ build_http_activation_spec(cfg, "preview-deployment", "staging", "preview-svc");
+ } except ValueError as e {
+ msg = str(e);
+ for s in substrings {
+ assert s in msg , f"expected '{s}' in error message: {msg}";
+ }
+ return;
+ }
+ assert False , "expected build_http_activation_spec to raise ValueError";
+}
+
+test "config with both target_port and target_port_name raises a jac.toml-relative error" {
+ _assert_config_error(
+ _cfg(target_port=8080, target_port_name="http"),
+ "target_port",
+ "http_activation"
+ );
+}
+
+test "config with neither concurrency_target nor request_rate_target raises a jac.toml-relative error" {
+ _assert_config_error(
+ _cfg(concurrency_target=None), "concurrency_target", "request_rate_target"
+ );
+}
+
+test "config with empty rules raises instead of silently deploying an unreachable target" {
+ _assert_config_error(_cfg(rules=[]), "rules");
+}
+
+test "a rule header missing its required 'name' key raises a jac.toml-relative error, not a bare KeyError" {
+ _assert_config_error(_cfg(rules=[{"headers": [{"value": "yes"}]}]), "name");
+}
+
+
+# --- Section E: apply/destroy wiring helpers (injectable KEDAAutoscaler) ----
+# These back the monolith (kubernetes_target.jac) and microservices
+# (manifest_builder.jac / microservice/target.jac) deploy-path call sites.
+# kubernetes_target.jac's deploy() is a single large method that talks to a
+# real cluster end to end with no isolated seam to unit test (it is only
+# ever exercised against a live cluster, in test_deploy_k8s.jac) - these two
+# functions exist so the HTTP-activation call itself stays testable without
+# that whole method, by taking the KEDAAutoscaler as an explicit parameter.
+test "apply_http_activation_for_target calls apply_http_activation with a correctly-targeted spec when enabled" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ custom_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ result = apply_http_activation_for_target(
+ _cfg(), "preview-deployment", "staging", "preview-svc", keda
+ );
+ assert result == True;
+ create_calls = custom_api.create_namespaced_custom_object.call_args_list;
+ assert len(create_calls) == 2;
+ route_body = create_calls[0][1]["body"];
+ assert route_body["spec"]["target"]["service"] == "preview-svc";
+ scaled_obj_body = create_calls[1][1]["body"];
+ assert scaled_obj_body["metadata"]["name"]
+ == keda.http_scaled_object_name_for("preview-deployment");
+}
+
+test "apply_http_activation_for_target is a no-op when disabled or unset" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ assert apply_http_activation_for_target(
+ _cfg(enabled=False), "preview-deployment", "staging", "preview-svc", keda
+ )
+ is None;
+ assert apply_http_activation_for_target(
+ {}, "preview-deployment", "staging", "preview-svc", keda
+ )
+ is None;
+ custom_api.create_namespaced_custom_object.assert_not_called();
+}
+
+test "destroy_http_activation_for_target always sweeps both resources, independent of current config" {
+ # Deliberately unconditional: a resource created while http_activation was
+ # enabled must still be cleaned up if the config later disables it or
+ # drops the block entirely, otherwise it is orphaned. destroy_http_
+ # activation is 404-safe per-resource, so calling it unconditionally
+ # (mirroring the base autoscaler's own unconditional destroy() call) is
+ # always safe, whether or not anything was ever actually created.
+ mock_api = unittest.mock.MagicMock();
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ destroy_http_activation_for_target("preview-deployment", "staging", keda);
+ delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
+ plurals = [c[1]["plural"] for c in delete_calls];
+ assert "interceptorroutes" in plurals;
+ assert "scaledobjects" in plurals;
+}
+
+test "destroy_http_activation_for_target swallows a permission error instead of aborting the rest of teardown" {
+ # The HTTP Add-on CRD group (http.keda.sh) is installed separately from
+ # core KEDA; a cluster's RBAC may cover keda.sh (which the base
+ # autoscaler already needs) without yet covering this newer group. A 403
+ # here must not stop the Deployment/Service/PVC/ingress/monitoring/
+ # database cleanup that runs after this call in the monolith destroy
+ # path, mirroring the "HTTP Add-on might not be fully available" warn-
+ # and-continue precedent already used by apply_http_activation's
+ # preflight check.
+ mock_api = unittest.mock.MagicMock();
+ mock_api.delete_namespaced_custom_object.side_effect = ApiException(status=403);
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ raised = False;
+ try {
+ destroy_http_activation_for_target("preview-deployment", "staging", keda);
+ } except ApiException {
+ raised = True;
+ }
+ assert not raised , "A 403 must not propagate out of destroy_http_activation_for_target";
+}
+
+test "destroy_http_activation_for_target still propagates an unexpected error like a 500" {
+ # Only a 403 is treated as "HTTP Add-on RBAC not provisioned yet" and
+ # swallowed. A 500/502/503 is a genuinely different failure mode (the
+ # API server itself is having trouble, not a permission gap) and must
+ # still surface to the caller instead of silently leaving the
+ # InterceptorRoute/ScaledObject orphaned with no error reported.
+ mock_api = unittest.mock.MagicMock();
+ mock_api.delete_namespaced_custom_object.side_effect = ApiException(status=500);
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ raised = False;
+ try {
+ destroy_http_activation_for_target("preview-deployment", "staging", keda);
+ } except ApiException {
+ raised = True;
+ }
+ assert raised , "A 500 must propagate out of destroy_http_activation_for_target";
+}
diff --git a/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac b/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac
new file mode 100644
index 00000000000..3f0116c4b19
--- /dev/null
+++ b/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac
@@ -0,0 +1,1386 @@
+import from kubernetes.client.exceptions { ApiException }
+import from jaclang.scale.deploy.target.kubernetes.manifest_builder {
+ ManifestBuilder,
+ GATEWAY_NAME,
+ ScalingMode
+}
+import from jaclang.scale.deploy.target.kubernetes.target {
+ KubernetesTarget,
+ apply_http_activations_for_bundle,
+ reap_stale_scaling_resources_for_bundle
+}
+import unittest.mock;
+import from jaclang.scale.deploy.target.kubernetes.kubernetes_config {
+ KubernetesConfig
+}
+import from jaclang.scale.observability.standard_logger { StandardLogger }
+import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
+import from jaclang.scale.deploy.autoscale.autoscaler {
+ AutoscalerSpec,
+ Trigger,
+ TriggerAuth
+}
+import from jaclang.scale.deploy.autoscale.http_activation {
+ HTTPRoutingRule,
+ HTTPPathMatch,
+ HTTPHeaderMatch,
+ HTTPServiceTarget,
+ HTTPRequestRateMetric,
+ HTTPStaticResponse,
+ HTTPColdStartSpec,
+ HTTPTimeoutSpec
+}
+import from jaclang.scale.tests.deploy.http_activation_test_support {
+ default_http_activation_config as _cfg,
+ default_http_activation_spec as _spec,
+ mocked_keda_autoscaler as _mocked_keda
+}
+
+
+def _builder(
+ services: dict, k8s_config: KubernetesConfig | None = None
+) -> ManifestBuilder {
+ return ManifestBuilder(
+ k8s_config=k8s_config or KubernetesConfig(app_name="t", namespace="t-ns"),
+ microservices_config={"services": services},
+ dry_run=True
+ );
+}
+
+
+# Minimal Deployment/Service manifests: _resolve_scaling only reads
+# metadata.name off each, so a full manifest is unnecessary here.
+def _dep(name: str) -> dict {
+ return {"metadata": {"name": name}};
+}
+
+
+def _svc(name: str) -> dict {
+ return {"metadata": {"name": name}};
+}
+
+
+# --- Section A: ManifestBuilder._get_http_activation_config -----------------
+# Thin pass-through (config dict in, same merged dict or None out) - the actual
+# dict-to-spec translation is build_http_activation_spec's job, already covered
+# in test_http_activation_config.jac. This only proves the enabled gate and the
+# per-service <- top-level inheritance merge; the gateway exclusion and the
+# metric-vs-activation decision live one layer up, in _resolve_scaling.
+test "service with http_activation.enabled=true returns the config dict unchanged" {
+ b = _builder(
+ {"billing_ops": {"http_activation": {"enabled": True, "target_port": 8080}}}
+ );
+ cfg = b._get_http_activation_config("billing_ops");
+ assert cfg is not None;
+ assert cfg["target_port"] == 8080;
+}
+
+test "service with http_activation disabled or unset returns None" {
+ b = _builder(
+ {"billing_ops": {"http_activation": {"enabled": False}}, "other_svc": {}}
+ );
+ assert b._get_http_activation_config("billing_ops") is None;
+ assert b._get_http_activation_config("other_svc") is None;
+}
+
+test "per-service http_activation inherits unset keys from [scale.kubernetes.http_activation], down to no per-service block at all" {
+ b = _builder(
+ {"billing_ops": {"http_activation": {"enabled": True, "target_port": 8080}}},
+ k8s_config=KubernetesConfig(
+ app_name="t",
+ namespace="t-ns",
+ http_activation={"concurrency_target": 10, "polling_interval": 15}
+ )
+ );
+ cfg = b._get_http_activation_config("billing_ops");
+ assert cfg is not None;
+ assert cfg["target_port"] == 8080;
+ assert cfg["concurrency_target"] == 10;
+ assert cfg["polling_interval"] == 15;
+
+ # Same merge at its edge: no per-service key at all still inherits
+ # `enabled` too -- absent and {} are the same input to the merge.
+ b2 = _builder(
+ {"other_svc": {}},
+ k8s_config=KubernetesConfig(
+ app_name="t",
+ namespace="t-ns",
+ http_activation={
+ "enabled": True,
+ "target_port": 8080,
+ "concurrency_target": 10
+ }
+ )
+ );
+ cfg2 = b2._get_http_activation_config("other_svc");
+ assert cfg2 is not None;
+ assert cfg2["target_port"] == 8080;
+ assert cfg2["concurrency_target"] == 10;
+}
+
+test "per-service http_activation overrides [scale.kubernetes.http_activation] on shared keys" {
+ b = _builder(
+ {
+ "billing_ops": {
+ "http_activation": {"enabled": True, "concurrency_target": 5}
+ }
+ },
+ k8s_config=KubernetesConfig(
+ app_name="t",
+ namespace="t-ns",
+ http_activation={"enabled": True, "concurrency_target": 10}
+ )
+ );
+ cfg = b._get_http_activation_config("billing_ops");
+ assert cfg["concurrency_target"] == 5;
+}
+
+test "a service can explicitly opt out with enabled=false even when the top-level default is enabled" {
+ b = _builder(
+ {"opted_out": {"http_activation": {"enabled": False}}},
+ k8s_config=KubernetesConfig(
+ app_name="t", namespace="t-ns", http_activation={"enabled": True}
+ )
+ );
+ assert b._get_http_activation_config("opted_out") is None;
+}
+
+
+# --- Section B: _resolve_scaling (the single scaling decision) --------------
+test "_resolve_scaling: an http_activation service is HTTP_ACTIVATION, carries the {name}-deployment target, and emits no metric autoscaler" {
+ b = _builder(
+ {
+ "billing_ops": {
+ "http_activation": {
+ "enabled": True,
+ "target_port": 8080,
+ "concurrency_target": 10,
+ "rules": [{"hosts": ["*"]}]
+ }
+ }
+ }
+ );
+ plan = b._resolve_scaling(
+ "billing_ops", _dep("billing-ops-deployment"), _svc("billing-ops-service")
+ );
+ assert plan["mode"] == ScalingMode.HTTP_ACTIVATION;
+ # The scale target is the real Deployment name, never the bare k8s name -
+ # this is the exact drift that 404'd apply_http_activation on a real cluster.
+ assert plan["scale_target_name"] == "billing-ops-deployment";
+ assert plan["service_name"] == "billing-ops-service";
+ # Mutual exclusion: activation replaces the metric autoscaler, never coexists
+ # (KEDA rejects a second ScaledObject on an HPA-managed target).
+ assert plan["autoscaler"] == {};
+}
+
+test "_resolve_scaling: the gateway never inherits http_activation even when the top-level default enables it" {
+ b = _builder(
+ {},
+ k8s_config=KubernetesConfig(
+ app_name="t",
+ namespace="t-ns",
+ http_activation={
+ "enabled": True,
+ "target_port": 8080,
+ "concurrency_target": 10
+ }
+ )
+ );
+ # A real logger (not None, not a MagicMock) so the gateway-exclusion branch
+ # actually calls logger.warn - this is what catches a wrong method name like
+ # .warning, which only surfaces on a live deploy otherwise.
+ b.logger = StandardLogger();
+ plan = b._resolve_scaling(
+ GATEWAY_NAME, _dep("gateway-deployment"), _svc("gateway-service")
+ );
+ # The gateway is the ingress entry point: scaling it to zero black-holes all
+ # inbound traffic, so it must never pick up the shared activation default.
+ assert plan["mode"] != ScalingMode.HTTP_ACTIVATION;
+ assert plan["http_activation"] == {};
+}
+
+test "_resolve_scaling: a plain service with the default hpa is METRIC, not activation" {
+ b = _builder({"worker": {}});
+ plan = b._resolve_scaling(
+ "worker", _dep("worker-deployment"), _svc("worker-service")
+ );
+ assert plan["mode"] == ScalingMode.METRIC;
+ assert plan["autoscaler"] != {};
+ assert plan["http_activation"] == {};
+ assert plan["scale_target_name"] == "worker-deployment";
+}
+
+
+# --- Section C: apply_http_activations_for_bundle (per-service apply loop) ---
+test "apply_http_activations_for_bundle applies only HTTP_ACTIVATION services, using the carried scale-target + service names" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ custom_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ bundle = {
+ "scaling": {
+ "billing_ops": {
+ "mode": ScalingMode.HTTP_ACTIVATION,
+ "scale_target_name": "billing-ops-deployment",
+ "service_name": "billing-ops-service",
+ "autoscaler": {},
+ "http_activation": _cfg()
+ },
+ "worker": {
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": "worker-deployment",
+ "service_name": "worker-service",
+ "autoscaler": {"min": 1, "max": 3},
+ "http_activation": {}
+ }
+ }
+ };
+ apply_http_activations_for_bundle(bundle, "t-ns", keda);
+ create_calls = custom_api.create_namespaced_custom_object.call_args_list;
+ # Two resources (InterceptorRoute + ScaledObject) for billing_ops only; the
+ # METRIC-mode worker is skipped entirely.
+ assert len(create_calls) == 2;
+ route_body = create_calls[0][1]["body"];
+ assert route_body["spec"]["target"]["service"] == "billing-ops-service";
+ assert route_body["metadata"]["namespace"] == "t-ns";
+}
+
+test "apply_http_activations_for_bundle is a no-op on an empty bundle" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ apply_http_activations_for_bundle({}, "t-ns", keda);
+ custom_api.create_namespaced_custom_object.assert_not_called();
+}
+
+
+# --- Section D: reap_stale_scaling_resources_for_bundle (mode-switch cleanup)
+# A service can switch scaling mode between deploys. Applying the new mode's
+# resource never removes the old one on its own, so these prove the reap step
+# deletes whichever mode a service is NOT in, for every mode transition -
+# exactly what a fresh-deploy-only e2e cannot exercise.
+test "a METRIC service has its stale HTTP activation resources reaped, autoscaler left alone" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ autoscaler = unittest.mock.MagicMock();
+ bundle = {
+ "scaling": {
+ "worker": {
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": "worker-deployment",
+ "service_name": "worker-service",
+ "autoscaler": {"min": 1, "max": 3},
+ "http_activation": {}
+ }
+ }
+ };
+ reap_stale_scaling_resources_for_bundle(bundle, "t-ns", autoscaler, keda);
+ autoscaler.destroy.assert_not_called();
+ delete_calls = custom_api.delete_namespaced_custom_object.call_args_list;
+ assert len(delete_calls) == 2;
+ deleted_names = {c[1]["name"] for c in delete_calls};
+ assert deleted_names
+ == {"worker-deployment-http-route", "worker-deployment-http-scaledobject"};
+}
+
+test "an HTTP_ACTIVATION service has its stale metric autoscaler destroyed, activation left alone" {
+ # destroy() resolves its own resource name internally (resource_name_for
+ # is called inside KEDAAutoscaler.destroy / HPAAutoscaler.destroy), so
+ # the reap call site must pass the plain k8s-safe service name, not a
+ # pre-resolved one -- a MagicMock with a canned resource_name_for return
+ # would stay green even if the call site started double-wrapping it, so
+ # this pins the literal argument destroy() receives instead.
+ (keda, custom_api, _, _) = _mocked_keda();
+ autoscaler = unittest.mock.MagicMock();
+ bundle = {
+ "scaling": {
+ "billing_ops": {
+ "mode": ScalingMode.HTTP_ACTIVATION,
+ "scale_target_name": "billing-ops-deployment",
+ "service_name": "billing-ops-service",
+ "autoscaler": {},
+ "http_activation": _cfg()
+ }
+ }
+ };
+ reap_stale_scaling_resources_for_bundle(bundle, "t-ns", autoscaler, keda);
+ autoscaler.destroy.assert_called_once_with("billing-ops", "t-ns");
+ custom_api.delete_namespaced_custom_object.assert_not_called();
+}
+
+test "a NONE service has both its stale autoscaler and HTTP activation resources reaped" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ autoscaler = unittest.mock.MagicMock();
+ bundle = {
+ "scaling": {
+ "idle_svc": {
+ "mode": ScalingMode.NONE,
+ "scale_target_name": "idle-svc-deployment",
+ "service_name": "idle-svc-service",
+ "autoscaler": {},
+ "http_activation": {}
+ }
+ }
+ };
+ reap_stale_scaling_resources_for_bundle(bundle, "t-ns", autoscaler, keda);
+ autoscaler.destroy.assert_called_once();
+ assert len(custom_api.delete_namespaced_custom_object.call_args_list) == 2;
+}
+
+test "reap_stale_scaling_resources_for_bundle is a no-op on an empty bundle" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ autoscaler = unittest.mock.MagicMock();
+ reap_stale_scaling_resources_for_bundle({}, "t-ns", autoscaler, keda);
+ autoscaler.destroy.assert_not_called();
+ custom_api.delete_namespaced_custom_object.assert_not_called();
+}
+
+
+# --- Section E: _validate_http_activation_rules (interceptor routing collisions)
+# Two services that both inherit the shared [scale.kubernetes.http_activation]
+# default unchanged resolve to identical InterceptorRoute match rules; the
+# interceptor then has no way to tell which target a matching request wakes.
+test "two services sharing identical non-empty rules raise ValueError" {
+ b = _builder({});
+ scaling = {
+ "billing_ops": {
+ "mode": ScalingMode.HTTP_ACTIVATION,
+ "http_activation": {"rules": [{"hosts": ["*"]}]}
+ },
+ "worker": {
+ "mode": ScalingMode.HTTP_ACTIVATION,
+ "http_activation": {"rules": [{"hosts": ["*"]}]}
+ },
+ "gateway_ok": {"mode": ScalingMode.METRIC, "http_activation": {}}
+ };
+ raised = False;
+ try {
+ b._validate_http_activation_rules(scaling);
+ } except ValueError as e {
+ raised = True;
+ assert "billing_ops" in str(e);
+ assert "worker" in str(e);
+ }
+ assert raised;
+}
+
+def _activated(hosts: list[str] | None = None) -> dict[str, any] {
+ rules = [{"hosts": hosts}] if hosts else [];
+ return {"mode": ScalingMode.HTTP_ACTIVATION, "http_activation": {"rules": rules}};
+}
+
+test "distinct rules, both-empty rules, and a lone HTTP_ACTIVATION peer never collide" {
+ b = _builder({});
+ non_colliding_scalings = [
+ # distinct rules per service: no shared match criteria to collide on
+ {
+ "billing_ops": _activated(["billing.example.com"]),
+ "worker": _activated(["worker.example.com"])
+ },
+ # both empty: already individually dead, not ambiguous with each other
+ {"billing_ops": _activated(), "worker": _activated()},
+ # only one is HTTP_ACTIVATION; a METRIC peer's rules don't matter
+ {
+ "billing_ops": _activated(["*"]),
+ "worker": {"mode": ScalingMode.METRIC, "http_activation": {}}
+ }
+ ];
+ for scaling in non_colliding_scalings {
+ b._validate_http_activation_rules(scaling);
+ }
+}
+
+test "generate_manifests raises when two services inherit the shared catch-all rule unchanged" {
+ import from jaclang.scale.config.app_config { AppConfig }
+ import tempfile;
+ import from pathlib { Path }
+ services = {"billing_ops": {"http_activation": {"enabled": True}}, "worker": {}};
+ with tempfile.TemporaryDirectory() as project {
+ for name in list(services.keys()) + ["main"] {
+ (Path(project) / f"{name}.jac").write_text("");
+ }
+ b = ManifestBuilder(
+ k8s_config=KubernetesConfig(
+ app_name="t",
+ namespace="t-ns",
+ bundle_storage_class="standard",
+ http_activation={
+ "enabled": True,
+ "target_port": 8080,
+ "concurrency_target": 10,
+ "rules": [{"hosts": ["*"]}]
+ }
+ ),
+ microservices_config={
+ "routes": {name: f"/{name}" for name in services},
+ "services": services
+ },
+ dry_run=True
+ );
+ raised = False;
+ try {
+ b.generate_manifests(AppConfig(code_folder=project, app_name="t"), "");
+ } except ValueError as e {
+ raised = True;
+ assert "billing_ops" in str(e);
+ assert "worker" in str(e);
+ }
+ assert raised;
+ }
+}
+
+
+# --- Section F: _apply_or_replace's patch_body contract ---------------------
+# HTTP activation's "a redeploy doesn't reset a scaled-to-zero target" guarantee
+# depends entirely on _apply_or_replace sending patch_body (with replicas
+# stripped) to `update` instead of the full manifest, while a fresh `create`
+# still gets the full manifest. Pin that contract directly so a future change
+# to _apply_or_replace can't silently break it.
+
+def _target -> KubernetesTarget {
+ return KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
+}
+
+
+test "_apply_or_replace sends patch_body, not the full manifest, to update when the target exists" {
+ mock_api = unittest.mock.MagicMock();
+ manifest = {
+ "metadata": {"name": "svc-deployment"},
+ "spec": {"replicas": 3, "template": {}}
+ };
+ patch_body = {"metadata": {"name": "svc-deployment"}, "spec": {"template": {}}};
+ _target()._apply_or_replace(
+ mock_api,
+ "read_namespaced_deployment",
+ "patch_namespaced_deployment",
+ "create_namespaced_deployment",
+ "t-ns",
+ manifest,
+ patch_body
+ );
+ mock_api.patch_namespaced_deployment.assert_called_once_with(
+ name="svc-deployment", namespace="t-ns", body=patch_body
+ );
+ mock_api.create_namespaced_deployment.assert_not_called();
+}
+
+
+test "_apply_or_replace's create path gets the full manifest, replicas included, even when patch_body omits it" {
+ mock_api = unittest.mock.MagicMock();
+ mock_api.read_namespaced_deployment.side_effect = ApiException(status=404);
+ manifest = {
+ "metadata": {"name": "svc-deployment"},
+ "spec": {"replicas": 3, "template": {}}
+ };
+ patch_body = {"metadata": {"name": "svc-deployment"}, "spec": {"template": {}}};
+ _target()._apply_or_replace(
+ mock_api,
+ "read_namespaced_deployment",
+ "patch_namespaced_deployment",
+ "create_namespaced_deployment",
+ "t-ns",
+ manifest,
+ patch_body
+ );
+ mock_api.create_namespaced_deployment.assert_called_once_with(
+ namespace="t-ns", body=manifest
+ );
+ mock_api.patch_namespaced_deployment.assert_not_called();
+}
+
+
+test "_apply_or_replace sends the full manifest to update when no patch_body is given" {
+ mock_api = unittest.mock.MagicMock();
+ manifest = {"metadata": {"name": "svc-deployment"}, "spec": {"replicas": 3}};
+ _target()._apply_or_replace(
+ mock_api,
+ "read_namespaced_deployment",
+ "patch_namespaced_deployment",
+ "create_namespaced_deployment",
+ "t-ns",
+ manifest
+ );
+ mock_api.patch_namespaced_deployment.assert_called_once_with(
+ name="svc-deployment", namespace="t-ns", body=manifest
+ );
+}
+
+
+# --- Section G: KEDA ScaledObject/TriggerAuthentication -- manifest shape,
+# apply/redeploy sequencing, and destroy, exercised together end to end
+# instead of one private helper at a time. Only the Kubernetes API client is
+# mocked; KEDAAutoscaler.apply()/destroy() run for real.
+def _as_any(v: any) -> any {
+ return v;
+}
+
+def _keda_apply_mocks -> tuple[any, any];
+
+impl _keda_apply_mocks -> tuple[any, any] {
+ KEDAAutoscaler._preflight_cache.clear();
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_cluster_custom_object.return_value = {};
+ mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ return (mock_api, unittest.mock.MagicMock());
+}
+
+test "apply() produces a complete multi-trigger ScaledObject, creates its TriggerAuthentication first with the right labels, and rejects a duplicate trigger identity before any write" {
+ (mock_api, mock_v2) = _keda_apply_mocks();
+ keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
+ spec = AutoscalerSpec(
+ scale_target_name="orders-deployment",
+ app_name="orders",
+ namespace="staging",
+ min_replicas=2,
+ max_replicas=10,
+ idle_replicas=0,
+ initial_cooldown_period=120,
+ behavior_overlay={
+ "scaleDown": {"stabilizationWindowSeconds": 600, "selectPolicy": "Min"}
+ },
+ triggers=[
+ Trigger(type="cpu", metadata={"averageUtilization": "60"}),
+ Trigger(type="memory", metadata={}),
+ Trigger(
+ type="prometheus",
+ metadata={"serverAddress": "http://prom:9090", "threshold": 100}
+ ),
+ Trigger(
+ type="redis",
+ metadata={"address": "redis:6379"},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ result = keda.apply(spec);
+ assert result == True;
+
+ create_calls = mock_api.create_namespaced_custom_object.call_args_list;
+ assert len(create_calls) == 2;
+ assert create_calls[0][1]["plural"] == "triggerauthentications";
+ assert create_calls[1][1]["plural"] == "scaledobjects";
+
+ ta_body = create_calls[0][1]["body"];
+ so_body = create_calls[1][1]["body"];
+ assert ta_body["metadata"]["labels"]["app"] == "orders";
+ assert so_body["metadata"]["name"] == "orders-scaledobject";
+ assert ta_body["metadata"]["labels"]["jac-scale/owner"] == "orders-scaledobject";
+ triggers = so_body["spec"]["triggers"];
+ ref_name = triggers[3]["authenticationRef"]["name"];
+ assert ta_body["metadata"]["name"] == ref_name;
+
+ assert so_body["spec"]["scaleTargetRef"]["name"] == "orders-deployment";
+ assert so_body["spec"]["minReplicaCount"] == 2;
+ assert so_body["spec"]["maxReplicaCount"] == 10;
+ assert so_body["spec"]["idleReplicaCount"] == 0;
+ assert so_body["spec"]["initialCooldownPeriod"] == 120;
+ assert [t["type"] for t in triggers] == ["cpu", "memory", "prometheus", "redis"];
+ # cpu/memory resolve to KEDA's Utilization shape, memory defaulting to 80
+ # when its metadata is empty; a non-resource type carries no metricType
+ # and coerces integer metadata values to strings for the KEDA API.
+ assert triggers[0]["metricType"] == "Utilization";
+ assert triggers[0]["metadata"]["value"] == "60";
+ assert triggers[1]["metricType"] == "Utilization";
+ assert triggers[1]["metadata"]["value"] == "80";
+ assert "metricType" not in triggers[2];
+ assert triggers[2]["metadata"]["threshold"] == "100";
+ assert triggers[3]["name"] == "cache";
+ behavior = so_body["spec"]["advanced"]["horizontalPodAutoscalerConfig"]["behavior"];
+ assert behavior["scaleDown"]["selectPolicy"] == "Min";
+
+ dup_spec = AutoscalerSpec(
+ scale_target_name="orders-deployment",
+ app_name="orders",
+ namespace="staging",
+ triggers=[
+ Trigger(type="redis", metadata={}, name="cache"),
+ Trigger(type="rabbitmq", metadata={}, name="cache")
+ ]
+ );
+ raised = False;
+ try {
+ keda.apply(dup_spec);
+ } except ValueError as e {
+ raised = True;
+ assert "cache" in str(e);
+ }
+ assert raised;
+ # The rejected spec must not have written anything beyond the first apply.
+ assert len(mock_api.create_namespaced_custom_object.call_args_list) == 2;
+}
+
+test "redeploy patches the ScaledObject instead of duplicating it, prunes a dropped trigger's TriggerAuthentication, and keeps one still referenced" {
+ (mock_api, mock_v2) = _keda_apply_mocks();
+ keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
+ spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ app_name="svc",
+ namespace="default",
+ triggers=[
+ Trigger(
+ type="redis",
+ metadata={"address": "redis:6379"},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ keda.apply(spec);
+
+ # Redeploy dropping the auth trigger: the ScaledObject now exists (get
+ # succeeds) and the stale TriggerAuthentication is still on the cluster.
+ stale_ta_name = mock_api.create_namespaced_custom_object.call_args_list[0][1][
+ "body"
+ ]["metadata"]["name"];
+ mock_api.get_namespaced_custom_object.side_effect = None;
+ mock_api.get_namespaced_custom_object.return_value = {
+ "metadata": {"name": "svc-deployment-scaledobject"}
+ };
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": stale_ta_name}}]
+ };
+ result = keda.apply(
+ AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ app_name="svc",
+ namespace="default",
+ triggers=[Trigger(type="cpu", metadata={})]
+ )
+ );
+ assert result == True;
+ patch_calls = [
+ c
+ for c in mock_api.patch_namespaced_custom_object.call_args_list
+ if c[1]["plural"] == "scaledobjects"
+ ];
+ assert len(patch_calls) == 1;
+ prune_calls = [
+ c
+ for c in mock_api.delete_namespaced_custom_object.call_args_list
+ if c[1]["plural"] == "triggerauthentications"
+ ];
+ assert len(prune_calls) == 1;
+ assert prune_calls[0][1]["name"] == stale_ta_name;
+
+ # A third deploy that keeps the same auth trigger must not prune it.
+ mock_api.delete_namespaced_custom_object.reset_mock();
+ keda2 = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
+ keep_spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ app_name="svc",
+ namespace="default",
+ triggers=[
+ Trigger(
+ type="redis",
+ metadata={"address": "redis:6379"},
+ name="cache",
+ auth=TriggerAuth(
+ secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
+ )
+ )
+ ]
+ );
+ desired_name = keda2._trigger_auth_name("svc", keep_spec.triggers[0], 0);
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": desired_name}}]
+ };
+ keda2.apply(keep_spec);
+ kept_prune_calls = [
+ c
+ for c in mock_api.delete_namespaced_custom_object.call_args_list
+ if c[1]["plural"] == "triggerauthentications"
+ ];
+ assert len(kept_prune_calls) == 0;
+}
+
+test "switching the autoscaler engine to keda deletes a competing HPA (a no-op when none exists), and a missing CRD skips creation with a cached, logged diagnostic" {
+ (mock_api, mock_v2) = _keda_apply_mocks();
+ mock_logger = unittest.mock.MagicMock();
+ keda = KEDAAutoscaler(
+ _custom_api=mock_api, _v2_api=mock_v2, logger=_as_any(mock_logger)
+ );
+ spec = AutoscalerSpec(
+ scale_target_name="svc-deployment",
+ app_name="svc",
+ namespace="default",
+ triggers=[Trigger(type="cpu", metadata={})]
+ );
+ keda.apply(spec);
+ mock_v2.delete_namespaced_horizontal_pod_autoscaler.assert_called_once_with(
+ name="svc-hpa", namespace="default"
+ );
+
+ mock_v2.delete_namespaced_horizontal_pod_autoscaler.side_effect = ApiException(
+ status=404
+ );
+ keda2 = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
+ assert keda2.apply(spec) == True , "engine-switch HPA deletion must be a no-op, not a failure, when no HPA exists";
+
+ KEDAAutoscaler._preflight_cache.clear();
+ missing_crd_api = unittest.mock.MagicMock();
+ missing_crd_api.list_cluster_custom_object.side_effect = ApiException(status=404);
+ keda3 = KEDAAutoscaler(_custom_api=missing_crd_api, logger=_as_any(mock_logger));
+ assert keda3.apply(spec) == False;
+ missing_crd_api.create_namespaced_custom_object.assert_not_called();
+ mock_logger.warn.assert_called_once();
+ warn_msg = mock_logger.warn.call_args[0][0];
+ assert "KEDA CRDs not found" in warn_msg;
+ assert "keda.sh/docs" in warn_msg;
+ keda3.preflight();
+ assert missing_crd_api.list_cluster_custom_object.call_count == 1 , "the missing-CRD result must be cached, not re-queried";
+}
+
+test "destroy sweeps the ScaledObject and every TriggerAuthentication it owns, tolerating an already-gone resource or an absent TriggerAuthentication CRD; destroy_collection also sweeps competing HPAs and is silent when KEDA itself is not installed" {
+ mock_api = unittest.mock.MagicMock();
+ mock_api.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": "my-svc-scaledobject-89abcdef-ta"}}]
+ };
+ keda = KEDAAutoscaler(_custom_api=mock_api);
+ keda.destroy("my-svc", "staging");
+ mock_api.list_namespaced_custom_object.assert_called_once_with(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace="staging",
+ plural="triggerauthentications",
+ label_selector="jac-scale/owner=my-svc-scaledobject,managed=jac-scale"
+ );
+ delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
+ assert len(delete_calls) == 2;
+ assert delete_calls[0][1]["plural"] == "triggerauthentications";
+ assert delete_calls[0][1]["name"] == "my-svc-scaledobject-89abcdef-ta";
+ assert delete_calls[1][1]["plural"] == "scaledobjects";
+ assert delete_calls[1][1]["name"] == "my-svc-scaledobject";
+
+ # Already gone: a 404 on both deletes is tolerated, not raised.
+ mock_api2 = unittest.mock.MagicMock();
+ mock_api2.list_namespaced_custom_object.return_value = {"items": []};
+ mock_api2.delete_namespaced_custom_object.side_effect = ApiException(status=404);
+ KEDAAutoscaler(_custom_api=mock_api2).destroy("my-svc", "staging");
+
+ # TriggerAuthentication CRD absent entirely: the ScaledObject delete
+ # still runs.
+ mock_api3 = unittest.mock.MagicMock();
+ mock_api3.list_namespaced_custom_object.side_effect = ApiException(status=404);
+ KEDAAutoscaler(_custom_api=mock_api3).destroy("my-svc", "staging");
+ mock_api3.delete_namespaced_custom_object.assert_called_once_with(
+ group="keda.sh",
+ version="v1alpha1",
+ namespace="staging",
+ plural="scaledobjects",
+ name="my-svc-scaledobject"
+ );
+
+ # destroy_collection sweeps ScaledObjects, TriggerAuthentications, and
+ # InterceptorRoutes together, plus any competing HPA, and stays silent
+ # when the CRD group itself is missing (404/422).
+ mock_api4 = unittest.mock.MagicMock();
+ mock_api4.list_namespaced_custom_object.return_value = {
+ "items": [{"metadata": {"name": "svc-scaledobject"}}]
+ };
+ mock_v2 = unittest.mock.MagicMock();
+ KEDAAutoscaler(_custom_api=mock_api4, _v2_api=mock_v2).destroy_collection(
+ "staging", "managed=jac-scale"
+ );
+ plurals = [
+ c[1]["plural"] for c in mock_api4.delete_namespaced_custom_object.call_args_list
+ ];
+ assert set(plurals)
+ == {"scaledobjects", "triggerauthentications", "interceptorroutes"};
+ mock_v2.delete_collection_namespaced_horizontal_pod_autoscaler.assert_called_once_with(
+ namespace="staging", label_selector="managed=jac-scale"
+ );
+
+ for status in (404, 422) {
+ mock_api5 = unittest.mock.MagicMock();
+ mock_api5.list_namespaced_custom_object.side_effect = ApiException(
+ status=status
+ );
+ KEDAAutoscaler(
+ _custom_api=mock_api5, _v2_api=unittest.mock.MagicMock()
+ ).destroy_collection(
+ "staging", "managed=jac-scale"
+ );
+ }
+}
+
+
+# --- Section H: KEDA HTTP Add-on activation -- InterceptorRoute/ScaledObject
+# shape, apply ordering, target validation, capability discovery, and
+# destroy, exercised together end to end. Only the Kubernetes API client is
+# mocked; KEDAAutoscaler.apply_http_activation()/destroy_http_activation()
+# run for real.
+test "apply_http_activation encodes multi-rule routing (AND-within-rule, OR-between-rules, multi-path expansion), cold_start, timeouts, and request_rate, then creates InterceptorRoute before ScaledObject; a redeploy patches both instead of duplicating" {
+ (keda, custom_api, _, _) = _mocked_keda();
+ custom_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ spec = _spec(
+ namespace="staging",
+ rules=[
+ HTTPRoutingRule(
+ hosts=["preview.jac.dev"],
+ paths=[HTTPPathMatch(value="/app"), HTTPPathMatch(value="/api")],
+ headers=[HTTPHeaderMatch(name="x-preview-id", value="123")]
+ ),
+ HTTPRoutingRule(hosts=["fallback.jac.dev"])
+ ],
+ cold_start=HTTPColdStartSpec(
+ placeholder=HTTPStaticResponse(status_code=503, body="warming up"),
+ fallback_service=HTTPServiceTarget(service="fallback-svc", port=8080)
+ ),
+ timeouts=HTTPTimeoutSpec(readiness="30s", request="60s", response_header="5s"),
+ concurrency=None,
+ request_rate=HTTPRequestRateMetric(
+ target_value=50, rate_window="5m", granularity="10s"
+ )
+ );
+ result = keda.apply_http_activation(spec);
+ assert result == True;
+
+ create_calls = custom_api.create_namespaced_custom_object.call_args_list;
+ assert len(create_calls) == 2;
+ assert create_calls[0][1]["plural"] == "interceptorroutes";
+ assert create_calls[1][1]["plural"] == "scaledobjects";
+
+ route_spec = create_calls[0][1]["body"]["spec"];
+ # The multi-path rule expands into two OR-grouped match blocks, each
+ # still carrying its shared hosts/headers (AND-within-rule); the
+ # single-host fallback rule stays its own separate block.
+ assert len(route_spec["rules"]) == 3;
+ app_rules = [
+ r
+ for r in route_spec["rules"]
+ if r["hosts"] == ["preview.jac.dev"]
+ ];
+ assert len(app_rules) == 2;
+ path_values = [r["path"]["value"] for r in app_rules];
+ assert sorted(path_values) == ["/api", "/app"];
+ for r in app_rules {
+ assert r["headers"][0]["name"] == "x-preview-id";
+ }
+ fallback_rules = [
+ r
+ for r in route_spec["rules"]
+ if r["hosts"] == ["fallback.jac.dev"]
+ ];
+ assert len(fallback_rules) == 1;
+ assert "path" not in fallback_rules[0];
+
+ assert route_spec["coldStart"]["placeholder"]["response"]["statusCode"] == 503;
+ assert route_spec["coldStart"]["fallback"]["service"]["name"] == "fallback-svc";
+ assert route_spec["timeouts"]["readiness"] == "30s";
+ assert route_spec["timeouts"]["request"] == "60s";
+ scaling_metric = route_spec["scalingMetric"];
+ assert scaling_metric["requestRate"]["targetValue"] == 50;
+ assert scaling_metric["requestRate"]["window"] == "5m";
+ assert "concurrency" not in scaling_metric;
+
+ so_spec = create_calls[1][1]["body"]["spec"];
+ trigger_metadata = so_spec["triggers"][0]["metadata"];
+ assert trigger_metadata["targetValue"] == "50";
+ assert trigger_metadata["window"] == "5m";
+ assert trigger_metadata["granularity"] == "10s";
+ assert so_spec["triggers"][0]["metadata"]["interceptorRoute"]
+ == keda.interceptor_route_name_for(spec.scale_target_name);
+
+ # Redeploy: both resources now exist, so both get patched, not recreated.
+ custom_api.get_namespaced_custom_object.side_effect = None;
+ custom_api.get_namespaced_custom_object.return_value = {
+ "metadata": {"name": "existing"}
+ };
+ keda.apply_http_activation(spec);
+ patch_calls = custom_api.patch_namespaced_custom_object.call_args_list;
+ assert len(patch_calls) == 2;
+ assert patch_calls[0][1]["plural"] == "interceptorroutes";
+ assert patch_calls[1][1]["plural"] == "scaledobjects";
+ assert len(custom_api.create_namespaced_custom_object.call_args_list) == 2 , "the redeploy must not create a second time";
+}
+
+test "apply_http_activation validates the spec itself (port XOR port_name, concurrency or request_rate) and its scale target and Service before writing anything: rejects a bare Pod, an unrecognized kind without scale_target_plural, and a missing target or Service; accepts a StatefulSet and a custom kind with scale_target_plural set" {
+ rejections = [
+ (
+ "bare Pod",
+ _spec(scale_target_name="preview-pod", scale_target_kind="Pod"),
+ "/scale"
+ ),
+ (
+ "unrecognized kind, no plural",
+ _spec(scale_target_name="preview-rollout", scale_target_kind="Rollout"),
+ "scale_target_plural"
+ ),
+ (
+ "both port and port_name set",
+ _spec(
+ target=HTTPServiceTarget(
+ service="preview-svc", port=8080, port_name="http"
+ )
+ ),
+ "not both"
+ ),
+ (
+ "neither port nor port_name set",
+ _spec(target=HTTPServiceTarget(service="preview-svc")),
+ "must set exactly one of port or port_name."
+ ),
+ (
+ "neither concurrency nor request_rate set",
+ _spec(concurrency=None),
+ "concurrency or request_rate"
+ )
+ ];
+ for (label, spec, expected_substring) in rejections {
+ (keda, custom_api, _, _) = _mocked_keda();
+ raised = False;
+ error_msg = "";
+ try {
+ keda.apply_http_activation(spec);
+ } except ValueError as e {
+ raised = True;
+ error_msg = str(e);
+ }
+ assert raised , f"'{label}' must raise ValueError";
+ assert expected_substring in error_msg , f"'{label}': expected '{expected_substring}' in: {error_msg}";
+ custom_api.create_namespaced_custom_object.assert_not_called();
+ }
+
+ (keda2, custom_api2, _, _) = _mocked_keda();
+ custom_api2.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ assert keda2.apply_http_activation(
+ _spec(scale_target_name="preview-sts", scale_target_kind="StatefulSet")
+ )
+ == True;
+
+ (keda3, custom_api3, _, _) = _mocked_keda();
+ result = keda3.apply_http_activation(
+ _spec(
+ scale_target_name="preview-rollout",
+ scale_target_kind="Rollout",
+ scale_target_api_version="argoproj.io/v1alpha1",
+ scale_target_plural="rollouts"
+ )
+ );
+ assert result == True;
+ target_checks = [
+ c
+ for c in custom_api3.get_namespaced_custom_object.call_args_list
+ if c[1].get("plural") == "rollouts"
+ ];
+ assert len(target_checks) == 1;
+ assert target_checks[0][1]["group"] == "argoproj.io";
+
+ (keda4, custom_api4, _, _) = _mocked_keda();
+ custom_api4.get_namespaced_custom_object.side_effect = ApiException(status=404);
+ raised = False;
+ try {
+ keda4.apply_http_activation(
+ _spec(
+ scale_target_name="missing-rollout",
+ scale_target_kind="Rollout",
+ scale_target_api_version="argoproj.io/v1alpha1",
+ scale_target_plural="rollouts"
+ )
+ );
+ } except ValueError {
+ raised = True;
+ }
+ assert raised , "a custom target with scale_target_plural set must still be checked for existence";
+
+ missing_target_apps = unittest.mock.MagicMock();
+ missing_target_apps.read_namespaced_deployment.side_effect = ApiException(
+ status=404
+ );
+ (keda5, _, _, _) = _mocked_keda(apps_api=missing_target_apps);
+ raised = False;
+ try {
+ keda5.apply_http_activation(_spec(scale_target_name="missing-deployment"));
+ } except ValueError {
+ raised = True;
+ }
+ assert raised , "a missing Deployment scale target must raise";
+
+ missing_service_core = unittest.mock.MagicMock();
+ missing_service_core.read_namespaced_service.side_effect = ApiException(status=404);
+ (keda6, _, _, _) = _mocked_keda(core_api=missing_service_core);
+ raised = False;
+ try {
+ keda6.apply_http_activation(
+ _spec(target=HTTPServiceTarget(service="missing-svc", port=8080))
+ );
+ } except ValueError {
+ raised = True;
+ }
+ assert raised , "a missing Service target must raise";
+}
+
+test "apply_http_activation returns False and creates nothing when the HTTP Add-on's CRDs are absent or core KEDA itself is missing, and raises when the Add-on is installed but genuinely broken" {
+ (keda, custom_api, _, _) = _mocked_keda(http_addon_installed=False);
+ assert keda.apply_http_activation(_spec()) == False;
+ custom_api.create_namespaced_custom_object.assert_not_called();
+
+ core_missing_api = unittest.mock.MagicMock();
+ def core_missing_side_effect(*args: any, **kwargs: any) -> any {
+ if kwargs.get("group") == "keda.sh" {
+ raise ApiException(status=404);
+ }
+ return {};
+ }
+ core_missing_api.list_cluster_custom_object.side_effect = core_missing_side_effect;
+ keda2 = KEDAAutoscaler(
+ _custom_api=core_missing_api,
+ _core_api=unittest.mock.MagicMock(),
+ _apps_api=unittest.mock.MagicMock()
+ );
+ assert keda2.apply_http_activation(_spec()) == False , "core KEDA missing must skip activation even if the Add-on itself is healthy";
+ core_missing_api.create_namespaced_custom_object.assert_not_called();
+
+ broken_scaler_core = unittest.mock.MagicMock();
+ def broken_scaler_side_effect(name: str, *args: any, **kwargs: any) -> any {
+ if "scaler" in name {
+ raise ApiException(status=404);
+ }
+ return {};
+ }
+ broken_scaler_core.read_namespaced_service.side_effect = broken_scaler_side_effect;
+ keda3 = KEDAAutoscaler(
+ _custom_api=unittest.mock.MagicMock(),
+ _core_api=broken_scaler_core,
+ _apps_api=unittest.mock.MagicMock()
+ );
+ raised = False;
+ error_msg = "";
+ try {
+ keda3.apply_http_activation(_spec());
+ } except ValueError as e {
+ raised = True;
+ error_msg = str(e);
+ }
+ assert raised , "an installed but broken Add-on (missing scaler Service) must raise, not silently skip";
+ assert "scaler" in error_msg.lower();
+}
+
+test "InterceptorRoute and ScaledObject names are deterministic and distinct; destroy_http_activation always sweeps both, tolerating a 403 on one while still attempting the other, and never masking a genuine 500 behind a later tolerable 403; destroy_collection sweeps InterceptorRoutes too but only tolerates a 403 there, not on keda.sh, unless best_effort" {
+ keda = KEDAAutoscaler();
+ route_name = keda.interceptor_route_name_for("preview-deployment");
+ scaled_obj_name = keda.http_scaled_object_name_for("preview-deployment");
+ assert route_name != scaled_obj_name;
+ assert route_name == keda.interceptor_route_name_for("preview-deployment");
+
+ mock_api = unittest.mock.MagicMock();
+ KEDAAutoscaler(_custom_api=mock_api).destroy_http_activation(
+ "preview-deployment", "staging"
+ );
+ plurals = [
+ c[1]["plural"] for c in mock_api.delete_namespaced_custom_object.call_args_list
+ ];
+ assert set(plurals) == {"interceptorroutes", "scaledobjects"};
+
+ mock_api2 = unittest.mock.MagicMock();
+ mock_api2.delete_namespaced_custom_object.side_effect = [
+ ApiException(status=403), # InterceptorRoute delete forbidden
+ None # ScaledObject delete still attempted, and succeeds
+ ];
+ status2 = None;
+ try {
+ KEDAAutoscaler(_custom_api=mock_api2).destroy_http_activation(
+ "preview-deployment", "staging"
+ );
+ } except ApiException as e {
+ status2 = e.status;
+ }
+ assert status2 == 403 , "the 403 must still surface after both deletes are attempted";
+ plurals2 = [
+ c[1]["plural"] for c in mock_api2.delete_namespaced_custom_object.call_args_list
+ ];
+ assert set(plurals2) == {"interceptorroutes", "scaledobjects"};
+
+ mock_api3 = unittest.mock.MagicMock();
+ mock_api3.delete_namespaced_custom_object.side_effect = [
+ ApiException(status=500), # a genuine server error on the first delete
+ ApiException(status=403) # a tolerable error on the second
+ ];
+ status3 = None;
+ try {
+ KEDAAutoscaler(_custom_api=mock_api3).destroy_http_activation(
+ "preview-deployment", "staging"
+ );
+ } except ApiException as e {
+ status3 = e.status;
+ }
+ assert status3 == 500 , "a 500 on the first delete must not be masked by a later tolerable 403";
+
+ # The same priority the other way around: a tolerable 403 on the first
+ # delete must not mask a genuine 500 on the second.
+ mock_api3b = unittest.mock.MagicMock();
+ mock_api3b.delete_namespaced_custom_object.side_effect = [
+ ApiException(status=403),
+ ApiException(status=500)
+ ];
+ status3b = None;
+ try {
+ KEDAAutoscaler(_custom_api=mock_api3b).destroy_http_activation(
+ "preview-deployment", "staging"
+ );
+ } except ApiException as e {
+ status3b = e.status;
+ }
+ assert status3b == 500 , "a 500 on the second delete must not be masked by a tolerable 403 on the first";
+
+ def destroy_collection_raised(mock: any, best_effort: bool = False) -> bool {
+ try {
+ KEDAAutoscaler(
+ _custom_api=mock, _v2_api=unittest.mock.MagicMock()
+ ).destroy_collection(
+ "staging", "managed=jac-scale", best_effort=best_effort
+ );
+ } except ApiException {
+ return True;
+ }
+ return False;
+ }
+
+ mock_api4 = unittest.mock.MagicMock();
+ def list_side_effect(**kwargs: any) -> any {
+ if kwargs.get("group") == "http.keda.sh" {
+ raise ApiException(status=403);
+ }
+ return {"items": [{"metadata": {"name": "preview-deployment-scaledobject"}}]};
+ }
+ mock_api4.list_namespaced_custom_object.side_effect = list_side_effect;
+ assert not destroy_collection_raised(mock_api4) , "a 403 listing InterceptorRoutes must not abort the sweep of the other resource kinds";
+ swept_plurals = [
+ c[1]["plural"] for c in mock_api4.delete_namespaced_custom_object.call_args_list
+ ];
+ assert "scaledobjects" in swept_plurals;
+ assert "triggerauthentications" in swept_plurals;
+
+ mock_api5 = unittest.mock.MagicMock();
+ mock_api5.list_namespaced_custom_object.side_effect = ApiException(status=403);
+ assert destroy_collection_raised(mock_api5) , "a 403 on the core keda.sh group is not tolerated by default";
+ assert not destroy_collection_raised(mock_api5, best_effort=True) , "best_effort=True must tolerate a 403 on every resource group, including keda.sh";
+}
+
+def _keda_for(
+ custom_api: any = None, core_api: any = None, apps_api: any = None
+) -> KEDAAutoscaler {
+ return KEDAAutoscaler(
+ _custom_api=custom_api or unittest.mock.MagicMock(),
+ _core_api=core_api or unittest.mock.MagicMock(),
+ _apps_api=apps_api or unittest.mock.MagicMock()
+ );
+}
+
+def _custom_api_for(
+ core: (int | None) = None,
+ http_v1beta1: (int | None) = None,
+ legacy_v1alpha1: (int | None) = None
+) -> any {
+ api = unittest.mock.MagicMock();
+ def side_effect(*args: any, **kwargs: any) -> any {
+ status = None;
+ if kwargs.get("group") == "keda.sh" {
+ status = core;
+ } elif kwargs.get("version") == "v1beta1" {
+ status = http_v1beta1;
+ } elif kwargs.get("version") == "v1alpha1" {
+ status = legacy_v1alpha1;
+ }
+ if status is not None {
+ raise ApiException(status=status);
+ }
+ return {};
+ }
+ api.list_cluster_custom_object.side_effect = side_effect;
+ return api;
+}
+
+def _core_api_for(
+ scaler: (int | None) = None, interceptor: (int | None) = None
+) -> any {
+ api = unittest.mock.MagicMock();
+ def side_effect(name: str, *args: any, **kwargs: any) -> any {
+ status = interceptor if "interceptor" in name else scaler;
+ if status is not None {
+ raise ApiException(status=status);
+ }
+ return {};
+ }
+ api.read_namespaced_service.side_effect = side_effect;
+ return api;
+}
+
+test "discover_capabilities produces a distinct diagnostic for every install/permission combination" {
+ # core-vs-Add-on presence, per-service health, and RBAC-denied vs
+ # genuinely-absent must each report their own diagnosis -- an operator
+ # debugging a broken activation reads this message, not the code.
+ scenarios = [
+ (
+ "core-only",
+ _custom_api_for(http_v1beta1=404, legacy_v1alpha1=404),
+ _core_api_for()
+ ),
+ ("full-install", _custom_api_for(), _core_api_for()),
+ ("missing-external-scaler", _custom_api_for(), _core_api_for(scaler=404)),
+ (
+ "missing-interceptor-service",
+ _custom_api_for(),
+ _core_api_for(interceptor=404)
+ ),
+ (
+ "legacy-addon-needs-upgrade",
+ _custom_api_for(http_v1beta1=404),
+ _core_api_for()
+ ),
+ ("rbac-denied-on-core", _custom_api_for(core=403), _core_api_for()),
+ (
+ "rbac-denied-on-http-addon",
+ _custom_api_for(http_v1beta1=403),
+ _core_api_for()
+ ),
+ (
+ "rbac-denied-on-legacy-probe",
+ _custom_api_for(http_v1beta1=404, legacy_v1alpha1=403),
+ _core_api_for()
+ )
+ ];
+ for (label, custom_api, core_api) in scenarios {
+ keda = _keda_for(custom_api=custom_api, core_api=core_api);
+ caps = keda.discover_capabilities();
+ msg = f"scenario '{label}' produced: warnings={caps.warnings} errors={caps.errors}";
+
+ if label == "core-only" {
+ assert caps.core_available == True , msg;
+ assert caps.http_addon_available == False , msg;
+ } elif label == "full-install" {
+ assert caps.core_available == True , msg;
+ assert caps.http_addon_available == True , msg;
+ assert caps.errors == [] , msg;
+ assert caps.interceptor_route_api_version == "v1beta1" , msg;
+ } elif label == "missing-external-scaler" {
+ assert any([("scaler" in e.lower()) for e in caps.errors]) , msg;
+ } elif label == "missing-interceptor-service" {
+ assert any([("interceptor" in e.lower()) for e in caps.errors]) , msg;
+ } elif label == "legacy-addon-needs-upgrade" {
+ assert any([("upgrade" in w.lower()) for w in caps.warnings]) , msg;
+ assert caps.http_addon_available == False , msg;
+ } elif label == "rbac-denied-on-core" {
+ assert caps.core_available == False , msg;
+ assert any(
+ [
+ ("permission" in e.lower() or "rbac" in e.lower())
+ for e in caps.errors
+ ]
+ ) , msg;
+ } elif label == "rbac-denied-on-http-addon" {
+ assert any(
+ [
+ ("permission" in e.lower() or "rbac" in e.lower())
+ for e in caps.errors
+ ]
+ ) , msg;
+ legacy_calls = [
+ c
+ for c in custom_api.list_cluster_custom_object.call_args_list
+ if c[1].get("plural") == "httpscaledobjects"
+ ];
+ assert legacy_calls == [] , (
+ f"{label}: RBAC-denied v1beta1 must not fall through to the "
+ "legacy-version probe (that's only for a genuinely absent API)"
+ );
+ } elif label == "rbac-denied-on-legacy-probe" {
+ assert any(
+ [
+ ("permission" in e.lower() or "rbac" in e.lower())
+ for e in caps.errors
+ ]
+ ) , (
+ f"{label}: an RBAC-denied legacy probe must be reported as a "
+ "permission error, not silently treated as 'not installed'"
+ );
+ }
+ }
+}
+
+test "discover_capabilities caches per (cluster, service-address, client-identity) and only re-queries on refresh=True or invalidate_capabilities(); format_capabilities and preflight agree with the underlying state, and FQDN service addresses resolve to name and namespace" {
+ custom_api = _custom_api_for();
+ keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
+ keda.discover_capabilities();
+ keda.discover_capabilities();
+ assert custom_api.list_cluster_custom_object.call_count == 2 , "a second call must hit the cache (2 calls = one discovery round: core + http)";
+ keda.discover_capabilities(refresh=True);
+ assert custom_api.list_cluster_custom_object.call_count == 4 , "refresh=True must bypass the cache";
+ keda.invalidate_capabilities();
+ keda.discover_capabilities();
+ assert custom_api.list_cluster_custom_object.call_count == 6 , "invalidate_capabilities must force the next call to requery";
+
+ ready_keda = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
+ ready_caps = ready_keda.discover_capabilities();
+ assert "ready" in ready_keda.format_capabilities(ready_caps).lower();
+
+ not_ready_keda = _keda_for(
+ custom_api=_custom_api_for(http_v1beta1=404, legacy_v1alpha1=404),
+ core_api=_core_api_for()
+ );
+ not_ready_caps = not_ready_keda.discover_capabilities();
+ assert "helm" in not_ready_keda.format_capabilities(not_ready_caps).lower();
+
+ core_missing_keda = _keda_for(
+ custom_api=_custom_api_for(core=404), core_api=_core_api_for()
+ );
+ core_missing_caps = core_missing_keda.discover_capabilities();
+ assert core_missing_caps.http_addon_available == True;
+ assert "not ready"
+ in core_missing_keda.format_capabilities(core_missing_caps).lower() , (
+ "a healthy Add-on with missing core KEDA must not be reported as ready"
+ );
+
+ # Two instances with the same cluster host but a different
+ # http_scaler_address must not share a cache entry.
+ keda_a = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
+ caps_a = keda_a.discover_capabilities();
+ assert caps_a.errors == [];
+ keda_b = KEDAAutoscaler(
+ _custom_api=_custom_api_for(),
+ _core_api=_core_api_for(scaler=404),
+ http_scaler_address="custom-scaler.other-ns:9090"
+ );
+ caps_b = keda_b.discover_capabilities();
+ assert any([("scaler" in e.lower()) for e in caps_b.errors]) , "a distinct http_scaler_address must not reuse instance A's cached result";
+
+ # Two instances with the same cluster key and service addresses but a
+ # different injected client must not share a cache entry either.
+ keda_c = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
+ assert keda_c.discover_capabilities().errors == [];
+ keda_d = KEDAAutoscaler(
+ _custom_api=_custom_api_for(core=403), _core_api=_core_api_for()
+ );
+ caps_d = keda_d.discover_capabilities();
+ assert any(
+ [("permission" in e.lower() or "rbac" in e.lower()) for e in caps_d.errors]
+ ) , "a distinct (RBAC-restricted) client must not reuse instance C's cached result";
+
+ fqdn_core = _core_api_for();
+ KEDAAutoscaler(
+ _custom_api=_custom_api_for(),
+ _core_api=fqdn_core,
+ _apps_api=unittest.mock.MagicMock(),
+ http_scaler_address="keda-add-ons-http-external-scaler.keda.svc.cluster.local:9090",
+ interceptor_service_address="keda-add-ons-http-interceptor-proxy.keda.svc.cluster.local:8080"
+ ).discover_capabilities();
+ lookups = [
+ (c[1]["name"], c[1]["namespace"])
+ for c in fqdn_core.read_namespaced_service.call_args_list
+ ];
+ assert lookups
+ == [
+ ("keda-add-ons-http-external-scaler", "keda"),
+ ("keda-add-ons-http-interceptor-proxy", "keda")
+ ] , f"FQDN service addresses must parse as name.namespace; got: {lookups}";
+
+ preflight_api = unittest.mock.MagicMock();
+ preflight_api.list_cluster_custom_object.side_effect = ApiException(status=403);
+ raised = False;
+ try {
+ KEDAAutoscaler(_custom_api=preflight_api).preflight();
+ } except ApiException {
+ raised = True;
+ }
+ assert not raised , "preflight must report a 403 as a warning, not an unhandled exception";
+}
diff --git a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac b/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
deleted file mode 100644
index e932b15ae81..00000000000
--- a/jac/jaclang/scale/tests/deploy/test_keda_autoscaler.jac
+++ /dev/null
@@ -1,806 +0,0 @@
-import unittest.mock;
-import from kubernetes.client.exceptions { ApiException }
-import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
-import from jaclang.scale.deploy.autoscale.autoscaler {
- AutoscalerSpec,
- Trigger,
- TriggerAuth
-}
-
-
-test "cpu trigger is translated to KEDA Utilization format" {
- keda = KEDAAutoscaler();
- result = keda._build_trigger(
- Trigger(type="cpu", metadata={"averageUtilization": "70"}), "myapp", 0
- );
- assert result["type"] == "cpu";
- assert result["metricType"] == "Utilization";
- assert result["metadata"]["value"] == "70";
-
- assert "metricType" not in result["metadata"];
-}
-
-test "memory trigger uses its own default 80 when metadata is empty" {
- keda = KEDAAutoscaler();
- result = keda._build_trigger(Trigger(type="memory", metadata={}), "myapp", 0);
- assert result["type"] == "memory";
- assert result["metricType"] == "Utilization";
- assert result["metadata"]["value"] == "80";
-}
-
-test "non-resource trigger metadata integer values are coerced to strings" {
- keda = KEDAAutoscaler();
- result = keda._build_trigger(
- Trigger(
- type="prometheus",
- metadata={"serverAddress": "http://prom:9090", "threshold": 100}
- ),
- "myapp",
- 0
- );
- assert result["type"] == "prometheus";
- assert result["metadata"]["threshold"] == "100";
- assert result["metadata"]["serverAddress"] == "http://prom:9090";
-
- assert "metricType" not in result;
-}
-
-test "trigger name is propagated to KEDA trigger dict" {
- keda = KEDAAutoscaler();
- result = keda._build_trigger(
- Trigger(type="redis", metadata={}, name="redis-queue"), "myapp", 0
- );
- assert result["name"] == "redis-queue";
-}
-
-test "trigger with auth adds authenticationRef whose name matches the created TriggerAuthentication" {
- keda = KEDAAutoscaler();
- result = keda._build_trigger(
- Trigger(
- type="redis",
- metadata={},
- name="redis-queue",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- ),
- app_name="myapp",
- trigger_index=0
- );
- assert result["authenticationRef"] == {"name": "myapp-c3a558ec-ta"};
-}
-
-test "TriggerAuthentication names are scoped per service and per trigger position" {
- # A shared name lets one deploy silently patch another's TriggerAuthentication with
- # the wrong credentials. Names must differ across services (Gap 1) and across
- # positions of an unnamed trigger within one spec (Gap 2).
- keda = KEDAAutoscaler();
- trigger = Trigger(
- type="redis",
- metadata={},
- auth=TriggerAuth(secret_refs={"password": {"name": "secret", "key": "pw"}})
- );
- order = keda._build_trigger(trigger, "order-service", 0)["authenticationRef"][
- "name"
- ];
- payment = keda._build_trigger(trigger, "payment-service", 0)["authenticationRef"][
- "name"
- ];
- order_second = keda._build_trigger(trigger, "order-service", 1)["authenticationRef"][
- "name"
- ];
- assert order.startswith("order-service-") and order.endswith("-ta");
- assert payment.startswith("payment-service-");
- assert order != payment;
- assert order != order_second;
-}
-
-test "TriggerAuthentication manifest carries app and owner labels for reconciliation" {
- # apply()'s prune step and destroy() both find TriggerAuthentications by
- # this label, not by parsing the ScaledObject's authenticationRef.
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="order-service-deployment",
- app_name="order-service",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={"address": "redis:6379"},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- )
- ]
- );
- manifests = keda._build_manifests(spec);
- ta = [
- m
- for m in manifests
- if m["kind"] == "TriggerAuthentication"
- ][0];
- labels = ta["metadata"]["labels"];
- assert labels["app"] == "order-service";
- assert labels["jac-scale/owner"] == "order-service-scaledobject";
- assert labels["managed"] == "jac-scale";
-}
-
-test "duplicate trigger identities raise ValueError before any cluster write" {
- # Two triggers resolving to the same identity would overwrite each other's
- # TriggerAuthentication; apply() must reject the spec up front.
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- app_name="order-service",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- ),
- Trigger(
- type="rabbitmq",
- metadata={},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "rmq-secret", "key": "pw"}}
- )
- )
- ]
- );
- raised = False;
- try {
- keda.apply(spec);
- } except ValueError as e {
- raised = True;
- assert "cache" in str(e);
- }
- assert raised;
-}
-
-
-test "build manifests rejects duplicate trigger identities so the dry run refuses what apply refuses" {
- # The rendered YAML is documented as pipeable into kubectl apply; if only
- # apply() validated, the dry run would emit colliding TriggerAuthentications
- # that silently resolve to whichever secret kubectl applied last.
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- app_name="order-service",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- ),
- Trigger(
- type="rabbitmq",
- metadata={},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "rmq-secret", "key": "pw"}}
- )
- )
- ]
- );
- raised = False;
- try {
- keda._build_manifests(spec);
- } except ValueError as e {
- raised = True;
- assert "cache" in str(e);
- }
- assert raised;
-}
-
-
-test "build manifests rejects an unnamed trigger colliding with an explicitly named one" {
- # An unnamed trigger auto-keys to "-". KEDA's admission webhook
- # cannot see this collision because only one trigger carries a name, so the
- # builder is the last line of defense for both render and apply.
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- app_name="order-service",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={},
- auth=TriggerAuth(
- secret_refs={"password": {"name": "right-secret", "key": "pw"}}
- )
- ),
- Trigger(
- type="rabbitmq",
- metadata={},
- name="redis-0",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "wrong-secret", "key": "pw"}}
- )
- )
- ]
- );
- raised = False;
- try {
- keda._build_manifests(spec);
- } except ValueError as e {
- raised = True;
- assert "redis-0" in str(e);
- }
- assert raised;
-}
-
-
-test "build manifests produces a complete and valid ScaledObject body" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="orders-deployment",
- namespace="staging",
- min_replicas=2,
- max_replicas=10,
- triggers=[Trigger(type="cpu", metadata={"averageUtilization": "60"})]
- );
- m = keda._build_manifests(spec)[0];
- assert m["metadata"]["name"] == "orders-deployment-scaledobject";
- assert m["apiVersion"] == "keda.sh/v1alpha1";
- assert m["kind"] == "ScaledObject";
- assert m["metadata"]["labels"]["managed"] == "jac-scale";
- assert m["spec"]["scaleTargetRef"]["name"] == "orders-deployment";
- assert m["spec"]["minReplicaCount"] == 2;
- assert m["spec"]["maxReplicaCount"] == 10;
- assert m["spec"]["triggers"][0]["metadata"]["value"] == "60";
-}
-
-test "build manifests applies behavior_overlay through advanced.horizontalPodAutoscalerConfig" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="orders-deployment",
- namespace="staging",
- min_replicas=2,
- max_replicas=10,
- triggers=[Trigger(type="cpu", metadata={"averageUtilization": "60"})],
- behavior_overlay={
- "scaleDown": {
- "stabilizationWindowSeconds": 600,
- "selectPolicy": "Min",
- "policies": [{"type": "Pods", "value": 2, "periodSeconds": 300}]
- }
- }
- );
- m = keda._build_manifests(spec)[0];
- behavior = m["spec"]["advanced"]["horizontalPodAutoscalerConfig"]["behavior"];
- assert behavior["scaleDown"]["stabilizationWindowSeconds"] == 600;
- assert behavior["scaleDown"]["selectPolicy"] == "Min";
- assert behavior["scaleDown"]["policies"]
- == [{"type": "Pods", "value": 2, "periodSeconds": 300}];
- assert behavior["scaleUp"]["stabilizationWindowSeconds"] == 60;
-}
-
-test "build manifests adds idleReplicaCount zero for scale-to-zero" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment", namespace="default", idle_replicas=0
- );
- m = keda._build_manifests(spec)[0];
- assert m["spec"]["idleReplicaCount"] == 0;
-}
-
-test "build manifests omits idleReplicaCount when not configured" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment", namespace="default", idle_replicas=None
- );
- m = keda._build_manifests(spec)[0];
- assert "idleReplicaCount" not in m["spec"];
-}
-
-test "build manifests adds initialCooldownPeriod when set above zero" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- initial_cooldown_period=120
- );
- m = keda._build_manifests(spec)[0];
- assert m["spec"]["initialCooldownPeriod"] == 120;
-}
-
-test "build manifests omits initialCooldownPeriod when zero" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- initial_cooldown_period=0
- );
- m = keda._build_manifests(spec)[0];
- assert "initialCooldownPeriod" not in m["spec"];
-}
-
-test "build manifests falls back to default cpu trigger when triggers list is empty" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment", namespace="default", triggers=[]
- );
- m = keda._build_manifests(spec)[0];
- assert len(m["spec"]["triggers"]) == 1;
- assert m["spec"]["triggers"][0]["type"] == "cpu";
-}
-
-test "build manifests includes every trigger in the ScaledObject spec" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[
- Trigger(type="cpu", metadata={"averageUtilization": "50"}),
- Trigger(
- type="prometheus",
- metadata={"threshold": "100", "query": "sum(rate(req[1m]))"}
- )
- ]
- );
- m = keda._build_manifests(spec)[0];
- assert len(m["spec"]["triggers"]) == 2;
- assert m["spec"]["triggers"][0]["type"] == "cpu";
- assert m["spec"]["triggers"][1]["type"] == "prometheus";
-}
-
-test "build manifests with hpa bounds and non-cpu trigger produces ScaledObject with both triggers" {
- keda = KEDAAutoscaler();
- spec = AutoscalerSpec(
- scale_target_name="orders-deployment",
- namespace="staging",
- min_replicas=2,
- max_replicas=10,
- triggers=[
- Trigger(type="cpu", metadata={"averageUtilization": "60"}),
- Trigger(
- type="redis",
- metadata={
- "address": "redis:6379",
- "listName": "jobs",
- "listLength": "10"
- },
- name="redis-jobs"
- )
- ]
- );
- m = keda._build_manifests(spec)[0];
- assert m["spec"]["minReplicaCount"] == 2;
- assert m["spec"]["maxReplicaCount"] == 10;
- trigger_types = [t["type"] for t in m["spec"]["triggers"]];
- assert "cpu" in trigger_types;
- assert "redis" in trigger_types;
- assert len(trigger_types) == 2;
-}
-
-
-test "preflight returns empty list when KEDA CRDs are installed" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {"items": []};
- keda = KEDAAutoscaler(_custom_api=mock_api);
- assert keda.preflight() == [];
-}
-
-test "preflight returns warning with kubectl install command when cluster returns 404" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.side_effect = ApiException(status=404);
- keda = KEDAAutoscaler(_custom_api=mock_api);
- warnings = keda.preflight();
- assert len(warnings) == 1;
- assert "KEDA CRDs not found" in warnings[0];
- assert "keda.sh/docs" in warnings[0];
-}
-
-test "preflight treats 422 the same as 404" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.side_effect = ApiException(status=422);
- keda = KEDAAutoscaler(_custom_api=mock_api);
- warnings = keda.preflight();
- assert len(warnings) == 1;
- assert "KEDA CRDs not found" in warnings[0];
-}
-
-test "preflight result is cached so the API is called only once across two calls" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {"items": []};
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.preflight();
- keda.preflight();
- assert mock_api.list_cluster_custom_object.call_count == 1;
-}
-
-
-test "apply skips ScaledObject creation when KEDA CRDs are not installed" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.side_effect = ApiException(status=404);
- keda = KEDAAutoscaler(_custom_api=mock_api);
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- result = keda.apply(spec);
- assert result == False;
- mock_api.create_namespaced_custom_object.assert_not_called();
- mock_api.patch_namespaced_custom_object.assert_not_called();
-}
-
-test "apply logs the install docs link when KEDA CRDs are not installed" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.side_effect = ApiException(status=404);
- mock_logger = unittest.mock.MagicMock();
- keda = KEDAAutoscaler(_custom_api=mock_api, logger=mock_logger);
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- keda.apply(spec);
- mock_logger.warn.assert_called_once();
- warn_msg = mock_logger.warn.call_args[0][0];
- assert "KEDA CRDs not found" in warn_msg;
- assert "keda.sh/docs" in warn_msg;
-}
-
-test "TriggerAuthentication name written to cluster matches authenticationRef name in ScaledObject" {
- # _build_trigger (authenticationRef) and _build_trigger_auth_manifest (the
- # resource) must agree on the name end to end, or KEDA cannot resolve the
- # secret at admission time.
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = [
- ApiException(status=404), # TriggerAuthentication does not exist yet
- ApiException(status=404) # ScaledObject does not exist yet
- ];
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- spec = AutoscalerSpec(
- scale_target_name="order-service-deployment",
- app_name="order-service",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={"address": "redis:6379"},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- )
- ]
- );
- keda.apply(spec);
- create_calls = mock_api.create_namespaced_custom_object.call_args_list;
- assert len(create_calls) == 2;
- ta_name = create_calls[0][1]["body"]["metadata"]["name"];
- ref_name = create_calls[1][1]["body"]["spec"]["triggers"][0]["authenticationRef"][
- "name"
- ];
- assert ta_name == ref_name;
-}
-
-test "apply creates TriggerAuthentication before ScaledObject" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = [
- ApiException(status=404),
- ApiException(status=404)
- ];
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={"address": "redis:6379"},
- name="redis-queue",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- )
- ]
- );
- result = keda.apply(spec);
- assert result == True;
- create_calls = mock_api.create_namespaced_custom_object.call_args_list;
- assert len(create_calls) == 2;
- assert create_calls[0][1]["plural"] == "triggerauthentications";
- assert create_calls[1][1]["plural"] == "scaledobjects";
-}
-
-test "apply creates ScaledObject when it does not yet exist on the cluster" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- result = keda.apply(spec);
- assert result == True;
- create_calls = [
- c
- for c in mock_api.create_namespaced_custom_object.call_args_list
- if c[1].get("plural") == "scaledobjects"
- ];
- assert len(create_calls) == 1;
- mock_api.patch_namespaced_custom_object.assert_not_called();
-}
-
-test "apply patches ScaledObject when it already exists on the cluster" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.return_value = {
- "metadata": {"name": "svc-deployment-scaledobject"}
- };
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- result = keda.apply(spec);
- assert result == True;
- patch_calls = [
- c
- for c in mock_api.patch_namespaced_custom_object.call_args_list
- if c[1].get("plural") == "scaledobjects"
- ];
- assert len(patch_calls) == 1;
-}
-
-test "apply prunes a TriggerAuthentication the redeployed trigger set no longer references" {
- # A redeploy that renames or drops a trigger must not leave its old
- # TriggerAuthentication behind, unreferenced by the ScaledObject.
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- mock_api.list_namespaced_custom_object.return_value = {
- "items": [{"metadata": {"name": "svc-deployment-scaledobject-stale-ta"}}]
- };
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- result = keda.apply(spec);
- assert result == True;
- mock_api.list_namespaced_custom_object.assert_called_once_with(
- group="keda.sh",
- version="v1alpha1",
- namespace="default",
- plural="triggerauthentications",
- label_selector="jac-scale/owner=svc-deployment-scaledobject,managed=jac-scale"
- );
- prune_calls = [
- c
- for c in mock_api.delete_namespaced_custom_object.call_args_list
- if c[1]["plural"] == "triggerauthentications"
- ];
- assert len(prune_calls) == 1;
- assert prune_calls[0][1]["name"] == "svc-deployment-scaledobject-stale-ta";
-}
-
-test "apply keeps a TriggerAuthentication that is still referenced by the current trigger set" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- namespace="default",
- triggers=[
- Trigger(
- type="redis",
- metadata={"address": "redis:6379"},
- name="cache",
- auth=TriggerAuth(
- secret_refs={"password": {"name": "redis-secret", "key": "pw"}}
- )
- )
- ]
- );
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- desired_name = keda._trigger_auth_name("svc-deployment", spec.triggers[0], 0);
- mock_api.list_namespaced_custom_object.return_value = {
- "items": [{"metadata": {"name": desired_name}}]
- };
- result = keda.apply(spec);
- assert result == True;
- prune_calls = [
- c
- for c in mock_api.delete_namespaced_custom_object.call_args_list
- if c[1]["plural"] == "triggerauthentications"
- ];
- assert len(prune_calls) == 0;
-}
-
-
-test "apply deletes competing HPA when switching from hpa engine to keda" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- mock_v2 = unittest.mock.MagicMock();
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- app_name="svc",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- keda.apply(spec);
- mock_v2.delete_namespaced_horizontal_pod_autoscaler.assert_called_once_with(
- name="svc-hpa", namespace="default"
- );
-}
-
-test "apply engine-switch HPA deletion is a no-op when no HPA exists" {
- KEDAAutoscaler._preflight_cache.clear();
- mock_api = unittest.mock.MagicMock();
- mock_api.list_cluster_custom_object.return_value = {};
- mock_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- mock_v2 = unittest.mock.MagicMock();
- mock_v2.delete_namespaced_horizontal_pod_autoscaler.side_effect = ApiException(
- status=404
- );
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
- spec = AutoscalerSpec(
- scale_target_name="svc-deployment",
- app_name="svc",
- namespace="default",
- triggers=[Trigger(type="cpu", metadata={})]
- );
- result = keda.apply(spec);
- assert result == True;
-}
-
-test "destroy collection also sweeps competing HPAs" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {"items": []};
- mock_v2 = unittest.mock.MagicMock();
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=mock_v2);
- keda.destroy_collection("default", "managed=jac-scale");
- mock_v2.delete_collection_namespaced_horizontal_pod_autoscaler.assert_called_once_with(
- namespace="default", label_selector="managed=jac-scale"
- );
-}
-
-test "destroy resolves the ScaledObject name from app_name and deletes it" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {"items": []};
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc", "staging");
- delete_calls = [
- c
- for c in mock_api.delete_namespaced_custom_object.call_args_list
- if c[1]["plural"] == "scaledobjects"
- ];
- assert len(delete_calls) == 1;
- assert delete_calls[0][1]
- == {
- "group": "keda.sh",
- "version": "v1alpha1",
- "namespace": "staging",
- "plural": "scaledobjects",
- "name": "my-svc-scaledobject"
- };
-}
-
-test "destroy is a no-op when the ScaledObject is already gone" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {"items": []};
- mock_api.delete_namespaced_custom_object.side_effect = ApiException(status=404);
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc", "staging");
-}
-
-test "destroy sweeps TriggerAuthentications owned by this ScaledObject before deleting it" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {
- "items": [{"metadata": {"name": "my-svc-scaledobject-89abcdef-ta"}}]
- };
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc", "staging");
- mock_api.list_namespaced_custom_object.assert_called_once_with(
- group="keda.sh",
- version="v1alpha1",
- namespace="staging",
- plural="triggerauthentications",
- label_selector="jac-scale/owner=my-svc-scaledobject,managed=jac-scale"
- );
- delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
- assert len(delete_calls) == 2;
- assert delete_calls[0][1]
- == {
- "group": "keda.sh",
- "version": "v1alpha1",
- "namespace": "staging",
- "plural": "triggerauthentications",
- "name": "my-svc-scaledobject-89abcdef-ta"
- };
- assert delete_calls[1][1]["plural"] == "scaledobjects";
-}
-
-test "destroy tolerates a TriggerAuthentication that is already deleted" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {
- "items": [{"metadata": {"name": "my-svc-scaledobject-89abcdef-ta"}}]
- };
- mock_api.delete_namespaced_custom_object.side_effect = [
- ApiException(status=404), # the TriggerAuthentication
- None # the ScaledObject
- ];
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc", "staging");
- assert len(mock_api.delete_namespaced_custom_object.call_args_list) == 2;
-}
-
-test "destroy still deletes the ScaledObject when the TriggerAuthentication CRD is absent" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.side_effect = ApiException(status=404);
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy("my-svc", "staging");
- mock_api.delete_namespaced_custom_object.assert_called_once_with(
- group="keda.sh",
- version="v1alpha1",
- namespace="staging",
- plural="scaledobjects",
- name="my-svc-scaledobject"
- );
-}
-
-
-test "destroy collection removes all labeled ScaledObjects and TriggerAuthentications" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {
- "items": [{"metadata": {"name": "svc-scaledobject"}}]
- };
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- keda.destroy_collection("staging", "managed=jac-scale");
- delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
- assert len(delete_calls) == 3;
- plurals = [c[1]["plural"] for c in delete_calls];
- assert "scaledobjects" in plurals;
- assert "triggerauthentications" in plurals;
- assert "interceptorroutes" in plurals;
-}
-
-test "destroy collection is silent when KEDA CRDs are not installed (404)" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.side_effect = ApiException(status=404);
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- keda.destroy_collection("staging", "managed=jac-scale");
-}
-
-test "destroy collection is silent when cluster returns 422 for absent CRD group" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.side_effect = ApiException(status=422);
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- keda.destroy_collection("staging", "managed=jac-scale");
-}
diff --git a/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac b/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac
deleted file mode 100644
index 99e5af1f39e..00000000000
--- a/jac/jaclang/scale/tests/deploy/test_keda_http_activation.jac
+++ /dev/null
@@ -1,789 +0,0 @@
-import unittest.mock;
-import from kubernetes.client.exceptions { ApiException }
-import from jaclang.scale.deploy.autoscale.keda_autoscaler { KEDAAutoscaler }
-import from jaclang.scale.deploy.autoscale.http_activation {
- HTTPActivationSpec,
- HTTPRoutingRule,
- HTTPPathMatch,
- HTTPHeaderMatch,
- HTTPServiceTarget,
- HTTPConcurrencyMetric,
- HTTPRequestRateMetric,
- HTTPStaticResponse,
- HTTPColdStartSpec,
- HTTPTimeoutSpec
-}
-
-# Base HTTPActivationSpec shared by most tests below: a Deployment target on
-# port 8080 with a concurrency=10 trigger. Override only what a given test
-# cares about; pass concurrency=None when testing the metric-required check.
-def _spec(**overrides: any) -> HTTPActivationSpec {
- defaults = {
- "name": "preview",
- "namespace": "default",
- "scale_target_name": "preview-deployment",
- "target": HTTPServiceTarget(service="preview-svc", port=8080),
- "concurrency": HTTPConcurrencyMetric(target_value=10)
- };
- if "request_rate" in overrides {
- defaults.pop("concurrency");
- }
- defaults.update(overrides);
- return HTTPActivationSpec(**defaults);
-}
-
-# KEDAAutoscaler wired to MagicMock clients, with the HTTP Add-on preflight
-# check (list_cluster_custom_object) pre-configured as installed by default.
-# Pass a pre-configured apps_api/core_api/custom_api to control target,
-# Service, or route/ScaledObject existence for a specific test. Returns the
-# mocks alongside the instance since KEDAAutoscaler's api fields are typed
-# `any | None`, which the checker won't let a caller re-narrow after the fact.
-def _mocked_keda(
- http_addon_installed: bool = True,
- custom_api: any = None,
- apps_api: any = None,
- core_api: any = None
-) -> tuple[KEDAAutoscaler, any, any, any] {
- custom_api = custom_api or unittest.mock.MagicMock();
- if http_addon_installed {
- custom_api.list_cluster_custom_object.return_value = {};
- } else {
- custom_api.list_cluster_custom_object.side_effect = ApiException(status=404);
- }
- apps_api = apps_api or unittest.mock.MagicMock();
- core_api = core_api or unittest.mock.MagicMock();
- keda = KEDAAutoscaler(
- _custom_api=custom_api, _apps_api=apps_api, _core_api=core_api
- );
- return (keda, custom_api, apps_api, core_api);
-}
-
-
-# --- Section A: typed model validation -------------------------------------
-test "validate rejects a Service target with both port and port_name set" {
- keda = KEDAAutoscaler();
- spec = _spec(
- target=HTTPServiceTarget(service="preview-svc", port=8080, port_name="http")
- );
- raised = False;
- try {
- keda._validate_http_activation_spec(spec);
- } except ValueError as e {
- raised = True;
- }
- assert raised , "Expected ValueError when both port and port_name are set";
-}
-
-test "validate rejects a Service target with neither port nor port_name set" {
- keda = KEDAAutoscaler();
- spec = _spec(target=HTTPServiceTarget(service="preview-svc"));
- raised = False;
- try {
- keda._validate_http_activation_spec(spec);
- } except ValueError as e {
- raised = True;
- }
- assert raised , "Expected ValueError when neither port nor port_name is set";
-}
-
-test "validate requires at least one of concurrency or request_rate" {
- keda = KEDAAutoscaler();
- spec = _spec(concurrency=None);
- raised = False;
- try {
- keda._validate_http_activation_spec(spec);
- } except ValueError as e {
- raised = True;
- }
- assert raised , "Expected ValueError when neither concurrency nor request_rate is set";
-}
-
-test "request_rate defaults window to 1m and granularity to 1s when unset" {
- metric = HTTPRequestRateMetric(target_value=100);
- assert metric.window == "1m";
- assert metric.granularity == "1s";
-}
-
-
-# --- Section B: routing-rule grouping (AND-within-rule / OR-between-rules) -
-test "single rule with host, path, and header collapses into one AND-grouped match block" {
- keda = KEDAAutoscaler();
- rules = [
- HTTPRoutingRule(
- hosts=["preview.jac.dev"],
- paths=[HTTPPathMatch(value="/app")],
- headers=[HTTPHeaderMatch(name="x-preview-id", value="123")]
- )
- ];
- result = keda._build_routing_rules(rules);
- assert len(result) == 1;
- assert result[0]["hosts"] == ["preview.jac.dev"];
- assert result[0]["path"]["value"] == "/app";
- assert result[0]["headers"][0]["name"] == "x-preview-id";
-}
-
-test "two rules in the list produce two separate OR-grouped match blocks" {
- keda = KEDAAutoscaler();
- rules = [
- HTTPRoutingRule(hosts=["a.jac.dev"]),
- HTTPRoutingRule(hosts=["b.jac.dev"])
- ];
- result = keda._build_routing_rules(rules);
- assert len(result) == 2;
- assert result[0]["hosts"] == ["a.jac.dev"];
- assert result[1]["hosts"] == ["b.jac.dev"];
-}
-
-test "rule with only hosts omits empty path and headers keys" {
- keda = KEDAAutoscaler();
- rules = [HTTPRoutingRule(hosts=["a.jac.dev"])];
- result = keda._build_routing_rules(rules);
- assert "hosts" in result[0];
- assert "path" not in result[0];
- assert "headers" not in result[0];
-}
-
-test "rule with multiple paths expands into one OR-grouped match block per path" {
- keda = KEDAAutoscaler();
- rules = [
- HTTPRoutingRule(
- hosts=["preview.jac.dev"],
- paths=[HTTPPathMatch(value="/app"), HTTPPathMatch(value="/api")],
- headers=[HTTPHeaderMatch(name="x-preview-id", value="123")]
- )
- ];
- result = keda._build_routing_rules(rules);
- assert len(result) == 2;
- path_values = [entry["path"]["value"] for entry in result];
- assert "/app" in path_values;
- assert "/api" in path_values;
- for entry in result {
- assert entry["hosts"] == ["preview.jac.dev"];
- assert entry["headers"][0]["name"] == "x-preview-id";
- }
-}
-
-
-# --- Section C: InterceptorRoute manifest builder ---------------------------
-test "build_interceptor_route produces a complete and valid InterceptorRoute body" {
- keda = KEDAAutoscaler();
- spec = _spec(
- namespace="staging", rules=[HTTPRoutingRule(hosts=["preview.jac.dev"])]
- );
- m = keda._build_interceptor_route(spec);
- body = m["body"];
- assert body["apiVersion"] == "http.keda.sh/v1beta1";
- assert body["kind"] == "InterceptorRoute";
- assert body["metadata"]["namespace"] == "staging";
- assert body["metadata"]["labels"]["managed"] == "jac-scale";
- assert body["spec"]["target"]["service"] == "preview-svc";
- assert body["spec"]["target"]["port"] == 8080;
- assert body["spec"]["scalingMetric"]["concurrency"]["targetValue"] == 10;
- assert len(body["spec"]["rules"]) == 1;
-}
-
-test "build_interceptor_route uses port_name instead of port when port is unset" {
- keda = KEDAAutoscaler();
- spec = _spec(target=HTTPServiceTarget(service="preview-svc", port_name="http"));
- m = keda._build_interceptor_route(spec);
- assert m["body"]["spec"]["target"]["portName"] == "http";
- assert "port" not in m["body"]["spec"]["target"];
-}
-
-test "build_interceptor_route includes cold_start placeholder only when set" {
- keda = KEDAAutoscaler();
- with_cold_start = keda._build_interceptor_route(
- _spec(
- cold_start=HTTPColdStartSpec(
- placeholder=HTTPStaticResponse(status_code=503, body="warming up")
- )
- )
- );
- without_cold_start = keda._build_interceptor_route(_spec());
- assert with_cold_start["body"]["spec"]["coldStart"]["placeholder"]["response"][
- "statusCode"
- ] == 503;
- assert "coldStart" not in without_cold_start["body"]["spec"];
-}
-
-test "build_interceptor_route includes cold_start fallback_service only when set" {
- keda = KEDAAutoscaler();
- spec = _spec(
- cold_start=HTTPColdStartSpec(
- fallback_service=HTTPServiceTarget(service="fallback-svc", port=8080)
- )
- );
- m = keda._build_interceptor_route(spec);
- assert m["body"]["spec"]["coldStart"]["fallback"]["service"]["name"] == "fallback-svc";
- assert "placeholder" not in m["body"]["spec"]["coldStart"];
-}
-
-test "build_interceptor_route encodes request_rate as scalingMetric.requestRate" {
- keda = KEDAAutoscaler();
- spec = _spec(
- request_rate=HTTPRequestRateMetric(
- target_value=50, window="5m", granularity="10s"
- )
- );
- m = keda._build_interceptor_route(spec);
- scaling_metric = m["body"]["spec"]["scalingMetric"];
- assert scaling_metric["requestRate"]["targetValue"] == 50;
- assert scaling_metric["requestRate"]["window"] == "5m";
- assert scaling_metric["requestRate"]["granularity"] == "10s";
- assert "concurrency" not in scaling_metric;
-}
-
-test "build_interceptor_route includes timeouts only when set, omitted otherwise" {
- keda = KEDAAutoscaler();
- with_timeouts = keda._build_interceptor_route(
- _spec(
- timeouts=HTTPTimeoutSpec(
- readiness="30s", request="60s", response_header="5s"
- )
- )
- );
- without_timeouts = keda._build_interceptor_route(_spec());
- assert with_timeouts["body"]["spec"]["timeouts"]["readiness"] == "30s";
- assert with_timeouts["body"]["spec"]["timeouts"]["request"] == "60s";
- assert "timeouts" not in without_timeouts["body"]["spec"];
-}
-
-
-# --- Section D: ScaledObject external-push builder --------------------------
-test "build_http_scaled_object produces a complete external-push ScaledObject" {
- keda = KEDAAutoscaler();
- spec = _spec(namespace="staging", min_replicas=0, max_replicas=5);
- m = keda._build_http_scaled_object(spec);
- body = m["body"];
- trigger = body["spec"]["triggers"][0];
- assert body["apiVersion"] == "keda.sh/v1alpha1";
- assert body["kind"] == "ScaledObject";
- assert body["spec"]["minReplicaCount"] == 0;
- assert body["spec"]["maxReplicaCount"] == 5;
- assert trigger["type"] == "external-push";
- assert trigger["metadata"]["interceptorRoute"] == keda.interceptor_route_name_for(
- "preview-deployment"
- );
- assert "scalerAddress" in trigger["metadata"];
-}
-
-test "build_http_scaled_object encodes concurrency metric as targetValue" {
- keda = KEDAAutoscaler();
- spec = _spec(concurrency=HTTPConcurrencyMetric(target_value=25));
- m = keda._build_http_scaled_object(spec);
- metadata = m["body"]["spec"]["triggers"][0]["metadata"];
- assert metadata["targetValue"] == "25";
- assert "window" not in metadata;
-}
-
-test "build_http_scaled_object encodes request_rate metric with window and granularity" {
- keda = KEDAAutoscaler();
- spec = _spec(
- request_rate=HTTPRequestRateMetric(
- target_value=50, window="5m", granularity="10s"
- )
- );
- m = keda._build_http_scaled_object(spec);
- metadata = m["body"]["spec"]["triggers"][0]["metadata"];
- assert metadata["targetValue"] == "50";
- assert metadata["window"] == "5m";
- assert metadata["granularity"] == "10s";
-}
-
-
-# --- Section E: standalone-Pod rejection ------------------------------------
-test "apply_http_activation rejects a standalone Pod target with a /scale explanation" {
- keda = KEDAAutoscaler();
- spec = _spec(scale_target_name="preview-pod", scale_target_kind="Pod");
- raised = False;
- error_msg = "";
- try {
- keda.apply_http_activation(spec);
- } except ValueError as e {
- raised = True;
- error_msg = str(e);
- }
- assert raised , "Expected ValueError when scale_target_kind is Pod";
- assert "/scale" in error_msg , "Error message must explain the /scale requirement";
-}
-
-test "apply_http_activation accepts a StatefulSet target without raising" {
- (keda, custom_api, _, _) = _mocked_keda();
- custom_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- spec = _spec(
- scale_target_name="preview-statefulset", scale_target_kind="StatefulSet"
- );
- result = keda.apply_http_activation(spec);
- assert result == True;
-}
-
-test "apply_http_activation raises for an unrecognized scale_target_kind when scale_target_plural is unset" {
- (keda, custom_api, apps_api, _) = _mocked_keda();
- spec = _spec(scale_target_name="preview-rollout", scale_target_kind="Rollout");
- raised = False;
- error_msg = "";
- try {
- keda.apply_http_activation(spec);
- } except ValueError as e {
- raised = True;
- error_msg = str(e);
- }
- assert raised , "Expected ValueError when scale_target_kind is unrecognized and scale_target_plural is unset";
- assert "Rollout" in error_msg;
- assert "scale_target_plural" in error_msg;
- apps_api.read_namespaced_deployment.assert_not_called();
- apps_api.read_namespaced_stateful_set.assert_not_called();
- custom_api.create_namespaced_custom_object.assert_not_called();
-}
-
-test "apply_http_activation validates a custom target when scale_target_plural is set" {
- (keda, custom_api, apps_api, _) = _mocked_keda();
- spec = _spec(
- scale_target_name="preview-rollout",
- scale_target_kind="Rollout",
- scale_target_api_version="argoproj.io/v1alpha1",
- scale_target_plural="rollouts"
- );
- result = keda.apply_http_activation(spec);
- assert result == True;
- apps_api.read_namespaced_deployment.assert_not_called();
- apps_api.read_namespaced_stateful_set.assert_not_called();
- target_check_calls = [
- c
- for c in custom_api.get_namespaced_custom_object.call_args_list
- if c[1].get("plural") == "rollouts"
- ];
- assert len(target_check_calls) == 1;
- assert target_check_calls[0][1]["group"] == "argoproj.io";
- assert target_check_calls[0][1]["version"] == "v1alpha1";
- assert target_check_calls[0][1]["name"] == "preview-rollout";
-}
-
-test "apply_http_activation raises when a custom target with scale_target_plural is missing" {
- (keda, custom_api, _, _) = _mocked_keda();
- custom_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- spec = _spec(
- scale_target_name="missing-rollout",
- scale_target_kind="Rollout",
- scale_target_api_version="argoproj.io/v1alpha1",
- scale_target_plural="rollouts"
- );
- raised = False;
- try {
- keda.apply_http_activation(spec);
- } except ValueError as e {
- raised = True;
- }
- assert raised , "Expected ValueError when a custom target with scale_target_plural is missing";
- custom_api.create_namespaced_custom_object.assert_not_called();
-}
-
-
-# --- Section F: naming -------------------------------------------------------
-test "InterceptorRoute and ScaledObject names are deterministic and distinct" {
- keda = KEDAAutoscaler();
- route_name = keda.interceptor_route_name_for("preview-deployment");
- scaled_obj_name = keda.http_scaled_object_name_for("preview-deployment");
- assert route_name != scaled_obj_name;
- assert route_name == keda.interceptor_route_name_for("preview-deployment");
- assert scaled_obj_name == keda.http_scaled_object_name_for("preview-deployment");
-}
-
-
-# --- Section G: apply ordering, validation, and preflight -------------------
-test "apply_http_activation creates InterceptorRoute before ScaledObject when neither exists" {
- (keda, custom_api, _, _) = _mocked_keda();
- custom_api.get_namespaced_custom_object.side_effect = ApiException(status=404);
- result = keda.apply_http_activation(_spec());
- assert result == True;
- create_calls = custom_api.create_namespaced_custom_object.call_args_list;
- assert len(create_calls) == 2;
- assert create_calls[0][1]["plural"] == "interceptorroutes";
- assert create_calls[1][1]["plural"] == "scaledobjects";
-}
-
-test "apply_http_activation patches InterceptorRoute and ScaledObject when both already exist" {
- (keda, custom_api, _, _) = _mocked_keda();
- custom_api.get_namespaced_custom_object.return_value = {
- "metadata": {"name": "existing"}
- };
- result = keda.apply_http_activation(_spec());
- assert result == True;
- patch_calls = custom_api.patch_namespaced_custom_object.call_args_list;
- assert len(patch_calls) == 2;
- assert patch_calls[0][1]["plural"] == "interceptorroutes";
- assert patch_calls[1][1]["plural"] == "scaledobjects";
- custom_api.create_namespaced_custom_object.assert_not_called();
-}
-
-test "apply_http_activation raises when the referenced scale target or Service does not exist" {
- custom_api = unittest.mock.MagicMock();
-
- missing_target_apps = unittest.mock.MagicMock();
- missing_target_apps.read_namespaced_deployment.side_effect = ApiException(
- status=404
- );
- (keda, _, _, _) = _mocked_keda(custom_api=custom_api, apps_api=missing_target_apps);
- raised_for_missing_target = False;
- try {
- keda.apply_http_activation(_spec(scale_target_name="missing-deployment"));
- } except ValueError as e {
- raised_for_missing_target = True;
- }
- assert raised_for_missing_target , "Expected ValueError when the scale target is missing";
-
- missing_service_core = unittest.mock.MagicMock();
- missing_service_core.read_namespaced_service.side_effect = ApiException(status=404);
- (keda2, _, _, _) = _mocked_keda(
- custom_api=custom_api, core_api=missing_service_core
- );
- raised_for_missing_service = False;
- try {
- keda2.apply_http_activation(
- _spec(target=HTTPServiceTarget(service="missing-svc", port=8080))
- );
- } except ValueError as e {
- raised_for_missing_service = True;
- }
- assert raised_for_missing_service , "Expected ValueError when the Service is missing";
- custom_api.create_namespaced_custom_object.assert_not_called();
-}
-
-test "apply_http_activation returns False and skips creation when HTTP Add-on CRDs are not installed" {
- (keda, custom_api, _, _) = _mocked_keda(http_addon_installed=False);
- result = keda.apply_http_activation(_spec());
- assert result == False;
- custom_api.create_namespaced_custom_object.assert_not_called();
-}
-
-
-# --- Section H: destroy / partial-apply cleanup ------------------------------
-test "destroy_http_activation removes both InterceptorRoute and ScaledObject" {
- mock_api = unittest.mock.MagicMock();
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy_http_activation("preview-deployment", "staging");
- delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
- plurals = [c[1]["plural"] for c in delete_calls];
- assert "interceptorroutes" in plurals;
- assert "scaledobjects" in plurals;
-}
-
-test "destroy_http_activation is a no-op for the InterceptorRoute when it is already gone" {
- mock_api = unittest.mock.MagicMock();
- mock_api.delete_namespaced_custom_object.side_effect = [
- ApiException(status=404),
- None
- ];
- keda = KEDAAutoscaler(_custom_api=mock_api);
- keda.destroy_http_activation("preview-deployment", "staging");
- assert mock_api.delete_namespaced_custom_object.call_count == 2;
-}
-
-test "destroy_collection sweeps InterceptorRoutes alongside ScaledObjects and TriggerAuthentications" {
- mock_api = unittest.mock.MagicMock();
- mock_api.list_namespaced_custom_object.return_value = {
- "items": [{"metadata": {"name": "preview-deployment-http-route"}}]
- };
- keda = KEDAAutoscaler(_custom_api=mock_api, _v2_api=unittest.mock.MagicMock());
- keda.destroy_collection("staging", "managed=jac-scale");
- delete_calls = mock_api.delete_namespaced_custom_object.call_args_list;
- plurals = [c[1]["plural"] for c in delete_calls];
- assert "interceptorroutes" in plurals;
-}
-
-
-# --- Section I: capability discovery -----------------------------------------
-# _capabilities_cache is per-instance (not static), so a freshly constructed
-# KEDAAutoscaler always starts with an empty cache -- no explicit clearing
-# needed. apps_api is mocked too (even though discovery itself never touches
-# it) so that apply_http_activation's target-existence check doesn't fall
-# through to a real, unconfigured AppsV1Api.
-def _keda_for(
- custom_api: any = None, core_api: any = None, apps_api: any = None
-) -> KEDAAutoscaler {
- return KEDAAutoscaler(
- _custom_api=custom_api or unittest.mock.MagicMock(),
- _core_api=core_api or unittest.mock.MagicMock(),
- _apps_api=apps_api or unittest.mock.MagicMock()
- );
-}
-
-# Fails list_cluster_custom_object per API group/version so one mock can stand
-# in for "core missing", "HTTP Add-on missing", "legacy Add-on present", or any
-# RBAC-denied combination of the three. None for a given kwarg means that
-# discovery call succeeds.
-def _custom_api_for(
- core: (int | None) = None,
- http_v1beta1: (int | None) = None,
- legacy_v1alpha1: (int | None) = None
-) -> any {
- api = unittest.mock.MagicMock();
- def side_effect(*args: any, **kwargs: any) -> any {
- status = None;
- if kwargs.get("group") == "keda.sh" {
- status = core;
- } elif kwargs.get("version") == "v1beta1" {
- status = http_v1beta1;
- } elif kwargs.get("version") == "v1alpha1" {
- status = legacy_v1alpha1;
- }
- if status is not None {
- raise ApiException(status=status);
- }
- return {};
- }
- api.list_cluster_custom_object.side_effect = side_effect;
- return api;
-}
-
-# Fails read_namespaced_service for the scaler and/or interceptor Service by
-# matching on which name jac-scale looks up.
-def _core_api_for(
- scaler: (int | None) = None, interceptor: (int | None) = None
-) -> any {
- api = unittest.mock.MagicMock();
- def side_effect(name: str, *args: any, **kwargs: any) -> any {
- status = interceptor if "interceptor" in name else scaler;
- if status is not None {
- raise ApiException(status=status);
- }
- return {};
- }
- api.read_namespaced_service.side_effect = side_effect;
- return api;
-}
-
-test "discover_capabilities produces a distinct diagnostic for every failure mode" {
- scenarios = [
- (
- "core-only",
- _custom_api_for(http_v1beta1=404, legacy_v1alpha1=404),
- _core_api_for()
- ),
- ("full-install", _custom_api_for(), _core_api_for()),
- ("missing-external-scaler", _custom_api_for(), _core_api_for(scaler=404)),
- (
- "missing-interceptor-service",
- _custom_api_for(),
- _core_api_for(interceptor=404)
- ),
- (
- "legacy-addon-needs-upgrade",
- _custom_api_for(http_v1beta1=404),
- _core_api_for()
- ),
- ("rbac-denied-on-core", _custom_api_for(core=403), _core_api_for()),
- (
- "rbac-denied-on-http-addon",
- _custom_api_for(http_v1beta1=403),
- _core_api_for()
- ),
- (
- "rbac-denied-on-legacy-probe",
- _custom_api_for(http_v1beta1=404, legacy_v1alpha1=403),
- _core_api_for()
- )
- ];
- for (label, custom_api, core_api) in scenarios {
- keda = _keda_for(custom_api=custom_api, core_api=core_api);
- caps = keda.discover_capabilities();
- msg = f"scenario '{label}' produced: warnings={caps.warnings} errors={caps.errors}";
-
- if label == "core-only" {
- assert caps.core_available == True , msg;
- assert caps.http_addon_available == False , msg;
- } elif label == "full-install" {
- assert caps.core_available == True , msg;
- assert caps.http_addon_available == True , msg;
- assert caps.errors == [] , msg;
- assert caps.interceptor_route_api_version == "v1beta1" , msg;
- } elif label == "missing-external-scaler" {
- assert any([("scaler" in e.lower()) for e in caps.errors]) , msg;
- } elif label == "missing-interceptor-service" {
- assert any([("interceptor" in e.lower()) for e in caps.errors]) , msg;
- } elif label == "legacy-addon-needs-upgrade" {
- assert any([("upgrade" in w.lower()) for w in caps.warnings]) , msg;
- assert caps.http_addon_available == False , msg;
- } elif label == "rbac-denied-on-core" {
- assert caps.core_available == False , msg;
- assert any(
- [
- ("permission" in e.lower() or "rbac" in e.lower())
- for e in caps.errors
- ]
- ) , msg;
- } elif label == "rbac-denied-on-http-addon" {
- assert any(
- [
- ("permission" in e.lower() or "rbac" in e.lower())
- for e in caps.errors
- ]
- ) , msg;
- legacy_calls = [
- c
- for c in custom_api.list_cluster_custom_object.call_args_list
- if c[1].get("plural") == "httpscaledobjects"
- ];
- assert legacy_calls == [] , (
- f"{label}: RBAC-denied v1beta1 must not fall through to the "
- "legacy-version probe (that's only for a genuinely absent API)"
- );
- } elif label == "rbac-denied-on-legacy-probe" {
- assert any(
- [
- ("permission" in e.lower() or "rbac" in e.lower())
- for e in caps.errors
- ]
- ) , (
- f"{label}: an RBAC-denied legacy probe must be reported as a "
- "permission error, not silently treated as 'not installed'"
- );
- }
- }
-}
-
-test "discover_capabilities caches per cluster and only re-queries when refresh=True" {
- custom_api = _custom_api_for();
- keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
- keda.discover_capabilities();
- keda.discover_capabilities();
- assert custom_api.list_cluster_custom_object.call_count == 2 , "second call should hit cache, not requery (core + http checks = 2 calls total)";
- keda.discover_capabilities(refresh=True);
- assert custom_api.list_cluster_custom_object.call_count == 4 , "refresh=True should bypass the cache and requery";
-}
-
-test "invalidate_capabilities clears the cached result for the current cluster" {
- custom_api = _custom_api_for();
- keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
- keda.discover_capabilities();
- keda.invalidate_capabilities();
- keda.discover_capabilities();
- assert custom_api.list_cluster_custom_object.call_count == 4 , "invalidate_capabilities should force the next call to requery";
-}
-
-test "apply_http_activation raises when capabilities are discovered but broken" {
- keda = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for(scaler=404));
- raised = False;
- error_msg = "";
- try {
- keda.apply_http_activation(_spec());
- } except ValueError as e {
- raised = True;
- error_msg = str(e);
- }
- assert raised , "Expected ValueError when the HTTP Add-on is installed but its scaler Service is missing";
- assert "scaler" in error_msg.lower();
-}
-
-test "apply_http_activation skips activation when core KEDA is missing, even if the HTTP Add-on and its services are healthy" {
- custom_api = _custom_api_for(core=404);
- keda = _keda_for(custom_api=custom_api, core_api=_core_api_for());
- result = keda.apply_http_activation(_spec());
- assert result == False , "A partial install (Add-on healthy, core missing) must not proceed to create resources";
- custom_api.create_namespaced_custom_object.assert_not_called();
-}
-
-test "discover_capabilities cache key accounts for instance-specific service addresses" {
- keda_a = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
- caps_a = keda_a.discover_capabilities();
- assert caps_a.errors == [] , "Sanity check: instance A's own services should resolve cleanly";
-
- # Instance B targets the same cluster host as instance A but with a
- # different http_scaler_address -- proves the two don't get conflated
- # even when both resolve to a cache entry keyed off the same host.
- keda_b = KEDAAutoscaler(
- _custom_api=_custom_api_for(),
- _core_api=_core_api_for(scaler=404),
- http_scaler_address="custom-scaler.other-ns:9090"
- );
- caps_b = keda_b.discover_capabilities();
- assert any([("scaler" in e.lower()) for e in caps_b.errors]) , (
- "Instance B has a different http_scaler_address than instance A and "
- "its scaler Service is missing -- it must not reuse instance A's "
- "healthy cached result just because they share a cluster host"
- );
-}
-
-test "discover_capabilities cache key accounts for distinct Kubernetes client identities" {
- keda_a = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
- caps_a = keda_a.discover_capabilities();
- assert caps_a.errors == [] , "Sanity check: instance A's own client resolves cleanly";
-
- # Same cluster key and same default service addresses as instance A --
- # only the injected custom_api differs, representing a distinct
- # Kubernetes client/service account with RBAC denied on core discovery.
- # _capabilities_cache is per-instance, so there is no shared cache for
- # this to leak through even before checking the assertion below; this
- # proves instance B's own result reflects its own (restricted) client
- # rather than accidentally matching instance A's.
- keda_b = KEDAAutoscaler(
- _custom_api=_custom_api_for(core=403), _core_api=_core_api_for()
- );
- caps_b = keda_b.discover_capabilities();
- assert any(
- [("permission" in e.lower() or "rbac" in e.lower()) for e in caps_b.errors]
- ) , (
- "Instance B has a different (RBAC-restricted) Kubernetes client than "
- "instance A even though cluster key and service addresses match -- "
- "it must not reuse instance A's healthy cached result"
- );
-}
-
-test "format_capabilities gives a concise message for both ready and not-ready cases" {
- keda = _keda_for(custom_api=_custom_api_for(), core_api=_core_api_for());
- ready = keda.discover_capabilities();
- assert "ready" in keda.format_capabilities(ready).lower();
-
- keda2 = _keda_for(
- custom_api=_custom_api_for(http_v1beta1=404, legacy_v1alpha1=404),
- core_api=_core_api_for()
- );
- not_ready = keda2.discover_capabilities();
- assert "helm" in keda2.format_capabilities(not_ready).lower();
-}
-
-test "preflight treats a 403 as a distinct error instead of an unhandled exception" {
- KEDAAutoscaler._preflight_cache.clear();
- custom_api = unittest.mock.MagicMock();
- custom_api.list_cluster_custom_object.side_effect = ApiException(status=403);
- keda = KEDAAutoscaler(_custom_api=custom_api);
- raised = False;
- try {
- keda.preflight();
- } except ApiException as e {
- raised = True;
- }
- assert not raised , "A 403 must be caught and reported, not propagated as an unhandled ApiException";
-}
-
-test "discover_capabilities resolves fully-qualified service addresses to name and namespace" {
- core_api = _core_api_for();
- keda = KEDAAutoscaler(
- _custom_api=_custom_api_for(),
- _core_api=core_api,
- _apps_api=unittest.mock.MagicMock(),
- http_scaler_address="keda-add-ons-http-external-scaler.keda.svc.cluster.local:9090",
- interceptor_service_address="keda-add-ons-http-interceptor-proxy.keda.svc.cluster.local:8080"
- );
- caps = keda.discover_capabilities();
- assert caps.errors == [] , f"FQDN service addresses must resolve cleanly, got: {caps.errors}";
- lookups = [
- (c[1]["name"], c[1]["namespace"])
- for c in core_api.read_namespaced_service.call_args_list
- ];
- assert lookups
- == [
- ("keda-add-ons-http-external-scaler", "keda"),
- ("keda-add-ons-http-interceptor-proxy", "keda")
- ] , f"FQDN must parse as name.namespace, ignoring the cluster-domain suffix; got: {lookups}";
-}
-
-test "format_capabilities does not report ready when core KEDA is missing" {
- keda = _keda_for(custom_api=_custom_api_for(core=404), core_api=_core_api_for());
- caps = keda.discover_capabilities();
- assert caps.http_addon_available == True , "Sanity check: only core should be missing in this scenario";
- msg = keda.format_capabilities(caps);
- assert "not ready" in msg.lower() , (
- f"A healthy Add-on with missing core KEDA must not be reported as ready; got: {msg}"
- );
-}
diff --git a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
index 95474915013..e69de4b7d7a 100644
--- a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
+++ b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
@@ -6,11 +6,20 @@ import from jaclang.scale.deploy.target.kubernetes.kubernetes_config {
KubernetesConfig
}
import from jaclang.scale.deploy.target.kubernetes.target { KubernetesTarget }
+import from jaclang.scale.deploy.target.kubernetes.manifest_builder { ScalingMode }
test "hpa engine renders the HPA manifest the apply path would build" {
target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
- bundle = {"autoscalers": {"api": {"min": 2, "max": 7, "cpu_target": 60}}};
+ bundle = {
+ "scaling": {
+ "api": {
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": "api-deployment",
+ "autoscaler": {"min": 2, "max": 7, "cpu_target": 60}
+ }
+ }
+ };
manifests = target.render_autoscaler_manifests(bundle);
assert len(manifests) == 1;
m = manifests[0];
@@ -28,7 +37,15 @@ test "keda engine renders the ScaledObject body, not the name/body wrapper" {
app_name="t", namespace="t-ns", autoscaler_engine="keda"
)
);
- bundle = {"autoscalers": {"api": {"min": 1, "max": 4}}};
+ bundle = {
+ "scaling": {
+ "api": {
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": "api-deployment",
+ "autoscaler": {"min": 1, "max": 4}
+ }
+ }
+ };
manifests = target.render_autoscaler_manifests(bundle);
assert len(manifests) == 1;
m = manifests[0];
@@ -39,7 +56,33 @@ test "keda engine renders the ScaledObject body, not the name/body wrapper" {
}
-test "a bundle with no autoscalers renders nothing" {
+test "the scale target comes from the resolved plan, not re-derived from the service name" {
+ target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
+ bundle = {
+ "scaling": {
+ "api": {
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": "not-the-k8s-safe-name-deployment",
+ "autoscaler": {"min": 1, "max": 3}
+ }
+ }
+ };
+ manifests = target.render_autoscaler_manifests(bundle);
+ assert manifests[0]["spec"]["scaleTargetRef"]["name"]
+ == "not-the-k8s-safe-name-deployment";
+}
+
+
+test "a service scaled by http_activation renders no autoscaler manifest" {
+ target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
+ bundle = {
+ "scaling": {"api": {"mode": ScalingMode.HTTP_ACTIVATION, "autoscaler": {}}}
+ };
+ assert target.render_autoscaler_manifests(bundle) == [];
+}
+
+
+test "a bundle with no scaling renders nothing" {
target = KubernetesTarget(config=KubernetesConfig(app_name="t", namespace="t-ns"));
assert target.render_autoscaler_manifests({"deployments": {}}) == [];
}
@@ -55,22 +98,26 @@ test "keda engine renders the TriggerAuthentication apply() would create for an
)
);
bundle = {
- "autoscalers": {
+ "scaling": {
"api": {
- "min": 1,
- "max": 4,
- "triggers": [
- {
- "type": "redis",
- "name": "cache",
- "metadata": {"address": "redis:6379"},
- "auth": {
- "secret_refs": {
- "password": {"name": "redis-secret", "key": "pw"}
+ "mode": ScalingMode.METRIC,
+ "scale_target_name": "api-deployment",
+ "autoscaler": {
+ "min": 1,
+ "max": 4,
+ "triggers": [
+ {
+ "type": "redis",
+ "name": "cache",
+ "metadata": {"address": "redis:6379"},
+ "auth": {
+ "secret_refs": {
+ "password": {"name": "redis-secret", "key": "pw"}
+ }
}
}
- }
- ]
+ ]
+ }
}
}
};
diff --git a/jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/README.md b/jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/README.md
index 2471a7a8e04..55dca97a117 100644
--- a/jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/README.md
+++ b/jac/jaclang/scale/tests/fixtures/keda_http_activation_e2e/README.md
@@ -1,39 +1,41 @@
# KEDA HTTP Add-on activation e2e fixture
-Zero-replica `echo` Deployment (`hashicorp/http-echo`) plus its Service,
-applied as a raw manifest so the e2e script can drive a real KEDA HTTP
-Add-on scale-from-zero cycle against it.
+A minimal Jac app (`app.jac`) deployed via `jac scale deploy`, with
+`[scale.kubernetes.http_activation]` enabled in `jac.toml` so the deploy
+wires up the KEDA HTTP Add-on's `InterceptorRoute` + `ScaledObject` for real
+scale-to-zero activation (#7475).
```
keda_http_activation_e2e/
- fixture.yaml Namespace + Deployment (replicas: 0) + Service
+ app.jac A single public walker (`echo`) reporting a JSON message.
+ jac.toml [scale.kubernetes.http_activation] config; deploys as app "echo".
```
-There is no `jac.toml` here: this fixture is not a Jac app, just the
-Kubernetes objects the KEDA HTTP Add-on scales, so nothing builds or
-runs a client for it.
+This fixture is a real Jac app deployed through the normal CLI path, not a
+raw manifest -- `jac scale deploy` builds the Deployment/Service and, from
+the `http_activation` config, the `InterceptorRoute`/`ScaledObject` too. See
+`../http_activation_toml_e2e/` for the sibling fixture this one follows the
+same pattern as.
## What the e2e covers
[`../deploy/keda_http_activation_real_e2e.sh`](../deploy/keda_http_activation_real_e2e.sh)
-drives [`../deploy/keda_http_activation_verify.jac`](../deploy/keda_http_activation_verify.jac),
-which calls `KEDAAutoscaler.apply_http_activation` / `destroy_http_activation`
-directly against whatever cluster the current kubeconfig points at. No
-mocking. The flow:
-
-1. Apply this fixture; confirm `echo` starts at 0 replicas.
-2. Call `apply_http_activation` twice: once to create the
- `InterceptorRoute` + `ScaledObject`, once more to exercise the
- get-then-patch branch against a real API server.
-3. Poll both resources' `status.conditions[type=Ready]` until each
- reports Ready, so a reconciliation problem fails here with a clear
- message instead of surfacing later as an opaque interceptor timeout.
-4. Port-forward the HTTP Add-on interceptor and send a request through
- it; this should block on the cold start, then return 200 once the
- target is Ready.
+drives the deploy against whatever cluster the current kubeconfig points at.
+No mocking. The flow:
+
+1. Deploy via `jac scale deploy app.jac` from this directory.
+2. Redeploy once more, to confirm the `InterceptorRoute`/`ScaledObject`
+ reconcile is idempotent (get-then-patch, not create-or-duplicate) against
+ a real API server.
+3. Poll both resources' `status.conditions[type=Ready]` until each reports
+ Ready, so a reconciliation problem fails here with a clear message
+ instead of surfacing later as an opaque interceptor timeout.
+4. Port-forward the HTTP Add-on interceptor and `POST /walker/echo` through
+ it; this should block on the cold start, then return 200 once the target
+ is Ready.
5. Wait for `echo` to scale 0 to 1 and become Available.
-6. Stop traffic and wait for the cooldown period to elapse, then
- confirm `echo` scales back down to 0.
+6. Stop traffic and wait for the cooldown period to elapse, then confirm
+ `echo` scales back down to 0.
## Prerequisites
@@ -77,6 +79,44 @@ components in a Running state.
The script also preflight-checks for the `interceptorroutes.http.keda.sh`
CRD and fails immediately with the commands above if it is missing.
+`jac.toml`'s `[dev] jaclang_source` points at this checkout's own `jac/`
+source, so the deploy runs your in-tree code, not a published release.
+
+On `kind`, the cluster has no RWX-capable default `StorageClass` for the
+bundle PVC (its built-in `standard` class is `ReadWriteOnce`), so you need
+to provision one first:
+
+```bash
+kubectl apply -f - < 5 on concurrency=10" in out;
+ assert "scale-to-zero via KEDA HTTP Add-on" in out;
+ assert "1 HTTP activation" in out;
+ # The metric-autoscaler HPA line must NOT appear for an activation service.
+ assert "HPA:" not in out;
+}
+
test "validate: reserved name __gateway__ as a microservice -> error" {
diags = _validate({"deployments": {}}, {"routes": {"__gateway__": "/x"}}, {});
assert any(d.severity == "error" and "reserved" in d.message for d in diags);
diff --git a/release_notes/unreleased/jaclang/7709.feature.md b/release_notes/unreleased/jaclang/7709.feature.md
new file mode 100644
index 00000000000..c9eb600f94e
--- /dev/null
+++ b/release_notes/unreleased/jaclang/7709.feature.md
@@ -0,0 +1 @@
+- **Feature: jac-scale KEDA HTTP Add-on activation is now configurable via `jac.toml` (#7709)**: the scale-to-zero-on-HTTP-traffic feature added programmatically in #7421 can now be turned on for a service entirely from config, with no Jac driver script required. New `[scale.kubernetes.http_activation]` block covers monolith deploys; a per-service `[scale.microservices.services..http_activation]` override covers microservice deploys, falling back to the top-level block for any key left unset. Both cover the target port, the concurrency/request-rate scaling metric, routing rules, cold-start response, and interceptor timeouts. `jac start --scale` now reconciles the `InterceptorRoute` and `ScaledObject` for any service with `http_activation.enabled = true`, and teardown cleans up both, including when the base autoscaler engine is `"hpa"`, closing a gap where those resources could otherwise be orphaned. Since the HTTP Add-on's CRD group installs and is permissioned separately from core KEDA, teardown now also tolerates a cluster where that group's RBAC isn't provisioned yet, without aborting cleanup of the rest of the deployment. The programmatic API (`HTTPActivationSpec`, `apply_http_activation`/`destroy_http_activation`) is unchanged and remains available for control-plane callers with a dynamic create/destroy lifecycle, such as an IDE-preview orchestrator. Each service resolves to exactly one scaling mode: a target with `http_activation.enabled = true` is driven only by its `ScaledObject` (they can't coexist with a metric autoscaler on one target -- KEDA's admission webhook rejects a `ScaledObject` for a workload already managed by an HPA), its scale target is the service's own generated Deployment, and the post-deploy HTTP reachability probe is skipped (the target may legitimately sit at 0 replicas with nothing to reach; a crash-loop check on the pods runs instead). The gateway is never HTTP-activated -- it is the ingress entry point and must stay warm, so it never inherits a shared `enabled = true` -- and `jac scale plan` lists any service configured for HTTP activation. No `PodDisruptionBudget` is emitted for an HTTP-activated service, since a replica floor is unsatisfiable once the target is legitimately at zero, and a redeploy no longer resets a scaled-to-zero target back to its baseline replica count. An empty `rules` list, or two services that resolve to identical routing rules, is now a build-time error rather than a silently-unreachable or ambiguously-routed deploy. Note: with `min_replicas = 0`, you must route inbound traffic through the KEDA HTTP interceptor yourself for now; jac-scale does not yet rewire the gateway or Ingress to the interceptor, so a request that reaches the app Service directly will not wake a scaled-to-zero pod (#7959).
From 6385e13903b55e29440d30da28c0184892b4e757 Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 05:12:06 +0530
Subject: [PATCH 07/13] style: jac fmt the backported changes for this branch
---
.../scale/deploy/target/kubernetes/target.jac | 6 ++--
jac/jaclang/scale/runtime/cli/plan.jac | 2 +-
.../tests/deploy/test_deployment_overlay.jac | 30 +++++++++-------
.../scale/tests/deploy/test_factories.jac | 5 +--
.../deploy/test_http_activation_config.jac | 17 ++++-----
.../test_http_activation_microservices.jac | 35 +++++++++++--------
.../deploy/test_memory_trigger_guard.jac | 10 +++---
.../deploy/test_show_yaml_autoscalers.jac | 8 ++---
.../scale/tests/microservices/test_plan.jac | 2 --
9 files changed, 61 insertions(+), 54 deletions(-)
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/target.jac b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
index 7348ba8f967..c6b2f14cc12 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
@@ -546,8 +546,7 @@ obj KubernetesTarget(KubernetesTargetBase) {
http_activation_svc_names: set = {
str(svc_name)
for svc_name in scaling.keys()
- if dict(scaling.get(svc_name, {})).get("mode")
- == ScalingMode.HTTP_ACTIVATION
+ if dict(scaling.get(svc_name, {})).get("mode") == ScalingMode.HTTP_ACTIVATION
};
names: list[str] = [];
app_labels: list[str] = [];
@@ -1207,8 +1206,7 @@ obj KubernetesTarget(KubernetesTargetBase) {
for (svc_name, raw_manifest) in deployments.items() {
dep_manifest = with_config_revision(raw_manifest, config_revision);
patch_body = None;
- if dict(scaling.get(svc_name, {})).get("mode")
- == ScalingMode.HTTP_ACTIVATION {
+ if dict(scaling.get(svc_name, {})).get("mode") == ScalingMode.HTTP_ACTIVATION {
patch_body = dict(dep_manifest);
patch_body["spec"] = {
k: v
diff --git a/jac/jaclang/scale/runtime/cli/plan.jac b/jac/jaclang/scale/runtime/cli/plan.jac
index cd7c9069d5e..84926d68f59 100644
--- a/jac/jaclang/scale/runtime/cli/plan.jac
+++ b/jac/jaclang/scale/runtime/cli/plan.jac
@@ -448,7 +448,7 @@ obj Plan {
http: Any = (
plan_svc.get("http_activation")
if isinstance(plan_svc, dict)
- and mode == ScalingMode.HTTP_ACTIVATION
+ and mode == ScalingMode.HTTP_ACTIVATION
else None
);
services.append(
diff --git a/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac b/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac
index cfb69367131..2f6560d472d 100644
--- a/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac
+++ b/jac/jaclang/scale/tests/deploy/test_deployment_overlay.jac
@@ -53,10 +53,12 @@ test "unnamed lists are replaced wholesale" {
test "build_behavior with no overlay returns the hardcoded default" {
spec = AutoscalerSpec(scale_target_name="x", namespace="ns");
result = Autoscaler._build_behavior(spec);
- assert result["scaleDown"]["policies"]
- == [{"type": "Percent", "value": 50, "periodSeconds": 60}];
- assert result["scaleUp"]["policies"]
- == [{"type": "Pods", "value": 2, "periodSeconds": 60}];
+ assert result["scaleDown"]["policies"] == [
+ {"type": "Percent", "value": 50, "periodSeconds": 60}
+ ];
+ assert result["scaleUp"]["policies"] == [
+ {"type": "Pods", "value": 2, "periodSeconds": 60}
+ ];
}
test "build_behavior with an empty overlay dict is a no-op" {
@@ -84,11 +86,13 @@ test "build_behavior overlay replaces scaleDown policy shape, leaves scaleUp def
result = Autoscaler._build_behavior(spec);
assert result["scaleDown"]["stabilizationWindowSeconds"] == 600;
assert result["scaleDown"]["selectPolicy"] == "Min";
- assert result["scaleDown"]["policies"]
- == [{"type": "Pods", "value": 2, "periodSeconds": 300}];
+ assert result["scaleDown"]["policies"] == [
+ {"type": "Pods", "value": 2, "periodSeconds": 300}
+ ];
assert result["scaleUp"]["stabilizationWindowSeconds"] == 60;
- assert result["scaleUp"]["policies"]
- == [{"type": "Pods", "value": 2, "periodSeconds": 60}];
+ assert result["scaleUp"]["policies"] == [
+ {"type": "Pods", "value": 2, "periodSeconds": 60}
+ ];
}
test "build_behavior overlay on scaleUp does not disturb the scaleDown default" {
@@ -102,11 +106,13 @@ test "build_behavior overlay on scaleUp does not disturb the scaleDown default"
}
);
result = Autoscaler._build_behavior(spec);
- assert result["scaleUp"]["policies"]
- == [{"type": "Percent", "value": 100, "periodSeconds": 15}];
+ assert result["scaleUp"]["policies"] == [
+ {"type": "Percent", "value": 100, "periodSeconds": 15}
+ ];
assert result["scaleUp"]["stabilizationWindowSeconds"] == 60;
- assert result["scaleDown"]["policies"]
- == [{"type": "Percent", "value": 50, "periodSeconds": 60}];
+ assert result["scaleDown"]["policies"] == [
+ {"type": "Percent", "value": 50, "periodSeconds": 60}
+ ];
}
test "build_behavior overlay policies list replaces wholesale, not merged by item" {
diff --git a/jac/jaclang/scale/tests/deploy/test_factories.jac b/jac/jaclang/scale/tests/deploy/test_factories.jac
index 286d38e765a..36b9e86c953 100644
--- a/jac/jaclang/scale/tests/deploy/test_factories.jac
+++ b/jac/jaclang/scale/tests/deploy/test_factories.jac
@@ -331,8 +331,9 @@ test "hpa autoscaler build manifests applies behavior_overlay onto the default s
behavior = result["spec"]["behavior"];
assert behavior["scaleDown"]["stabilizationWindowSeconds"] == 600;
assert behavior["scaleDown"]["selectPolicy"] == "Min";
- assert behavior["scaleDown"]["policies"]
- == [{"type": "Pods", "value": 2, "periodSeconds": 300}];
+ assert behavior["scaleDown"]["policies"] == [
+ {"type": "Pods", "value": 2, "periodSeconds": 300}
+ ];
assert behavior["scaleUp"]["stabilizationWindowSeconds"] == 60;
}
diff --git a/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac b/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac
index 09b0099fca1..209efb1d5d6 100644
--- a/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac
+++ b/jac/jaclang/scale/tests/deploy/test_http_activation_config.jac
@@ -16,12 +16,10 @@ import from jaclang.scale.tests.deploy.http_activation_test_support {
test "disabled or missing enabled key returns None instead of a spec" {
assert build_http_activation_spec(
_cfg(enabled=False), "preview-deployment", "staging", "preview-svc"
- )
- is None;
+ ) is None;
assert build_http_activation_spec(
{}, "preview-deployment", "staging", "preview-svc"
- )
- is None;
+ ) is None;
}
@@ -171,20 +169,19 @@ test "apply_http_activation_for_target calls apply_http_activation with a correc
route_body = create_calls[0][1]["body"];
assert route_body["spec"]["target"]["service"] == "preview-svc";
scaled_obj_body = create_calls[1][1]["body"];
- assert scaled_obj_body["metadata"]["name"]
- == keda.http_scaled_object_name_for("preview-deployment");
+ assert scaled_obj_body["metadata"]["name"] == keda.http_scaled_object_name_for(
+ "preview-deployment"
+ );
}
test "apply_http_activation_for_target is a no-op when disabled or unset" {
(keda, custom_api, _, _) = _mocked_keda();
assert apply_http_activation_for_target(
_cfg(enabled=False), "preview-deployment", "staging", "preview-svc", keda
- )
- is None;
+ ) is None;
assert apply_http_activation_for_target(
{}, "preview-deployment", "staging", "preview-svc", keda
- )
- is None;
+ ) is None;
custom_api.create_namespaced_custom_object.assert_not_called();
}
diff --git a/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac b/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac
index 3f0116c4b19..185669656aa 100644
--- a/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac
+++ b/jac/jaclang/scale/tests/deploy/test_http_activation_microservices.jac
@@ -274,8 +274,10 @@ test "a METRIC service has its stale HTTP activation resources reaped, autoscale
delete_calls = custom_api.delete_namespaced_custom_object.call_args_list;
assert len(delete_calls) == 2;
deleted_names = {c[1]["name"] for c in delete_calls};
- assert deleted_names
- == {"worker-deployment-http-route", "worker-deployment-http-scaledobject"};
+ assert deleted_names == {
+ "worker-deployment-http-route",
+ "worker-deployment-http-scaledobject"
+ };
}
test "an HTTP_ACTIVATION service has its stale metric autoscaler destroyed, activation left alone" {
@@ -784,8 +786,11 @@ test "destroy sweeps the ScaledObject and every TriggerAuthentication it owns, t
plurals = [
c[1]["plural"] for c in mock_api4.delete_namespaced_custom_object.call_args_list
];
- assert set(plurals)
- == {"scaledobjects", "triggerauthentications", "interceptorroutes"};
+ assert set(plurals) == {
+ "scaledobjects",
+ "triggerauthentications",
+ "interceptorroutes"
+ };
mock_v2.delete_collection_namespaced_horizontal_pod_autoscaler.assert_called_once_with(
namespace="staging", label_selector="managed=jac-scale"
);
@@ -878,8 +883,9 @@ test "apply_http_activation encodes multi-rule routing (AND-within-rule, OR-betw
assert trigger_metadata["targetValue"] == "50";
assert trigger_metadata["window"] == "5m";
assert trigger_metadata["granularity"] == "10s";
- assert so_spec["triggers"][0]["metadata"]["interceptorRoute"]
- == keda.interceptor_route_name_for(spec.scale_target_name);
+ assert so_spec["triggers"][0]["metadata"]["interceptorRoute"] == keda.interceptor_route_name_for(
+ spec.scale_target_name
+ );
# Redeploy: both resources now exist, so both get patched, not recreated.
custom_api.get_namespaced_custom_object.side_effect = None;
@@ -945,8 +951,7 @@ test "apply_http_activation validates the spec itself (port XOR port_name, concu
custom_api2.get_namespaced_custom_object.side_effect = ApiException(status=404);
assert keda2.apply_http_activation(
_spec(scale_target_name="preview-sts", scale_target_kind="StatefulSet")
- )
- == True;
+ ) == True;
(keda3, custom_api3, _, _) = _mocked_keda();
result = keda3.apply_http_activation(
@@ -1326,8 +1331,9 @@ test "discover_capabilities caches per (cluster, service-address, client-identit
);
core_missing_caps = core_missing_keda.discover_capabilities();
assert core_missing_caps.http_addon_available == True;
- assert "not ready"
- in core_missing_keda.format_capabilities(core_missing_caps).lower() , (
+ assert "not ready" in core_missing_keda.format_capabilities(
+ core_missing_caps
+ ).lower() , (
"a healthy Add-on with missing core KEDA must not be reported as ready"
);
@@ -1368,11 +1374,10 @@ test "discover_capabilities caches per (cluster, service-address, client-identit
(c[1]["name"], c[1]["namespace"])
for c in fqdn_core.read_namespaced_service.call_args_list
];
- assert lookups
- == [
- ("keda-add-ons-http-external-scaler", "keda"),
- ("keda-add-ons-http-interceptor-proxy", "keda")
- ] , f"FQDN service addresses must parse as name.namespace; got: {lookups}";
+ assert lookups == [
+ ("keda-add-ons-http-external-scaler", "keda"),
+ ("keda-add-ons-http-interceptor-proxy", "keda")
+ ] , f"FQDN service addresses must parse as name.namespace; got: {lookups}";
preflight_api = unittest.mock.MagicMock();
preflight_api.list_cluster_custom_object.side_effect = ApiException(status=403);
diff --git a/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac b/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
index d9d6a57a4b4..e54e8bc6832 100644
--- a/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
+++ b/jac/jaclang/scale/tests/deploy/test_memory_trigger_guard.jac
@@ -53,8 +53,9 @@ test "hpa.behavior on one service does not leak onto another" {
"orders_ops": {}
}
);
- assert b._get_autoscaler_config("billing_ops")["behavior"]
- == {"scaleDown": {"selectPolicy": "Min"}};
+ assert b._get_autoscaler_config("billing_ops")["behavior"] == {
+ "scaleDown": {"selectPolicy": "Min"}
+ };
assert b._get_autoscaler_config("orders_ops")["behavior"] == {};
}
@@ -77,8 +78,9 @@ test "hpa.behavior works for the gateway via the __gateway__ service key" {
b = _builder(
{GATEWAY_NAME: {"hpa": {"behavior": {"scaleDown": {"selectPolicy": "Min"}}}}}
);
- assert b._get_autoscaler_config(GATEWAY_NAME)["behavior"]
- == {"scaleDown": {"selectPolicy": "Min"}};
+ assert b._get_autoscaler_config(GATEWAY_NAME)["behavior"] == {
+ "scaleDown": {"selectPolicy": "Min"}
+ };
}
test "malformed hpa.behavior warns and falls back to the empty fragment" {
diff --git a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
index e69de4b7d7a..debd914d480 100644
--- a/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
+++ b/jac/jaclang/scale/tests/deploy/test_show_yaml_autoscalers.jac
@@ -68,8 +68,7 @@ test "the scale target comes from the resolved plan, not re-derived from the ser
}
};
manifests = target.render_autoscaler_manifests(bundle);
- assert manifests[0]["spec"]["scaleTargetRef"]["name"]
- == "not-the-k8s-safe-name-deployment";
+ assert manifests[0]["spec"]["scaleTargetRef"]["name"] == "not-the-k8s-safe-name-deployment";
}
@@ -127,6 +126,7 @@ test "keda engine renders the TriggerAuthentication apply() would create for an
auth_name = manifests[0]["spec"]["triggers"][0]["authenticationRef"]["name"];
assert manifests[1]["kind"] == "TriggerAuthentication";
assert manifests[1]["metadata"]["name"] == auth_name;
- assert manifests[1]["spec"]["secretTargetRef"]
- == [{"parameter": "password", "name": "redis-secret", "key": "pw"}];
+ assert manifests[1]["spec"]["secretTargetRef"] == [
+ {"parameter": "password", "name": "redis-secret", "key": "pw"}
+ ];
}
diff --git a/jac/jaclang/scale/tests/microservices/test_plan.jac b/jac/jaclang/scale/tests/microservices/test_plan.jac
index 4ed98b2fb77..8e146794ba7 100644
--- a/jac/jaclang/scale/tests/microservices/test_plan.jac
+++ b/jac/jaclang/scale/tests/microservices/test_plan.jac
@@ -232,8 +232,6 @@ test "render smoke: realistic bundle prints every section" {
}
-
-
test "render: an http_activation service shows scale-to-zero and counts in Totals" {
bundle: dict[str, Any] = {
"deployments": {"llm": _dep(image="llm:v1")},
From 845ef52171dc9f918dc59dd0af0d9d3de4143ccb Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 05:12:30 +0530
Subject: [PATCH 08/13] chore: drop release-note fragments; the 0.34.x line
writes its notes at publish time, not from this directory
---
release_notes/unreleased/jaclang/6908.bugfix.md | 1 -
release_notes/unreleased/jaclang/7620.feature.md | 1 -
release_notes/unreleased/jaclang/7709.feature.md | 1 -
release_notes/unreleased/jaclang/7899.feature.md | 1 -
release_notes/unreleased/jaclang/8008.bugfix.md | 1 -
release_notes/unreleased/jaclang/8424.bugfix.md | 2 --
6 files changed, 7 deletions(-)
delete mode 100644 release_notes/unreleased/jaclang/6908.bugfix.md
delete mode 100644 release_notes/unreleased/jaclang/7620.feature.md
delete mode 100644 release_notes/unreleased/jaclang/7709.feature.md
delete mode 100644 release_notes/unreleased/jaclang/7899.feature.md
delete mode 100644 release_notes/unreleased/jaclang/8008.bugfix.md
delete mode 100644 release_notes/unreleased/jaclang/8424.bugfix.md
diff --git a/release_notes/unreleased/jaclang/6908.bugfix.md b/release_notes/unreleased/jaclang/6908.bugfix.md
deleted file mode 100644
index 3d07cb421e6..00000000000
--- a/release_notes/unreleased/jaclang/6908.bugfix.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Fix: KEDA `TriggerAuthentication` resources are now reconciled by ownership label**: `KEDAAutoscaler` previously orphaned `TriggerAuthentication` resources in two ways. A redeploy with a changed trigger set created new auths but never removed the ones the `ScaledObject` no longer referenced, and `destroy()` deleted only the `ScaledObject`, leaving every auth it created behind. `_build_trigger_auth_manifest` now stamps every `TriggerAuthentication` it builds with `app` and `jac-scale/owner` labels (the owner is the `ScaledObject` name, computed by one shared `_scaled_object_name()` helper instead of being derived separately, and divergently once `spec.autoscaler_name` is unset, in `apply()` and `_build_manifests()`), so `--show-yaml` and `apply()` write the same labels. `apply()` prunes any owned auth not in the freshly-applied set after writing the current ones, and `destroy(app_name, namespace)` deletes owned auths by that label (404-safe) before deleting the `ScaledObject`, instead of parsing the live `ScaledObject` to find them, so cleanup works even when the `ScaledObject` was already deleted out of band.
diff --git a/release_notes/unreleased/jaclang/7620.feature.md b/release_notes/unreleased/jaclang/7620.feature.md
deleted file mode 100644
index f36979113dd..00000000000
--- a/release_notes/unreleased/jaclang/7620.feature.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Feature: KEDA HTTP Add-on capability discovery and preflight diagnostics**: jac-scale's KEDA autoscaler engine gains `discover_capabilities`, a structured preflight check that distinguishes a missing KEDA core install from a missing or legacy HTTP Add-on, an RBAC-denied discovery from a genuinely absent API, and a missing external-scaler or interceptor-proxy Service, each with its own actionable diagnostic and the exact Helm install or upgrade command where relevant, alongside a link to the current getting-started guide in case the command has drifted. Results are cached per cluster, with `discover_capabilities(refresh=True)` and `invalidate_capabilities()` as explicit refresh paths, and `format_capabilities` for a concise CLI-ready summary. `apply_http_activation` now calls this check directly: it raises when the Add-on is installed but broken (missing service, unsupported version, RBAC denied) instead of silently deploying a workload that will never activate from HTTP traffic, while still returning `False` gracefully when the Add-on is simply absent.
diff --git a/release_notes/unreleased/jaclang/7709.feature.md b/release_notes/unreleased/jaclang/7709.feature.md
deleted file mode 100644
index c9eb600f94e..00000000000
--- a/release_notes/unreleased/jaclang/7709.feature.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Feature: jac-scale KEDA HTTP Add-on activation is now configurable via `jac.toml` (#7709)**: the scale-to-zero-on-HTTP-traffic feature added programmatically in #7421 can now be turned on for a service entirely from config, with no Jac driver script required. New `[scale.kubernetes.http_activation]` block covers monolith deploys; a per-service `[scale.microservices.services..http_activation]` override covers microservice deploys, falling back to the top-level block for any key left unset. Both cover the target port, the concurrency/request-rate scaling metric, routing rules, cold-start response, and interceptor timeouts. `jac start --scale` now reconciles the `InterceptorRoute` and `ScaledObject` for any service with `http_activation.enabled = true`, and teardown cleans up both, including when the base autoscaler engine is `"hpa"`, closing a gap where those resources could otherwise be orphaned. Since the HTTP Add-on's CRD group installs and is permissioned separately from core KEDA, teardown now also tolerates a cluster where that group's RBAC isn't provisioned yet, without aborting cleanup of the rest of the deployment. The programmatic API (`HTTPActivationSpec`, `apply_http_activation`/`destroy_http_activation`) is unchanged and remains available for control-plane callers with a dynamic create/destroy lifecycle, such as an IDE-preview orchestrator. Each service resolves to exactly one scaling mode: a target with `http_activation.enabled = true` is driven only by its `ScaledObject` (they can't coexist with a metric autoscaler on one target -- KEDA's admission webhook rejects a `ScaledObject` for a workload already managed by an HPA), its scale target is the service's own generated Deployment, and the post-deploy HTTP reachability probe is skipped (the target may legitimately sit at 0 replicas with nothing to reach; a crash-loop check on the pods runs instead). The gateway is never HTTP-activated -- it is the ingress entry point and must stay warm, so it never inherits a shared `enabled = true` -- and `jac scale plan` lists any service configured for HTTP activation. No `PodDisruptionBudget` is emitted for an HTTP-activated service, since a replica floor is unsatisfiable once the target is legitimately at zero, and a redeploy no longer resets a scaled-to-zero target back to its baseline replica count. An empty `rules` list, or two services that resolve to identical routing rules, is now a build-time error rather than a silently-unreachable or ambiguously-routed deploy. Note: with `min_replicas = 0`, you must route inbound traffic through the KEDA HTTP interceptor yourself for now; jac-scale does not yet rewire the gateway or Ingress to the interceptor, so a request that reaches the app Service directly will not wake a scaled-to-zero pod (#7959).
diff --git a/release_notes/unreleased/jaclang/7899.feature.md b/release_notes/unreleased/jaclang/7899.feature.md
deleted file mode 100644
index f38d1bdc9b8..00000000000
--- a/release_notes/unreleased/jaclang/7899.feature.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Per-service HPA/KEDA scale-down (and scale-up) rate can now be overridden** without being clobbered on the next deploy: `[scale.microservices.services.NAME.hpa.behavior]` accepts a raw HPA `behavior` fragment (`scaleUp`/`scaleDown`, each with `stabilizationWindowSeconds`/`policies`/`selectPolicy`) that is deep-merged over the previously-hardcoded `{"type": "Percent", "value": 50, "periodSeconds": 60}` scale-down shape - same merge semantics as `deployment_overlay`, applied to both the `"hpa"` and `"keda"` autoscaler engines since both route through `Autoscaler._build_behavior`. Connection-heavy services (WebSockets, SSE) can now ask for a gentle scale-down (e.g. at most 2 pods every 5 minutes) without imposing that rate on every other service via the app-global `autoscaler_cooldown`.
diff --git a/release_notes/unreleased/jaclang/8008.bugfix.md b/release_notes/unreleased/jaclang/8008.bugfix.md
deleted file mode 100644
index ad9e4a3829d..00000000000
--- a/release_notes/unreleased/jaclang/8008.bugfix.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Fix: --show-yaml includes autoscalers**: the dry-run YAML now prints the HPA / KEDA ScaledObject a deploy would apply, built from the same spec as the apply path.
diff --git a/release_notes/unreleased/jaclang/8424.bugfix.md b/release_notes/unreleased/jaclang/8424.bugfix.md
deleted file mode 100644
index 37272c5b913..00000000000
--- a/release_notes/unreleased/jaclang/8424.bugfix.md
+++ /dev/null
@@ -1,2 +0,0 @@
-- **Scale: `--show-yaml` now renders the KEDA `TriggerAuthentication` an authenticated trigger creates**: `render_autoscaler_manifests` built its preview from each engine's `_build_manifests`, which for KEDA only ever returned the `ScaledObject`; the `TriggerAuthentication` for a trigger with `auth.secret_refs` was built and written to the cluster exclusively inside `KEDAAutoscaler.apply`, so it never existed as a value the render path could see. A config with an authenticated trigger therefore dry-ran clean and then `apply()` silently created a `TriggerAuthentication` the preview never showed, referencing the very Secret the preview was supposed to let a user check before it landed. `_build_manifests` now returns one `list[dict]` contract for both engines: HPA still returns a single-element list, KEDA returns the `ScaledObject` followed by every `TriggerAuthentication` its triggers need, built by a pure function extracted out of the old apply-only helper so `apply()` and the render path share one source instead of the render path shape-sniffing whatever `_build_manifests` happened to return. Apply-time behavior is unchanged: `TriggerAuthentication` objects are still written to the cluster before the `ScaledObject` that references them.
- Trigger validation also moved from `apply()` into `_build_manifests`: a config whose triggers resolve to colliding identities now fails the dry run with the same `ValueError` that `apply()` raises, instead of rendering `TriggerAuthentication` manifests that overwrite one another when the YAML stream is piped into `kubectl apply -f -`.
From 4072735e927d5d0e424feb201969e69d7148abf3 Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 05:12:47 +0530
Subject: [PATCH 09/13] chore: restore this branch's typeshed PROVENANCE stamp
(clobbered by a local stdlib copy during backport testing)
---
jac/jaclang/vendor/typeshed/PROVENANCE.md | 22 +++++++++-------------
1 file changed, 9 insertions(+), 13 deletions(-)
diff --git a/jac/jaclang/vendor/typeshed/PROVENANCE.md b/jac/jaclang/vendor/typeshed/PROVENANCE.md
index 4b80c4bb095..eaaea1e8154 100644
--- a/jac/jaclang/vendor/typeshed/PROVENANCE.md
+++ b/jac/jaclang/vendor/typeshed/PROVENANCE.md
@@ -1,17 +1,13 @@
# Vendored typeshed (stdlib stubs only)
The Python standard-library type stubs from typeshed. They are NOT committed:
-`stdlib/` is gitignored and rebuilt at the pinned commit by the Zig bootstrap
-seed `bootstrap/fetch_typeshed.zig`, which `build.zig` runs as its
-`fetch-typeshed` step so the `jac` binary bundles the stubs. It is a Zig seed
-and not the Jac payload tool because these stubs are what every compilation
-type-checks against, so they have to exist before the payload tool itself can
-be compiled (#8785). The payload tool keeps its own `fetch-typeshed`
-subcommand, reading the same pin, for the already-built tool's use. Only this
-file, `PIN`, `TARBALL_SHA256`, and `LICENSE` are tracked.
+`stdlib/` is gitignored and rebuilt at the pinned commit by the `fetch-typeshed`
+subcommand of `launcher/payload.zig` (which `build.zig` runs so the `jac` binary
+bundles the stubs). Only this file, `PIN`, `TARBALL_SHA256`, and `LICENSE` are
+tracked.
-Integrity: the fetcher downloads the GitHub tarball for the pinned commit
-and verifies the **decompressed tar's** sha256 against `TARBALL_SHA256` (git's
+Integrity: `payload.zig` downloads the GitHub tarball for the pinned commit and
+verifies the **decompressed tar's** sha256 against `TARBALL_SHA256` (git's
`archive` output is content-stable for a commit), so a swapped tarball cannot
slip in -- the same guarantee git's content-addressing gave the old `git fetch`.
@@ -25,8 +21,8 @@ from the project venv.
To bump:
1. Put the new commit SHA in `PIN`.
-2. Get the new hash with the payload tool's `typeshed-sha` subcommand (run
- `jaclang.payload.cli` with `typeshed-sha `; `build.zig` drives the
- same tool for its fetch steps) and write the printed value into
+2. Get the new hash: `zig build` builds the tool, then
+ `./.zig-cache/.../payload typeshed-sha ` (or build it directly with
+ `zig build-exe launcher/payload.zig`) and write the printed value into
`TARBALL_SHA256`.
3. Update the Commit line above and commit `PIN`, `TARBALL_SHA256`, `PROVENANCE.md`.
From 061af14519213157e2202154b463b3c0bb82f891 Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 04:37:21 +0530
Subject: [PATCH 10/13] fix(scale): seal the app venv into the .jab so
scale-to-zero wakes skip jac install (#8695)
A woken pod spent 21s of its 44s wake rebuilding /app/.jac/venv from the
bundle's own vendored wheels, identically every time, because /app is an
emptyDir and the .jab never carried the built venv. The dependency
closure is fully determined at seal time, so build it there instead:
- pack_jab now runs the seal binary's own 'jac install' inside the
staging tree against the freshly vendored wheels (the exact install
pods run today) and seals lib/*/site-packages plus a .jac-deps-hash
marker into the .jab under .jac/venv/. The existing pvc-bootstrap
untar populates the pod venv with zero pod-side script changes.
- install_specs returns early when the marker matches the resolved
specs, before ensure_venv can classify the interpreter-less sealed
venv as corrupted and delete it. Only pack_jab ever writes the
marker, so local 'jac install' behavior is unchanged.
- The payload stays deterministic (guarded by the existing digest
determinism test): bin/ shebangs, pyvenv.cfg, __pycache__/*.pyc and
ensurepip's seeded pip never ship, and RECORD lines pointing outside
site-packages are dropped.
- Staging failures (cross-arch hosts, thin bundles) log and fall back
to today's boot-time install; JAC_SEAL_SKIP_VENV=1 opts out.
- Drive-by fix the e2e surfaced: vendor_wheels resolved the project
config via discovery, which 'jac run' roots at the script's own tree,
so any dev-source seal vendored the jac checkout's closure instead of
the app's. It now loads the staged jac.toml directly.
(cherry picked from commit fd35eb828c83bab1f6f7d0182f44a911b9a4d275)
---
jac/jaclang/project/providers.jac | 31 +++-
jac/jaclang/scale/injector/bundle.jac | 171 ++++++++++++++++++
.../tests/microservices/test_seal_venv.jac | 168 +++++++++++++++++
jac/jaclang/scale/utils/vendor_wheels.jac | 9 +-
jac/tests/project/test_dependencies.jac | 138 +++++++++++++-
.../unreleased/jaclang/8695.bugfix.md | 1 +
6 files changed, 514 insertions(+), 4 deletions(-)
create mode 100644 jac/jaclang/scale/tests/microservices/test_seal_venv.jac
create mode 100644 release_notes/unreleased/jaclang/8695.bugfix.md
diff --git a/jac/jaclang/project/providers.jac b/jac/jaclang/project/providers.jac
index 520f904526c..35d408221ae 100644
--- a/jac/jaclang/project/providers.jac
+++ b/jac/jaclang/project/providers.jac
@@ -13,6 +13,29 @@ import from jaclang.project.pyvenv {
glob EMIT_EJECT: str = "eject",
EMIT_PUBLISH: str = "publish";
+glob VENV_DEPS_MARKER: str = ".jac-deps-hash";
+
+
+def _venv_deps_hash(specs: list[str]) -> str {
+ import hashlib;
+ return hashlib.sha256("\n".join(sorted(specs)).encode("utf-8")).hexdigest();
+}
+
+
+def venv_deps_satisfied(venv_dir: Path, specs: list[str]) -> bool {
+ try {
+ marker = (venv_dir / VENV_DEPS_MARKER).read_text(encoding="utf-8");
+ } except OSError {
+ return False;
+ }
+ return marker.strip() == _venv_deps_hash(specs);
+}
+
+
+def write_venv_deps_marker(venv_dir: Path, specs: list[str]) {
+ (venv_dir / VENV_DEPS_MARKER).write_text(_venv_deps_hash(specs), encoding="utf-8");
+}
+
obj ResolvedDependency {
has name: str,
version: str = "",
@@ -306,8 +329,14 @@ obj PythonProvider(EcosystemProvider) {
return self._install_global(specs, ctx);
}
venv_dir = self._project_venv(config);
- ensure_venv(venv_dir, ctx.verbose);
is_dry_run = ctx.is_dry_run();
+ # Checked before ensure_venv: a venv restored from a sealed bundle
+ # carries a marker but no host-runnable interpreter, so probing it
+ # with ensure_venv would classify it as corrupted and delete it.
+ if not is_dry_run and venv_deps_satisfied(venv_dir, specs) {
+ return True;
+ }
+ ensure_venv(venv_dir, ctx.verbose);
(before_snapshot_ok, before_set) = (
self._snapshot_installed(config) if not is_dry_run else (False, set())
);
diff --git a/jac/jaclang/scale/injector/bundle.jac b/jac/jaclang/scale/injector/bundle.jac
index 855e49c2848..ab751f0405f 100644
--- a/jac/jaclang/scale/injector/bundle.jac
+++ b/jac/jaclang/scale/injector/bundle.jac
@@ -322,6 +322,125 @@ def _vendor_stage(stage: Path, sanitized_toml: (str | None), pod_binary: bytes)
}
+def _resolved_python_specs(stage: Path) -> list[str] {
+ import from jaclang.project.config { JacConfig }
+ import from jaclang.project.providers { get_python_provider, resolve_plan }
+ config = JacConfig.load(stage / "jac.toml");
+ deps = resolve_plan(config).for_ecosystem("python");
+ return get_python_provider().specs_for(deps);
+}
+
+
+def _strip_external_record_lines(site: Path) {
+ # Entry-point scripts under bin/ embed the staging path in their shebang,
+ # so their RECORD hash lines differ on every seal; bin/ never ships, and
+ # dropping its lines keeps RECORD (and the bundle digest) deterministic.
+ for record in sorted(site.`glob("*.dist-info/RECORD")) {
+ lines = record.read_text(encoding="utf-8").splitlines(keepends=True);
+ kept = [
+ line
+ for line in lines
+ if not line.startswith("../")
+ ];
+ if len(kept) != len(lines) {
+ record.write_text("".join(kept), encoding="utf-8");
+ }
+ }
+}
+
+
+def staged_venv_files(stage: Path) -> list[Path] {
+ import from jaclang.project.providers { VENV_DEPS_MARKER }
+ import from jaclang.project.pyvenv { get_venv_site_packages }
+ venv = stage / ".jac" / "venv";
+ marker = venv / VENV_DEPS_MARKER;
+ if not marker.is_file() {
+ return [];
+ }
+ site = get_venv_site_packages(venv);
+ if not site.is_dir() {
+ return [];
+ }
+ files = [marker];
+ for path in sorted(site.rglob("*")) {
+ if (
+ path.is_file()
+ and path.suffix != ".pyc"
+ and "__pycache__" not in path.parts
+ ) {
+ files.append(path);
+ }
+ }
+ return files;
+}
+
+
+def _prune_seed_pip(site: Path, specs: list[str]) {
+ # ensurepip seeds pip into every fresh venv, but a sealed venv never
+ # runs pip again (the marker short-circuits jac install), so shipping
+ # it only bloats the bundle. Kept when the app itself depends on pip.
+ import re;
+ for spec in specs {
+ if re.split(r"[<>=!~\[ ;@]", spec.strip())[0].lower() == "pip" {
+ return;
+ }
+ }
+ for item in sorted(site.iterdir()) {
+ if item.name == "pip" or (
+ item.name.startswith("pip-") and item.name.endswith(".dist-info")
+ ) {
+ shutil.rmtree(str(item), ignore_errors=True);
+ }
+ }
+}
+
+
+def _venv_stage(stage: Path, pod_binary: bytes, timeout_s: int = 900) {
+ jac_path = stage.parent / ".seal-venv-jac";
+ jac_path.write_bytes(pod_binary);
+ jac_path.chmod(0o755);
+ env = release_binary_env();
+ # The same offline install the pod bootstrap runs. Offline also keeps the
+ # staged venv a pure function of the vendored wheels, so the bundle
+ # digest stays deterministic across seals.
+ env["PIP_NO_INDEX"] = "1";
+ env["PIP_FIND_LINKS"] = str(stage / "_vendor" / "wheels");
+ env["JAC_CLIENT_SKIP_NPM_INSTALL"] = "1";
+ try {
+ result = subprocess.run(
+ [str(jac_path), "install"],
+ capture_output=True,
+ text=True,
+ timeout=timeout_s,
+ env=env,
+ cwd=str(stage)
+ );
+ } finally {
+ jac_path.unlink(missing_ok=True);
+ }
+ if result.returncode != 0 {
+ raise RuntimeError(
+ f"seal-time venv build failed (host cannot install the "
+ f"pod-platform wheels?): "
+ f"{(result.stderr or result.stdout).strip()[-300:]}"
+ );
+ }
+ venv = stage / ".jac" / "venv";
+ import from jaclang.project.pyvenv { get_venv_site_packages }
+ site = get_venv_site_packages(venv);
+ if not site.is_dir() {
+ raise RuntimeError(
+ f"seal-time venv build left no site-packages under {venv}"
+ );
+ }
+ specs = _resolved_python_specs(stage);
+ _prune_seed_pip(site, specs);
+ _strip_external_record_lines(site);
+ import from jaclang.project.providers { write_venv_deps_marker }
+ write_venv_deps_marker(venv, specs);
+}
+
+
def pack_jab(project_dir: str, provisioned_database: str, pod_binary: bytes) -> bytes {
if not pod_binary {
raise ValueError(
@@ -350,6 +469,34 @@ def pack_jab(project_dir: str, provisioned_database: str, pod_binary: bytes) ->
);
}
}
+ if (
+ not os.environ.get("JAC_SEAL_SKIP_VENV")
+ and (staged / "_vendor" / "wheels").is_dir()
+ ) {
+ try {
+ _venv_stage(staged, pod_binary);
+ } except Exception as exc {
+ shutil.rmtree(str(staged / ".jac"), ignore_errors=True);
+ logger.warning(
+ f"Seal-time venv staging failed; pods will build the venv "
+ f"at boot instead. {exc}"
+ );
+ }
+ }
+ if (
+ not os.environ.get("JAC_SEAL_SKIP_VENV")
+ and (staged / "_vendor" / "wheels").is_dir()
+ ) {
+ try {
+ _venv_stage(staged, seal_binary, seal_timeout_setting(project_dir));
+ } except Exception as exc {
+ shutil.rmtree(str(staged / ".jac"), ignore_errors=True);
+ logger.warning(
+ f"Seal-time venv staging failed; pods will build the venv "
+ f"at boot instead. {exc}"
+ );
+ }
+ }
buf = io.BytesIO();
(tar, gz) = open_det_tar(buf);
try {
@@ -393,6 +540,30 @@ def pack_jab(project_dir: str, provisioned_database: str, pod_binary: bytes) ->
f".jab; pods install python deps offline."
);
}
+ venv_files = staged_venv_files(staged);
+ for path in venv_files {
+ rel = path.relative_to(staged);
+ tar.add(str(path), arcname=str(rel), filter=det_tar_member);
+ }
+ if venv_files {
+ logger.info(
+ f"Sealed a prebuilt venv ({len(venv_files)} files) "
+ f"into the .jab; pods skip the boot-time dependency "
+ f"install."
+ );
+ }
+ venv_files = staged_venv_files(staged);
+ for path in venv_files {
+ rel = path.relative_to(staged);
+ tar.add(str(path), arcname=str(rel), filter=det_tar_member);
+ }
+ if venv_files {
+ logger.info(
+ f"Sealed a prebuilt venv ({len(venv_files)} files) "
+ f"into the .jab; pods skip the boot-time dependency "
+ f"install."
+ );
+ }
if sanitized is not None {
data = sanitized.encode("utf-8");
info = tarfile.TarInfo(name="jac.toml");
diff --git a/jac/jaclang/scale/tests/microservices/test_seal_venv.jac b/jac/jaclang/scale/tests/microservices/test_seal_venv.jac
new file mode 100644
index 00000000000..9e86897a748
--- /dev/null
+++ b/jac/jaclang/scale/tests/microservices/test_seal_venv.jac
@@ -0,0 +1,168 @@
+"""Seal-time venv staging: the .jab ships a prebuilt venv so pods skip the
+boot-time `jac install` (#8695)."""
+
+import io;
+import os;
+import tarfile;
+import tempfile;
+import from pathlib { Path }
+import from jaclang.scale.injector.bundle {
+ _strip_external_record_lines,
+ pack_jab,
+ staged_venv_files
+}
+import from jaclang.project.providers { write_venv_deps_marker }
+
+
+def _pod_binary -> bytes | None {
+ # The checkout's own binary stands in for the downloaded pod binary, same
+ # as test_fat_bundle; skip when the toolchain has not been built.
+ jac_bin = Path(__file__).parents[4] / "zig-out" / "bin" / "jac";
+ if not jac_bin.is_file() {
+ return None;
+ }
+ return jac_bin.read_bytes();
+}
+
+
+def _app(toml: str) -> str {
+ d = tempfile.mkdtemp(prefix="jac-sealvenv-app-");
+ Path(d, "jac.toml").write_text(toml);
+ Path(d, "svc.jac").write_text("def:pub ping -> str { return \"pong\"; }\n");
+ return d;
+}
+
+
+def _names(raw: bytes) -> list[str] {
+ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tar {
+ return tar.getnames();
+ }
+}
+
+
+def _member(raw: bytes, name: str) -> bytes {
+ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tar {
+ member = tar.extractfile(name);
+ assert member is not None , f"member {name} not extractable";
+ return member.read();
+ }
+}
+
+
+def _fake_staged_venv(stage: Path) -> Path {
+ site = stage / ".jac" / "venv" / "lib" / "python3.14" / "site-packages";
+ (site / "pkg").mkdir(parents=True);
+ (site / "pkg" / "mod.py").write_text("x = 1\n");
+ (site / "pkg" / "__pycache__").mkdir();
+ (site / "pkg" / "__pycache__" / "mod.cpython-314.pyc").write_bytes(b"\x00");
+ (site / "stray.pyc").write_bytes(b"\x00");
+ bin_dir = stage / ".jac" / "venv" / "bin";
+ bin_dir.mkdir(parents=True);
+ (bin_dir / "python3").write_text("");
+ return site;
+}
+
+
+test "staged_venv_files is empty without the seal marker" {
+ stage = Path(tempfile.mkdtemp(prefix="jac-sealvenv-stage-"));
+ _fake_staged_venv(stage);
+ assert staged_venv_files(stage) == [];
+}
+
+
+test "staged_venv_files ships marker plus site-packages, never pyc or bin" {
+ stage = Path(tempfile.mkdtemp(prefix="jac-sealvenv-stage-"));
+ _fake_staged_venv(stage);
+ write_venv_deps_marker(stage / ".jac" / "venv", ["pkg==1.0"]);
+ rels = [p.relative_to(stage).as_posix() for p in staged_venv_files(stage)];
+ assert ".jac/venv/.jac-deps-hash" in rels , rels;
+ assert ".jac/venv/lib/python3.14/site-packages/pkg/mod.py" in rels , rels;
+ assert not any([".pyc" in r for r in rels]) , rels;
+ assert not any(["__pycache__" in r for r in rels]) , rels;
+ assert not any([r.startswith(".jac/venv/bin/") for r in rels]) , rels;
+}
+
+
+test "RECORD lines pointing outside site-packages are stripped" {
+ site = Path(tempfile.mkdtemp(prefix="jac-sealvenv-rec-"));
+ info = site / "pkg-1.0.dist-info";
+ info.mkdir(parents=True);
+ record = info / "RECORD";
+ record.write_text(
+ "pkg/mod.py,sha256=abc,10\n"
+ "../../../bin/pkg-cli,sha256=varies-per-stage,99\n"
+ "pkg-1.0.dist-info/RECORD,,\n"
+ );
+ _strip_external_record_lines(site);
+ kept = record.read_text();
+ assert "pkg/mod.py" in kept;
+ assert "pkg-1.0.dist-info/RECORD" in kept;
+ assert "bin/pkg-cli" not in kept;
+ before = kept;
+ _strip_external_record_lines(site);
+ assert record.read_text() == before;
+}
+
+
+test "pack_jab seals a prebuilt venv the pod can import from" {
+ pod = _pod_binary();
+ if pod is None {
+ return;
+ }
+ toml = (
+ "[project]\nname = \"mini\"\n\n[dependencies]\n"
+ "python-dotenv = \"==1.2.1\"\n\n[scale]\nfat_bundle = true\n"
+ );
+ raw = pack_jab(_app(toml), "", pod);
+ names = _names(raw);
+ venv_names = [
+ n
+ for n in names
+ if n.startswith(".jac/venv/")
+ ];
+ assert ".jac/venv/.jac-deps-hash" in venv_names , names[:40];
+ dotenv_members = [
+ n
+ for n in venv_names
+ if "/site-packages/dotenv/" in n and n.endswith(".py")
+ ];
+ assert dotenv_members , venv_names[:40];
+ assert not any([n.endswith(".pyc") for n in venv_names]) , venv_names[:40];
+ assert not any(["__pycache__" in n for n in venv_names]) , venv_names[:40];
+ assert not any([n.startswith(".jac/venv/bin/") for n in venv_names]);
+ seed_pip = [
+ n
+ for n in venv_names
+ if "/site-packages/pip/" in n or "/site-packages/pip-" in n
+ ];
+ assert not seed_pip , seed_pip[:10];
+ records = [
+ n
+ for n in venv_names
+ if n.endswith(".dist-info/RECORD")
+ ];
+ assert records , venv_names[:40];
+ for rec in records {
+ assert "../" not in _member(raw, rec).decode("utf-8") , rec;
+ }
+}
+
+
+test "JAC_SEAL_SKIP_VENV ships wheels but no venv" {
+ pod = _pod_binary();
+ if pod is None {
+ return;
+ }
+ toml = (
+ "[project]\nname = \"mini\"\n\n[dependencies]\n"
+ "python-dotenv = \"==1.2.1\"\n\n[scale]\nfat_bundle = true\n"
+ );
+ os.environ["JAC_SEAL_SKIP_VENV"] = "1";
+ try {
+ names = _names(pack_jab(_app(toml), "", pod));
+ } finally {
+ del os.environ["JAC_SEAL_SKIP_VENV"];
+ }
+ assert any([n.startswith("_vendor/wheels/") for n in names]);
+ assert not any([n.startswith(".jac/venv/") for n in names]) , names[:40];
+}
diff --git a/jac/jaclang/scale/utils/vendor_wheels.jac b/jac/jaclang/scale/utils/vendor_wheels.jac
index 999c517ff8b..4e7d07b0fad 100644
--- a/jac/jaclang/scale/utils/vendor_wheels.jac
+++ b/jac/jaclang/scale/utils/vendor_wheels.jac
@@ -3,7 +3,7 @@ import sys;
import from pathlib { Path }
import from jaclang.cli.console { console }
import from jaclang.jac0core.sealed { python_tag }
-import from jaclang.project.config { JacConfig, get_config_for_path }
+import from jaclang.project.config { JacConfig }
import from jaclang.project.providers { get_python_provider, resolve_plan }
import from jaclang.runtimelib.jab_vendor {
download_wheels,
@@ -64,7 +64,12 @@ def main {
sys.exit(2);
}
app_dir = Path(args[0]).resolve();
- config = get_config_for_path(app_dir);
+ # Loaded from the staged jac.toml directly, never via config discovery:
+ # `jac run` roots the explicit project config at THIS script's own tree,
+ # so discovery would resolve the jac checkout's closure instead of the
+ # app's whenever the script runs from a source tree.
+ toml_path = app_dir / "jac.toml";
+ config = JacConfig.load(toml_path) if toml_path.is_file() else None;
if config is None {
console.error(f"No jac.toml under {app_dir}; nothing to vendor.");
sys.exit(1);
diff --git a/jac/tests/project/test_dependencies.jac b/jac/tests/project/test_dependencies.jac
index 865f266e585..d1c1ce9157b 100644
--- a/jac/tests/project/test_dependencies.jac
+++ b/jac/tests/project/test_dependencies.jac
@@ -29,10 +29,13 @@ import from jaclang.project.providers {
InstallContext,
PythonProvider,
ResolvedDependency,
+ VENV_DEPS_MARKER,
get_provider_registry,
get_python_provider,
reset_provider_registry,
- resolve_plan
+ resolve_plan,
+ venv_deps_satisfied,
+ write_venv_deps_marker
}
"""Reset global config singleton."""
@@ -1971,3 +1974,136 @@ test "install package failure returns error code" {
reset_config();
}
}
+
+# ===== sealed-venv marker fast path =====
+test "venv_deps_satisfied matches the marker regardless of spec order" {
+ (tmp, project_dir) = make_temp_project();
+ try {
+ venv_dir = project_dir / ".jac" / "venv";
+ venv_dir.mkdir(parents=True);
+ write_venv_deps_marker(venv_dir, ["b==2", "a==1"]);
+ assert venv_deps_satisfied(venv_dir, ["a==1", "b==2"]);
+ assert not venv_deps_satisfied(venv_dir, ["a==1", "b==3"]);
+ assert not venv_deps_satisfied(venv_dir, ["a==1"]);
+ } finally {
+ shutil.rmtree(tmp, ignore_errors=True);
+ }
+}
+
+test "venv_deps_satisfied is False without a marker or without a venv" {
+ (tmp, project_dir) = make_temp_project();
+ try {
+ venv_dir = project_dir / ".jac" / "venv";
+ assert not venv_deps_satisfied(venv_dir, ["a==1"]);
+ venv_dir.mkdir(parents=True);
+ assert not venv_deps_satisfied(venv_dir, ["a==1"]);
+ } finally {
+ shutil.rmtree(tmp, ignore_errors=True);
+ }
+}
+
+test "install_specs skips pip entirely when the marker matches" {
+ # The marker is written only by the seal (pack_jab), never by a local
+ # install, so this venv stands in for one restored from a sealed bundle:
+ # no interpreter inside, only the marker. install_specs must return
+ # before ensure_venv, which would classify it as corrupted and delete it.
+ reset_config();
+ (tmp, project_dir) = make_temp_project();
+ try {
+ config = JacConfig.load(project_dir / "jac.toml");
+ venv_dir = config.get_venv_dir();
+ venv_dir.mkdir(parents=True);
+ write_venv_deps_marker(venv_dir, ["requests>=2.28.0"]);
+ python = PythonProvider();
+ with patch("jaclang.project.providers.ensure_venv") as mock_ev, patch.object(
+ PythonProvider, "_run_project_pip"
+ ) as mock_pip {
+ result = python.install_specs(
+ config, ["requests>=2.28.0"], InstallContext()
+ );
+ assert result is True;
+ mock_ev.assert_not_called();
+ mock_pip.assert_not_called();
+ }
+ assert venv_dir.exists();
+ } finally {
+ shutil.rmtree(tmp, ignore_errors=True);
+ reset_config();
+ }
+}
+
+test "install_specs runs pip when the marker is stale" {
+ reset_config();
+ (tmp, project_dir) = make_temp_project();
+ try {
+ config = JacConfig.load(project_dir / "jac.toml");
+ venv_dir = config.get_venv_dir();
+ venv_dir.mkdir(parents=True);
+ write_venv_deps_marker(venv_dir, ["requests>=2.27.0"]);
+ python = PythonProvider();
+ with patch("jaclang.project.providers.ensure_venv") as _ev, patch.object(
+ PythonProvider, "_run_project_pip"
+ ) as mock_pip {
+ mock_pip.return_value = (0, "Successfully installed", "");
+ result = python.install_specs(
+ config, ["requests>=2.28.0"], InstallContext()
+ );
+ assert result is True;
+ assert mock_pip.call_count == 3;
+ }
+ } finally {
+ shutil.rmtree(tmp, ignore_errors=True);
+ reset_config();
+ }
+}
+
+test "a matching marker never short-circuits a dry run" {
+ reset_config();
+ (tmp, project_dir) = make_temp_project();
+ try {
+ config = JacConfig.load(project_dir / "jac.toml");
+ venv_dir = config.get_venv_dir();
+ venv_dir.mkdir(parents=True);
+ write_venv_deps_marker(venv_dir, ["requests>=2.28.0"]);
+ python = PythonProvider();
+ with patch("jaclang.project.providers.ensure_venv") as _ev, patch.object(
+ PythonProvider, "_run_project_pip"
+ ) as mock_pip {
+ mock_pip.return_value = (0, "Would install requests", "");
+ result = python.install_specs(
+ config,
+ ["requests>=2.28.0"],
+ InstallContext(install_flags=["--dry-run"])
+ );
+ assert result is True;
+ mock_pip.assert_called();
+ }
+ } finally {
+ shutil.rmtree(tmp, ignore_errors=True);
+ reset_config();
+ }
+}
+
+test "a local install never writes the marker" {
+ # Local `jac install` semantics stay unchanged: only pack_jab stamps the
+ # marker, so repeated local installs keep running pip (and keep repairing
+ # a hand-broken venv) exactly as before.
+ reset_config();
+ (tmp, project_dir) = make_temp_project();
+ try {
+ config = JacConfig.load(project_dir / "jac.toml");
+ venv_dir = config.get_venv_dir();
+ python = PythonProvider();
+ with patch("jaclang.project.providers.ensure_venv") as _ev, patch.object(
+ PythonProvider, "_run_project_pip"
+ ) as mock_pip {
+ mock_pip.return_value = (0, "Successfully installed", "");
+ assert python.install_specs(config, ["requests>=2.28.0"], InstallContext())
+ is True;
+ }
+ assert not (venv_dir / VENV_DEPS_MARKER).exists();
+ } finally {
+ shutil.rmtree(tmp, ignore_errors=True);
+ reset_config();
+ }
+}
diff --git a/release_notes/unreleased/jaclang/8695.bugfix.md b/release_notes/unreleased/jaclang/8695.bugfix.md
new file mode 100644
index 00000000000..441c6e1c76c
--- /dev/null
+++ b/release_notes/unreleased/jaclang/8695.bugfix.md
@@ -0,0 +1 @@
+- **Fix: scale-to-zero pods no longer rebuild their venv on every wake**: `jac scale deploy` now builds the app's Python environment once at seal time, from the same vendored wheels pods already ship, and seals it into the `.jab` bundle. A woken pod unpacks it with the rest of the bundle and `jac install` verifies a dependency marker instead of reinstalling, cutting the measured 21s per-wake install (issue #8695) to about a second. Seal hosts that cannot install the pod-platform wheels (cross-arch deploys, thin bundles) fall back to the previous boot-time install automatically; `JAC_SEAL_SKIP_VENV=1` opts out explicitly.
From 0905a50c5e32b29160cfaa27d689f115a87cddf3 Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 09:27:35 +0530
Subject: [PATCH 11/13] chore: drop the seal-venv release-note fragment on this
line too
---
release_notes/unreleased/jaclang/8695.bugfix.md | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 release_notes/unreleased/jaclang/8695.bugfix.md
diff --git a/release_notes/unreleased/jaclang/8695.bugfix.md b/release_notes/unreleased/jaclang/8695.bugfix.md
deleted file mode 100644
index 441c6e1c76c..00000000000
--- a/release_notes/unreleased/jaclang/8695.bugfix.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Fix: scale-to-zero pods no longer rebuild their venv on every wake**: `jac scale deploy` now builds the app's Python environment once at seal time, from the same vendored wheels pods already ship, and seals it into the `.jab` bundle. A woken pod unpacks it with the rest of the bundle and `jac install` verifies a dependency marker instead of reinstalling, cutting the measured 21s per-wake install (issue #8695) to about a second. Seal hosts that cannot install the pod-platform wheels (cross-arch deploys, thin bundles) fall back to the previous boot-time install automatically; `JAC_SEAL_SKIP_VENV=1` opts out explicitly.
From 3e35bef5e600d3b823be235e525dd657d6476aa1 Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 09:46:15 +0530
Subject: [PATCH 12/13] fix(scale): adapt the seal-time venv stage to the
0.34.x line
The 0.34 standalone install routes through PIP_PREFIX (as the pod
bootstrap does), so the stage exports it plus PIP_IGNORE_INSTALLED so a
seal host whose runtime site already satisfies specs cannot produce a
silently empty venv. Also removes a duplicate venv-stage hook the
cherry-pick auto-applied with main's argument names, and ports the e2e
tests to this line's _pod_binary()/pack_jab signature.
---
jac/jaclang/scale/injector/bundle.jac | 31 +++++++------------------
jac/tests/project/test_dependencies.jac | 5 ++--
2 files changed, 11 insertions(+), 25 deletions(-)
diff --git a/jac/jaclang/scale/injector/bundle.jac b/jac/jaclang/scale/injector/bundle.jac
index ab751f0405f..ff2bc1db1b1 100644
--- a/jac/jaclang/scale/injector/bundle.jac
+++ b/jac/jaclang/scale/injector/bundle.jac
@@ -364,9 +364,7 @@ def staged_venv_files(stage: Path) -> list[Path] {
files = [marker];
for path in sorted(site.rglob("*")) {
if (
- path.is_file()
- and path.suffix != ".pyc"
- and "__pycache__" not in path.parts
+ path.is_file() and path.suffix != ".pyc" and "__pycache__" not in path.parts
) {
files.append(path);
}
@@ -386,9 +384,8 @@ def _prune_seed_pip(site: Path, specs: list[str]) {
}
}
for item in sorted(site.iterdir()) {
- if item.name == "pip" or (
- item.name.startswith("pip-") and item.name.endswith(".dist-info")
- ) {
+ if item.name == "pip"
+ or (item.name.startswith("pip-") and item.name.endswith(".dist-info")) {
shutil.rmtree(str(item), ignore_errors=True);
}
}
@@ -405,6 +402,10 @@ def _venv_stage(stage: Path, pod_binary: bytes, timeout_s: int = 900) {
# digest stays deterministic across seals.
env["PIP_NO_INDEX"] = "1";
env["PIP_FIND_LINKS"] = str(stage / "_vendor" / "wheels");
+ env["PIP_PREFIX"] = str(stage / ".jac" / "venv");
+ # The seal host's own runtime site may already satisfy some specs, which
+ # would make pip skip them and leave the staged prefix silently empty.
+ env["PIP_IGNORE_INSTALLED"] = "1";
env["JAC_CLIENT_SKIP_NPM_INSTALL"] = "1";
try {
result = subprocess.run(
@@ -429,9 +430,7 @@ def _venv_stage(stage: Path, pod_binary: bytes, timeout_s: int = 900) {
import from jaclang.project.pyvenv { get_venv_site_packages }
site = get_venv_site_packages(venv);
if not site.is_dir() {
- raise RuntimeError(
- f"seal-time venv build left no site-packages under {venv}"
- );
+ raise RuntimeError(f"seal-time venv build left no site-packages under {venv}");
}
specs = _resolved_python_specs(stage);
_prune_seed_pip(site, specs);
@@ -483,20 +482,6 @@ def pack_jab(project_dir: str, provisioned_database: str, pod_binary: bytes) ->
);
}
}
- if (
- not os.environ.get("JAC_SEAL_SKIP_VENV")
- and (staged / "_vendor" / "wheels").is_dir()
- ) {
- try {
- _venv_stage(staged, seal_binary, seal_timeout_setting(project_dir));
- } except Exception as exc {
- shutil.rmtree(str(staged / ".jac"), ignore_errors=True);
- logger.warning(
- f"Seal-time venv staging failed; pods will build the venv "
- f"at boot instead. {exc}"
- );
- }
- }
buf = io.BytesIO();
(tar, gz) = open_det_tar(buf);
try {
diff --git a/jac/tests/project/test_dependencies.jac b/jac/tests/project/test_dependencies.jac
index d1c1ce9157b..af839843914 100644
--- a/jac/tests/project/test_dependencies.jac
+++ b/jac/tests/project/test_dependencies.jac
@@ -2098,8 +2098,9 @@ test "a local install never writes the marker" {
PythonProvider, "_run_project_pip"
) as mock_pip {
mock_pip.return_value = (0, "Successfully installed", "");
- assert python.install_specs(config, ["requests>=2.28.0"], InstallContext())
- is True;
+ assert python.install_specs(
+ config, ["requests>=2.28.0"], InstallContext()
+ ) is True;
}
assert not (venv_dir / VENV_DEPS_MARKER).exists();
} finally {
From e52c273b34c236f89b441eca98919d41f7045663 Mon Sep 17 00:00:00 2001
From: Kugesan Sivasothynathan
Date: Fri, 4 Sep 2026 10:25:48 +0530
Subject: [PATCH 13/13] test(scale): count a zero-parked scale-to-zero
deployment as ready in the fleet wait
Measurement scaffolding standing in for #8723: with a KEDA min-0
service whose trigger is idle at deploy time, the rollout gate waited
1200s for a deployment the autoscaler had deliberately parked at 0 and
failed the deploy (jacBuilder run 33836166656). #8723's status model is
the real fix on main; this branch only needs the gate to pass so the
wake can be measured.
---
.../scale/deploy/target/kubernetes/target.jac | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/jac/jaclang/scale/deploy/target/kubernetes/target.jac b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
index c6b2f14cc12..bda640801b2 100644
--- a/jac/jaclang/scale/deploy/target/kubernetes/target.jac
+++ b/jac/jaclang/scale/deploy/target/kubernetes/target.jac
@@ -629,9 +629,16 @@ obj KubernetesTarget(KubernetesTargetBase) {
for name in names {
try {
d = apps_v1.read_namespaced_deployment(name=name, namespace=ns);
- complete = deployment_rollout_complete(d)
- if require_full
- else (d.status.ready_replicas or 0) >= 1;
+ # Measurement scaffolding standing in for #8723: an
+ # autoscaler that parks a scale-to-zero service at 0
+ # replicas is desired state, not a stuck rollout.
+ parked = (d.spec.replicas or 0) == 0;
+ complete = parked
+ or (
+ deployment_rollout_complete(d)
+ if require_full
+ else (d.status.ready_replicas or 0) >= 1
+ );
if complete {
ready += 1;
}