Skip to content
Closed
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
11 changes: 8 additions & 3 deletions docs/adr/0009-client-contract-and-transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,18 @@ passthrough proxy is expected to expose:
| GET | `/api/applications` | 200 `Application[]` — the platform's application inventory (mocked in the shim; Hub serves its real records). Source of truth for resolved params/credentials, see ADR 0010. |
| GET | `/api/agents` | 200 `AgentResource[]` (full CRs, metadata+spec), **filtered to `konveyor.io/managed=true`** |
| GET | `/api/agents/:name` | 200 `AgentResource` \| 404 (never label-filtered) |
| GET | `/api/llmproviders[/:name]` | 200 `LLMProvider[]` \| `LLMProvider` \| 404 |
| GET | `/api/gateways[/:name]` | 200 `Gateway[]` \| `Gateway` \| 404 (was `/api/llmproviders` before the #100 rename) |
| GET | `/api/skillcards[/:name]` | 200 `SkillCard[]` \| `SkillCard` \| 404 |
| GET | `/api/skillcollections[/:name]` | 200 `SkillCollection[]` \| `SkillCollection` \| 404 |
| GET | `/api/agentruns` | 200 `AgentRun[]` (full CRs) |
| POST | `/api/agentruns` (body `{agentRef, params?: Record<string,string>, instructions?, applicationRef?}`) | 201 `AgentRun` (generateName `ui-`, params mapped to `[{name,value}]`). When `applicationRef` is set, the platform resolves the Agent's declared param/credential sources from that application (ADR 0010): resolved params merge under caller-supplied ones, credentials become `spec.envFrom`. 400 on unknown `applicationRef`, or a required param with a recognized source the application cannot supply. |
| GET | `/api/agentruns[?application=<hub id>]` | 200 `AgentRun[]` (full CRs). `application` filters by the `konveyor.io/application` label (ADR 0010) — a `client.List()` label selector, never a client-side scan. Runs predating the label are not selected. 400 on a non-numeric id; 400 on any resource that cannot honour the filter, never a silent unfiltered list. |
| POST | `/api/agentruns` (body `{agentRef, params?: Record<string,string>, instructions?, applicationRef?, targetBranch?, gateway?}`) | 201 `AgentRun` (generateName `ui-`, params mapped to `[{name,value}]`). When `applicationRef` is set, the platform resolves the Agent's declared param/credential sources from that application (ADR 0010): resolved params merge under caller-supplied ones, credentials become `spec.envFrom`, and the run is stamped `konveyor.io/application: "<id>"`. 400 on unknown `applicationRef`, or a required param with a recognized source the application cannot supply. |
| GET | `/api/agentruns/:name` | 200 `AgentRun` \| 404 |
| DELETE | `/api/agentruns/:name` | 204 |
| GET | `/api/agentworkflows[/:name]` | 200 `AgentWorkflow[]` \| `AgentWorkflow` \| 404 (list **filtered to `konveyor.io/managed=true`**) |
| GET | `/api/agentworkflowruns[?application=<hub id>]` | 200 `AgentWorkflowRun[]`. Same `application` semantics as `/api/agentruns`; composes with the managed filter as one selector. **Stage runs do not carry the parent's application label** — the controller builds their labels from scratch — so filtering `agentruns` finds single runs only. |
| GET | `/api/agentworkflowruns/:name` | 200 `AgentWorkflowRun` \| 404 |
| POST | `/api/agentworkflowruns` (body `{workflowRef, params?, applicationRef?, targetBranch?, gateway?}`) | 201 `AgentWorkflowRun` (generateName `ui-`), labelled `konveyor.io/managed` plus `konveyor.io/application` when scoped. |
| DELETE | `/api/agentworkflowruns/:name` | 204 |
| WS | `/api/agentruns/:name/acp` | Resolves the run's ACP endpoint (waitForAcpEndpoint semantics, 60s), opens a port-forward tunnel to the pod, dials `ws://127.0.0.1:<tunnel>/acp` upstream WITH `X-Secret-Key` (key read from the run's Secret), then pipes frames bidirectionally. Client close → close upstream + tunnel; upstream close/error → close client 1011 with reason. |

The shim itself is unauthenticated (localhost dev tool) and serves
Expand Down
37 changes: 37 additions & 0 deletions docs/adr/0010-platform-resolved-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,43 @@ Agents that Konveyor UIs know how to drive carry
(`GET /api/agents` in SHIM API v1 does); unlabeled Agents remain usable by
other consumers and invisible to Konveyor UIs.

**Runs carry a second platform label: `konveyor.io/application: "<hub id>"`**,
written at create time on both AgentRun and AgentWorkflowRun whenever the
caller supplies an application, so per-application run views are a label
selector instead of a scan. Same reasoning as the managed label — a
namespaced label the platform owns, no CRD schema involvement.

This is **additive to `APP_ID` in `spec.env`, not a replacement**. The two
serve different consumers and neither can do the other's job:

| | carrier | consumer | why it can't be the other |
|---|---|---|---|
| `APP_ID` | `spec.env` | the **pod** — the harness resolves the application from Hub at runtime | env vars are not indexable; answering "runs for app 42" means fetching every run and parsing `spec.env` |
| `konveyor.io/application` | `metadata.labels` | the **API** — `?application=<id>` becomes a `client.List()` label selector | a label is not visible inside the container |

Consequences, all verified in the shim prototype:

- Hub application ids must parse as a uint64 (`hub.ParseAppID` requires
it), capping them at 20 digits — inside the apiserver's 63-character
label-value limit, in a label-safe alphabet. One uint64-bounded check
covers both concerns: a run the harness would reject at startup, and a
create the apiserver would reject outright. A plain digits regex is not
that check — a 21-digit id passes it and overflows `ParseUint` anyway.
- Runs created before the label are invisible to the selector. A filtered
list is "runs we can prove belong to 42", not "every run that ever
touched 42". Callers needing the old runs keep a `spec.env` fallback for
one release.
- **Workflow stage runs do not inherit it.** The controller builds each
stage AgentRun's labels from scratch, so filtering `agentruns` finds
single runs only. Propagating parent labels to stage runs is an upstream
controller change, tracked separately.

Status upstream: konveyor/agentic-controller ADR 0006 currently specifies
the opposite — "the application ID goes directly on the CR as an env var"
and "other resource types are listed unfiltered". That ADR is *proposed*;
this section is the amendment being taken to it. Until it lands, the label
exists only in the shim.

### (b) Param sources: generic field, namespaced values, no enum

A param may declare a **source identifier** — a free-form, namespaced
Expand Down
35 changes: 35 additions & 0 deletions packages/agentic-client/src/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,18 @@ function decodeBase64Utf8(b64: string, keyName: string): string {
*/
export const MANAGED_LABEL = "konveyor.io/managed";

/**
* Label stamping a run with the Hub application it works on, so "which runs
* belong to application 42?" is a server-side label selector rather than a
* fetch-everything-and-scan-spec.env walk. Written at create time on both
* AgentRun and AgentWorkflowRun; the value is the Hub application id.
*
* Runs created before this label existed do not carry it and are invisible
* to the selector — a filtered list is "runs we can prove belong to 42",
* not "every run that ever touched 42".
*/
export const APPLICATION_LABEL = "konveyor.io/application";

/**
* Agent annotation mapping param name -> source identifier, e.g.
* {"repository": "konveyor.io/application-repository-url"}. A param with a
Expand Down Expand Up @@ -442,6 +454,29 @@ export function invalidTargetBranchReason(branch: string): string | undefined {
return undefined;
}

/**
* Why this application id cannot be used for a run, or undefined when it
* can. Hub ids are numeric and the harness's APP_ID parser requires a uint,
* so a non-numeric id is a run that dies at startup — and, since the id is
* also stamped as the {@link APPLICATION_LABEL} value, a create the
* apiserver would reject outright. One check covers both.
*/
/** What hub.ParseAppID accepts: strconv.ParseUint(s, 10, 64). */
const UINT64_MAX = 18446744073709551615n;

export function invalidApplicationIdReason(id: string): string | undefined {
if (!/^\d+$/.test(id)) {
return `application id "${id}" is not numeric — the harness's APP_ID parser requires a uint Hub id`;
}
// Digits alone are not enough: a 21-digit id passes the regex, then
// overflows the harness's ParseUint at startup. uint64 also caps ids at
// 20 characters, inside the apiserver's 63-char label-value limit.
if (BigInt(id) > UINT64_MAX) {
return `application id "${id}" overflows uint64 — the harness's APP_ID parser rejects it`;
}
return undefined;
}

export const RUN_ENV = {
HUB_BASE_URL: "HUB_BASE_URL",
HUB_TOKEN: "HUB_TOKEN",
Expand Down
241 changes: 241 additions & 0 deletions packages/hub-shim/dev/application-filter-smoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* E2E smoke for the per-application run filter against a LIVE shim + cluster.
*
* Browser-constraint like dev/browser-smoke.ts: only globalThis.fetch, only
* shim routes — every cluster assertion goes through the shim's own GET
* endpoints, exactly what a browser UI could verify.
*
* Asserts the konveyor.io/application contract (client#3):
* 1. an application-scoped AgentRun is stamped with the label at create
* 2. an application-scoped AgentWorkflowRun is too, without losing managed
* 3. a run created with no applicationRef carries no application label
* 4. ?application=<id> returns that application's runs and nothing else
* 5. the filter is server-side — a foreign id returns an empty list while
* the unfiltered list still holds the runs
* 6. a filter the endpoint cannot honour is a 400, never a silent full list
*
* Needs a REAL Hub inventory: application-scoped creates are refused against
* the offline stub, so the smoke skips itself when the shim reports one.
*
* Run with the shim already up: npm run smoke:appfilter (SHIM_URL overrides)
* Deletes every run it creates.
*/

const BASE = process.env.SHIM_URL ?? "http://127.0.0.1:7080";
const APPLICATION_LABEL = "konveyor.io/application";

let failures = 0;
function pass(step: string, detail?: string): void {
console.log(`PASS ${step}${detail ? ` — ${detail}` : ""}`);
}
function fail(step: string, detail: string): never {
failures++;
console.error(`FAIL ${step} — ${detail}`);
throw new SmokeAbort(step);
}
class SmokeAbort extends Error {
constructor(step: string) {
super(`aborted at step: ${step}`);
}
}

interface NamedCR {
metadata: { name: string; labels?: Record<string, string> };
}
interface ShimApplication {
id: string;
name: string;
repository?: { url?: string; branch?: string };
}

async function call(
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; body: unknown }> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body === undefined ? undefined : { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
try {
return { status: res.status, body: JSON.parse(text) as unknown };
} catch {
return { status: res.status, body: text };
}
}

async function get<T>(path: string): Promise<T> {
const res = await call("GET", path);
if (res.status !== 200) fail(`GET ${path}`, `HTTP ${res.status} ${JSON.stringify(res.body)}`);
return res.body as T;
}

const names = (list: NamedCR[]) => list.map((r) => r.metadata.name).sort();
const has = (list: NamedCR[], name: string) => list.some((r) => r.metadata.name === name);

/** Runs created by this smoke, torn down in reverse order. */
const created: Array<{ plural: string; name: string }> = [];

async function createRun(
plural: string,
body: Record<string, unknown>,
step: string,
): Promise<NamedCR> {
const res = await call("POST", `/api/${plural}`, body);
if (res.status !== 201) fail(step, `HTTP ${res.status} ${JSON.stringify(res.body)}`);
const run = res.body as NamedCR;
created.push({ plural, name: run.metadata.name });
return run;
}

async function main(): Promise<void> {
// -- inventory: the filter is only meaningful against real Hub ids
const inventoryRes = await fetch(`${BASE}/api/applications`);
if (!inventoryRes.ok) fail("inventory", `GET /api/applications -> HTTP ${inventoryRes.status}`);
if (inventoryRes.headers.get("X-Inventory-Source") !== "hub") {
console.log(
"application-filter-smoke: SKIPPED — shim is serving the offline stub inventory, " +
"which refuses application-scoped runs. Point HUB_URL at a reachable Hub.",
);
return;
}
const applications = ((await inventoryRes.json()) as ShimApplication[]).filter((a) =>
/^\d+$/.test(a.id),
);
if (applications.length < 2) {
fail("inventory", `need two numeric-id applications to prove isolation, got ${applications.length}`);
}
const [appA, appB] = applications;
pass("inventory", `filtering between application ${appA.id} and ${appB.id}`);

// A managed agent is the one a real per-application view drives.
const agents = await get<NamedCR[]>("/api/agents");
if (agents.length === 0) fail("agent fixture", "no managed agents — POST /api/defaults first");
const agentRef = agents[0].metadata.name;

try {
// -- 1. the single-run create stamps the label
const runA = await createRun(
"agentruns",
{ agentRef, applicationRef: appA.id },
"create AgentRun",
);
if (runA.metadata.labels?.[APPLICATION_LABEL] !== appA.id) {
fail("AgentRun label", `expected ${appA.id}, got ${runA.metadata.labels?.[APPLICATION_LABEL]}`);
}
pass("AgentRun label", `${runA.metadata.name} -> ${APPLICATION_LABEL}=${appA.id}`);

const runB = await createRun(
"agentruns",
{ agentRef, applicationRef: appB.id },
"create AgentRun (second application)",
);

// -- 2/3. the workflow-run create stamps it too, and only when scoped
const workflows = await get<NamedCR[]>("/api/agentworkflows");
let workflowRunA: NamedCR | undefined;
if (workflows.length === 0) {
pass("AgentWorkflowRun label", "skipped — no managed workflows on this cluster");
} else {
const workflowRef = workflows[0].metadata.name;
workflowRunA = await createRun(
"agentworkflowruns",
{ workflowRef, applicationRef: appA.id },
"create AgentWorkflowRun",
);
const labels = workflowRunA.metadata.labels ?? {};
if (labels[APPLICATION_LABEL] !== appA.id) {
fail("AgentWorkflowRun label", `expected ${appA.id}, got ${labels[APPLICATION_LABEL]}`);
}
if (labels["konveyor.io/managed"] !== "true") {
fail("AgentWorkflowRun label", "application label displaced the managed label");
}
pass("AgentWorkflowRun label", `${workflowRunA.metadata.name} keeps managed + application`);

const unscoped = await createRun(
"agentworkflowruns",
{ workflowRef },
"create unscoped AgentWorkflowRun",
);
if (unscoped.metadata.labels?.[APPLICATION_LABEL] !== undefined) {
fail("unscoped run", `expected no application label, got ${unscoped.metadata.labels?.[APPLICATION_LABEL]}`);
}
pass("unscoped run", "no applicationRef -> no application label");
}

// -- 4/5. the filter isolates, and the unfiltered list is unaffected
const filteredA = await get<NamedCR[]>(`/api/agentruns?application=${appA.id}`);
if (!has(filteredA, runA.metadata.name) || has(filteredA, runB.metadata.name)) {
fail(
`?application=${appA.id}`,
`expected ${runA.metadata.name} without ${runB.metadata.name}, got ${names(filteredA).join(", ")}`,
);
}
pass(`?application=${appA.id}`, `${filteredA.length} run(s), ${runB.metadata.name} excluded`);

const filteredB = await get<NamedCR[]>(`/api/agentruns?application=${appB.id}`);
if (!has(filteredB, runB.metadata.name) || has(filteredB, runA.metadata.name)) {
fail(`?application=${appB.id}`, `leaked across applications: ${names(filteredB).join(", ")}`);
}
pass(`?application=${appB.id}`, `${filteredB.length} run(s), ${runA.metadata.name} excluded`);

// An id no run carries proves the apiserver is doing the selecting.
const foreign = await get<NamedCR[]>("/api/agentruns?application=999999");
if (foreign.length !== 0) {
fail("unmatched application", `expected [], got ${names(foreign).join(", ")}`);
}
pass("unmatched application", "empty list, not a silent full list");

const unfiltered = await get<NamedCR[]>("/api/agentruns");
if (!has(unfiltered, runA.metadata.name) || !has(unfiltered, runB.metadata.name)) {
fail("unfiltered list", "filtering removed runs from the unfiltered list");
}
pass("unfiltered list", `${unfiltered.length} run(s) with both applications present`);

if (workflowRunA) {
const wfrA = await get<NamedCR[]>(`/api/agentworkflowruns?application=${appA.id}`);
if (!has(wfrA, workflowRunA.metadata.name)) {
fail(`agentworkflowruns?application=${appA.id}`, `missing ${workflowRunA.metadata.name}`);
}
const wfrB = await get<NamedCR[]>(`/api/agentworkflowruns?application=${appB.id}`);
if (has(wfrB, workflowRunA.metadata.name)) {
fail(`agentworkflowruns?application=${appB.id}`, "leaked across applications");
}
pass("agentworkflowruns filter", `isolated ${appA.id} from ${appB.id}`);
}

// -- 6. an unhonourable filter is loud
const rejects: Array<[string, string]> = [
["non-run resource", "/api/agents?application=" + appA.id],
["empty value", "/api/agentruns?application="],
["non-numeric id", "/api/agentruns?application=not-a-number"],
// Digits alone must not pass: 21 nines overflows the harness's
// uint64 APP_ID parse, so the filter rejects what the harness would.
["uint64 overflow", "/api/agentruns?application=999999999999999999999"],
];
for (const [label, path] of rejects) {
const res = await call("GET", path);
if (res.status !== 400) {
fail(`400 on ${label}`, `expected 400, got HTTP ${res.status} for ${path}`);
}
pass(`400 on ${label}`, String((res.body as { error?: string })?.error ?? res.status));
}
} finally {
for (const { plural, name } of created.reverse()) {
const res = await call("DELETE", `/api/${plural}/${name}`);
if (res.status !== 204) console.error(`WARN leaked ${plural}/${name} (HTTP ${res.status})`);
}
if (created.length > 0) pass("cleanup", `deleted ${created.length} run(s)`);
}
}

main()
.then(() => {
if (failures === 0) console.log("application-filter-smoke: all checks passed");
})
.catch((err) => {
if (!(err instanceof SmokeAbort)) console.error(`FAIL unexpected — ${err}`);
process.exitCode = 1;
});
3 changes: 2 additions & 1 deletion packages/hub-shim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"start": "tsx src/server.ts",
"typecheck": "tsc --noEmit",
"smoke": "tsx dev/browser-smoke.ts",
"smoke:defaults": "tsx dev/defaults-smoke.ts"
"smoke:defaults": "tsx dev/defaults-smoke.ts",
"smoke:appfilter": "tsx dev/application-filter-smoke.ts"
},
"dependencies": {
"@kubernetes/client-node": "^1.4.0",
Expand Down
Loading