Skip to content
Merged
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
2 changes: 1 addition & 1 deletion clients/lci/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ impl ApiClient {
pub fn new(http: reqwest::Client, base: impl Into<String>, token: impl Into<String>) -> Self {
Self {
http,
base: base.into(),
base: base.into().trim_end_matches('/').to_string(),
Comment thread
leghadjeu-christian marked this conversation as resolved.
token: std::sync::Arc::new(tokio::sync::RwLock::new(token.into())),
}
}
Expand Down
2 changes: 1 addition & 1 deletion clients/lci/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use directories::ProjectDirs;
use std::path::PathBuf;

/// Default control-plane base URL (prod). Override with `CONTROL_PLANE_URL`.
pub const DEFAULT_API_URL: &str = "https://code-intelligence-api.ai.camer.digital";
pub const DEFAULT_API_URL: &str = "https://code-intelligence-api.ai.camer.digital/api/v2";
/// Default OIDC issuer (Keycloak realm). Override with `OIDC_ISSUER`.
pub const DEFAULT_ISSUER: &str = "https://auth.verif.fyi/realms/camer-digital";
/// Default public client id. Override with `OIDC_CLIENT_ID`.
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/0109-api-v2-route-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR-0109: Version all control-plane routes under `/api/v2`

- **Status:** Accepted
- **Date:** 2026-07-28
- **Deciders:** @stephane-segning, @leghadjeu-christian

## Context

Every route in the control plane was registered at the root path with no version prefix. Three
different auth models (OIDC-gated dashboard routes, shared-bearer runner-internal routes, and
admin routes) shared one flat, unversioned namespace. There was no mechanism to introduce a
breaking API change without affecting all consumers simultaneously.

## Decision

All consumer-facing routes are nested under `/api/v2` using Axum's `.nest()`. The router is
split into two functions:

- `api_v2_router()` — returns all versioned routes as a `Router<AppState>`
- `app()` — mounts the versioned sub-router plus the infra probes

Health probes (`/healthz`, `/readyz`) and `/metrics` stay at root because they are consumed by
Kubernetes and Prometheus respectively, not by API clients.

The `/api/v2` prefix is carried in the env var, not appended by client constructors. Both internal
and external clients trim trailing slashes and use the value as-is. The Helm chart values are
updated to include the prefix:

- `apps/web`: `controlPlaneUrl()` uses `AUTH_BACKEND_URL` as-is — chart sets `…:8080/api/v2`
- `lci` CLI: `ApiClient::new()` trims trailing slashes — `api_url` must include `/api/v2`
- `agent-clients`: `ControlPlaneClient::new()` trims trailing slashes — `CONTROL_PLANE_INTERNAL_URL` must include `/api/v2`

This is consistent: every consumer has one place (the env var or config value) where the full API
base is set. The Helm chart update is in the companion PR (ADORSYS-GIS/ai-helm).

The cutover is hard — old flat paths return 404 immediately after deployment. The chart update
must be deployed in the same window as the new image.

## Consequences

- The single `api_v2_router()` function is the canonical list of all versioned routes; adding a
route requires touching one place.
- Operators must ensure `CONTROL_PLANE_INTERNAL_URL` and `AUTH_BACKEND_URL` include `/api/v2`.
The chart default values are updated in the companion Helm PR (ADORSYS-GIS/ai-helm#817).
- Local dev env vars must also be updated if set explicitly (e.g. `CONTROL_PLANE_URL=http://localhost:8080/api/v2`).
2 changes: 1 addition & 1 deletion services/agent-clients/src/control_plane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ pub struct ControlPlaneClient {
impl ControlPlaneClient {
pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
Self {
Comment thread
leghadjeu-christian marked this conversation as resolved.
base_url: base_url.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
token: token.into(),
http: reqwest::Client::new(),
}
Expand Down
4 changes: 2 additions & 2 deletions services/control-plane/src/http/webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2675,7 +2675,7 @@ mod tests {
});
let body = Bytes::from(serde_json::to_vec(&payload).unwrap());

let response = gitlab_webhook_body(state, 1001, headers, body).await;
let response = gitlab_webhook_body(state, 2001, headers, body).await;
assert_eq!(response.status(), StatusCode::ACCEPTED);

let (preset, entry_point): (String, String) = sqlx::query_as(
Expand Down Expand Up @@ -2768,7 +2768,7 @@ mod tests {
});
let body = Bytes::from(serde_json::to_vec(&payload).unwrap());

let response = gitlab_webhook_body(state, 1001, headers, body).await;
let response = gitlab_webhook_body(state, 2002, headers, body).await;
assert_eq!(response.status(), StatusCode::ACCEPTED);

let preset: String = sqlx::query_scalar(
Expand Down
15 changes: 10 additions & 5 deletions services/control-plane/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,9 @@ fn db_readiness(has_pool: bool, ping_ok: bool, allow_no_db: bool) -> DbReadiness
}
}

fn app(state: AppState) -> Router {
fn api_v2_router() -> Router<AppState> {
Router::new()
.route("/healthz", get(liveness))
.route("/readyz", get(readiness))
.route("/metrics", get(metrics_endpoint))
// Path-scoped webhook ingress — one route per forge, no header-sniffing.
// Webhook ingress: path-scoped per forge, no header-sniffing.
.route(
"/webhook/github",
post(webhook::github_webhook).layer(DefaultBodyLimit::max(webhook::MAX_BODY_BYTES)),
Expand Down Expand Up @@ -448,6 +445,14 @@ fn app(state: AppState) -> Router {
"/internal/tasks/{id}/propose-pr",
post(internal::propose_pr).layer(DefaultBodyLimit::max(32 * 1024 * 1024)),
)
}

fn app(state: AppState) -> Router {
Router::new()
.route("/healthz", get(liveness))
.route("/readyz", get(readiness))
.route("/metrics", get(metrics_endpoint))
Comment thread
leghadjeu-christian marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 correctness

lci CLI compiled-in default URL stays bare → /me 404s against /api/v2 server

The server now nests every consumer-facing route under /api/v2 (this .nest line), but the lci CLI's compiled-in default DEFAULT_API_URL (clients/lci/src/config.rs:23) is still the bare origin "https://code-intelligence-api.ai.camer.digital" with no /api/v2, and Config::resolve (clients/lci/src/config.rs:122-126 — flags → CONTROL_PLANE_URL → file.api_url → DEFAULT_API_URL, trailing-slash trim only) never appends it. After the prod control plane deploys this PR, a default-configured operator (no CONTROL_PLANE_URL, no config.toml) running lci login hits GET /me ⇒ 404, and every subsequent TUI call too.

The maintainer's reply addresses the Helm-chart-managed env vars (CONTROL_PLANE_INTERNAL_URL for the runner, AUTH_BACKEND_URL for apps/web) — both live in the chart and so the companion Helm PR can stamp /api/v2 into them. The lci CLI has no chart handhold: it ships as a binary, and the only place its default is set is the source constant in config.rs:23, unchanged by this diff. ADR-0109 writes "lci CLI's api_url must include /api/v2" — but never names DEFAULT_API_URL or tells the release to bump it, so the rollout continues to ship a default that no longer works server-side. Fix: bump DEFAULT_API_URL to "https://code-intelligence-api.ai.camer.digital/api/v2" (one-line change, no client-constructor change needed) so default-configured CLI users keep working; the env-var/"the env var is the single source of truth" framing is preserved for anyone who overrides it. P1 not P0 — no data loss / security, but a guaranteed regression on a realistic input (an existing lci user upgrading to the new server without overriding CONTROL_PLANE_URL).

Evidence: services/control-plane/src/main.rs:454 .nest("/api/v2", api_v2_router()) (new contract requiring /api/v2); clients/lci/src/config.rs:23 DEFAULT_API_URL = "https://code-intelligence-api.ai.camer.digital" (unchanged, bare origin); clients/lci/src/config.rs:122-126 Config::resolve precedence defaults to DEFAULT_API_URL with only trailing-slash trim.

Was this useful? React 👍/👎 to give us feedback

.nest("/api/v2", api_v2_router())
.layer(axum::middleware::from_fn(track_http_metrics))
.with_state(state)
}
Expand Down
Loading