Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions docs/docs/reference/plugins/jac-scale-kubernetes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -454,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.<name>.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<br/>(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
Expand Down
30 changes: 30 additions & 0 deletions docs/docs/tutorials/production/kubernetes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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
Expand Down
31 changes: 30 additions & 1 deletion jac/jaclang/project/providers.jac
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand Down Expand Up @@ -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())
);
Expand Down
Loading