diff --git a/.superpowers/sdd/final-fixes-report.md b/.superpowers/sdd/final-fixes-report.md new file mode 100644 index 00000000..5ad549c9 --- /dev/null +++ b/.superpowers/sdd/final-fixes-report.md @@ -0,0 +1,127 @@ +# Final-Review Fixes Report — marketplace P4 + +## FIX 1 — proceed-gate must return 409, not 500 + +**File changed:** `asdlc-service/internal/feature/design/design_huma.go` (line ~207) + +Added before the catch-all 500 in the `save-design` handler: +```go +if errors.Is(err, ErrUnresolvedDependency) { + return nil, huma.Error409Conflict(err.Error()) +} +``` +`errors` was already imported. The `ErrUnresolvedDependency` doc-comment in `design_service.go` already stated "surfaced (as 409 by the controller)" — now true. + +**New handler test file:** `asdlc-service/internal/feature/design/design_huma_test.go` + +Two tests added: +- `TestSaveAndProceedHandler_UnresolvedDependency_Returns409`: wraps the sentinel with `fmt.Errorf("%w: ...", ErrUnresolvedDependency, ...)`, drives the Huma handler via `humatest`, asserts 409 and the gate message in the response body. +- `TestSaveAndProceedHandler_SpecNotApproved_Returns409`: regression guard for the existing ErrSpecNotApproved → 409 mapping. + +**Test result:** PASS (`go test ./internal/feature/design/... → ok`) + +--- + +## FIX 2/3 — access-pending / Reason taxonomy doc alignment + +**File changed:** `asdlc-service/models/design.go` (Reason field doc-comment, lines ~60-69) + +Updated to enumerate ALL reasons with their origin: +- Platform-computed: `access-required`, `not-found`, `needs-spec` +- Client-derived (NOT emitted by platform): `access-pending` (console derives from in-flight AccessRequest), `needs-input` +- `""` (n/a or resolved) + +`resolveOrgServices` behavior unchanged — comment only. + +--- + +## FIX 4 — open drawer shows stale dep after onChanged + +**File changed:** `console/src/pages/ProjectArchitecturePage.tsx` + +Added a `useEffect` keyed on `effectiveComponents` (after the `const effectiveComponents = ...` line): +```tsx +useEffect(() => { + if (!activeDep) return; + for (const comp of effectiveComponents) { + if (comp.name === activeDep.component) { + const freshDep = comp.dependencies?.find((d) => d.name === activeDep.dependency.name); + if (freshDep) { + setActiveDep({ component: activeDep.component, dependency: freshDep }); + } + return; + } + } +}, [effectiveComponents]); +``` +When no match is found (dep removed), `activeDep` is left as-is — no crash. Drawer props/contract unchanged. + +**Build result:** `pnpm run build` ✓ — `pnpm vitest run src/pages/architecture/` → 4 files, 19 tests PASS + +--- + +## FIX 5 — stale doc reference + +**File changed:** `console/src/services/api/specs.ts` (line ~23) + +Changed: +``` +See asdlc-service/internal/feature/dependencies/spec_huma.go (A4). +``` +to: +``` +See asdlc-service/internal/feature/design/design_huma.go (A4). +``` + +--- + +## FIX 6 — stale comment in dispatch_cascade_hook.go + +**File changed:** `asdlc-service/internal/feature/codingagent/dispatch_cascade_hook.go` (~line 197) + +Updated the comment from "consumer resumes on its OWN org-service gate independently" to reflect the block-at-proceed model (A2c): "The consumer cannot proceed past the save-and-proceed gate until the provider is granted (block-at-proceed model, A2c); there is no separate org-service On-Hold gate." Behavior unchanged. + +--- + +## FIX 7 — SSRF guard: reject CGNAT 100.64.0.0/10 + +**File changed:** `asdlc-service/internal/feature/artifacts/spec_collect.go` + +Added package-level `cgnatNet` var (lazy init): +```go +var cgnatNet = func() *net.IPNet { + _, n, _ := net.ParseCIDR("100.64.0.0/10") + return n +}() +``` + +Added to the IP guard in `FetchSpecFromURL`'s `DialContext`: +```go +if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || cgnatNet.Contains(ip) { +``` + +**Test result:** `go test ./internal/feature/artifacts/...` → ok (existing tests cover the guard; the CGNAT range is not tested by the existing stubs, but the production code change is build-verified and logic is trivially correct). + +--- + +## Verify-command outputs + +### 1. Go tests +``` +cd asdlc-service && go test ./internal/feature/design/... ./internal/feature/artifacts/... ./internal/feature/codingagent/... +ok github.com/wso2/asdlc/asdlc-service/internal/feature/design 0.643s +ok github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts 0.543s +ok github.com/wso2/asdlc/asdlc-service/internal/feature/codingagent 0.593s +``` + +### 2. make openapi +``` +wrote api/openapi.yaml (147086 bytes) +``` +(Not committed — gitignored.) + +### 3. Console build + vitest +``` +pnpm run build → ✓ built in 17.71s +pnpm vitest run src/pages/architecture/ → 4 files, 19 tests PASS +``` diff --git a/CONTEXT.md b/CONTEXT.md index 0b665094..09be36db 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -201,3 +201,157 @@ pattern**: per-org OAuth client-secret + per-run ExternalSecret + definitions. - `openchoreo/` — OC source. Authoritative for what `ClusterWorkflow`/`WorkflowRun`/`SecretReference`/`GitSecret` actually mean. + +## Dependencies ("marketplace") + +### `Marketplace` +**Working title** for app-factory's dependency-management subsystem: +identifying what each user component needs during the design phase, collecting +the config/secrets required to reach those needs, and wiring them through to +the coding agent and the deployed component. Not a browsable storefront — +there is no publish/consume surface. + +### `Dependency` +A single, unified, kind-discriminated entry on a component's design +(frontmatter `dependencies`), **subsuming the legacy `dependsOn` +(same-project siblings) and `dependentApis` (external HTTP APIs) fields**. +Authored during the design phase. Invariant carried over from the +cross-component-wiring work: the dependency identifier must be a primitive +the LLM didn't invent. + +### `internal dependency` +A dependency that runs within wso2cloud: a sibling component in the same +project (`component`), a service in another project of the same org +(`org-service`), or (future) a [[#platform-resource]] the platform provisions +on demand. The platform resolves URLs/credentials itself; the user is never +asked for them. + +### `platform-resource` +An internal dependency that wso2cloud **provisions on demand** — future, not +yet available in wso2cloud, so its shape is not pinned down here. One +dependency kind sub-typed by `resourceType` (e.g. `database`, `message-queue`, +`cache`, `identity-provider`, …); the provisionable set is a **platform +catalog** so new types are added without changing the taxonomy. Scope (per +project vs. per organization) is undecided. Provisioning maps onto OpenChoreo's +`ResourceType` system. + +### `external dependency` +A dependency that does not run within wso2cloud — a SaaS (Salesforce, GitHub), +a public/corporate API, or user-managed infrastructure (a MySQL, a queue). +**One generic kind** (`kind: external`); the old `external-api` / `saas` / +`external-resource` split is retired. The platform asks the user for the +config/secret **values**; how to use it (which SDK, which auth, where the spec +lives) is carried in the dependency's free-form `description`, not a kind enum. +A library with no external service behind it is not a dependency. + +### `connection` +A registered [[#external-dependency]] the org reuses. Two layers: a **registry** +(app-factory-owned, **org-level**) holding the definition — `name`, +`description`, the config **key schema**, and "used by" bindings, read by the +architect so other projects reuse the shape; and **values + wiring** (OpenChoreo, +**per project** — see [[#connection-resourcetype]]) created when the user fills +values via a config-collection task. "Reuse" = reuse of the *schema*, not the +*credentials* (values are per-project; #3245 Resources can't be shared +cross-project). `component` / `org-service` need no connection — the platform +resolves them per consumer. + +### `connection form` +The shape of an external connection = its **`config` key schema** (which keys, +which `secret`). It determines the [[#connection-resourcetype]]'s wiring +(ConfigMap + ESO ExternalSecret + outputs); `authType` is **not** part of it — +the auth *mechanism* is the agent's code, carried in `description`. A schema +change for a connection = a new ResourceType (suffix), never an edit, so RTs are +effectively immutable. + +### `connection ResourceType` +The OpenChoreo `ResourceType` (namespaced, per-org) backing an external +connection's values + wiring, per the **Resource model** +([openchoreo#3245](https://github.com/openchoreo/openchoreo/discussions/3245), +shipped v1.1). Get-or-created **per connection** (named after it; wiring from its +[[#connection-form]]) in the org NS; a `Resource` is cut per project, a +`ResourceReleaseBinding` per env (BFF-authored + pinned — +the Resource path has no `AutoDeploy`). Secrets land via a store-backed ESO +`ExternalSecret` the RT emits into the workload's release NS (value written +through SM-API); the workload consumes outputs via +`Workload.spec.dependencies.resources[].ref` + `envBindings`, gated by +`ResourceDependenciesReady`. Living in the shared org NS, it is visible to both +app-factory and Devant ([[#oc-project]]s are a single shared entity per org). +Supersedes the earlier BFF-env / ConfigurationGroup (#3595) approaches. + +### `credential class` +Whether an external connection's credential may be exposed to the browser: +**publishable** (a client-side key, fine in a SPA's `window._env_`) or +**secret** (server-side only — a backend `service`, never a SPA). The primary +signal is the per-key `secret: true|false`; `credential class` is the SPA +browser-exposure advisory layered on top. + +### `image-based component` +A project component whose **origin** is a prebuilt container image (e.g. +Keycloak), not agent-written source. Resolved (image + version) and configured +via the same resolution + config-collection flow as a [[#connection]], deployed +as an ordinary OC Component; other components reach it via the `component` +dependency + OC Connections. **Not** a dependency kind, and **not** a +[[#platform-resource]] identity-provider (which the *platform* provisions and +shares) — an image component is a project running *its own* image. + +### `component config` +Runtime config **variables a user supplies** on a component (source or image) — +plain settings and/or secrets that are *not* an external service. Collected via a +config-collection task + the per-env value form, injected into the component's +own ReleaseBinding (plain as env literals; secrets via the **component-native** +path — the ComponentType template emits an ESO `ExternalSecret` + `secretKeyRef`). +The Resource model is reserved for external [[#connection]]s; component config +never needs a Resource. + +### `dependency task` +A typed task on the project board representing the work needed to satisfy a +dependency: **config-collection** (done by the user — completes when values +are saved in the console) or **resource-provisioning** (future — done by the +platform; completes when the OC `Resource` is ready and its outputs resolve). +Part of one task graph with component-implementation tasks; any task type can +gate any other. **Only task types that produce repo artifacts get GitHub +issues** (component implementation today); config-collection tasks are +platform-only — nothing dependency-related is written to (currently public) +project repos. + +### `resolution` / `scoped resolution chat` +How a dependency the (single-turn) architect couldn't resolve alone gets +settled on the Architecture page. The dependency shows as a **tile** +(`ambiguous` — has candidates; `unresolved` — needs input). The target UI is a +**scoped resolution chat** — a per-dependency side chat (own conversational +agent + tools) that presents candidates as **chips** and talks through +discovery; an interim inline-card form does the same before that ships. +Resolving **pins** the choice; an **Apply / regenerate** action folds all pins +into one architect re-run. A design with `ambiguous`/`unresolved` dependencies +cannot be saved. + +--- + + +### `ClusterResourceType` +The **cluster-scoped, platform-engineer-authored** OpenChoreo provisioning +template (`openchoreo.dev/v1alpha1`, `kind: ClusterResourceType`) a +[[#platform-resource]] references. app-factory **discovers** the installed set +read-only (`ListClusterResourceTypes`) and **never authors** them — the cluster +operator installs them (wso2cloud in cloud; the local `deployments/` scripts +stand in for the platform-engineer locally, e.g. the `postgres-cnpg` sample +backed by the CloudNativePG operator). Distinct from a +[[#connection-resourcetype]], which is **namespaced, per-org, and +app-factory-authored** for an external connection's wiring. The dependency's +`resourceType` open-string IS the discovered `ClusterResourceType.metadata.name` +— no abstract→concrete indirection. See +[[adr-platform-resource-via-direct-oc-resource-model]]. + +### `ResourceProvisioner` +The BFF seam (P5) that fills a [[#platform-resource]]'s outputs. +**`OCNativeProvisioner`** (built) authors a `Resource` + per-env +`ResourceReleaseBinding` against a discovered [[#clusterresourcetype]] and lets +the OC controller + the cluster's real provisioner fill the binding outputs +**asynchronously** (a readiness watcher observes the native `Ready` condition — +a real DB takes minutes). **`PlatformProvisioner`** is a **reserved** second +implementation for `wso2-enterprise/wso2cloud#638` ("Platform Services +Provisioning") — designed-for, not built. Consumption +(`workload.spec.dependencies.resources[]`) is identical regardless of which +implementation provisioned. The P1-era "platform-resource = future" note above +is realized by P5 **locally**; it remains future in wso2cloud (#638). See +[[adr-platform-resource-via-direct-oc-resource-model]]. diff --git a/agents/src/agents/architect/architect.eval.ts b/agents/src/agents/architect/architect.eval.ts index a07434cc..8ec3ca6e 100644 --- a/agents/src/agents/architect/architect.eval.ts +++ b/agents/src/agents/architect/architect.eval.ts @@ -27,9 +27,10 @@ */ import { test } from "node:test"; +import assert from "node:assert/strict"; import { runArchitect } from "./run.js"; import type { FinalizeResolver } from "./tools.js"; -import { ArchitectInput, ArchitectOutput } from "./schema.js"; +import { ArchitectInput, ArchitectOutput, Dependency } from "./schema.js"; import { modelFromEnv, makeCollectingSink, @@ -43,6 +44,70 @@ import { type SampleResult, } from "../../eval/harness.js"; +// ── Schema-level unit tests for needsSpec / specUrl (A5) ──────────────────── +// These run offline (no model call) and verify the Zod schema additions. +// The full eval (real model + web_search) is nondeterministic and only runs +// when ANTHROPIC_API_KEY is set (see "architect eval" test below). + +test("external dependency schema: needsSpec + specUrl are optional on external variant", () => { + // Base external without new fields — must still validate (backwards-compat). + const base = { + kind: "external", + name: "openweather", + description: "OpenWeatherMap REST API", + config: [{ key: "OPENWEATHER_API_KEY", secret: true }], + }; + const baseResult = Dependency.safeParse(base); + assert.ok(baseResult.success, `base external failed: ${JSON.stringify(baseResult.error?.issues)}`); + + // External with needsSpec: true and a specUrl — must validate. + const withSpec = { + ...base, + needsSpec: true, + specUrl: "https://openweathermap.org/openapi.json", + candidates: [{ label: "OpenWeatherMap API", url: "https://openweathermap.org/api" }], + }; + const withSpecResult = Dependency.safeParse(withSpec); + assert.ok(withSpecResult.success, `external with needsSpec+specUrl failed: ${JSON.stringify(withSpecResult.error?.issues)}`); + if (withSpecResult.success && withSpecResult.data.kind === "external") { + assert.strictEqual(withSpecResult.data.needsSpec, true); + assert.strictEqual(withSpecResult.data.specUrl, "https://openweathermap.org/openapi.json"); + } + + // External with needsSpec: true but NO specUrl — must validate (status unresolved). + const noSpecUrl = { + ...base, + needsSpec: true, + status: "unresolved" as const, + }; + const noSpecUrlResult = Dependency.safeParse(noSpecUrl); + assert.ok(noSpecUrlResult.success, `external with needsSpec+no specUrl failed: ${JSON.stringify(noSpecUrlResult.error?.issues)}`); + + // SaaS-SDK style: needsSpec omitted — must validate. + const sdkStyle = { + kind: "external", + name: "salesforce", + description: "Salesforce CRM — use @salesforce/core npm package v6.x", + config: [ + { key: "SF_CLIENT_ID", secret: false }, + { key: "SF_CLIENT_SECRET", secret: true }, + ], + }; + const sdkResult = Dependency.safeParse(sdkStyle); + assert.ok(sdkResult.success, `SaaS-SDK external without needsSpec failed: ${JSON.stringify(sdkResult.error?.issues)}`); + if (sdkResult.success && sdkResult.data.kind === "external") { + assert.strictEqual(sdkResult.data.needsSpec, undefined); + } +}); + +// NOTE: The full eval that tests model behavior (web_search calls, needsSpec +// emitted for a REST API prompt) requires a PAID Anthropic API call and +// nondeterministic web_search results. It cannot reliably assert on exact +// URLs. A fixture-driven eval for this would belong in the "architect eval" +// test below, gated by ANTHROPIC_API_KEY. The schema-level test above covers +// the structural contract deterministically. +// ───────────────────────────────────────────────────────────────────────────── + interface ArchitectFixture { name: string; difficulty?: string; diff --git a/agents/src/agents/architect/prompt.ts b/agents/src/agents/architect/prompt.ts index 4d601d18..ba16b9a2 100644 --- a/agents/src/agents/architect/prompt.ts +++ b/agents/src/agents/architect/prompt.ts @@ -54,21 +54,46 @@ Call finalize() to end the session. If finalize returns validation issues, addre - buildpack is always "docker". - Stack-specific code, port, layout, Dockerfile, runtime-config, CORS, auth, persistence patterns live in the Platform skills below — apply them. - dependencies of kind 'component' must reference other components' names verbatim. - - Prefer fewer components over many — fold related concerns into the component that owns them rather than spinning off helpers. The Platform skills below carry the specific decomposition anti-patterns and their rationale; apply them (e.g. no separate auth/identity/login/session component and no \`/auth/*\` endpoints per \`thunder-authentication\`; no separate storage/database/persistence component and no scheduled-task/cronjob component per \`go\`). + - Prefer fewer components over many — fold related concerns into the component that owns them rather than spinning off helpers. The Platform skills below carry the specific decomposition anti-patterns and their rationale; apply them (e.g. no separate auth/identity/login/session component and no \`/auth/*\` endpoints per \`thunder-authentication\`; no separate storage/database/persistence component and no scheduled-task/cronjob component per \`go\`). **BUT "no database component" ≠ "ignore the database":** when a component needs to persist data, declare that datastore as a \`platform-resource\` dependency ON that component (see Dependencies below) so the platform provisions it — a dependency on the owning component, never a separate component and never omitted. # Dependencies (the unified model) Every component carries a single \`dependencies\` list — everything it needs from outside itself, each entry discriminated by \`kind\`. This ONE list replaces the old \`dependsOn\` + \`dependentApis\` split. Classify each need into exactly one kind: - **\`component\`** — a sibling component built by THIS project (e.g. a web-app's backend \`todo-api\`). \`name\` must match the sibling's name verbatim. The platform resolves its URL (deploy-gated) and wires the connection. This is the old \`dependsOn\`. - - **\`org-service\`** — a service published by ANOTHER project in the same organisation (e.g. an organisation-wide \`employee-api\` directory). Before proposing one, call \`list_org_endpoints\` to discover what in-org services exist: it returns each published endpoint's org-service \`name\` (= the provider component name to declare), its project, and \`namespaceVisible\`. Only declare an \`org-service\` dependency when the target's \`namespaceVisible\` is **true** — that means the provider has published it for cross-project use. If the service you need shows \`namespaceVisible: false\` (it exists but isn't published cross-project) or isn't listed at all, do NOT silently invent it: declare it and call \`resolve_dependency\` to mark it \`unresolved\` so the user can request access. Declare it **by name only** (the platform resolves + injects the internal URL); do NOT invent a \`url\`, and do NOT create a sibling component of your own for it. \`{ "kind": "org-service", "name": "employee-api", "description": "Organisation-wide employee directory — name, email, department." }\` + - **\`org-service\`** — a service published by ANOTHER project in the same organisation (e.g. an organisation-wide employee directory). Before proposing one, call \`list_org_endpoints\` to discover what in-org services exist: it returns each endpoint's org-service \`name\`, its project, and \`namespaceVisible\`. **Use the \`name\` value from \`list_org_endpoints\` EXACTLY AND VERBATIM** as the dependency \`name\` — it is project-prefixed (e.g. \`hr-directory-employee-api\`, NOT \`employee-api\`). Do NOT shorten it, strip the project prefix, or invent a friendlier form; the platform matches the dependency to the catalog by this exact string, and a shortened name resolves to nothing (\`not-found\`). Only declare an \`org-service\` dependency when the target's \`namespaceVisible\` is **true** — that means the provider has published it for cross-project use. If the service you need shows \`namespaceVisible: false\` (it exists but isn't published cross-project) or isn't listed at all, do NOT silently invent it: declare it (still using the exact catalog \`name\` when it exists) and call \`resolve_dependency\` to mark it \`unresolved\` so the user can request access. Declare it **by name only** (the platform resolves + injects the internal URL); do NOT invent a \`url\`, and do NOT create a sibling component of your own for it. \`{ "kind": "org-service", "name": "hr-directory-employee-api", "description": "Organisation-wide employee directory — name, email, department." }\` - **\`external\`** — an off-platform service the user must supply values for: a SaaS reached via an SDK (Salesforce, GitHub), a public/corporate REST API (OpenWeather), or a user-managed database. ONE generic kind. Carry: - \`name\` (lowercase kebab-case, the connection key, e.g. \`openweather\`, \`salesforce\`) - \`description\` (free-form: which SDK to initialise, which auth scheme, where the API spec lives — so the coding agent knows how to use it) - \`config\` (the env-var key SCHEMA the component codes against — list each key, mark credentials/tokens \`secret: true\`, plain values like base URLs \`secret: false\`). You declare the KEYS only; the user provides the VALUES later. A base URL is a config key (it varies per environment), not metadata. Example: \`{ "kind": "external", "name": "openweather", "description": "OpenWeatherMap current-weather REST API; call GET {base}/data/2.5/weather?q=&appid={key}.", "config": [ { "key": "OPENWEATHER_BASE_URL", "secret": false }, { "key": "OPENWEATHER_API_KEY", "secret": true } ] }\` - REUSE existing connections: before proposing an \`external\` dependency, call \`list_connections\` to see what this organization has ALREADY registered. If a registered connection fits the need, reuse its EXACT \`name\` and config-key schema (call \`get_connection_schema\` to confirm the keys) instead of inventing a new name/shape — the user has already provided its values, so a matching name avoids re-collecting them. Only invent a new connection when nothing registered fits. - - **\`platform-resource\`** — a resource the PLATFORM provisions (database, message-queue, cache, identity-provider), named by \`resourceType\`. Forward-looking; declare when the spec clearly wants a platform-managed datastore, otherwise prefer \`external\` for a user-managed one. Provisioning is wired in a later phase. + +## External dependency discovery (web_search) + +When you need to propose a NEW \`external\` dependency that is not already in \`list_connections\`: + +1. **Reuse-first**: always call \`list_connections\` before proposing a new external. If a registered connection fits, reuse it (see above). + +2. **Discover with web_search**: for a new external, call \`web_search\` to identify the service and its integration style. Search for the service name + "OpenAPI spec" or "REST API docs" or "npm package". Put any useful URLs you find in \`candidates[]\` — each entry is \`{ label, description, url }\` (e.g. the API homepage, a docs page, a spec URL). + +3. **Classify the integration style**, then set the fields accordingly: + + **REST / GraphQL API** (the component calls specific HTTP endpoints): + - Set \`needsSpec: true\` — a machine-readable spec is required for the coding agent. + - If the search surfaces a published OpenAPI / Swagger / AsyncAPI URL (e.g. \`/openapi.json\`, \`/swagger.yaml\`, a GitHub raw URL, or an official "OpenAPI spec" link), set \`specUrl\` to that URL. **Never fetch or inline the spec yourself** — the PLATFORM fetches and stores it; your job is to record the URL hint. + - Derive \`config\` keys from the spec's \`securitySchemes\` when known: + - \`apiKey\` scheme → one key for the API key (e.g. \`OPENWEATHER_API_KEY\`, \`secret: true\`) plus a base-URL key (\`secret: false\`). + - \`oauth2\` with \`clientCredentials\` flow → two keys: client id (\`secret: false\`) + client secret (\`secret: true\`), plus a base-URL key (\`secret: false\`). + - When the securityScheme is unknown or not found, fall back to a sensible guess (API key + base URL) and note the uncertainty in \`description\`. + - If \`needsSpec: true\` and no \`specUrl\` was found, set \`status: "unresolved"\` so the user knows they must supply a spec before the coding agent can proceed. + + **SaaS SDK** (the component uses a language-level SDK, not raw HTTP): + - Omit \`needsSpec\` (or set \`false\`) — a machine spec is not required; the SDK encapsulates the API surface. + - Name the language package + exact version in \`description\` and in \`componentAgentInstructions\` (e.g. \`"Use the @salesforce/core npm package v6.x. Initialise with a connected-app OAuth2 client."\`). + +4. **Always** emit \`candidates[]\` with the URLs you found during web_search so the user can verify the sources. + - **\`platform-resource\`**: infrastructure the PLATFORM provisions for a component — a **database** (persistent storage), cache, or message queue (vs \`external\`, which the user manages, and vs a sibling \`component\`). **TRIGGER — this is not optional: whenever a component must persist data or needs a datastore / cache / queue (the spec says "database", "persistence", "store … in Postgres", "save records", a data store of any kind, etc.) you MUST emit a \`platform-resource\` dependency ON that component.** Do NOT treat persistence as an internal implementation detail and omit it, and do NOT spin off a separate database/storage component (per the decomposition rule at the top) — the datastore is a \`platform-resource\` DEPENDENCY on the owning component. Steps: (1) call \`list_platform_resource_types\` to see what the cluster offers (each entry has a \`name\`, \`parameters\`, and \`outputs\` the component will read as env vars); (2) emit \`{ "kind": "platform-resource", "name": , "resourceType": , "description": "what it stores / why" }\`. If NO offered type matches the need, still emit the dependency and call \`resolve_dependency\` to mark it \`unresolved\`. Do NOT invent instance parameters (size / version) — the user supplies them in the console. ## Resolution status diff --git a/agents/src/agents/architect/run.ts b/agents/src/agents/architect/run.ts index b4a2cf56..95972757 100644 --- a/agents/src/agents/architect/run.ts +++ b/agents/src/agents/architect/run.ts @@ -24,6 +24,11 @@ */ import { streamText, stepCountIs, type LanguageModel, type ToolSet } from "ai"; +// anthropic is imported here solely for the provider-executed web_search tool. +// The architect is Anthropic-only today (model.ts hard-wires createAnthropic); +// this import is safe. If a non-Anthropic provider is ever added, guard the +// injection below with a provider-type check so non-Anthropic runs still work. +import { anthropic } from "@ai-sdk/anthropic"; import { DesignDoc } from "./doc.js"; import { buildTools, type SseSink, type FinalizeResolver } from "./tools.js"; import { systemPrompt, buildUserPrompt } from "./prompt.js"; @@ -38,6 +43,12 @@ export interface ArchitectRunResult { /** Validator issues against the final doc — empty when finalized. */ issues: ValidationIssue[]; usage: { inputTokens: number; outputTokens: number }; + /** + * Last finishReason reported by the model. "content-filter" means Anthropic's + * safety system declined the input (a benign refusal, not an agent fault) — + * the route surfaces that distinctly. + */ + finishReason?: string; } export interface ArchitectRunOpts { @@ -69,10 +80,19 @@ export async function runArchitect( const { model, input, sink, finalizer, abortSignal } = opts; const doc = DesignDoc.fromPrevious(input.previousDesign); - // Design tools + any read-only discovery tools (connection-registry MCP). - const tools = { ...buildTools(doc, sink, finalizer, input.wireframes ?? {}), ...(opts.extraTools ?? {}) }; + // Design tools + any read-only discovery tools (connection-registry MCP) + + // provider-executed web_search for external dependency discovery (A5). + // The project is Anthropic-only (model.ts uses createAnthropic exclusively), + // so the injection is unconditional. If a non-Anthropic provider is added, + // guard this with: isAnthropicModel(model) && { web_search: ... } + const tools = { + ...buildTools(doc, sink, finalizer, input.wireframes ?? {}), + ...(opts.extraTools ?? {}), + web_search: anthropic.tools.webSearch_20250305({ maxUses: 4 }), + }; let usage = { inputTokens: 0, outputTokens: 0 }; + let finishReason: string | undefined; const result = streamText({ model, @@ -90,6 +110,7 @@ export async function runArchitect( inputTokens: ev.usage?.inputTokens ?? 0, outputTokens: ev.usage?.outputTokens ?? 0, }; + finishReason = ev.finishReason; console.log( `[architect] finish=${ev.finishReason} steps=${ev.steps?.length ?? 0} in=${usage.inputTokens} out=${usage.outputTokens}`, ); @@ -119,5 +140,6 @@ export async function runArchitect( design: doc.materialize(), issues: validate(doc), usage, + finishReason, }; } diff --git a/agents/src/agents/architect/schema.ts b/agents/src/agents/architect/schema.ts index 59c7f826..0319ac44 100644 --- a/agents/src/agents/architect/schema.ts +++ b/agents/src/agents/architect/schema.ts @@ -111,6 +111,18 @@ const ExternalDependency = z.object({ .describe( "The config key SCHEMA the agent codes against (which keys, which are secret). Values are collected later from the user, NOT here. A URL is a config key (it varies per env), not metadata.", ), + needsSpec: z + .boolean() + .optional() + .describe( + "True for a REST/GraphQL API the component must call by specific endpoints (⇒ a spec is required). False/omitted for SaaS-SDK or trivial keys-only externals.", + ), + specUrl: z + .string() + .optional() + .describe( + "If web-search found a published OpenAPI/Swagger spec, the URL to it. The PLATFORM fetches + stores it; do NOT fetch or inline it yourself.", + ), status: DependencyStatus.optional(), candidates: z.array(DependencyCandidate).optional(), }); diff --git a/agents/src/agents/tech-lead/run.ts b/agents/src/agents/tech-lead/run.ts index faae58a0..aa79b7a8 100644 --- a/agents/src/agents/tech-lead/run.ts +++ b/agents/src/agents/tech-lead/run.ts @@ -28,7 +28,7 @@ import { streamObject, type LanguageModel } from "ai"; import { - PlanArraySchema, + PlanResultSchema, PlanItemSchema, type TechLeadPlanInput, type PlanIssue, @@ -79,8 +79,13 @@ export async function runTechLeadPlan( model, system: planSystemPrompt, prompt: buildPlanUserPrompt(input), - schema: PlanArraySchema, + schema: PlanResultSchema, abortSignal, + // Force the older tool-based structured-output path. The default `auto` picks + // Anthropic's native structured-outputs beta (`structured-outputs-2025-11-13`) + // for sonnet-4-5, which hangs streamObject here (the object never finalizes), + // breaking task generation. `jsonTool` restores the stable P4 behavior. + providerOptions: { anthropic: { structuredOutputMode: "jsonTool" } }, onError: ({ error }) => { console.error("[tech-lead/plan] streamObject error:", error); }, @@ -101,17 +106,20 @@ export async function runTechLeadPlan( }; // Seal-rule: emit element i when the partial array length ≥ i+2 (so element - // i is no longer the trailing one still being typed). + // i is no longer the trailing one still being typed). The stream now yields + // `{ tasks: [...] }` (object-wrapped, see PlanResultSchema) — read `.tasks`. for await (const partial of result.partialObjectStream) { if (isClosed?.()) break; - if (!Array.isArray(partial)) continue; - const sealedTo = partial.length - 2; - for (let i = sealedThrough + 1; i <= sealedTo; i++) sealOne(i, partial[i]); + const arr = partial?.tasks; + if (!Array.isArray(arr)) continue; + const sealedTo = arr.length - 2; + for (let i = sealedThrough + 1; i <= sealedTo; i++) sealOne(i, arr[i]); } // Stream ended — flush the trailing element(s). const final = await result.object; - for (let i = sealedThrough + 1; i < final.length; i++) sealOne(i, final[i]); + const finalTasks = final.tasks; + for (let i = sealedThrough + 1; i < finalTasks.length; i++) sealOne(i, finalTasks[i]); const issues = validatePlan({ items: sealed, diff --git a/agents/src/agents/tech-lead/schema.ts b/agents/src/agents/tech-lead/schema.ts index 7f7781dc..5fd5c6cb 100644 --- a/agents/src/agents/tech-lead/schema.ts +++ b/agents/src/agents/tech-lead/schema.ts @@ -69,6 +69,12 @@ export type PlanItem = z.infer; // The full plan is a non-empty (in fresh mode) array of PlanItem. export const PlanArraySchema = z.array(PlanItemSchema); +// The plan streamed by the tech-lead, wrapped in an object (rather than a bare +// top-level array) so the stable `jsonTool` structured-output path produces a +// valid tool `input_schema` — Anthropic requires it to be `type: object`; a +// top-level array is rejected. The elements live under `tasks`. +export const PlanResultSchema = z.object({ tasks: PlanArraySchema }); + // Lightweight skill projection shipped to the planner — name + description // only. The planner uses these as context for splitting tasks but does not // load the bodies (those go to the detail phase via TechLeadDetailItem). diff --git a/agents/src/server/routes/architect.ts b/agents/src/server/routes/architect.ts index 1c52ec08..3e3018ea 100644 --- a/agents/src/server/routes/architect.ts +++ b/agents/src/server/routes/architect.ts @@ -100,7 +100,7 @@ export function registerArchitect(app: express.Express) { const extraTools = mcpUrl ? await loadMcpTools(mcpUrl) : {}; try { - const { finalized } = await runArchitect({ + const { finalized, finishReason } = await runArchitect({ model, input: parsed.data, sink, @@ -112,10 +112,16 @@ export function registerArchitect(app: express.Express) { // Loop ended without finalize() succeeding. Could be: stepCountIs hit, // model gave up, or upstream error. Emit error so BFF doesn't write. if (!finalized && !res.writableEnded) { + // Anthropic maps a model-level safety refusal to finishReason + // "content-filter" — the model declines the input and returns almost + // no output. Surface it distinctly so the user knows to rephrase + // rather than reading it as an agent malfunction. + const refused = finishReason === "content-filter"; writeFrame(res, { type: "error", - errorText: - "architect agent ended without calling finalize — design not produced", + errorText: refused + ? "The AI assistant declined this request as it appears to conflict with its usage policies. Please rephrase and try again." + : "architect agent ended without calling finalize — design not produced", }); } diff --git a/agents/src/server/routes/requirements-chat.ts b/agents/src/server/routes/requirements-chat.ts index 547bdb47..e1df4474 100644 --- a/agents/src/server/routes/requirements-chat.ts +++ b/agents/src/server/routes/requirements-chat.ts @@ -97,6 +97,14 @@ export function registerRequirementsChat(app: express.Express) { const doc = new RequirementsDoc(parsed.data.files); const tools = buildTools(doc, sse, finalizer, parsed.data.mode); + // The model can end a turn without calling finish() for two very + // different reasons: a benign refusal from Anthropic's safety system + // (finishReason === "content-filter" — the model declines the input and + // emits ~nothing) versus an actual agent malfunction. We track the last + // finishReason so the fallthrough below can surface the refusal case with + // a clear, user-facing message instead of an opaque + // "ended without calling finish". + let lastFinishReason: string | undefined; try { const result = streamText({ model, @@ -116,6 +124,7 @@ export function registerRequirementsChat(app: express.Express) { console.error("[requirements-chat] streamText error:", error); }, onFinish: (ev) => { + lastFinishReason = ev.finishReason; console.log( `[requirements-chat] finish=${ev.finishReason} steps=${ev.steps?.length ?? 0} in=${ev.usage?.inputTokens ?? 0} out=${ev.usage?.outputTokens ?? 0}`, ); @@ -132,10 +141,17 @@ export function registerRequirementsChat(app: express.Express) { } if (!finalizer.finalized && !res.writableEnded) { + // Anthropic maps a model-level safety refusal to + // finishReason "content-filter" — the model declines the input and + // returns almost no output. Surface it as a clear, actionable message + // rather than the generic agent-malfunction error so the user knows to + // rephrase, and downstream logs aren't misread as an infra fault. + const refused = lastFinishReason === "content-filter"; writeFrame(res, { type: "error", - errorText: - "requirements-chat agent ended without calling finish", + errorText: refused + ? "The AI assistant declined this request as it appears to conflict with its usage policies. Please rephrase and try again." + : "requirements-chat agent ended without calling finish", }); } diff --git a/agents/src/skills/document-generation/requirements-from-prompt.ts b/agents/src/skills/document-generation/requirements-from-prompt.ts index 11d95556..6cc7ac3c 100644 --- a/agents/src/skills/document-generation/requirements-from-prompt.ts +++ b/agents/src/skills/document-generation/requirements-from-prompt.ts @@ -40,7 +40,7 @@ One or two sentences describing what the product is and who it's for. No more. # Personas 1-4 personas. Often one is enough — only add more if the product truly requires distinct kinds of users. -ho + Each on a single line, under 12 words, in the form: - role — what they do with the product. @@ -81,6 +81,17 @@ Unless the user explicitly asked, DO NOT include: If the core flow genuinely requires distinguishing between two types of users (e.g., a requester and an approver), that's fine — express it through the personas and the stories, not as a separate auth capability. +## Keep explicit external dependencies (these are product facts, not tech details) + +A technology you INVENT is noise — leave it out. But an external system the user EXPLICITLY says the product must use, consume, or integrate with is a product-level fact that defines the product's boundary, and you MUST keep it as a Feature in plain language. This OVERRIDES the "no tech stack / no architecture" rule below — that rule is about UNREQUESTED implementation choices, never about a dependency the user asked for. Preserve, when the user states it: + +- A third-party / external service or API the product calls (e.g. a payment, mapping, weather, or currency service — especially one needing an API key or account). +- Another team's or the organization's EXISTING shared service the product should consume instead of building its own (e.g. "use the organization's Product Catalog service"). +- A backing resource the user says the PLATFORM should provide/provision (e.g. "a database provisioned by the platform", a managed queue or cache) — as opposed to the product managing its own. +- A stated data-ownership boundary (e.g. "must NOT store products itself", "does not keep its own copy"). + +Write each as one plain feature bullet describing what the product does with that system — e.g. "Product details come from the organization's shared Product Catalog service.", "Order totals are converted using live rates from an external currency service.", "Orders are saved in a database the platform provides." Keep the business voice: do NOT name a specific vendor/product/library the user did not name, and do NOT add schemas, field lists, endpoints, or config keys — just preserve the dependency and the boundary. Dropping a dependency the user explicitly required is a failure: the later design stage never sees this prompt, only these requirements, so an omitted integration is lost for good. + ## Budget (hard caps) - Overview: at most 2 sentences. @@ -90,7 +101,7 @@ If the core flow genuinely requires distinguishing between two types of users (e ## Detail level -- No implementation details, tech stack, or architecture. +- No implementation details, tech stack, or architecture — EXCEPT the user-stated external dependencies covered above, which you keep. - No data schemas, field lists, or validation rules. - No edge cases, error handling, or failure modes. - No timelines, milestones, or team structure. diff --git a/asdlc-service/api/app.go b/asdlc-service/api/app.go index 1fdcb1f7..15d7eb04 100644 --- a/asdlc-service/api/app.go +++ b/asdlc-service/api/app.go @@ -28,6 +28,7 @@ import ( "github.com/wso2/asdlc/asdlc-service/internal/feature/connections" "github.com/wso2/asdlc/asdlc-service/internal/feature/organization" "github.com/wso2/asdlc/asdlc-service/internal/feature/orgcreds" + "github.com/wso2/asdlc/asdlc-service/internal/feature/resources" "github.com/wso2/asdlc/asdlc-service/internal/feature/task" "github.com/wso2/asdlc/asdlc-service/internal/feature/webhook" "github.com/wso2/asdlc/asdlc-service/internal/platform/humakit" @@ -91,6 +92,11 @@ type AppParams struct { // catalog of in-org service endpoints (the `org-service` targets, P3). nil ⇒ // the tool reports an empty catalog. OrgEndpointCatalog *connections.OrgEndpointCatalog + + // ResourceTypeCatalog backs the MCP `list_platform_resource_types` tool — the + // read-only catalog of cluster-installed ClusterResourceTypes (P5). nil ⇒ the + // tool reports an empty list. + ResourceTypeCatalog *resources.ResourceTypeCatalog } // NewHandler assembles the full HTTP handler with middleware and routes. @@ -141,7 +147,7 @@ func NewHandler(params AppParams) http.Handler { // org's registered `external` connections during design. Raw handler (MCP is // JSON-RPC, not REST/OpenAPI), so it lives on the outer mux, ungated. if params.ConnectionRegistry != nil { - mux.Handle("POST /internal/organizations/{orgHandle}/mcp", connections.NewMCPHandler(params.ConnectionRegistry, params.OrgEndpointCatalog)) + mux.Handle("POST /internal/organizations/{orgHandle}/mcp", connections.NewMCPHandler(params.ConnectionRegistry, params.OrgEndpointCatalog, params.ResourceTypeCatalog)) } // Test-only reset endpoint — truncates local DB tables. INT-4: gated on diff --git a/asdlc-service/api/huma_register.go b/asdlc-service/api/huma_register.go index fc27af16..222165b9 100644 --- a/asdlc-service/api/huma_register.go +++ b/asdlc-service/api/huma_register.go @@ -24,6 +24,7 @@ import ( "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" "github.com/wso2/asdlc/asdlc-service/internal/platform/auth" + "github.com/wso2/asdlc/asdlc-service/internal/feature/access" "github.com/wso2/asdlc/asdlc-service/internal/feature/component" "github.com/wso2/asdlc/asdlc-service/internal/feature/connections" "github.com/wso2/asdlc/asdlc-service/internal/feature/design" @@ -33,6 +34,7 @@ import ( "github.com/wso2/asdlc/asdlc-service/internal/feature/orgcreds" "github.com/wso2/asdlc/asdlc-service/internal/feature/project" "github.com/wso2/asdlc/asdlc-service/internal/feature/requirements" + "github.com/wso2/asdlc/asdlc-service/internal/feature/resources" "github.com/wso2/asdlc/asdlc-service/internal/feature/skills" "github.com/wso2/asdlc/asdlc-service/internal/feature/task" ) @@ -70,6 +72,9 @@ type HumaDeps struct { GitHubAppClientID string ConnectionValueSvc *connections.ValueService ConnectionRegistry *connections.Registry + AccessSvc *access.AccessService + ResourceSvc *resources.ResourceService + ResourceClient openchoreo.ResourceClient } // RegisterAllHuma registers every migrated feature's operations on the Huma API. @@ -91,6 +96,8 @@ func RegisterAllHuma(api huma.API, d HumaDeps) { orgcreds.RegisterOrgAnthropic(api, d.AnthropicSvc) skills.RegisterSkill(api, d.SkillSvc, d.SkillMutationSvc, d.SkillImportSvc) connections.RegisterConnections(api, d.ConnectionValueSvc, d.ConnectionRegistry) + resources.RegisterResources(api, d.ResourceSvc, d.ResourceClient) + access.RegisterAccess(api, d.AccessSvc) registerInfraHuma(api, d.TaskTokens, d.AnthropicSvc) } diff --git a/asdlc-service/clients/openchoreo/resource_client.go b/asdlc-service/clients/openchoreo/resource_client.go index 2bba9548..bed90910 100644 --- a/asdlc-service/clients/openchoreo/resource_client.go +++ b/asdlc-service/clients/openchoreo/resource_client.go @@ -473,7 +473,14 @@ func (c *resourceClient) EnsureBinding(ctx context.Context, namespace string, b func (c *resourceClient) GetBinding(ctx context.Context, namespace, name string) (*ResourceReleaseBinding, error) { out := &ResourceReleaseBinding{} - if _, err := c.do(ctx, http.MethodGet, nsBase(namespace)+"/resourcereleasebindings/"+name, nil, out); err != nil { + code, err := c.do(ctx, http.MethodGet, nsBase(namespace)+"/resourcereleasebindings/"+name, nil, out) + if err != nil { + // Mirror DeleteBinding: suppress 404 — binding may not exist yet (e.g. + // the provision watcher hasn't run). Callers receive (nil, nil) and + // must treat it as "not yet created" (ready=false, no outputs). + if code == http.StatusNotFound { + return nil, nil + } return nil, fmt.Errorf("get binding %q: %w", name, err) } return out, nil diff --git a/asdlc-service/cmd/asdlc-api/main.go b/asdlc-service/cmd/asdlc-api/main.go index 71717f3f..60d12204 100644 --- a/asdlc-service/cmd/asdlc-api/main.go +++ b/asdlc-service/cmd/asdlc-api/main.go @@ -46,6 +46,7 @@ import ( "github.com/wso2/asdlc/asdlc-service/database/migrations" "github.com/wso2/asdlc/asdlc-service/internal/contracts" "github.com/wso2/asdlc/asdlc-service/internal/credentials" + "github.com/wso2/asdlc/asdlc-service/internal/feature/access" "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" "github.com/wso2/asdlc/asdlc-service/internal/feature/codingagent" "github.com/wso2/asdlc/asdlc-service/internal/feature/component" @@ -57,6 +58,7 @@ import ( "github.com/wso2/asdlc/asdlc-service/internal/feature/orgcreds" "github.com/wso2/asdlc/asdlc-service/internal/feature/project" "github.com/wso2/asdlc/asdlc-service/internal/feature/requirements" + "github.com/wso2/asdlc/asdlc-service/internal/feature/resources" "github.com/wso2/asdlc/asdlc-service/internal/feature/runtimeconfig" "github.com/wso2/asdlc/asdlc-service/internal/feature/skills" "github.com/wso2/asdlc/asdlc-service/internal/feature/task" @@ -111,6 +113,7 @@ func main() { &models.WebhookPayload{}, &models.Organization{}, &models.Connection{}, + &models.AccessRequest{}, ) if err != nil { slog.Error("database init failed", "error", err) @@ -805,6 +808,9 @@ func main() { // the OC Workloads. Backs the MCP `list_org_endpoints` tool + org-service // consumer wiring. orgEndpointCatalog := connections.NewOrgEndpointCatalog(resourceClient) + // Platform-resource-type catalog (P5): read-only discovery of installed + // ClusterResourceTypes. Backs the MCP `list_platform_resource_types` tool. + resourceTypeCatalog := resources.NewResourceTypeCatalog(resourceClient) // Mark org-service deps resolved/unresolved in the design view against the // live catalog (supersedes the static external-API map for org-service). artifactStore.SetOrgServiceResolver(orgEndpointCatalog) @@ -814,6 +820,23 @@ func main() { _, derr := dispatchSvc.DispatchTasks(c, orgID, projectID) return derr }) + // Platform-resource provisioning (P5): author the OC Resource+binding model + // for a platform-resource dependency and mark the resource-provisioning task + // in-flight. Readiness is observed by the resource watcher (A5). + resourceProvisioner := resources.NewOCNativeProvisioner(resourceClient) + resourceSvc := resources.NewResourceService(artifactStore, resourceProvisioner, taskRepo) + + // Wire the platform-resource deprovisioner into project deletion so deleting a + // project tears down its provisioned databases (P5) instead of orphaning them. + if setter, ok := projectService.(project.ProjectServiceWithResourceProvisioner); ok { + setter.SetResourceDeprovisioner(resourceProvisioner) + } + + // Cross-project access requests (P3.5): a consumer asks a provider project to + // publish a project-only org-service org-wide. Creates the provider publish + // task + GitHub issue on the provider's repo and records an AccessRequest. + accessRepo := access.NewRepository(db) + accessSvc := access.NewAccessService(accessRepo, orgEndpointCatalog, issueService, taskRepo, artifactStore) // Materialise a task's bound connection secrets into the runner pod (envFrom). if setter, ok := dispatchSvc.(interface { SetConnectionSecretResolver(codingagent.ConnectionSecretResolveFunc) @@ -877,11 +900,15 @@ func main() { cascadeHook := codingagent.NewDispatchCascadeHook(db, dispatchSvc) cascadeHook.SetTraitSync(traitSyncService) cascadeHook.SetRuntimeConfig(runtimeConfigSvc) + // P3.5: close the access-request loop when a provider's org-publish task + // deploys — flip the requests to `granted`, persist exposesAPI.orgPublished + // (durability), and close the provider issue. Best-effort inside the cascade. + cascadeHook.SetAccessGrant(accessRepo, artifactStore, issueService) projector.SetDispatchHook(cascadeHook) task.RegisterHandlers(func(event, action string, h func(ctx context.Context, event, action string, payload []byte) error) { webhookRouter.Register(event, action, webhook.EventHandlerFunc(h)) - }, db, projector, wfRunService) + }, db, projector, wfRunService, accessSvc) webhook.RegisterInstallationHandlers(webhookRouter, db, credService, issueService, taskRepo) webhookCtrl := webhook.NewWebhookController(webhookVerifier, deliveryStore, webhookRouter, routingLookup, routingCache) @@ -891,6 +918,16 @@ func main() { // is configurable for tests via env. buildWatcher := codingagent.NewBuildWatcher(db, componentClient, projector, asServiceIdentity, wfRunService, cfg.BuildAuthRetryBudget) + // Resource readiness watcher — polls OC for the binding's native Ready + // condition on in-flight resource-provisioning tasks (status=building) and, + // when ready, transitions to `deployed` + cascades DispatchTasks so any + // gated component tasks are unblocked. The redispatch adapter mirrors the + // OnHoldWatcher closure (same shape: returns dispatched count). + resourceWatcher := codingagent.NewResourceWatcher(db, resourceClient, func(ctx context.Context, orgID, projectID string) (int, error) { + r, e := dispatchSvc.DispatchTasks(ctx, orgID, projectID) + return len(r), e + }, asServiceIdentity) + // Coding-agent watcher — same cadence, complementary to the GitHub // webhook path. Only acts on terminal-failed coding-agent WorkflowRuns; // success transitions ride the pull_request:ready_for_review webhook. @@ -989,6 +1026,7 @@ func main() { TaskJWT: taskJWT, ConnectionRegistry: connectionRegistry, OrgEndpointCatalog: orgEndpointCatalog, + ResourceTypeCatalog: resourceTypeCatalog, } // Code-first OpenAPI (Huma) feature dependencies. api.NewHandler creates the @@ -1022,6 +1060,9 @@ func main() { GitHubAppClientID: cfg.GithubAppClientID, ConnectionValueSvc: connectionValueSvc, ConnectionRegistry: connectionRegistry, + AccessSvc: accessSvc, + ResourceSvc: resourceSvc, + ResourceClient: resourceClient, } slog.Info("OpenChoreo API", "baseURL", cfg.PlatformAPI.BaseURL) @@ -1061,6 +1102,7 @@ func main() { defer cancelWatcher() go buildWatcher.Run(watcherCtx) go onHoldWatcher.Run(watcherCtx) + go resourceWatcher.Run(watcherCtx) go traitSyncWatcher.Run(watcherCtx) // Coding-agent run watchers — both can coexist because each filters diff --git a/asdlc-service/internal/feature/access/access_huma.go b/asdlc-service/internal/feature/access/access_huma.go new file mode 100644 index 00000000..2f6343bc --- /dev/null +++ b/asdlc-service/internal/feature/access/access_huma.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package access + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + + "github.com/wso2/asdlc/asdlc-service/internal/platform/humakit" + "github.com/wso2/asdlc/asdlc-service/models" +) + +type createAccessRequestInput struct { + humakit.OrgScopedInput + ProjectName string `path:"projectName" doc:"Consumer project name (DNS-label slug)"` + ComponentName string `path:"componentName" doc:"Consumer component requesting access"` + Body struct { + // OrgServiceName is the catalog key the consumer's dependency references + // — the provider OC component name to publish org-wide. + OrgServiceName string `json:"orgServiceName" doc:"Org-service name (provider OC component name) to request access to"` + } +} + +type createAccessRequestOutput struct { + Body *models.AccessRequest +} + +type listAccessRequestsInput struct { + humakit.OrgScopedInput + ProjectName string `path:"projectName" doc:"Consumer project name (DNS-label slug)"` +} + +type listAccessRequestsOutput struct { + Body []models.AccessRequest +} + +// RegisterAccess registers the cross-project access-request endpoint (P3.5). A +// consumer asks the provider project to publish a project-only org-service +// org-wide; this creates the provider publish task + GitHub issue (or dedupes +// onto an existing one) and records the request. +func RegisterAccess(api huma.API, svc *AccessService) { + huma.Register(api, huma.Operation{ + OperationID: "create-access-request", + Method: http.MethodPost, + Path: "/api/v1/organizations/{orgHandle}/projects/{projectName}/components/{componentName}/access-requests", + Summary: "Request cross-project access to a project-only org-service", + Tags: []string{"Access Requests"}, + Security: humakit.SecurityUserJWT, + DefaultStatus: http.StatusCreated, + }, func(ctx context.Context, in *createAccessRequestInput) (*createAccessRequestOutput, error) { + if svc == nil { + return nil, huma.Error503ServiceUnavailable("access requests are not configured") + } + if strings.TrimSpace(in.Body.OrgServiceName) == "" { + return nil, huma.Error422UnprocessableEntity("orgServiceName is required") + } + ar, err := svc.RequestAccess(ctx, RequestAccessInput{ + OrgHandle: in.OrgHandle, + ConsumerProject: in.ProjectName, + ConsumerComponent: in.ComponentName, + OrgServiceName: strings.TrimSpace(in.Body.OrgServiceName), + }) + if err != nil { + if errors.Is(err, ErrOrgServiceNotFound) { + return nil, huma.Error404NotFound("org service not found in catalog") + } + return nil, huma.Error500InternalServerError("failed to create access request", err) + } + return &createAccessRequestOutput{Body: ar}, nil + }) + + // Consumer-side list: every access request a project's components have + // raised, newest first. The console reads this to render per-dependency + // request status chips against the live design view (P3.5 §6). + huma.Register(api, huma.Operation{ + OperationID: "list-access-requests", + Method: http.MethodGet, + Path: "/api/v1/organizations/{orgHandle}/projects/{projectName}/access-requests", + Summary: "List a project's cross-project access requests", + Tags: []string{"Access Requests"}, + Security: humakit.SecurityUserJWT, + }, func(ctx context.Context, in *listAccessRequestsInput) (*listAccessRequestsOutput, error) { + if svc == nil { + return nil, huma.Error503ServiceUnavailable("access requests are not configured") + } + rows, err := svc.ListByConsumerProject(ctx, in.OrgHandle, in.ProjectName) + if err != nil { + return nil, huma.Error500InternalServerError("failed to list access requests", err) + } + if rows == nil { + rows = []models.AccessRequest{} + } + return &listAccessRequestsOutput{Body: rows}, nil + }) +} diff --git a/asdlc-service/internal/feature/access/repository.go b/asdlc-service/internal/feature/access/repository.go new file mode 100644 index 00000000..51a9397d --- /dev/null +++ b/asdlc-service/internal/feature/access/repository.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package access implements the cross-project access-request store (marketplace +// P3.5): the tracking/UX record for a consumer asking a provider project to +// publish an org service cross-project. This file is the repository; the request +// action + status sync are wired alongside in later increments. +package access + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +// ErrAccessRequestNotFound is returned when no access request matches the lookup. +var ErrAccessRequestNotFound = errors.New("access request not found") + +// Repository is the gorm-backed store for AccessRequest rows. +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } + +// Create inserts a new access request. The ID is minted here when unset (so the +// store works without relying on the DB `gen_random_uuid()` default), and the +// status defaults to `requested` when empty. +func (r *Repository) Create(ctx context.Context, ar *models.AccessRequest) error { + if ar == nil { + return fmt.Errorf("access: nil access request") + } + if ar.OrgID == "" || ar.ConsumerProjectID == "" { + return fmt.Errorf("access: orgID and consumerProjectID are required") + } + if ar.ID == "" { + ar.ID = uuid.NewString() + } + if ar.Status == "" { + ar.Status = models.AccessRequestStatusRequested + } + if err := r.db.WithContext(ctx).Create(ar).Error; err != nil { + return fmt.Errorf("access: create request: %w", err) + } + return nil +} + +// Get returns a single access request scoped to (org, id). Returns +// ErrAccessRequestNotFound when absent or owned by another org (no existence +// leak across orgs). +func (r *Repository) Get(ctx context.Context, orgID, id string) (*models.AccessRequest, error) { + if orgID == "" || id == "" { + return nil, fmt.Errorf("access: orgID and id are required") + } + var ar models.AccessRequest + err := r.db.WithContext(ctx).Where("org_id = ? AND id = ?", orgID, id).First(&ar).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAccessRequestNotFound + } + if err != nil { + return nil, fmt.Errorf("access: get %q: %w", id, err) + } + return &ar, nil +} + +// ListByConsumerProject returns every access request a project's components have +// raised, newest first. +func (r *Repository) ListByConsumerProject(ctx context.Context, orgID, projectID string) ([]models.AccessRequest, error) { + if orgID == "" || projectID == "" { + return nil, fmt.Errorf("access: orgID and projectID are required") + } + var out []models.AccessRequest + if err := r.db.WithContext(ctx). + Where("org_id = ? AND consumer_project_id = ?", orgID, projectID). + Order("created_at DESC"). + Find(&out).Error; err != nil { + return nil, fmt.Errorf("access: list consumer project %q: %w", projectID, err) + } + return out, nil +} + +// FindOpenForTarget returns an existing still-open (requested|in_progress) +// request targeting the given provider component, for idempotency/dedup — so +// multiple consumers of one provider endpoint reuse the same publish task. +// Returns (nil, nil) when there is no open request for that target. +func (r *Repository) FindOpenForTarget(ctx context.Context, orgID, providerProjectID, providerComponentName string) (*models.AccessRequest, error) { + if orgID == "" || providerProjectID == "" || providerComponentName == "" { + return nil, fmt.Errorf("access: orgID, providerProjectID and providerComponentName are required") + } + var ar models.AccessRequest + err := r.db.WithContext(ctx). + Where("org_id = ? AND provider_project_id = ? AND provider_component_name = ? AND status IN ?", + orgID, providerProjectID, providerComponentName, + []string{models.AccessRequestStatusRequested, models.AccessRequestStatusInProgress}). + Order("created_at DESC"). + First(&ar).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("access: find open for target %s/%s: %w", providerProjectID, providerComponentName, err) + } + return &ar, nil +} + +// UpdateStatus flips a request's status (and bumps updated_at). Returns +// ErrAccessRequestNotFound when no row matched. +func (r *Repository) UpdateStatus(ctx context.Context, id, status string) error { + if id == "" || status == "" { + return fmt.Errorf("access: id and status are required") + } + res := r.db.WithContext(ctx).Model(&models.AccessRequest{}). + Where("id = ?", id). + Update("status", status) + if res.Error != nil { + return fmt.Errorf("access: update status %q: %w", id, res.Error) + } + if res.RowsAffected == 0 { + return ErrAccessRequestNotFound + } + return nil +} + +// ListByProviderTask returns every consumer request riding on one provider +// publish task — used by the grant-sync to flip all consumers together when the +// provider task lands. +func (r *Repository) ListByProviderTask(ctx context.Context, providerTaskID string) ([]models.AccessRequest, error) { + if providerTaskID == "" { + return nil, fmt.Errorf("access: providerTaskID is required") + } + var out []models.AccessRequest + if err := r.db.WithContext(ctx). + Where("provider_task_id = ?", providerTaskID). + Order("created_at DESC"). + Find(&out).Error; err != nil { + return nil, fmt.Errorf("access: list by provider task %q: %w", providerTaskID, err) + } + return out, nil +} diff --git a/asdlc-service/internal/feature/access/service.go b/asdlc-service/internal/feature/access/service.go new file mode 100644 index 00000000..74382091 --- /dev/null +++ b/asdlc-service/internal/feature/access/service.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package access + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" + "github.com/wso2/asdlc/asdlc-service/internal/feature/connections" + "github.com/wso2/asdlc/asdlc-service/internal/feature/gitrepo" + "github.com/wso2/asdlc/asdlc-service/models" + "github.com/wso2/asdlc/asdlc-service/repositories" +) + +// ErrOrgServiceNotFound is returned when the requested org-service name resolves +// to no provider component in the org endpoint catalog (the `not-found` case — +// not requestable). Maps to a 404 at the endpoint. +var ErrOrgServiceNotFound = errors.New("org service not found in catalog") + +// taskRepo is the subset of repositories.TaskRepository the access flow needs: +// it creates the provider's publish ComponentTask. Narrowed to keep the +// service's surface honest (it never reads/lists tasks). +type taskRepo interface { + Create(ctx context.Context, task *models.ComponentTask) error +} + +// accessStore is the subset of *Repository the request + sync flows need — the +// idempotency lookup, the row insert, and the provider-task fan-out + status +// flip used by the reject close-out. Narrowed to an interface so the flow is +// testable without a DB (the concrete *Repository satisfies it). +type accessStore interface { + FindOpenForTarget(ctx context.Context, orgID, providerProjectID, providerComponentName string) (*models.AccessRequest, error) + Create(ctx context.Context, ar *models.AccessRequest) error + ListByProviderTask(ctx context.Context, providerTaskID string) ([]models.AccessRequest, error) + ListByConsumerProject(ctx context.Context, orgID, projectID string) ([]models.AccessRequest, error) + UpdateStatus(ctx context.Context, id, status string) error +} + +// AccessService implements the cross-project access-request flow (marketplace +// P3.5). A consumer component depends on an org-service published only +// project-only; requesting access creates a publish ComponentTask + GitHub issue +// on the PROVIDER's project/repo (the provider's later dispatch is their +// consent) and writes an AccessRequest tracking row. Many consumers of one +// provider endpoint dedupe onto a single provider task (one task, N rows). +// +// This service only CREATES; status sync (grant/reject) is a later increment. +type AccessService struct { + repo accessStore + catalog *connections.OrgEndpointCatalog + issueSvc gitrepo.IssueService + taskRepo taskRepo + store *artifacts.ArtifactStore +} + +// NewAccessService wires the access-request flow. catalog resolves the provider +// row; store reads the provider design for the component's app path; issueSvc + +// taskRepo create the provider publish task/issue; repo persists the row. +func NewAccessService( + repo accessStore, + catalog *connections.OrgEndpointCatalog, + issueSvc gitrepo.IssueService, + tr taskRepo, + store *artifacts.ArtifactStore, +) *AccessService { + return &AccessService{ + repo: repo, + catalog: catalog, + issueSvc: issueSvc, + taskRepo: tr, + store: store, + } +} + +// RequestAccessInput is the request-flow's input. orgHandle is the OC namespace / +// org handle; consumerProject + consumerComponent identify who is asking; +// orgServiceName is the catalog key the consumer's dependency references (== the +// provider OC component name). +type RequestAccessInput struct { + OrgHandle string + ConsumerProject string + ConsumerComponent string + OrgServiceName string +} + +// RequestAccess creates (or dedupes onto) the provider publish task + issue and +// records an AccessRequest. Steps: +// +// a. Resolve the provider catalog row by org-service name (404 if absent). +// Derive provider project + the provider's LOGICAL component name. +// b. Read the provider design to find the component's app path (best-effort — +// the issue body degrades gracefully without it). +// c. Idempotency: if an open request/task already targets this provider +// component, ride on the SAME provider task/issue — create only a new +// AccessRequest row for THIS consumer and return it. +// d. Otherwise create the GitHub issue on the provider repo + the org-publish +// ComponentTask on the provider project. +// e. Write the AccessRequest (status requested) and return it. +func (s *AccessService) RequestAccess(ctx context.Context, in RequestAccessInput) (*models.AccessRequest, error) { + if in.OrgHandle == "" || in.ConsumerProject == "" || in.ConsumerComponent == "" || in.OrgServiceName == "" { + return nil, fmt.Errorf("access: orgHandle, consumerProject, consumerComponent and orgServiceName are required") + } + + // (a) Resolve the provider row. The org-service name is the OC component + // name (catalog key). The provider project is the row's owner project; the + // provider's LOGICAL component name (what the design + repo know it as) is + // the OC component name with the project prefix trimmed. + row, ok, err := s.catalog.FindByComponent(ctx, in.OrgHandle, in.OrgServiceName) + if err != nil { + return nil, fmt.Errorf("access: resolve provider for %q: %w", in.OrgServiceName, err) + } + if !ok { + return nil, ErrOrgServiceNotFound + } + providerProject := row.Project + providerLogicalComponent := deriveLogicalComponent(row.Project, row.Component) + + // (b) App path from the provider design (best-effort). Used to point the + // agent at the right workload.yaml; the issue body degrades without it. + appPath := s.lookupAppPath(ctx, in.OrgHandle, providerProject, providerLogicalComponent) + + // (c) Idempotency: ride on an existing open provider task if one exists. + if existing, ferr := s.repo.FindOpenForTarget(ctx, in.OrgHandle, providerProject, providerLogicalComponent); ferr != nil { + return nil, ferr + } else if existing != nil { + ar := &models.AccessRequest{ + OrgID: in.OrgHandle, + ConsumerProjectID: in.ConsumerProject, + ConsumerComponentName: in.ConsumerComponent, + OrgServiceName: in.OrgServiceName, + ProviderProjectID: providerProject, + ProviderComponentName: providerLogicalComponent, + ProviderTaskID: existing.ProviderTaskID, + ProviderIssueNumber: existing.ProviderIssueNumber, + ProviderIssueURL: existing.ProviderIssueURL, + Status: models.AccessRequestStatusRequested, + } + if err := s.repo.Create(ctx, ar); err != nil { + return nil, err + } + slog.InfoContext(ctx, "access request deduped onto existing provider task", + "orgService", in.OrgServiceName, "providerTask", existing.ProviderTaskID, + "consumer", in.ConsumerProject+"/"+in.ConsumerComponent) + return ar, nil + } + + // (d) Create the provider GitHub issue + the org-publish ComponentTask. + body := gitrepo.BuildOrgPublishIssueBody(providerLogicalComponent, appPath, in.ConsumerProject) + title := fmt.Sprintf("Publish %s org-wide: add namespace visibility", providerLogicalComponent) + issue, err := s.issueSvc.CreateIssue(ctx, in.OrgHandle, providerProject, gitrepo.CreateIssueRequest{ + Title: title, + Body: body, + Labels: []string{"asdlc", "access-request"}, + }) + if err != nil { + return nil, fmt.Errorf("access: create provider issue on %s: %w", providerProject, err) + } + + task := &models.ComponentTask{ + ProjectID: providerProject, + OrgID: in.OrgHandle, + Type: models.TaskTypeOrgPublish, + ComponentName: providerLogicalComponent, + Title: title, + Rationale: fmt.Sprintf("Cross-project access request from %s: add namespace visibility so %s is consumable org-wide.", in.ConsumerProject, providerLogicalComponent), + Status: string(models.TaskStatusPending), + LifecycleStatus: string(models.TaskLifecycleGhIssueCreated), + ExecType: "WORKER", + IssueURL: issue.URL, + IssueNumber: issue.Number, + Labels: models.StringSlice{"asdlc", "access-request"}, + } + if err := s.taskRepo.Create(ctx, task); err != nil { + return nil, fmt.Errorf("access: create provider publish task: %w", err) + } + + // (e) Persist the tracking row. + ar := &models.AccessRequest{ + OrgID: in.OrgHandle, + ConsumerProjectID: in.ConsumerProject, + ConsumerComponentName: in.ConsumerComponent, + OrgServiceName: in.OrgServiceName, + ProviderProjectID: providerProject, + ProviderComponentName: providerLogicalComponent, + ProviderTaskID: task.ID, + ProviderIssueNumber: issue.Number, + ProviderIssueURL: issue.URL, + Status: models.AccessRequestStatusRequested, + } + if err := s.repo.Create(ctx, ar); err != nil { + return nil, err + } + + slog.InfoContext(ctx, "created cross-project access request", + "orgService", in.OrgServiceName, "providerProject", providerProject, + "providerComponent", providerLogicalComponent, "providerIssue", issue.URL, + "providerTask", task.ID, "consumer", in.ConsumerProject+"/"+in.ConsumerComponent) + return ar, nil +} + +// ListByConsumerProject returns every access request a consumer project's +// components have raised, newest first — the data the console reads to render +// per-dependency request status chips (P3.5 §6). +func (s *AccessService) ListByConsumerProject(ctx context.Context, orgHandle, projectName string) ([]models.AccessRequest, error) { + if orgHandle == "" || projectName == "" { + return nil, fmt.Errorf("access: orgHandle and projectName are required") + } + return s.repo.ListByConsumerProject(ctx, orgHandle, projectName) +} + +// RejectByProviderTask is the P3.5 reject close-out: the provider's +// `org-publish` task was rejected (its PR closed unmerged), so every consumer +// AccessRequest riding on that provider task is flipped to `rejected`. Already- +// terminal rows (granted/rejected) are skipped — only still-open requests move. +// Best-effort per row: a single UpdateStatus failure is logged and the loop +// continues; the method returns the first error so the caller can log it, but +// callers treat it as advisory (webhook processing must not fail on it). +func (s *AccessService) RejectByProviderTask(ctx context.Context, providerTaskID string) error { + if providerTaskID == "" { + return fmt.Errorf("access: providerTaskID is required") + } + rows, err := s.repo.ListByProviderTask(ctx, providerTaskID) + if err != nil { + return fmt.Errorf("access: list rejected provider task %q: %w", providerTaskID, err) + } + var firstErr error + flipped := 0 + for i := range rows { + if rows[i].Status == models.AccessRequestStatusGranted || rows[i].Status == models.AccessRequestStatusRejected { + continue + } + if uerr := s.repo.UpdateStatus(ctx, rows[i].ID, models.AccessRequestStatusRejected); uerr != nil { + slog.WarnContext(ctx, "access reject: UpdateStatus failed", + "accessRequest", rows[i].ID, "error", uerr) + if firstErr == nil { + firstErr = uerr + } + continue + } + flipped++ + } + slog.InfoContext(ctx, "access requests rejected on provider task rejection", + "providerTask", providerTaskID, "rejected", flipped) + return firstErr +} + +// lookupAppPath reads the provider project's design and returns the app path of +// the component matching the logical name (case-insensitive). Best-effort: on +// any error or a missing component it returns "" — the issue body degrades +// gracefully (it tells the agent to open "the component's workload.yaml"). +func (s *AccessService) lookupAppPath(ctx context.Context, orgHandle, providerProject, logicalComponent string) string { + if s.store == nil { + return "" + } + design, err := s.store.ReadDesign(ctx, orgHandle, providerProject) + if err != nil || design == nil { + if err != nil { + slog.WarnContext(ctx, "access: read provider design for app path failed", + "providerProject", providerProject, "error", err) + } + return "" + } + for i := range design.Components { + if strings.EqualFold(design.Components[i].Name, logicalComponent) { + return design.Components[i].AppPath + } + } + return "" +} + +// deriveLogicalComponent maps the provider OC component name (the catalog key, +// e.g. `hr-directory-employee-api`) to the LOGICAL component name the design + +// repo know it as (e.g. `employee-api`) by trimming the `-` prefix. The +// OC component name is conventionally `-`; when the prefix +// isn't present (no convention match), the OC name is returned unchanged. +func deriveLogicalComponent(project, ocComponent string) string { + prefix := project + "-" + if strings.HasPrefix(ocComponent, prefix) { + return strings.TrimPrefix(ocComponent, prefix) + } + return ocComponent +} + +// compile-time assertions: the concrete repos satisfy the narrowed interfaces. +var ( + _ taskRepo = (repositories.TaskRepository)(nil) + _ accessStore = (*Repository)(nil) +) diff --git a/asdlc-service/internal/feature/access/service_test.go b/asdlc-service/internal/feature/access/service_test.go new file mode 100644 index 00000000..2572adc9 --- /dev/null +++ b/asdlc-service/internal/feature/access/service_test.go @@ -0,0 +1,264 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package access + +import ( + "context" + "testing" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/internal/feature/connections" + "github.com/wso2/asdlc/asdlc-service/internal/feature/gitrepo" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// fakeRC embeds ResourceClient (nil) to satisfy the 12-method interface and +// overrides only ListWorkloadEndpoints — all the catalog needs. +type fakeRC struct { + openchoreo.ResourceClient + endpoints []openchoreo.WorkloadEndpointInfo +} + +func (f *fakeRC) ListWorkloadEndpoints(_ context.Context, _ string) ([]openchoreo.WorkloadEndpointInfo, error) { + return f.endpoints, nil +} + +// fakeIssueSvc records issues created; returns a fixed issue result. +type fakeIssueSvc struct { + created []gitrepo.CreateIssueRequest + nextNum int + createErr error +} + +func (f *fakeIssueSvc) CreateIssue(_ context.Context, _, _ string, req gitrepo.CreateIssueRequest) (*gitrepo.IssueResult, error) { + if f.createErr != nil { + return nil, f.createErr + } + f.created = append(f.created, req) + f.nextNum++ + return &gitrepo.IssueResult{Number: f.nextNum, URL: "https://github.com/acme/repo/issues/1", NodeID: "n"}, nil +} +func (f *fakeIssueSvc) ListIssues(context.Context, string, string, []string) ([]gitrepo.IssueInfo, error) { + return nil, nil +} +func (f *fakeIssueSvc) CloseIssue(context.Context, string, string, int, string) error { return nil } +func (f *fakeIssueSvc) CommentIssue(context.Context, string, string, int, string) error { return nil } +func (f *fakeIssueSvc) EditIssueBody(context.Context, string, string, int, string) error { + return nil +} + +// fakeTaskRepo records created tasks and assigns a stable ID. +type fakeTaskRepo struct { + created []*models.ComponentTask +} + +func (f *fakeTaskRepo) Create(_ context.Context, t *models.ComponentTask) error { + t.ID = "task-1" + f.created = append(f.created, t) + return nil +} + +// fakeRepo is an in-memory AccessRequest store (only the methods RequestAccess +// calls). It backs FindOpenForTarget / Create without a DB. +type fakeRepo struct { + rows []*models.AccessRequest +} + +func (r *fakeRepo) FindOpenForTarget(_ context.Context, orgID, providerProject, providerComponent string) (*models.AccessRequest, error) { + for _, ar := range r.rows { + if ar.OrgID == orgID && ar.ProviderProjectID == providerProject && + ar.ProviderComponentName == providerComponent && + (ar.Status == models.AccessRequestStatusRequested || ar.Status == models.AccessRequestStatusInProgress) { + return ar, nil + } + } + return nil, nil +} +func (r *fakeRepo) Create(_ context.Context, ar *models.AccessRequest) error { + if ar.ID == "" { + ar.ID = "ar-" + ar.ConsumerComponentName + } + r.rows = append(r.rows, ar) + return nil +} +func (r *fakeRepo) ListByProviderTask(_ context.Context, providerTaskID string) ([]models.AccessRequest, error) { + var out []models.AccessRequest + for _, ar := range r.rows { + if ar.ProviderTaskID == providerTaskID { + out = append(out, *ar) + } + } + return out, nil +} +func (r *fakeRepo) ListByConsumerProject(_ context.Context, orgID, projectID string) ([]models.AccessRequest, error) { + var out []models.AccessRequest + for _, ar := range r.rows { + if ar.OrgID == orgID && ar.ConsumerProjectID == projectID { + out = append(out, *ar) + } + } + return out, nil +} +func (r *fakeRepo) UpdateStatus(_ context.Context, id, status string) error { + for _, ar := range r.rows { + if ar.ID == id { + ar.Status = status + return nil + } + } + return ErrAccessRequestNotFound +} + +func sampleEndpoints() []openchoreo.WorkloadEndpointInfo { + return []openchoreo.WorkloadEndpointInfo{ + // OC component name = `-`; published project-only. + {Project: "hr-directory", Component: "hr-directory-employee-api", + Workload: "hr-directory-employee-api-wl", Name: "http", Type: "HTTP", + Port: 8080, Visibility: []string{"external"}}, + } +} + +func newSvc(repo accessStore, cat *connections.OrgEndpointCatalog, iss gitrepo.IssueService, tr taskRepo) *AccessService { + // store is nil — lookupAppPath tolerates it (best-effort). + return NewAccessService(repo, cat, iss, tr, nil) +} + +func TestRequestAccess_CreatesProviderTaskAndIssue(t *testing.T) { + cat := connections.NewOrgEndpointCatalog(&fakeRC{endpoints: sampleEndpoints()}) + iss := &fakeIssueSvc{} + tr := &fakeTaskRepo{} + repo := &fakeRepo{} + svc := newSvc(repo, cat, iss, tr) + + ar, err := svc.RequestAccess(context.Background(), RequestAccessInput{ + OrgHandle: "acme", + ConsumerProject: "store-front", + ConsumerComponent: "checkout", + OrgServiceName: "hr-directory-employee-api", + }) + if err != nil { + t.Fatalf("RequestAccess: %v", err) + } + if ar.ProviderProjectID != "hr-directory" { + t.Fatalf("providerProject = %q, want hr-directory", ar.ProviderProjectID) + } + if ar.ProviderComponentName != "employee-api" { + t.Fatalf("providerComponent = %q, want employee-api (logical)", ar.ProviderComponentName) + } + if ar.ProviderTaskID != "task-1" || ar.ProviderIssueNumber != 1 { + t.Fatalf("provider task/issue not linked: %+v", ar) + } + if len(tr.created) != 1 || tr.created[0].Type != models.TaskTypeOrgPublish { + t.Fatalf("want one org-publish task, got %+v", tr.created) + } + if len(iss.created) != 1 { + t.Fatalf("want one issue created, got %d", len(iss.created)) + } + hasLabel := false + for _, l := range iss.created[0].Labels { + if l == "access-request" { + hasLabel = true + } + } + if !hasLabel { + t.Fatalf("issue missing access-request label: %v", iss.created[0].Labels) + } +} + +func TestRequestAccess_IdempotentDedupe(t *testing.T) { + cat := connections.NewOrgEndpointCatalog(&fakeRC{endpoints: sampleEndpoints()}) + iss := &fakeIssueSvc{} + tr := &fakeTaskRepo{} + repo := &fakeRepo{} + svc := newSvc(repo, cat, iss, tr) + + in1 := RequestAccessInput{OrgHandle: "acme", ConsumerProject: "store-front", ConsumerComponent: "checkout", OrgServiceName: "hr-directory-employee-api"} + if _, err := svc.RequestAccess(context.Background(), in1); err != nil { + t.Fatalf("first request: %v", err) + } + // Second consumer, same provider target → no new task/issue, new row riding + // on the same provider task. + in2 := RequestAccessInput{OrgHandle: "acme", ConsumerProject: "other", ConsumerComponent: "viewer", OrgServiceName: "hr-directory-employee-api"} + ar2, err := svc.RequestAccess(context.Background(), in2) + if err != nil { + t.Fatalf("second request: %v", err) + } + if len(tr.created) != 1 { + t.Fatalf("dedupe failed: want 1 provider task, got %d", len(tr.created)) + } + if len(iss.created) != 1 { + t.Fatalf("dedupe failed: want 1 issue, got %d", len(iss.created)) + } + if ar2.ProviderTaskID != "task-1" { + t.Fatalf("second row not linked to shared task: %q", ar2.ProviderTaskID) + } + if len(repo.rows) != 2 { + t.Fatalf("want 2 access-request rows, got %d", len(repo.rows)) + } +} + +func TestRejectByProviderTask_FlipsOpenRowsOnly(t *testing.T) { + repo := &fakeRepo{rows: []*models.AccessRequest{ + {ID: "a", ProviderTaskID: "task-1", Status: models.AccessRequestStatusRequested}, + {ID: "b", ProviderTaskID: "task-1", Status: models.AccessRequestStatusInProgress}, + {ID: "c", ProviderTaskID: "task-1", Status: models.AccessRequestStatusGranted}, + {ID: "d", ProviderTaskID: "other", Status: models.AccessRequestStatusRequested}, + }} + svc := newSvc(repo, connections.NewOrgEndpointCatalog(&fakeRC{}), &fakeIssueSvc{}, &fakeTaskRepo{}) + + if err := svc.RejectByProviderTask(context.Background(), "task-1"); err != nil { + t.Fatalf("RejectByProviderTask: %v", err) + } + want := map[string]string{ + "a": models.AccessRequestStatusRejected, // open → rejected + "b": models.AccessRequestStatusRejected, // open → rejected + "c": models.AccessRequestStatusGranted, // already granted, untouched + "d": models.AccessRequestStatusRequested, // different task, untouched + } + for _, ar := range repo.rows { + if ar.Status != want[ar.ID] { + t.Fatalf("row %s status = %q, want %q", ar.ID, ar.Status, want[ar.ID]) + } + } +} + +func TestListByConsumerProject(t *testing.T) { + repo := &fakeRepo{rows: []*models.AccessRequest{ + {ID: "a", OrgID: "acme", ConsumerProjectID: "store-front", Status: models.AccessRequestStatusRequested}, + {ID: "b", OrgID: "acme", ConsumerProjectID: "other", Status: models.AccessRequestStatusGranted}, + }} + svc := newSvc(repo, connections.NewOrgEndpointCatalog(&fakeRC{}), &fakeIssueSvc{}, &fakeTaskRepo{}) + + rows, err := svc.ListByConsumerProject(context.Background(), "acme", "store-front") + if err != nil { + t.Fatalf("ListByConsumerProject: %v", err) + } + if len(rows) != 1 || rows[0].ID != "a" { + t.Fatalf("want only row a for store-front, got %+v", rows) + } +} + +func TestRequestAccess_NotFound(t *testing.T) { + cat := connections.NewOrgEndpointCatalog(&fakeRC{endpoints: sampleEndpoints()}) + svc := newSvc(&fakeRepo{}, cat, &fakeIssueSvc{}, &fakeTaskRepo{}) + _, err := svc.RequestAccess(context.Background(), RequestAccessInput{ + OrgHandle: "acme", ConsumerProject: "p", ConsumerComponent: "c", OrgServiceName: "no-such-svc", + }) + if err != ErrOrgServiceNotFound { + t.Fatalf("want ErrOrgServiceNotFound, got %v", err) + } +} diff --git a/asdlc-service/internal/feature/artifacts/artifact_service.go b/asdlc-service/internal/feature/artifacts/artifact_service.go index 7daebb26..cc08d429 100644 --- a/asdlc-service/internal/feature/artifacts/artifact_service.go +++ b/asdlc-service/internal/feature/artifacts/artifact_service.go @@ -167,6 +167,14 @@ type ArtifactService interface { DeleteDesignFile(ctx context.Context, orgID, projectID, sub string) error DeleteDesignDirectory(ctx context.Context, orgID, projectID, sub string) error + // CommitDesignFile lands a single `specs/design/` file on remote main + // directly (no version tag), using the org's service-identity credential. + // `sub` is relative to `specs/design/`. Returns the new commit SHA, or + // ("", nil) when the content already matches main. Used by internal + // durability touch-ups (e.g. the P3.5 grant cascade) that must NOT mint a + // new design version. + CommitDesignFile(ctx context.Context, orgID, projectID, sub, content, message string) (string, error) + // Save / Discard. SaveRequirements(ctx context.Context, orgID, projectID string, req SaveRequest) (*RequirementsSaveResult, error) SaveDesign(ctx context.Context, orgID, projectID string, req SaveRequest) (*DesignSaveResult, error) diff --git a/asdlc-service/internal/feature/artifacts/artifact_store.go b/asdlc-service/internal/feature/artifacts/artifact_store.go index 3f1ba46d..d79264b9 100644 --- a/asdlc-service/internal/feature/artifacts/artifact_store.go +++ b/asdlc-service/internal/feature/artifacts/artifact_store.go @@ -35,6 +35,11 @@ import ( // package stays free of an OC-client dependency. type OrgServiceResolver interface { IsNamespaceVisible(ctx context.Context, orgHandle, name string) (bool, error) + // ExistsAnyVisibility reports whether a component named `name` publishes ANY + // endpoint in the org catalog regardless of visibility — used to refine an + // org-service dep into `blocked`/`access-required` (exists, project-only) vs + // `unresolved`/`not-found` (no such component). P3.5. + ExistsAnyVisibility(ctx context.Context, orgHandle, name string) (bool, error) } // ArtifactStore wraps the in-process artifact service to add value beyond @@ -238,9 +243,10 @@ func (s *ArtifactStore) ReadDesign(ctx context.Context, orgID, projectID string) return design, nil } -// resolveOrgServices marks each `org-service` dependency `resolved` when its -// target is published namespace-visible in the org endpoint catalog, and -// `unresolved` otherwise (the provider hasn't published it cross-project yet). +// resolveOrgServices marks each `org-service` dependency with a 4-state +// status at read time: `resolved` (namespace-visible), `blocked` +// (exists project-only — consumer must request access), `unresolved`/not-found +// (absent from catalog), or leaves the status unchanged on catalog errors. // Falls back to the static external-API catalog when no dynamic resolver is // wired (tests / standalone). Best-effort: catalog errors leave status // untouched and never fail the read. orgID is the OC namespace (locally). The @@ -262,8 +268,25 @@ func (s *ArtifactStore) resolveOrgServices(ctx context.Context, orgID string, d } if visible { dep.Status = "resolved" - } else { - dep.Status = "unresolved" + dep.Reason = "" + continue + } + // Not namespace-visible: refine into `blocked` (project-only — + // requestable via access request) vs `unresolved` (absent — not + // in catalog at all). Best-effort: an ExistsAnyVisibility error + // leaves the dep unresolved with no reason rather than failing + // the read (P3.5). + dep.Status = "unresolved" + dep.Reason = "" + if exists, err := s.orgServices.ExistsAnyVisibility(ctx, orgID, dep.Name); err == nil { + if exists { + // Provider exists but publishes only project-only — + // consumer must request access (4-state: blocked). + dep.Status = "blocked" + dep.Reason = "access-required" + } else { + dep.Reason = "not-found" + } } continue } @@ -295,6 +318,166 @@ func (s *ArtifactStore) WriteDesign(ctx context.Context, orgID, projectID string return nil } +// WriteComponentDesign writes ONLY the given component's `design.md` (frontmatter +// + body) to the LOCAL CLONE's working tree, leaving every sibling file +// untouched. It renders through the same SplitDesign machinery used by +// WriteDesign — wrapping the single component in a throwaway DesignFile — so the +// frontmatter round-trips identically to a full write (no risk of a hand-rolled +// YAML drifting from the canonical encoder). +// +// IMPORTANT: this is a working-tree write only — it does NOT commit or push. A +// subsequent SaveDesign is required to persist the change. For internal +// durability touch-ups that must land on remote main WITHOUT minting a new +// design version (e.g. the P3.5 grant cascade), use SetComponentOrgPublished, +// which commits via the Git Data API. +func (s *ArtifactStore) WriteComponentDesign(ctx context.Context, orgID, projectID string, comp models.DesignComponent) error { + if comp.Name == "" { + return fmt.Errorf("write component design: empty component name") + } + files, err := SplitDesign(&DesignFile{Components: []models.DesignComponent{comp}}) + if err != nil { + return fmt.Errorf("render component %q design: %w", comp.Name, err) + } + subPath := componentDirPrefix + comp.Name + "/design.md" + content, ok := files[subPath] + if !ok { + return fmt.Errorf("render component %q design: %q missing from split", comp.Name, subPath) + } + if _, err := s.WriteDesignFile(ctx, orgID, projectID, subPath, content); err != nil { + return fmt.Errorf("write %s: %w", subPath, err) + } + return nil +} + +// SetComponentOrgPublished durably persists `exposesAPI.orgPublished:true` on +// the provider component and COMMITS that one `design.md` to remote main +// (no new design version tag). This is the P3.5 grant-cascade durability write: +// when a provider's `org-publish` task deploys, the flag must survive a future +// re-implementation so namespace visibility isn't silently dropped. +// +// `componentName` may be the design's logical component name OR the OC component +// name `-` — both forms match. Idempotent: a no-op (no commit) +// when the component already has the flag set, when no design exists, or when no +// matching component is found. Unlike WriteComponentDesign (working-tree only), +// this reaches a real GitHub commit so the change is never lost. +func (s *ArtifactStore) SetComponentOrgPublished(ctx context.Context, orgID, projectID, componentName string) error { + design, err := s.ReadDesign(ctx, orgID, projectID) + if err != nil { + return fmt.Errorf("read design: %w", err) + } + if design == nil { + return nil // no design yet — nothing to persist. + } + for i := range design.Components { + comp := design.Components[i] + if !designComponentMatches(comp.Name, projectID, componentName) { + continue + } + if comp.ExposesAPI == nil { + comp.ExposesAPI = &models.ExposesAPI{} + } + if comp.ExposesAPI.OrgPublished { + return nil // idempotent — already durable, no commit. + } + comp.ExposesAPI.OrgPublished = true + + // Render ONLY this component's design.md through the canonical encoder + // (same machinery as WriteComponentDesign) so the frontmatter round-trips + // identically, then commit that single file to remote main without tagging. + files, ferr := SplitDesign(&DesignFile{Components: []models.DesignComponent{comp}}) + if ferr != nil { + return fmt.Errorf("render component %q design: %w", comp.Name, ferr) + } + subPath := componentDirPrefix + comp.Name + "/design.md" + content, ok := files[subPath] + if !ok { + return fmt.Errorf("render component %q design: %q missing from split", comp.Name, subPath) + } + msg := fmt.Sprintf("chore(marketplace): mark %s org-published (namespace visibility)", comp.Name) + if _, cerr := s.artifactSvc.CommitDesignFile(ctx, orgID, projectID, subPath, content, msg); cerr != nil { + return fmt.Errorf("commit %s: %w", subPath, cerr) + } + return nil + } + return nil // no matching component — nothing to persist. +} + +// SetDependencySpecPath records specPath on the named dependency within the +// named component by writing that component's design.md to the working-tree +// draft (no commit, no version tag). This is the A4 spec-collection draft +// write: after StoreConsumedSpec writes the spec blob to the draft, this +// records the specPath on the dependency so the needsSpec gate is cleared on +// next read. Both changes are committed atomically when SaveDesign is called. +// +// Idempotent: a no-op (no write) when the component/dependency is not found +// or when specPath already matches. Returns nil in all no-op cases. +func (s *ArtifactStore) SetDependencySpecPath(ctx context.Context, orgID, projectID, component, depName, specPath string) error { + design, err := s.ReadDesign(ctx, orgID, projectID) + if err != nil { + return fmt.Errorf("read design: %w", err) + } + if design == nil { + return nil // no design yet — nothing to persist. + } + for i := range design.Components { + comp := design.Components[i] + if comp.Name != component { + continue + } + // Found the component — now find the dependency. + found := false + for j := range comp.Dependencies { + if comp.Dependencies[j].Name != depName { + continue + } + if comp.Dependencies[j].SpecPath == specPath && comp.Dependencies[j].SpecUrl == "" { + return nil // idempotent — already recorded + transient hint cleared. + } + comp.Dependencies[j].SpecPath = specPath + // Clear the transient architect hint now that specPath is recorded; also + // clear the computed status so the next read re-derives it from specPath + // (needsSpec+specPath set → resolved, not unresolved). + comp.Dependencies[j].SpecUrl = "" + comp.Dependencies[j].Status = "" + comp.Dependencies[j].Reason = "" + found = true + break + } + if !found { + return nil // dep not found — nothing to persist. + } + // Render ONLY this component's design.md through the canonical encoder, + // then write it to the working-tree draft via WriteDesignFile (not + // CommitDesignFile). The draft save (SaveDesign) will commit both the + // spec blob and this design.md atomically with the v- tag. + files, ferr := SplitDesign(&DesignFile{Components: []models.DesignComponent{comp}}) + if ferr != nil { + return fmt.Errorf("render component %q design: %w", comp.Name, ferr) + } + subPath := componentDirPrefix + comp.Name + "/design.md" + content, ok := files[subPath] + if !ok { + return fmt.Errorf("render component %q design: %q missing from split", comp.Name, subPath) + } + if _, werr := s.WriteDesignFile(ctx, orgID, projectID, subPath, content); werr != nil { + return fmt.Errorf("write %s: %w", subPath, werr) + } + return nil + } + return nil // component not found — nothing to persist. +} + +// designComponentMatches reports whether a design component named `logical` is +// the provider identified by `target`, which may be the bare logical name or +// the OC component name `-`. Mirrors the cascade's +// componentMatches so the durability write lands in either form. +func designComponentMatches(logical, project, target string) bool { + if strings.EqualFold(logical, target) { + return true + } + return strings.EqualFold(project+"-"+logical, target) +} + // ---- Helpers ------------------------------------------------------------ // IsNotFound is sugar for callers that want to distinguish "no artifact yet" @@ -355,6 +538,11 @@ type dependencyConfig struct { Kind string `yaml:"kind"` Name string `yaml:"name"` Description string `yaml:"description,omitempty"` + NeedsSpec bool `yaml:"needsSpec,omitempty"` + SpecPath string `yaml:"specPath,omitempty"` + // SpecUrl is a transient architect-supplied hint (published OpenAPI URL). + // Cleared by the BFF after a successful auto-fetch; never stored long-term. + SpecUrl string `yaml:"specUrl,omitempty"` Status string `yaml:"status,omitempty"` Config []configKeyConfig `yaml:"config,omitempty"` ResourceType string `yaml:"resourceType,omitempty"` @@ -439,16 +627,28 @@ func assembleDependencies(cfm componentFrontmatter) []models.Dependency { if d.Name == "" || d.Kind == "" { continue } - out = append(out, models.Dependency{ + dep := models.Dependency{ Kind: d.Kind, Name: d.Name, Description: d.Description, + NeedsSpec: d.NeedsSpec, + SpecPath: d.SpecPath, + SpecUrl: d.SpecUrl, Status: d.Status, Config: toModelConfigKeys(d.Config), ResourceType: d.ResourceType, Parameters: d.Parameters, Candidates: toModelCandidates(d.Candidates), - }) + } + // External deps that declare needsSpec but have no specPath yet are + // unresolved at read time — the user must supply the spec before the + // design can be saved. Do not override a status already set by a + // prior resolution pass for a different reason. + if dep.Kind == models.DependencyKindExternal && dep.NeedsSpec && strings.TrimSpace(dep.SpecPath) == "" && dep.Status == "" { + dep.Status = "unresolved" + dep.Reason = "needs-spec" + } + out = append(out, dep) } return out } @@ -508,6 +708,9 @@ func toCfgDeps(in []models.Dependency) []dependencyConfig { Kind: d.Kind, Name: d.Name, Description: d.Description, + NeedsSpec: d.NeedsSpec, + SpecPath: d.SpecPath, + SpecUrl: d.SpecUrl, Status: d.Status, Config: toCfgConfigKeys(d.Config), ResourceType: d.ResourceType, diff --git a/asdlc-service/internal/feature/artifacts/artifact_store_api_test.go b/asdlc-service/internal/feature/artifacts/artifact_store_api_test.go index 88422c1e..62a91060 100644 --- a/asdlc-service/internal/feature/artifacts/artifact_store_api_test.go +++ b/asdlc-service/internal/feature/artifacts/artifact_store_api_test.go @@ -114,6 +114,41 @@ func TestComponentFrontmatterAPIRoundTrip(t *testing.T) { } } +// TestWriteComponentDesignRendersOrgPublished verifies the rendering path +// WriteComponentDesign relies on: a single-component DesignFile splits to a +// `components//design.md` whose frontmatter carries `orgPublished: true`, +// and that file round-trips back to a component with OrgPublished set. (The +// store's PutFile is exercised via integration; this guards the encoding.) +func TestWriteComponentDesignRendersOrgPublished(t *testing.T) { + comp := models.DesignComponent{ + Name: "employee-api", + ComponentType: "service", + Language: "Go", + AppPath: "employee-api", + ComponentAgentInstructions: "serve employees", + ExposesAPI: &models.ExposesAPI{OrgPublished: true}, + } + files, err := SplitDesign(&DesignFile{Components: []models.DesignComponent{comp}}) + if err != nil { + t.Fatalf("SplitDesign: %v", err) + } + subPath := componentDirPrefix + comp.Name + "/design.md" + content, ok := files[subPath] + if !ok { + t.Fatalf("expected %s in files; got %v", subPath, keysOf(files)) + } + if !strings.Contains(content, "orgPublished: true") { + t.Fatalf("rendered design.md missing orgPublished:\n%s", content) + } + out, err := AssembleDesign(files) + if err != nil { + t.Fatalf("AssembleDesign: %v", err) + } + if len(out.Components) != 1 || out.Components[0].ExposesAPI == nil || !out.Components[0].ExposesAPI.OrgPublished { + t.Fatalf("orgPublished did not round-trip: %+v", out.Components) + } +} + func keysOf(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/asdlc-service/internal/feature/artifacts/commit_design_file.go b/asdlc-service/internal/feature/artifacts/commit_design_file.go new file mode 100644 index 00000000..55612870 --- /dev/null +++ b/asdlc-service/internal/feature/artifacts/commit_design_file.go @@ -0,0 +1,150 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package artifacts + +// A single-file commit primitive for `specs/design/` that lands a change on +// remote main directly — NO version tag. The tagged `SaveDesign` flow always +// mints the next `v-` revision; this path is for internal durability +// touch-ups (e.g. the P3.5 grant cascade persisting +// `exposesAPI.orgPublished:true`) where minting a new design version would be +// wrong. It reuses the same Git Data API primitives + service-identity +// credential as saveDesignViaAPI, but commits exactly one blob and stops short +// of the tag step. +// +// See docs/design/artifact-store-v2.md §8 for the underlying save-flow shape. + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path" + + "github.com/wso2/asdlc/asdlc-service/internal/feature/gitrepo" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// CommitDesignFile writes a single file under `specs/design/` to the working +// tree and commits it directly to remote main via the GitHub Git Data API, +// without creating a version tag. `subPath` is relative to `specs/design/` +// (forward slashes; nested components allowed); `content` is the new file body; +// `message` is the commit message. Returns the new commit SHA, or +// ("", nil) when the file already matches main (no-op, no commit). +// +// Credentials are the org's service-identity (the same +// gitOps.Resolver().Resolve(orgID) + ResolveSaveIdentities path SaveDesign and +// the agent dispatch use) — NO user JWT is required, so this is safe to call +// from the deploy cascade. +func (s *artifactService) CommitDesignFile(ctx context.Context, orgID, projectID, subPath, content, message string) (string, error) { + repoPath := path.Join(DesignDir, subPath) // forward-slash repo path + if err := validateRelPath(repoPath); err != nil { + return "", err + } + + mu := s.gitOps.RepoLock(projectID) + mu.Lock() + defer mu.Unlock() + + repoRecord, err := s.requireReadyRepo(ctx, orgID, projectID) + if err != nil { + return "", err + } + if err := s.gitOps.EnsureCloneReady(ctx, repoRecord); err != nil { + return "", fmt.Errorf("ensure clone: %w", err) + } + + owner, repo := models.OwnerRepoFromURL(repoRecord.RepoURL) + if owner == "" || repo == "" { + return "", fmt.Errorf("cannot derive owner/repo from RepoURL %q", repoRecord.RepoURL) + } + cred, err := s.gitOps.Resolver().Resolve(ctx, repoRecord.OrgID) + if err != nil { + return "", fmt.Errorf("resolve credential: %w", err) + } + + // Commit the single blob over current main under CAS retry. base_tree = + // current main tree carries every sibling file forward unchanged. + author, committer := s.gitOps.ResolveSaveIdentities(cred) + bucketKey := repoRecord.OrgID + ":" + repoRecord.ProjectID + + var newCommitSHA string + err = retryOnCASConflict(ctx, bucketKey, func() error { + mainSHA, ferr := s.gitOps.GitHubClient().GetRef(ctx, owner, repo, cred, "heads/"+repoRecord.DefaultBranch) + if ferr != nil { + return fmt.Errorf("get ref main: %w", ferr) + } + mainCommit, ferr := s.gitOps.GitHubClient().GetCommit(ctx, owner, repo, cred, mainSHA) + if ferr != nil { + return fmt.Errorf("get commit %s: %w", mainSHA, ferr) + } + + blobSHA, berr := s.gitOps.GitHubClient().CreateBlob(ctx, owner, repo, cred, []byte(content)) + if berr != nil { + return fmt.Errorf("create blob %s: %w", subPath, berr) + } + treeSHA, terr := s.gitOps.GitHubClient().CreateTree(ctx, owner, repo, cred, mainCommit.TreeSHA, []gitrepo.TreeEntry{{ + Path: repoPath, + Mode: "100644", + Type: "blob", + SHA: blobSHA, + }}) + if terr != nil { + return fmt.Errorf("create tree: %w", terr) + } + // If the new tree equals main's tree the content is unchanged — skip the + // commit so we never push an empty diff. + if treeSHA == mainCommit.TreeSHA { + newCommitSHA = "" + return nil + } + commitSHA, cerr := s.gitOps.GitHubClient().CreateCommit(ctx, owner, repo, cred, gitrepo.CreateCommitRequest{ + Message: message, + TreeSHA: treeSHA, + Parents: []string{mainSHA}, + Author: author, + Committer: committer, + }) + if cerr != nil { + return fmt.Errorf("create commit: %w", cerr) + } + if uerr := s.gitOps.GitHubClient().UpdateRef(ctx, owner, repo, cred, "heads/"+repoRecord.DefaultBranch, commitSHA, false); uerr != nil { + return uerr + } + newCommitSHA = commitSHA + return nil + }) + if err != nil { + if errors.Is(err, ErrConflictBudgetExhausted) { + return "", fmt.Errorf("commit design file: %w", err) + } + return "", fmt.Errorf("commit + update ref: %w", err) + } + + if newCommitSHA == "" { + return "", nil // unchanged — nothing committed. + } + + // Best-effort sync the local clone so subsequent reads see the change. + if perr := s.gitOps.BestEffortPullDefaultBranch(ctx, repoRecord); perr != nil { + slog.WarnContext(ctx, "best-effort pull after design-file commit failed", + "project", repoRecord.ProjectID, "error", perr) + } + + slog.InfoContext(ctx, "design file committed via api (untagged)", + "project", repoRecord.ProjectID, "path", repoPath, "commit", newCommitSHA) + return newCommitSHA, nil +} diff --git a/asdlc-service/internal/feature/artifacts/dependency_roundtrip_test.go b/asdlc-service/internal/feature/artifacts/dependency_roundtrip_test.go new file mode 100644 index 00000000..671013c7 --- /dev/null +++ b/asdlc-service/internal/feature/artifacts/dependency_roundtrip_test.go @@ -0,0 +1,122 @@ +package artifacts + +import ( + "strings" + "testing" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +func TestExternalNeedsSpecUnresolvedAtRead(t *testing.T) { + files, err := SplitDesign(&DesignFile{ + Overview: "x", + Components: []models.DesignComponent{{ + Name: "weather-api", ComponentType: "service", Language: "go", + Dependencies: []models.Dependency{{ + Kind: models.DependencyKindExternal, Name: "openweather", + Description: "weather", NeedsSpec: true, // no SpecPath + Config: []models.ConfigKey{{Key: "OPENWEATHER_API_KEY", Secret: true}}, + }}, + }}, + }) + if err != nil { + t.Fatal(err) + } + out, err := AssembleDesign(files) + if err != nil { + t.Fatal(err) + } + if got := out.Components[0].Dependencies[0].Status; got != "unresolved" { + t.Fatalf("want unresolved, got %q", got) + } +} + +func TestExternalNeedsSpecWithSpecPathResolved(t *testing.T) { + files, err := SplitDesign(&DesignFile{ + Overview: "x", + Components: []models.DesignComponent{{ + Name: "weather-api", ComponentType: "service", Language: "go", + Dependencies: []models.Dependency{{ + Kind: models.DependencyKindExternal, Name: "openweather", + Description: "weather", NeedsSpec: true, + SpecPath: "dependencies/openweather.openapi.yaml", + Config: []models.ConfigKey{{Key: "OPENWEATHER_API_KEY", Secret: true}}, + }}, + }}, + }) + if err != nil { + t.Fatal(err) + } + out, err := AssembleDesign(files) + if err != nil { + t.Fatal(err) + } + if got := out.Components[0].Dependencies[0].Status; got == "unresolved" { + t.Fatalf("want not-unresolved (specPath set), got %q", got) + } +} + +// TestExternalNeedsSpecReasonNeedsSpec asserts the 4-state model (task A2b): +// an external dep with needsSpec=true and no specPath must produce +// status="unresolved" AND reason="needs-spec". +func TestExternalNeedsSpecReasonNeedsSpec(t *testing.T) { + files, err := SplitDesign(&DesignFile{ + Overview: "x", + Components: []models.DesignComponent{{ + Name: "weather-api", ComponentType: "service", Language: "go", + Dependencies: []models.Dependency{{ + Kind: models.DependencyKindExternal, Name: "openweather", + Description: "weather", NeedsSpec: true, // no SpecPath + }}, + }}, + }) + if err != nil { + t.Fatal(err) + } + out, err := AssembleDesign(files) + if err != nil { + t.Fatal(err) + } + dep := out.Components[0].Dependencies[0] + if dep.Status != "unresolved" { + t.Errorf("needsSpec/no-specPath: status = %q, want %q", dep.Status, "unresolved") + } + if dep.Reason != "needs-spec" { + t.Errorf("needsSpec/no-specPath: reason = %q, want %q", dep.Reason, "needs-spec") + } +} + +func TestDependencyNeedsSpecRoundTrip(t *testing.T) { + in := &DesignFile{ + Overview: "x", + Components: []models.DesignComponent{{ + Name: "weather-api", + ComponentType: "service", + Language: "go", + Dependencies: []models.Dependency{{ + Kind: models.DependencyKindExternal, + Name: "openweather", + Description: "Current-weather REST API.", + NeedsSpec: true, + SpecPath: "dependencies/openweather.openapi.yaml", + Config: []models.ConfigKey{{Key: "OPENWEATHER_API_KEY", Secret: true}}, + }}, + }}, + } + files, err := SplitDesign(in) + if err != nil { + t.Fatalf("split: %v", err) + } + md := files["components/weather-api/design.md"] + if !strings.Contains(md, "needsSpec: true") || !strings.Contains(md, "specPath: dependencies/openweather.openapi.yaml") { + t.Fatalf("frontmatter missing needsSpec/specPath:\n%s", md) + } + out, err := AssembleDesign(files) + if err != nil { + t.Fatalf("assemble: %v", err) + } + dep := out.Components[0].Dependencies[0] + if !dep.NeedsSpec || dep.SpecPath != "dependencies/openweather.openapi.yaml" { + t.Fatalf("round-trip lost fields: %+v", dep) + } +} diff --git a/asdlc-service/internal/feature/artifacts/resolve_org_services_test.go b/asdlc-service/internal/feature/artifacts/resolve_org_services_test.go new file mode 100644 index 00000000..7effffbb --- /dev/null +++ b/asdlc-service/internal/feature/artifacts/resolve_org_services_test.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package artifacts + +import ( + "context" + "testing" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +// fakeOrgServiceResolver is a static OrgServiceResolver: `visible` lists the +// org-service names published namespace-visible, `exists` lists every name that +// has any endpoint in the catalog (regardless of visibility). A name in +// `visible` is implicitly in `exists`. +type fakeOrgServiceResolver struct { + visible map[string]bool + exists map[string]bool +} + +func (f fakeOrgServiceResolver) IsNamespaceVisible(_ context.Context, _, name string) (bool, error) { + return f.visible[name], nil +} + +func (f fakeOrgServiceResolver) ExistsAnyVisibility(_ context.Context, _, name string) (bool, error) { + return f.exists[name] || f.visible[name], nil +} + +// TestResolveOrgServices_BlockedAccessRequired asserts the 4-state model +// (task A2b): a project-only org-service (exists but NOT namespace-visible) +// must produce status="blocked" / reason="access-required". +func TestResolveOrgServices_BlockedAccessRequired(t *testing.T) { + store := &ArtifactStore{} + store.SetOrgServiceResolver(fakeOrgServiceResolver{ + visible: map[string]bool{}, + exists: map[string]bool{"payroll-internal": true}, + }) + + d := &DesignFile{Components: []models.DesignComponent{{ + Name: "consumer", + Dependencies: []models.Dependency{ + {Kind: models.DependencyKindOrgService, Name: "payroll-internal"}, + }, + }}} + + store.resolveOrgServices(context.Background(), "org", d) + + dep := d.Components[0].Dependencies[0] + if dep.Status != "blocked" { + t.Errorf("project-only org-service: status = %q, want %q", dep.Status, "blocked") + } + if dep.Reason != "access-required" { + t.Errorf("project-only org-service: reason = %q, want %q", dep.Reason, "access-required") + } +} + +// TestResolveOrgServices_AbsentNotFound asserts the 4-state model (task A2b): +// an org-service absent from the catalog must produce status="unresolved" / +// reason="not-found". +func TestResolveOrgServices_AbsentNotFound(t *testing.T) { + store := &ArtifactStore{} + store.SetOrgServiceResolver(fakeOrgServiceResolver{ + visible: map[string]bool{}, + exists: map[string]bool{}, + }) + + d := &DesignFile{Components: []models.DesignComponent{{ + Name: "consumer", + Dependencies: []models.Dependency{ + {Kind: models.DependencyKindOrgService, Name: "ghost-svc"}, + }, + }}} + + store.resolveOrgServices(context.Background(), "org", d) + + dep := d.Components[0].Dependencies[0] + if dep.Status != "unresolved" { + t.Errorf("absent org-service: status = %q, want %q", dep.Status, "unresolved") + } + if dep.Reason != "not-found" { + t.Errorf("absent org-service: reason = %q, want %q", dep.Reason, "not-found") + } +} + +// TestResolveOrgServices_ReasonSplit asserts the P3.5 reason refinement on top +// of the resolved/unresolved/blocked status (4-state model, task A2b): +// namespace-visible → resolved/""; +// exists-but-project-only → blocked/"access-required"; absent → unresolved/"not-found". +func TestResolveOrgServices_ReasonSplit(t *testing.T) { + store := &ArtifactStore{} + store.SetOrgServiceResolver(fakeOrgServiceResolver{ + visible: map[string]bool{"employee-api": true}, + exists: map[string]bool{"payroll-internal": true}, + }) + + d := &DesignFile{Components: []models.DesignComponent{{ + Name: "web", + Dependencies: []models.Dependency{ + {Kind: models.DependencyKindOrgService, Name: "employee-api"}, // namespace-visible + {Kind: models.DependencyKindOrgService, Name: "payroll-internal"}, // exists, project-only + {Kind: models.DependencyKindOrgService, Name: "ghost"}, // not in catalog + }, + }}} + + store.resolveOrgServices(context.Background(), "org", d) + + deps := d.Components[0].Dependencies + cases := []struct { + name string + wantStatus string + wantReason string + }{ + {"employee-api", "resolved", ""}, + {"payroll-internal", "blocked", "access-required"}, + {"ghost", "unresolved", "not-found"}, + } + for i, c := range cases { + if deps[i].Name != c.name { + t.Fatalf("dep[%d]: name = %q, want %q", i, deps[i].Name, c.name) + } + if deps[i].Status != c.wantStatus { + t.Errorf("%s: status = %q, want %q", c.name, deps[i].Status, c.wantStatus) + } + if deps[i].Reason != c.wantReason { + t.Errorf("%s: reason = %q, want %q", c.name, deps[i].Reason, c.wantReason) + } + } +} diff --git a/asdlc-service/internal/feature/artifacts/set_org_published_test.go b/asdlc-service/internal/feature/artifacts/set_org_published_test.go new file mode 100644 index 00000000..f00bfa54 --- /dev/null +++ b/asdlc-service/internal/feature/artifacts/set_org_published_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package artifacts + +import ( + "context" + "strings" + "testing" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +// commitRecord captures one CommitDesignFile call. +type commitRecord struct { + sub string + content string + message string +} + +// putRecord captures one PutFile call. +type putRecord struct { + relPath string + content string +} + +// fakeArtifactSvc embeds ArtifactService so it satisfies the interface while we +// implement only the methods the tests exercise: +// - ListDesignFiles (the read) +// - CommitDesignFile (the durable commit — used by SetComponentOrgPublished only) +// - PutFile (the working-tree draft write — used by WriteDesignFile, which +// StoreConsumedSpec and SetDependencySpecPath now call instead of CommitDesignFile) +// +// Every other method panics if reached. +type fakeArtifactSvc struct { + ArtifactService + files map[string]string + commits []commitRecord + puts []putRecord +} + +func (f *fakeArtifactSvc) ListDesignFiles(_ context.Context, _, _ string) (map[string]string, error) { + // Return a copy so in-place mutations to the map don't affect the fake's + // stored state between ReadDesign and WriteDesignFile calls. + out := make(map[string]string, len(f.files)) + for k, v := range f.files { + out[k] = v + } + return out, nil +} + +func (f *fakeArtifactSvc) CommitDesignFile(_ context.Context, _, _, sub, content, message string) (string, error) { + f.commits = append(f.commits, commitRecord{sub: sub, content: content, message: message}) + return "deadbeef", nil +} + +func (f *fakeArtifactSvc) PutFile(_ context.Context, _, _, relPath, content, _ string) (*PutResult, error) { + f.puts = append(f.puts, putRecord{relPath: relPath, content: content}) + // Also update the in-memory files map so that a subsequent ListDesignFiles + // (e.g. inside SetDependencySpecPath's ReadDesign) sees the written content. + // Keys in files are relative to specs/design/; relPath includes the full + // DesignDir prefix ("specs/design/..."), so strip the prefix. + const prefix = DesignDir + "/" + if key := strings.TrimPrefix(relPath, prefix); key != relPath { + f.files[key] = content + } + return &PutResult{SHA: "cafebabe"}, nil +} + +// designFilesFor builds a minimal working-tree map for a single service +// component, optionally pre-marked org-published. +func designFilesFor(t *testing.T, name string, alreadyPublished bool) map[string]string { + t.Helper() + comp := models.DesignComponent{ + Name: name, + ComponentType: "service", + Language: "Go", + AppPath: name, + ComponentAgentInstructions: "serve " + name, + ExposesAPI: &models.ExposesAPI{Managed: true}, + } + if alreadyPublished { + comp.ExposesAPI.OrgPublished = true + } + files, err := SplitDesign(&DesignFile{Overview: "root", Components: []models.DesignComponent{comp}}) + if err != nil { + t.Fatalf("SplitDesign: %v", err) + } + files[DesignRootFile] = "# Design\n" // ensure ReadDesign sees a non-empty root + return files +} + +func TestSetComponentOrgPublished_CommitsWhenUnset(t *testing.T) { + svc := &fakeArtifactSvc{files: designFilesFor(t, "employee-api", false)} + store := NewArtifactStore(svc) + + if err := store.SetComponentOrgPublished(context.Background(), "acme", "hr-directory", "employee-api"); err != nil { + t.Fatalf("SetComponentOrgPublished: %v", err) + } + if len(svc.commits) != 1 { + t.Fatalf("want exactly one commit, got %d", len(svc.commits)) + } + c := svc.commits[0] + if c.sub != "components/employee-api/design.md" { + t.Fatalf("unexpected committed path %q", c.sub) + } + if !strings.Contains(c.content, "orgPublished: true") { + t.Fatalf("committed design.md missing orgPublished:\n%s", c.content) + } + if !strings.Contains(c.message, "org-published") { + t.Fatalf("unexpected commit message %q", c.message) + } +} + +func TestSetComponentOrgPublished_IdempotentNoCommit(t *testing.T) { + svc := &fakeArtifactSvc{files: designFilesFor(t, "employee-api", true)} + store := NewArtifactStore(svc) + + if err := store.SetComponentOrgPublished(context.Background(), "acme", "hr-directory", "employee-api"); err != nil { + t.Fatalf("SetComponentOrgPublished: %v", err) + } + if len(svc.commits) != 0 { + t.Fatalf("already-published component must not commit, got %d", len(svc.commits)) + } +} + +func TestSetComponentOrgPublished_MatchesOCName(t *testing.T) { + svc := &fakeArtifactSvc{files: designFilesFor(t, "employee-api", false)} + store := NewArtifactStore(svc) + + // Provider identified by OC component name `-`. + if err := store.SetComponentOrgPublished(context.Background(), "acme", "hr-directory", "hr-directory-employee-api"); err != nil { + t.Fatalf("SetComponentOrgPublished: %v", err) + } + if len(svc.commits) != 1 { + t.Fatalf("OC-name match should commit once, got %d", len(svc.commits)) + } +} + +func TestSetComponentOrgPublished_NoMatchNoCommit(t *testing.T) { + svc := &fakeArtifactSvc{files: designFilesFor(t, "employee-api", false)} + store := NewArtifactStore(svc) + + if err := store.SetComponentOrgPublished(context.Background(), "acme", "hr-directory", "no-such-component"); err != nil { + t.Fatalf("SetComponentOrgPublished: %v", err) + } + if len(svc.commits) != 0 { + t.Fatalf("unknown component must not commit, got %d", len(svc.commits)) + } +} diff --git a/asdlc-service/internal/feature/artifacts/spec_collect.go b/asdlc-service/internal/feature/artifacts/spec_collect.go new file mode 100644 index 00000000..7dfa9a98 --- /dev/null +++ b/asdlc-service/internal/feature/artifacts/spec_collect.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// spec_collect.go — OpenAPI spec fetch → validate → normalize → store pipeline. +// +// Surfaces three entry points: +// - ValidateOpenAPI: parses and counts HTTP operations in an OpenAPI 3.x doc. +// - FetchSpecFromURL: SSRF-guarded HTTPS GET for user-supplied spec URLs. +// - (*ArtifactStore).StoreConsumedSpec: validate + normalize + commit the spec +// into the consumer component's dependencies/ directory. + +package artifacts + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// ---- ValidateOpenAPI ------------------------------------------------------- + +var httpMethods = map[string]bool{ + "get": true, "put": true, "post": true, "delete": true, + "options": true, "head": true, "patch": true, "trace": true, +} + +// ValidateOpenAPI parses an OpenAPI 3.x YAML/JSON document and returns the +// number of operations (method entries under paths). It errors if the doc is +// not OpenAPI 3.x or has no paths / operations. +func ValidateOpenAPI(raw string) (int, error) { + var doc map[string]any + if err := yaml.Unmarshal([]byte(raw), &doc); err != nil { + return 0, fmt.Errorf("not valid YAML/JSON: %w", err) + } + ver, _ := doc["openapi"].(string) + if !strings.HasPrefix(ver, "3.") { + return 0, fmt.Errorf("not an OpenAPI 3.x document (openapi: %q)", ver) + } + paths, ok := doc["paths"].(map[string]any) + if !ok || len(paths) == 0 { + return 0, fmt.Errorf("OpenAPI document has no paths") + } + count := 0 + for _, item := range paths { + ops, ok := item.(map[string]any) + if !ok { + continue + } + for method := range ops { + if httpMethods[strings.ToLower(method)] { + count++ + } + } + } + if count == 0 { + return 0, fmt.Errorf("OpenAPI document has no operations") + } + return count, nil +} + +// ---- FetchSpecFromURL ------------------------------------------------------ + +const ( + specFetchTimeout = 10 * time.Second + specMaxBytes = 5 << 20 // 5 MiB +) + +// cgnatNet is the IANA Shared Address Space (RFC 6598, 100.64.0.0/10) used +// by carrier-grade NAT. It is not publicly routable and must be blocked to +// prevent SSRF via CGNAT-addressed hosts. +var cgnatNet = func() *net.IPNet { + _, n, _ := net.ParseCIDR("100.64.0.0/10") + return n +}() + +// nat64Net is the IPv6 Well-Known Prefix for NAT64 (RFC 6052, 64:ff9b::/96). +// In a NAT64/DNS64 cluster a DNS64 resolver can synthesize a 64:ff9b:: AAAA for +// an attacker domain that NAT64 then routes to an embedded IPv4 — including the +// link-local cloud-metadata endpoint and the RFC1918 pod/service CIDR. None of +// Go's IsPrivate/IsLoopback/IsLinkLocalUnicast catch this prefix, so block it +// explicitly to close the metadata-SSRF-via-NAT64 vector. +var nat64Net = func() *net.IPNet { + _, n, _ := net.ParseCIDR("64:ff9b::/96") + return n +}() + +// maxRedirects is the maximum number of redirects FetchSpecFromURL will follow. +const maxRedirects = 5 + +// FetchSpecFromURL GETs an OpenAPI spec from a user-supplied URL with SSRF +// guards: https only, public IPs only (no loopback/private/link-local/ +// unspecified), size cap (5 MiB), timeout (10 s), redirect guard (https-only, +// max 5 hops), and TOCTOU-safe single-resolution dial (resolves the host +// exactly once and dials the validated IP directly — no second resolution). +// +// PLATFORM-TOUCHING — reviewed by platform-design-expert; do NOT weaken +// guards without a new review. +func FetchSpecFromURL(ctx context.Context, rawURL string) (string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || u.Scheme != "https" || u.Host == "" { + return "", fmt.Errorf("spec URL must be an absolute https URL") + } + dialer := &net.Dialer{Timeout: specFetchTimeout} + transport := &http.Transport{ + // DialContext resolves the host ONCE, validates every returned IP, then + // dials the chosen IP directly — eliminating the TOCTOU DNS-rebinding + // window that would exist if the dialer performed its own second lookup. + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid address %q: %w", addr, err) + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no IP addresses resolved for %s", host) + } + // Validate every resolved IP; reject the entire set if any is non-public. + // (Resolved IP is intentionally NOT echoed in the error — it would be a + // blind-SSRF oracle leaking internal DNS results; log server-side instead.) + for _, ip := range ips { + if !ip.IsGlobalUnicast() || ip.IsPrivate() || cgnatNet.Contains(ip) || nat64Net.Contains(ip) { + return nil, fmt.Errorf("refusing to fetch from non-public address") + } + } + // Dial the first validated IP directly — no second DNS resolution. + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port)) + }, + } + // CheckRedirect rejects any redirect whose target is not https and caps + // the total number of redirects at maxRedirects. This closes the + // https→http downgrade-via-redirect vector at the application layer. + checkRedirect := func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("redirect to non-https URL %q is not allowed", req.URL) + } + if len(via) >= maxRedirects { + return fmt.Errorf("too many redirects (max %d)", maxRedirects) + } + return nil + } + client := &http.Client{Timeout: specFetchTimeout, Transport: transport, CheckRedirect: checkRedirect} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/yaml, application/json, text/yaml, text/plain") + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch spec: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("spec URL returned %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, specMaxBytes+1)) + if err != nil { + return "", err + } + if len(body) > specMaxBytes { + return "", fmt.Errorf("spec exceeds %d bytes", specMaxBytes) + } + return string(body), nil +} + +// ErrInvalidSpecContent is a sentinel wrapped around validation-class errors +// from StoreConsumedSpec — depName path-traversal rejection and ValidateOpenAPI +// failures — so that callers can distinguish them from infrastructure errors +// (NormalizeOpenAPIYAML failures and WriteDesignFile storage failures). +// The design feature's CollectSpec re-wraps this as design.ErrInvalidSpec so +// the HTTP handler can map it to a 400 without importing artifacts. +var ErrInvalidSpecContent = errors.New("invalid spec content") + +// ---- StoreConsumedSpec ----------------------------------------------------- + +// StoreConsumedSpec validates + normalizes rawSpec and writes it to the +// consumer component's dependencies/ directory in the working-tree draft at: +// +// specs/design/components//dependencies/.openapi.yaml +// +// Returns the component-relative specPath ("dependencies/.openapi.yaml") +// and the operation count. Writes via WriteDesignFile → PutFile (working-tree +// only — no commit, no version tag). The file becomes part of the design draft +// and will be committed atomically when SaveDesign is called. +// +// Error classification: +// - %w-wraps ErrInvalidSpecContent: depName path-traversal rejection + ValidateOpenAPI failures (client/400). +// - bare errors: NormalizeOpenAPIYAML + WriteDesignFile failures (infra/500). +func (s *ArtifactStore) StoreConsumedSpec(ctx context.Context, orgID, projectID, component, depName, rawSpec string) (string, int, error) { + // Defense-in-depth: reject depName values that could escape the dependencies/ + // directory via path traversal — belt-and-suspenders even though depName is + // normally architect/catalog-controlled. + if strings.Contains(depName, "/") || strings.Contains(depName, `\`) || strings.Contains(depName, "..") { + return "", 0, fmt.Errorf("%w: invalid dependency name %q: must not contain path separators or '..'", ErrInvalidSpecContent, depName) + } + opCount, err := ValidateOpenAPI(rawSpec) + if err != nil { + return "", 0, fmt.Errorf("%w: %v", ErrInvalidSpecContent, err) + } + normalized, err := NormalizeOpenAPIYAML(rawSpec) + if err != nil { + return "", 0, fmt.Errorf("normalize spec: %w", err) + } + // specPath is component-relative (returned to callers / stored in dependency.specPath). + specPath := "dependencies/" + depName + ".openapi.yaml" + // subPath is relative to specs/design/ — WriteDesignFile prepends DesignDir internally. + subPath := componentDirPrefix + component + "/" + specPath + if _, werr := s.WriteDesignFile(ctx, orgID, projectID, subPath, normalized); werr != nil { + return "", 0, werr + } + return specPath, opCount, nil +} diff --git a/asdlc-service/internal/feature/artifacts/spec_collect_test.go b/asdlc-service/internal/feature/artifacts/spec_collect_test.go new file mode 100644 index 00000000..64d533d3 --- /dev/null +++ b/asdlc-service/internal/feature/artifacts/spec_collect_test.go @@ -0,0 +1,340 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package artifacts + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +// TestValidateOpenAPI_RejectsYAMLAliasBomb locks the transitive-dependency +// guarantee that yaml.v3 (>= v3.0.4) rejects alias-expansion ("billion laughs") +// bombs via its alias budget rather than OOM/hang — a platform-design-expert +// SSRF-review item (the fetched/pasted spec is attacker-controlled). The doc +// below has 8 levels each referencing the previous 10×, i.e. ~10^8 expansions; +// ValidateOpenAPI parses via yaml.Unmarshal first, so the budget guard must fire +// there. A future dependency downgrade that drops the budget would resurface the +// OOM and trip this test (via the hang timeout). +func TestValidateOpenAPI_RejectsYAMLAliasBomb(t *testing.T) { + var sb strings.Builder + sb.WriteString("a: &a \"lol\"\n") + prev := "a" + for _, name := range []string{"b", "c", "d", "e", "f", "g", "h", "i"} { + sb.WriteString(name + ": &" + name + " [") + for j := 0; j < 10; j++ { + if j > 0 { + sb.WriteString(",") + } + sb.WriteString("*" + prev) + } + sb.WriteString("]\n") + prev = name + } + bomb := sb.String() + + done := make(chan error, 1) + go func() { + _, err := ValidateOpenAPI(bomb) + done <- err + }() + select { + case err := <-done: + // Either the alias budget rejects it, or it parses to a non-OpenAPI doc — + // both are an error. The point is it returns quickly, not OOM/hang. + if err == nil { + t.Fatal("expected an error for a YAML alias bomb, got nil") + } + case <-time.After(15 * time.Second): + t.Fatal("ValidateOpenAPI did not return on a YAML alias bomb — alias budget not enforced (dep downgrade?)") + } +} + +const sampleSpec = `openapi: 3.0.3 +info: { title: Weather, version: "1.0" } +paths: + /weather: + get: { responses: { "200": { description: ok } } } + /forecast: + get: { responses: { "200": { description: ok } } } + post: { responses: { "201": { description: created } } } +` + +func TestValidateOpenAPICountsOperations(t *testing.T) { + n, err := ValidateOpenAPI(sampleSpec) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if n != 3 { + t.Fatalf("want 3 operations, got %d", n) + } +} + +func TestValidateOpenAPIRejectsNonOpenAPI(t *testing.T) { + if _, err := ValidateOpenAPI("foo: bar"); err == nil { + t.Fatal("expected error for non-openapi doc") + } +} + +func TestValidateOpenAPIRejectsSwagger2(t *testing.T) { + swagger2 := `swagger: "2.0" +info: { title: Old, version: "1.0" } +paths: + /ping: + get: { responses: { "200": { description: ok } } } +` + if _, err := ValidateOpenAPI(swagger2); err == nil { + t.Fatal("expected error for swagger 2.0 doc") + } +} + +func TestValidateOpenAPIRejectsNoPaths(t *testing.T) { + noPaths := `openapi: "3.0.3" +info: { title: Empty, version: "1.0" } +` + if _, err := ValidateOpenAPI(noPaths); err == nil { + t.Fatal("expected error for OpenAPI doc with no paths") + } +} + +// TestStoreConsumedSpec_PathAndCount verifies that StoreConsumedSpec: +// 1. returns specPath == "dependencies/.openapi.yaml" (component-relative) +// 2. returns the correct operation count from ValidateOpenAPI +// 3. writes a normalized spec to the working-tree draft via PutFile at +// "specs/design/components//dependencies/.openapi.yaml" +// 4. does NOT call CommitDesignFile (regression: spec was previously committed +// directly, causing the design-save diff to emit it as a DELETE tombstone) +func TestStoreConsumedSpec_PathAndCount(t *testing.T) { + svc := &fakeArtifactSvc{files: map[string]string{}} + store := NewArtifactStore(svc) + + specPath, opCount, err := store.StoreConsumedSpec( + context.Background(), + "acme", "weather-app", "frontend", "weather-api", + sampleSpec, + ) + if err != nil { + t.Fatalf("StoreConsumedSpec: %v", err) + } + if specPath != "dependencies/weather-api.openapi.yaml" { + t.Fatalf("want specPath %q, got %q", "dependencies/weather-api.openapi.yaml", specPath) + } + if opCount != 3 { + t.Fatalf("want opCount 3, got %d", opCount) + } + // Must write via PutFile (working-tree draft), not CommitDesignFile. + if len(svc.puts) != 1 { + t.Fatalf("want exactly one PutFile call, got %d", len(svc.puts)) + } + p := svc.puts[0] + // PutFile receives the full repo-relative path (DesignDir + "/" + subPath). + wantRelPath := "specs/design/components/frontend/dependencies/weather-api.openapi.yaml" + if p.relPath != wantRelPath { + t.Fatalf("want PutFile relPath %q, got %q", wantRelPath, p.relPath) + } + // Written content must be valid normalized YAML (NormalizeOpenAPIYAML output). + if strings.TrimSpace(p.content) == "" { + t.Fatal("written content must not be empty") + } + // Must still be parseable as OpenAPI after normalization. + normalizedOpCount, err := ValidateOpenAPI(p.content) + if err != nil { + t.Fatalf("normalized content fails ValidateOpenAPI: %v", err) + } + // Normalization must not drop any operations — the normalized output must + // contain the same number of operations as the original spec (3). + const wantOps = 3 + if normalizedOpCount != wantOps { + t.Fatalf("normalization changed operation count: want %d, got %d", wantOps, normalizedOpCount) + } + // REGRESSION: StoreConsumedSpec must NOT call CommitDesignFile. + // Previously it committed directly, causing saveDesignViaAPI to see the + // spec file in HEAD-but-not-working-tree and emit it as a DELETE tombstone. + if len(svc.commits) != 0 { + t.Fatalf("StoreConsumedSpec must not call CommitDesignFile (regression guard): got %d commits", len(svc.commits)) + } +} + +func TestStoreConsumedSpec_RejectsInvalidSpec(t *testing.T) { + svc := &fakeArtifactSvc{files: map[string]string{}} + store := NewArtifactStore(svc) + + _, _, err := store.StoreConsumedSpec( + context.Background(), + "acme", "weather-app", "frontend", "not-openapi", + "foo: bar", + ) + if err == nil { + t.Fatal("expected error for non-openapi spec") + } + if len(svc.puts) != 0 { + t.Fatalf("no PutFile must happen on invalid spec, got %d", len(svc.puts)) + } + if len(svc.commits) != 0 { + t.Fatalf("no commit must happen on invalid spec, got %d", len(svc.commits)) + } +} + +// TestStoreConsumedSpec_RejectsPathTraversalDepName verifies that depName +// values containing path separators or ".." are rejected before any file +// path is constructed (defense-in-depth path traversal guard). +func TestStoreConsumedSpec_RejectsPathTraversalDepName(t *testing.T) { + dangerousNames := []string{ + "../evil", + "../../etc/passwd", + "sub/name", + `sub\name`, + "..", + } + for _, name := range dangerousNames { + t.Run(name, func(t *testing.T) { + svc := &fakeArtifactSvc{files: map[string]string{}} + store := NewArtifactStore(svc) + + _, _, err := store.StoreConsumedSpec( + context.Background(), + "acme", "weather-app", "frontend", name, + sampleSpec, + ) + if err == nil { + t.Fatalf("expected error for dangerous depName %q, got nil", name) + } + if len(svc.puts) != 0 { + t.Fatalf("no PutFile must happen for dangerous depName %q, got %d puts", name, len(svc.puts)) + } + if len(svc.commits) != 0 { + t.Fatalf("no commit must happen for dangerous depName %q, got %d commits", name, len(svc.commits)) + } + }) + } +} + +// designFilesWithExternalDep builds a minimal working-tree map for a component +// that has one external dependency with needsSpec=true and no specPath yet. +func designFilesWithExternalDep(t *testing.T, compName, depName string) map[string]string { + t.Helper() + comp := models.DesignComponent{ + Name: compName, + ComponentType: "webapp", + Language: "TypeScript", + AppPath: compName, + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindExternal, + Name: depName, + NeedsSpec: true, + }, + }, + ComponentAgentInstructions: "consume " + depName, + } + files, err := SplitDesign(&DesignFile{Overview: "root", Components: []models.DesignComponent{comp}}) + if err != nil { + t.Fatalf("SplitDesign: %v", err) + } + files[DesignRootFile] = "# Design\n" + return files +} + +// TestSetDependencySpecPath_WritesDraftNotCommit verifies that SetDependencySpecPath: +// 1. writes the updated component design.md to the working-tree draft via PutFile +// 2. the written content contains the specPath field +// 3. does NOT call CommitDesignFile (regression: was previously committed +// separately, causing saveDesignViaAPI to miss the specPath update) +func TestSetDependencySpecPath_WritesDraftNotCommit(t *testing.T) { + svc := &fakeArtifactSvc{files: designFilesWithExternalDep(t, "frontend", "weather-api")} + store := NewArtifactStore(svc) + + err := store.SetDependencySpecPath( + context.Background(), + "acme", "weather-app", "frontend", "weather-api", + "dependencies/weather-api.openapi.yaml", + ) + if err != nil { + t.Fatalf("SetDependencySpecPath: %v", err) + } + // Must write via PutFile (working-tree draft), not CommitDesignFile. + if len(svc.puts) != 1 { + t.Fatalf("want exactly one PutFile call, got %d", len(svc.puts)) + } + p := svc.puts[0] + // PutFile receives the full repo-relative path. + wantRelPath := "specs/design/components/frontend/design.md" + if p.relPath != wantRelPath { + t.Fatalf("want PutFile relPath %q, got %q", wantRelPath, p.relPath) + } + // Written design.md must contain the specPath. + if !strings.Contains(p.content, "specPath:") { + t.Fatalf("written design.md missing specPath:\n%s", p.content) + } + if !strings.Contains(p.content, "dependencies/weather-api.openapi.yaml") { + t.Fatalf("written design.md missing the specPath value:\n%s", p.content) + } + // REGRESSION: SetDependencySpecPath must NOT call CommitDesignFile. + // Previously it committed the design.md directly, which diverged the local + // clone HEAD from the working tree and caused specPath to be lost on save. + if len(svc.commits) != 0 { + t.Fatalf("SetDependencySpecPath must not call CommitDesignFile (regression guard): got %d commits", len(svc.commits)) + } +} + +// TestSetDependencySpecPath_IdempotentNoPut verifies that SetDependencySpecPath +// is a no-op when the specPath is already set and SpecUrl is cleared. +func TestSetDependencySpecPath_IdempotentNoPut(t *testing.T) { + comp := models.DesignComponent{ + Name: "frontend", + ComponentType: "webapp", + Language: "TypeScript", + AppPath: "frontend", + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindExternal, + Name: "weather-api", + NeedsSpec: true, + SpecPath: "dependencies/weather-api.openapi.yaml", + // SpecUrl already cleared (as SetDependencySpecPath would do). + SpecUrl: "", + }, + }, + ComponentAgentInstructions: "consume weather-api", + } + files, err := SplitDesign(&DesignFile{Overview: "root", Components: []models.DesignComponent{comp}}) + if err != nil { + t.Fatalf("SplitDesign: %v", err) + } + files[DesignRootFile] = "# Design\n" + + svc := &fakeArtifactSvc{files: files} + store := NewArtifactStore(svc) + + err = store.SetDependencySpecPath( + context.Background(), + "acme", "weather-app", "frontend", "weather-api", + "dependencies/weather-api.openapi.yaml", + ) + if err != nil { + t.Fatalf("SetDependencySpecPath: %v", err) + } + if len(svc.puts) != 0 { + t.Fatalf("idempotent call must not write, got %d PutFile calls", len(svc.puts)) + } + if len(svc.commits) != 0 { + t.Fatalf("idempotent call must not commit, got %d CommitDesignFile calls", len(svc.commits)) + } +} diff --git a/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook.go b/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook.go index e8377e8a..ebef9236 100644 --- a/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook.go +++ b/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook.go @@ -18,12 +18,14 @@ package codingagent import ( "context" + "fmt" "hash/fnv" "log/slog" "gorm.io/gorm" "github.com/wso2/asdlc/asdlc-service/internal/feature/component" + "github.com/wso2/asdlc/asdlc-service/models" ) // projectSPARuntimeConfigEmitter re-emits env-config.js across a project's @@ -37,6 +39,35 @@ type projectSPARuntimeConfigEmitter interface { EmitForProjectSPAs(ctx context.Context, orgID, projectID string) error } +// accessGrantStore is the consumer port for the cross-project access-request +// store (marketplace P3.5). The cascade uses it to close the loop when a +// provider's `org-publish` task lands `deployed`: it finds the open request(s) +// targeting the just-deployed component and flips them all to `granted`. +// Satisfied structurally by *access.Repository — kept as a local interface so +// the cascade needn't import the access package. Mirrors the +// projectSPARuntimeConfigEmitter pattern above. +type accessGrantStore interface { + FindOpenForTarget(ctx context.Context, orgID, providerProjectID, providerComponentName string) (*models.AccessRequest, error) + ListByProviderTask(ctx context.Context, providerTaskID string) ([]models.AccessRequest, error) + UpdateStatus(ctx context.Context, id, status string) error +} + +// accessDesignStore is the consumer port for the durability write the grant +// uses to persist `exposesAPI.orgPublished:true` on the provider component. The +// store reads the design, matches the component (logical or OC name), flips the +// flag idempotently, and COMMITS the single design.md to the provider repo (no +// new version tag) using the org's service-identity credential. Satisfied by +// *artifacts.ArtifactStore. +type accessDesignStore interface { + SetComponentOrgPublished(ctx context.Context, orgID, projectID, componentName string) error +} + +// accessIssueCloser is the consumer port for closing the provider GitHub issue +// once the grant lands. Satisfied by gitrepo.IssueService. +type accessIssueCloser interface { + CloseIssue(ctx context.Context, orgID, projectID string, number int, comment string) error +} + // DispatchCascadeHook is the post-commit cascade fired by the webhook // projector whenever a task lands in `deployed`. It owns the per-project // advisory lock + eligibility scan + DispatchService.DispatchTasks call. The @@ -47,6 +78,11 @@ type DispatchCascadeHook struct { dispatch DispatchService traitSync *component.TraitSyncService runtimeConfig projectSPARuntimeConfigEmitter + + // P3.5 access-request grant close-out (all optional; nil ⇒ step skipped). + accessStore accessGrantStore + accessDesign accessDesignStore + accessIssues accessIssueCloser } func NewDispatchCascadeHook(db *gorm.DB, dispatch DispatchService) *DispatchCascadeHook { @@ -74,6 +110,22 @@ func (h *DispatchCascadeHook) SetRuntimeConfig(r projectSPARuntimeConfigEmitter) h.runtimeConfig = r } +// SetAccessGrant wires the P3.5 access-request grant close-out: when a +// provider's `org-publish` task lands `deployed`, the cascade flips every +// open AccessRequest riding on it to `granted`, persists +// `exposesAPI.orgPublished:true` on the provider component (durability), and +// closes the provider GitHub issue. All three collaborators are required for +// the step to run; a nil store disables it. Optional + best-effort — the grant +// never breaks the deploy cascade. +func (h *DispatchCascadeHook) SetAccessGrant(store accessGrantStore, design accessDesignStore, issues accessIssueCloser) { + if h == nil { + return + } + h.accessStore = store + h.accessDesign = design + h.accessIssues = issues +} + // OnTaskDeployed is the post-commit hook. Acquires a per-project advisory // lock so concurrent deploys against the same project serialise (a // dependent shared between two deps must dispatch exactly once when the @@ -138,6 +190,17 @@ func (h *DispatchCascadeHook) OnTaskDeployed(ctx context.Context, orgID, project } } + // P3.5 access-request grant close-out: if this just-deployed component is + // the PROVIDER of an open cross-project access request, flip every consumer + // request riding on its publish task to `granted`, persist + // exposesAPI.orgPublished (durability), and close the provider issue. The + // consumer cannot proceed past the save-and-proceed gate until the provider + // is granted (block-at-proceed model, A2c); there is no separate org-service + // On-Hold gate. This is purely the provider-side status close-out; the + // DispatchTasks call below will unblock any waiting consumer tasks. Best-effort: + // a sync failure here must never break the deploy cascade. + h.grantAccessRequests(ctx, orgID, projectID, componentName) + results, err := h.dispatch.DispatchTasks(ctx, orgID, projectID) if err != nil { slog.WarnContext(ctx, "dispatch cascade: DispatchTasks failed", @@ -151,6 +214,88 @@ func (h *DispatchCascadeHook) OnTaskDeployed(ctx context.Context, orgID, project ) } +// grantAccessRequests is the P3.5 provider-side close-out fired when an +// `org-publish` task lands `deployed`. componentName is the just-deployed +// component — the PROVIDER, in OC component-name form. Steps (each best-effort, +// none aborts the others or the surrounding cascade): +// +// 1. FindOpenForTarget — is there an open (requested|in_progress) request whose +// provider IS this component? If none, this is a normal deploy → return. +// 2. Persist exposesAPI.orgPublished:true on the provider component (durability, +// so a future re-implementation doesn't silently drop namespace visibility). +// 3. Flip EVERY request riding on the provider task → granted. +// 4. Close the provider GitHub issue. +func (h *DispatchCascadeHook) grantAccessRequests(ctx context.Context, orgID, projectID, componentName string) { + if h == nil || h.accessStore == nil { + return + } + + // (1) Is this deployed component the provider target of an open request? + open, err := h.accessStore.FindOpenForTarget(ctx, orgID, projectID, componentName) + if err != nil { + slog.WarnContext(ctx, "access grant: FindOpenForTarget failed", + "project", projectID, "component", componentName, "error", err) + return + } + if open == nil { + return // normal deploy — no access request waiting on this component. + } + + // (2) Durability: set exposesAPI.orgPublished:true on the provider design. + h.markOrgPublished(ctx, orgID, projectID, componentName) + + // (3) Flip every consumer request riding on this provider task to granted. + flipped := 0 + rows, err := h.accessStore.ListByProviderTask(ctx, open.ProviderTaskID) + if err != nil { + // Fall back to flipping just the one row we found. + slog.WarnContext(ctx, "access grant: ListByProviderTask failed; flipping single row", + "providerTask", open.ProviderTaskID, "error", err) + rows = []models.AccessRequest{*open} + } + for i := range rows { + if rows[i].Status == models.AccessRequestStatusGranted { + continue + } + if uerr := h.accessStore.UpdateStatus(ctx, rows[i].ID, models.AccessRequestStatusGranted); uerr != nil { + slog.WarnContext(ctx, "access grant: UpdateStatus failed", + "accessRequest", rows[i].ID, "error", uerr) + continue + } + flipped++ + } + + // (4) Close the provider GitHub issue (once — all rows share it). + if h.accessIssues != nil && open.ProviderIssueNumber > 0 { + comment := fmt.Sprintf("Published %s org-wide (namespace visibility). Closing — consumers will resume automatically.", componentName) + if cerr := h.accessIssues.CloseIssue(ctx, orgID, projectID, open.ProviderIssueNumber, comment); cerr != nil { + slog.WarnContext(ctx, "access grant: CloseIssue failed", + "project", projectID, "issue", open.ProviderIssueNumber, "error", cerr) + } + } + + slog.InfoContext(ctx, "access requests granted on provider deploy", + "project", projectID, "providerComponent", componentName, + "providerTask", open.ProviderTaskID, "granted", flipped) +} + +// markOrgPublished durably persists exposesAPI.orgPublished:true on the named +// provider component and COMMITS the change to the provider repo (durability per +// P3.5 §4) — not just a working-tree write, so a future re-implementation can't +// silently drop namespace visibility. The store handles component matching +// (logical or OC name), idempotency (no-op if already published), and the +// service-identity commit. Best-effort: any failure is logged and swallowed — +// the grant proceeds. +func (h *DispatchCascadeHook) markOrgPublished(ctx context.Context, orgID, projectID, componentName string) { + if h.accessDesign == nil { + return + } + if err := h.accessDesign.SetComponentOrgPublished(ctx, orgID, projectID, componentName); err != nil { + slog.WarnContext(ctx, "access grant: persist orgPublished durability failed", + "project", projectID, "component", componentName, "error", err) + } +} + func hashCascadeKey(s string) int64 { h := fnv.New64a() h.Write([]byte(s)) diff --git a/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook_test.go b/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook_test.go new file mode 100644 index 00000000..6c823e6f --- /dev/null +++ b/asdlc-service/internal/feature/codingagent/dispatch_cascade_hook_test.go @@ -0,0 +1,183 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package codingagent + +import ( + "context" + "testing" + + "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// fakeAccessStore is an in-memory accessGrantStore. +type fakeAccessStore struct { + rows []*models.AccessRequest + findTarget *models.AccessRequest // returned by FindOpenForTarget + findErr error +} + +func (f *fakeAccessStore) FindOpenForTarget(_ context.Context, _, _, _ string) (*models.AccessRequest, error) { + return f.findTarget, f.findErr +} +func (f *fakeAccessStore) ListByProviderTask(_ context.Context, providerTaskID string) ([]models.AccessRequest, error) { + var out []models.AccessRequest + for _, r := range f.rows { + if r.ProviderTaskID == providerTaskID { + out = append(out, *r) + } + } + return out, nil +} +func (f *fakeAccessStore) UpdateStatus(_ context.Context, id, status string) error { + for _, r := range f.rows { + if r.ID == id { + r.Status = status + return nil + } + } + return nil +} + +// fakeDesignStore is an in-memory accessDesignStore. It mirrors the real +// ArtifactStore.SetComponentOrgPublished contract: it matches the component +// (logical or OC `-` name), flips orgPublished idempotently, +// and records each component it "committed" so tests can assert the durability +// write fired exactly once. +type fakeDesignStore struct { + design *artifacts.DesignFile + written []models.DesignComponent + err error // when set, SetComponentOrgPublished returns it +} + +func (f *fakeDesignStore) SetComponentOrgPublished(_ context.Context, _, projectID, componentName string) error { + if f.err != nil { + return f.err + } + if f.design == nil { + return nil + } + for i := range f.design.Components { + comp := f.design.Components[i] + if !designComponentMatchesTest(comp.Name, projectID, componentName) { + continue + } + if comp.ExposesAPI != nil && comp.ExposesAPI.OrgPublished { + return nil // idempotent — no commit. + } + if comp.ExposesAPI == nil { + comp.ExposesAPI = &models.ExposesAPI{} + } + comp.ExposesAPI.OrgPublished = true + f.written = append(f.written, comp) + return nil + } + return nil +} + +func designComponentMatchesTest(logical, project, target string) bool { + return logical == target || project+"-"+logical == target +} + +// fakeIssueCloser records the closed issue number. +type fakeIssueCloser struct{ closed []int } + +func (f *fakeIssueCloser) CloseIssue(_ context.Context, _, _ string, number int, _ string) error { + f.closed = append(f.closed, number) + return nil +} + +func TestGrantAccessRequests_NoOpenRequest(t *testing.T) { + store := &fakeAccessStore{findTarget: nil} + design := &fakeDesignStore{} + h := &DispatchCascadeHook{} + h.SetAccessGrant(store, design, &fakeIssueCloser{}) + + h.grantAccessRequests(context.Background(), "acme", "hr-directory", "employee-api") + + if len(design.written) != 0 { + t.Fatalf("normal deploy should not write design, got %d writes", len(design.written)) + } +} + +func TestGrantAccessRequests_FlipsAllRowsSetsOrgPublishedClosesIssue(t *testing.T) { + store := &fakeAccessStore{ + findTarget: &models.AccessRequest{ID: "a", ProviderTaskID: "task-1", ProviderIssueNumber: 7, Status: models.AccessRequestStatusRequested}, + rows: []*models.AccessRequest{ + {ID: "a", ProviderTaskID: "task-1", Status: models.AccessRequestStatusRequested}, + {ID: "b", ProviderTaskID: "task-1", Status: models.AccessRequestStatusInProgress}, + }, + } + design := &fakeDesignStore{design: &artifacts.DesignFile{ + Components: []models.DesignComponent{ + {Name: "employee-api", ComponentType: "service"}, // ExposesAPI nil + }, + }} + issues := &fakeIssueCloser{} + h := &DispatchCascadeHook{} + h.SetAccessGrant(store, design, issues) + + h.grantAccessRequests(context.Background(), "acme", "hr-directory", "employee-api") + + for _, r := range store.rows { + if r.Status != models.AccessRequestStatusGranted { + t.Fatalf("row %s status = %q, want granted", r.ID, r.Status) + } + } + if len(design.written) != 1 { + t.Fatalf("want one orgPublished durability write, got %d", len(design.written)) + } + if design.written[0].ExposesAPI == nil || !design.written[0].ExposesAPI.OrgPublished { + t.Fatalf("written component missing orgPublished:true: %+v", design.written[0].ExposesAPI) + } + if len(issues.closed) != 1 || issues.closed[0] != 7 { + t.Fatalf("want issue 7 closed, got %v", issues.closed) + } +} + +func TestMarkOrgPublished_IdempotentWhenAlreadyTrue(t *testing.T) { + design := &fakeDesignStore{design: &artifacts.DesignFile{ + Components: []models.DesignComponent{ + {Name: "employee-api", ExposesAPI: &models.ExposesAPI{OrgPublished: true}}, + }, + }} + h := &DispatchCascadeHook{} + h.SetAccessGrant(&fakeAccessStore{}, design, &fakeIssueCloser{}) + + h.markOrgPublished(context.Background(), "acme", "hr-directory", "employee-api") + + if len(design.written) != 0 { + t.Fatalf("already-published component should not be rewritten, got %d writes", len(design.written)) + } +} + +func TestMarkOrgPublished_MatchesOCComponentName(t *testing.T) { + design := &fakeDesignStore{design: &artifacts.DesignFile{ + Components: []models.DesignComponent{ + {Name: "employee-api"}, + }, + }} + h := &DispatchCascadeHook{} + h.SetAccessGrant(&fakeAccessStore{}, design, &fakeIssueCloser{}) + + // Provider identified by OC component name `-`. + h.markOrgPublished(context.Background(), "acme", "hr-directory", "hr-directory-employee-api") + + if len(design.written) != 1 || !design.written[0].ExposesAPI.OrgPublished { + t.Fatalf("OC-name match should write orgPublished, got %+v", design.written) + } +} diff --git a/asdlc-service/internal/feature/codingagent/dispatch_consumer_wiring_test.go b/asdlc-service/internal/feature/codingagent/dispatch_consumer_wiring_test.go new file mode 100644 index 00000000..77921e67 --- /dev/null +++ b/asdlc-service/internal/feature/codingagent/dispatch_consumer_wiring_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package codingagent — consumer wiring renderer unit tests (A6). +// +// These tests assert that resolveConsumerDependenciesYAML renders a +// `dependencies.resources[]` block for platform-resource deps (DependsOnResources) +// with the _ env-var naming convention. No store, no OC client, +// no DB — pure YAML rendering driven by a fake binding reader. +package codingagent + +import ( + "context" + "strings" + "testing" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// fakePlatformBindingReader returns a hard-coded binding for a known name and +// nil for everything else. The binding name format is --. +func fakePlatformBindingReader(bindings map[string]*openchoreo.ResourceReleaseBinding) ConnectionBindingReader { + return func(_ context.Context, _, name string) (*openchoreo.ResourceReleaseBinding, error) { + b, ok := bindings[name] + if !ok { + return nil, nil + } + return b, nil + } +} + +// bindingWithOutputs builds a ready ResourceReleaseBinding with the given output names. +func bindingWithOutputs(names ...string) *openchoreo.ResourceReleaseBinding { + outputs := make([]openchoreo.ResolvedOutput, 0, len(names)) + for _, n := range names { + outputs = append(outputs, openchoreo.ResolvedOutput{Name: n}) + } + return &openchoreo.ResourceReleaseBinding{ + Status: &openchoreo.ResourceReleaseBindingStatus{ + Outputs: outputs, + }, + } +} + +// TestResolveConsumerDependenciesYAML_PlatformResourceDep asserts that for a +// task with DependsOnResources: ["db"], the rendered YAML includes a +// `dependencies.resources[]` entry with ref: -db and env bindings +// host→DB_HOST, password→DB_PASSWORD (_ uppercased convention). +func TestResolveConsumerDependenciesYAML_PlatformResourceDep(t *testing.T) { + const ( + orgID = "acme" + projectID = "todo" + depName = "db" + ) + // Platform resource binding name: --development + bindingName := projectID + "-" + depName + "-development" + + reader := fakePlatformBindingReader(map[string]*openchoreo.ResourceReleaseBinding{ + bindingName: bindingWithOutputs("host", "password"), + }) + + svc := &dispatchService{ + connBindingReader: reader, + // orgServiceResolver is nil: skips design-component resolution entirely. + } + + task := &models.ComponentTask{ + OrgID: orgID, + ProjectID: projectID, + ComponentName: "todo-app", + DependsOnResources: models.StringSlice{depName}, + } + + got, err := svc.resolveConsumerDependenciesYAML(context.Background(), task) + if err != nil { + t.Fatalf("resolveConsumerDependenciesYAML error: %v", err) + } + if got == "" { + t.Fatal("expected non-empty YAML, got empty string") + } + + // Expect the ref entry for -. + wantRef := "ref: " + projectID + "-" + depName + if !strings.Contains(got, wantRef) { + t.Errorf("YAML missing %q:\n%s", wantRef, got) + } + + // Expect _ env var naming: host→DB_HOST, password→DB_PASSWORD. + type envCheck struct { + output string + envVar string + } + for _, c := range []envCheck{ + {"host", "DB_HOST"}, + {"password", "DB_PASSWORD"}, + } { + // The YAML key is the output name, the value is the env var. + wantBinding := c.output + ": " + c.envVar + if !strings.Contains(got, wantBinding) { + t.Errorf("YAML missing env binding %q (want output %q → env %q):\n%s", + wantBinding, c.output, c.envVar, got) + } + } +} + +// TestResolveConsumerDependenciesYAML_PlatformResourceDep_SkipsUnprovisioned +// asserts that a platform-resource dep with no ready binding is silently skipped +// (returns "" when no other deps are present). +func TestResolveConsumerDependenciesYAML_PlatformResourceDep_SkipsUnprovisioned(t *testing.T) { + // Reader returns nil for every name — dep not provisioned yet. + reader := fakePlatformBindingReader(nil) + + svc := &dispatchService{connBindingReader: reader} + + task := &models.ComponentTask{ + OrgID: "acme", + ProjectID: "todo", + ComponentName: "todo-app", + DependsOnResources: models.StringSlice{"db"}, + } + + got, err := svc.resolveConsumerDependenciesYAML(context.Background(), task) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("expected empty string when binding unprovisioned, got:\n%s", got) + } +} + +// TestResolveConsumerDependenciesYAML_PlatformResourceDep_MultipleOutputs +// asserts that all outputs are rendered with the correct _ prefix. +func TestResolveConsumerDependenciesYAML_PlatformResourceDep_MultipleOutputs(t *testing.T) { + const ( + projectID = "myproj" + depName = "maindb" + ) + bindingName := projectID + "-" + depName + "-development" + reader := fakePlatformBindingReader(map[string]*openchoreo.ResourceReleaseBinding{ + bindingName: bindingWithOutputs("host", "port", "user", "password"), + }) + + svc := &dispatchService{connBindingReader: reader} + + task := &models.ComponentTask{ + OrgID: "org1", + ProjectID: projectID, + ComponentName: "worker", + DependsOnResources: models.StringSlice{depName}, + } + + got, err := svc.resolveConsumerDependenciesYAML(context.Background(), task) + if err != nil { + t.Fatalf("error: %v", err) + } + + for _, c := range []struct{ out, env string }{ + {"host", "MAINDB_HOST"}, + {"port", "MAINDB_PORT"}, + {"user", "MAINDB_USER"}, + {"password", "MAINDB_PASSWORD"}, + } { + want := c.out + ": " + c.env + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} diff --git a/asdlc-service/internal/feature/codingagent/dispatch_gating_test.go b/asdlc-service/internal/feature/codingagent/dispatch_gating_test.go new file mode 100644 index 00000000..e174b9f7 --- /dev/null +++ b/asdlc-service/internal/feature/codingagent/dispatch_gating_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package codingagent — dispatch gating unit tests. +// +// These tests assert the post-A2c invariants: +// - Same-project component dependencies still gate dispatch until the +// sibling task reaches `deployed`. +// - External connection dependencies still gate dispatch until the +// config-collection task reaches `deployed`. +// - Org-service (cross-project) dependencies do NOT gate dispatch; +// block-at-proceed (A2b) already guarantees a consumer cannot be +// dispatched while an org-service dep is unresolved. +package codingagent + +import ( + "testing" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +// ---- helpers ---------------------------------------------------------------- + +func makeTask(name string, deps, connDeps, orgServiceDeps []string) *models.ComponentTask { + return &models.ComponentTask{ + ComponentName: name, + DependsOnComponents: models.StringSlice(deps), + DependsOnConnections: models.StringSlice(connDeps), + DependsOnOrgServices: models.StringSlice(orgServiceDeps), + } +} + +// ---- depsAllDeployed unit tests --------------------------------------------- + +// TestDepsAllDeployed_NoDeps: a task with no deps is always dispatchable. +func TestDepsAllDeployed_NoDeps(t *testing.T) { + task := makeTask("frontend", nil, nil, nil) + if !depsAllDeployed(task, nil, nil, nil) { + t.Fatal("want true for a task with no deps, got false") + } +} + +// TestDepsAllDeployed_ComponentDepMet: a task whose same-project component dep +// is `deployed` should be dispatchable. +func TestDepsAllDeployed_ComponentDepMet(t *testing.T) { + task := makeTask("frontend", []string{"backend-api"}, nil, nil) + statusByComp := map[string]string{"backend-api": string(models.TaskStatusDeployed)} + if !depsAllDeployed(task, statusByComp, nil, nil) { + t.Fatal("want true when component dep is deployed, got false") + } +} + +// TestDepsAllDeployed_ComponentDepNotMet: a task whose same-project component +// dep is NOT yet `deployed` must be held (depsAllDeployed returns false). +// This is the core same-project gating contract that A2c must NOT break. +func TestDepsAllDeployed_ComponentDepNotMet(t *testing.T) { + task := makeTask("frontend", []string{"backend-api"}, nil, nil) + + // dep is pending — not deployed. + statusByComp := map[string]string{"backend-api": string(models.TaskStatusPending)} + if depsAllDeployed(task, statusByComp, nil, nil) { + t.Fatal("want false when component dep is pending (not yet deployed), got true") + } +} + +// TestDepsAllDeployed_ComponentDepUnknown: an unknown component name fails +// closed (false), protecting against misconfiguration. +func TestDepsAllDeployed_ComponentDepUnknown(t *testing.T) { + task := makeTask("frontend", []string{"backend-api"}, nil, nil) + // statusByComp is empty — dep is unknown. + if depsAllDeployed(task, map[string]string{}, nil, nil) { + t.Fatal("want false for unknown component dep (fail-closed), got true") + } +} + +// TestDepsAllDeployed_ConnectionDepMet: a task whose connection dep's config- +// collection task is `deployed` should be dispatchable. +func TestDepsAllDeployed_ConnectionDepMet(t *testing.T) { + task := makeTask("worker", nil, []string{"stripe"}, nil) + statusByConn := map[string]string{"stripe": string(models.TaskStatusDeployed)} + if !depsAllDeployed(task, nil, statusByConn, nil) { + t.Fatal("want true when connection dep is deployed, got false") + } +} + +// TestDepsAllDeployed_ConnectionDepNotMet: a task whose connection dep is not +// yet provisioned must be held. +func TestDepsAllDeployed_ConnectionDepNotMet(t *testing.T) { + task := makeTask("worker", nil, []string{"stripe"}, nil) + statusByConn := map[string]string{"stripe": string(models.TaskStatusPending)} + if depsAllDeployed(task, nil, statusByConn, nil) { + t.Fatal("want false when connection dep is not deployed, got true") + } +} + +// TestDepsAllDeployed_OrgServiceDepDoesNotGate is the key A2c regression test: +// a consumer task that lists a DependsOnOrgServices entry must be treated as +// immediately dispatchable — the org-service dep no longer gates dispatch. +// Under block-at-proceed (A2b) the consumer cannot have reached this point +// without its org-service deps already being `resolved`. +func TestDepsAllDeployed_OrgServiceDepDoesNotGate(t *testing.T) { + // Consumer has an org-service dep (cross-project), no same-project deps. + task := makeTask("consumer", nil, nil, []string{"payment-service"}) + // Both maps are empty — there is no "payment-service" entry anywhere. + // Post-A2c: this must return true (org-service deps are not checked here). + if !depsAllDeployed(task, map[string]string{}, map[string]string{}, nil) { + t.Fatal("A2c regression: org-service dep must NOT gate dispatch (block-at-proceed guarantees pre-resolution); want true, got false") + } +} + +// TestDepsAllDeployed_OrgServiceAndComponentMixed: a consumer with BOTH a +// same-project component dep and an org-service dep. Only the component dep +// gates; the org-service dep is ignored. +func TestDepsAllDeployed_OrgServiceAndComponentMixed(t *testing.T) { + task := makeTask("consumer", []string{"local-api"}, nil, []string{"payment-service"}) + statusByComp := map[string]string{"local-api": string(models.TaskStatusDeployed)} + + // org-service dep ("payment-service") is unknown — must still pass because + // the component dep is deployed and org-service no longer gates. + if !depsAllDeployed(task, statusByComp, nil, nil) { + t.Fatal("want true when same-project dep deployed and org-service dep present but ignored; got false") + } +} + +// TestDepsAllDeployed_OrgServiceAndComponentMixed_ComponentNotDeployed: if the +// same-project component dep is NOT deployed, the task is held regardless of +// the org-service dep state. +func TestDepsAllDeployed_OrgServiceAndComponentMixed_ComponentNotDeployed(t *testing.T) { + task := makeTask("consumer", []string{"local-api"}, nil, []string{"payment-service"}) + // local-api is in_progress, not deployed. + statusByComp := map[string]string{"local-api": string(models.TaskStatusInProgress)} + + if depsAllDeployed(task, statusByComp, nil, nil) { + t.Fatal("want false when same-project component dep is not deployed; got true") + } +} + +// TestDepsAllDeployed_AllDepsDeployed: a task with all dep types (component + +// connection + org-service) is dispatchable when component and connection deps +// are deployed (regardless of org-service state). +func TestDepsAllDeployed_AllDepsDeployed(t *testing.T) { + task := makeTask("consumer", + []string{"backend"}, + []string{"postgres"}, + []string{"payment-service"}, + ) + statusByComp := map[string]string{"backend": string(models.TaskStatusDeployed)} + statusByConn := map[string]string{"postgres": string(models.TaskStatusDeployed)} + + if !depsAllDeployed(task, statusByComp, statusByConn, nil) { + t.Fatal("want true when component and connection deps are deployed; got false") + } +} + +// TestDepsAllDeployed_GatesOnResource: a component task with a platform-resource +// dep is held until the resource-provisioning task reaches `deployed`. +func TestDepsAllDeployed_GatesOnResource(t *testing.T) { + task := &models.ComponentTask{DependsOnResources: models.StringSlice{"maindb"}} + byComp := map[string]string{} + byConn := map[string]string{} + notReady := map[string]string{"maindb": string(models.TaskStatusBuilding)} + if depsAllDeployed(task, byComp, byConn, notReady) { + t.Fatal("should gate while provisioning") + } + ready := map[string]string{"maindb": string(models.TaskStatusDeployed)} + if !depsAllDeployed(task, byComp, byConn, ready) { + t.Fatal("should dispatch when deployed") + } +} diff --git a/asdlc-service/internal/feature/codingagent/dispatch_service.go b/asdlc-service/internal/feature/codingagent/dispatch_service.go index d23b9677..842b2eee 100644 --- a/asdlc-service/internal/feature/codingagent/dispatch_service.go +++ b/asdlc-service/internal/feature/codingagent/dispatch_service.go @@ -340,13 +340,14 @@ func (s *dispatchService) DispatchTasks(ctx context.Context, orgID, projectID st return nil, fmt.Errorf("get credential identity: %w", err) } - // Deploy-gating. Build a {componentName → status} index for - // dependsOn resolution. A task is dispatchable only when every task - // it dependsOn (by component name) has reached `deployed`. This is - // per-batch — DependsOnComponents lists names that map 1:1 to tasks - // in the same batch (validated at persist time in task_stream.go). + // Deploy-gating. Build {componentName → status}, {connectionName → status}, + // and {resourceName → status} indexes for dependsOn resolution. A task is + // dispatchable only when every task it dependsOn has reached `deployed`. + // Per-batch — DependsOnComponents lists names that map 1:1 to tasks in the + // same batch (validated at persist time in task_stream.go). statusByComponent := make(map[string]string, len(tasks)) statusByConnection := make(map[string]string, len(tasks)) + statusByResource := make(map[string]string, len(tasks)) for _, t := range tasks { if t.Type == models.TaskTypeConfigCollection { if t.ConnectionName != "" { @@ -354,15 +355,23 @@ func (s *dispatchService) DispatchTasks(ctx context.Context, orgID, projectID st } continue } + if t.Type == models.TaskTypeResourceProvisioning { + if t.ResourceName != "" { + statusByResource[t.ResourceName] = t.Status + } + continue + } statusByComponent[t.ComponentName] = t.Status } - // Cross-project `org-service` gate (declarative-wiring refactor): a consumer - // is held On Hold until each org-service target is deployed + namespace- - // visible in the org catalog, so at dispatch the dependency is fully - // resolvable (the issue comment + the agent's workload.yaml block can name a - // real endpoint). nil ⇒ catalog not wired (tests) ⇒ org-service gate off. - orgServiceVisible := s.orgServiceGates(ctx, orgID, tasks) + // NOTE(A2c): The cross-project `org-service` gate (orgServiceGates / + // orgServiceVisible) has been retired. Under the block-at-proceed model + // (A2b), a consumer task can only be dispatched once its org-service deps + // are `resolved` (namespace-visible in the catalog), so it is impossible + // to reach this dispatch sweep with an unresolved org-service dep. The + // org-service On-Hold gate was therefore a redundant double-check; it is + // removed here. Same-project component and external-connection gating in + // depsAllDeployed are unchanged. var results []DispatchResult for i := range tasks { @@ -372,8 +381,13 @@ func (s *dispatchService) DispatchTasks(ctx context.Context, orgID, projectID st if task.Type == models.TaskTypeConfigCollection { continue } + // resource-provisioning tasks are driven by the resource provisioner + + // readiness watcher, not the coding agent — never dispatch them. + if task.Type == models.TaskTypeResourceProvisioning { + continue + } if task.Status == string(models.TaskStatusOnHold) { - if !depsAllDeployed(task, statusByComponent, statusByConnection, orgServiceVisible) { + if !depsAllDeployed(task, statusByComponent, statusByConnection, statusByResource) { continue } task.Status = string(models.TaskStatusPending) @@ -386,7 +400,7 @@ func (s *dispatchService) DispatchTasks(ctx context.Context, orgID, projectID st continue } - if !depsAllDeployed(task, statusByComponent, statusByConnection, orgServiceVisible) { + if !depsAllDeployed(task, statusByComponent, statusByConnection, statusByResource) { task.Status = string(models.TaskStatusOnHold) if err := s.taskRepo.Update(ctx, task); err != nil { slog.WarnContext(ctx, "set on_hold", "task", task.ID, "error", err) @@ -407,46 +421,24 @@ func (s *dispatchService) DispatchTasks(ctx context.Context, orgID, projectID st return results, nil } -// orgServiceGates resolves, once per dispatch sweep, which distinct -// `org-service` names referenced by the project's tasks are currently -// namespace-visible in the org catalog (provider deployed + published). The -// result feeds depsAllDeployed's org-service gate. Returns nil when the catalog -// resolver isn't wired (tests/legacy) — nil disables the gate. Fail-closed on a -// per-name resolve error (left false → the consumer stays On Hold; the 10s -// on_hold watcher retries), so a transient OC blip delays rather than mis-fires. -func (s *dispatchService) orgServiceGates(ctx context.Context, orgID string, tasks []models.ComponentTask) map[string]bool { - if s.orgServiceResolver == nil { - return nil - } - visible := map[string]bool{} - for ti := range tasks { - for _, name := range tasks[ti].DependsOnOrgServices { - if _, seen := visible[name]; seen { - continue - } - _, ok, err := s.orgServiceResolver.ResolveNamespaceVisible(ctx, orgID, name) - if err != nil { - slog.WarnContext(ctx, "org-service gate: resolve failed (holding consumer)", - "org", orgID, "orgService", name, "error", err) - ok = false - } - visible[name] = ok - } - } - return visible -} - -// depsAllDeployed returns true when every blocker the task lists is satisfied: -// each DependsOnComponents name maps to a task whose Status == deployed, AND -// each DependsOnConnections name maps to a config-collection task whose -// Status == deployed (i.e. the connection's values were collected + the OC -// Resource model provisioned), AND — when orgServiceVisible is non-nil — each -// DependsOnOrgServices name resolves to a namespace-visible provider endpoint -// in the org catalog (i.e. the cross-project provider is deployed + published). -// Unknown names return false (fail closed; the persist-time validator in -// task_stream.go is the upstream guard). orgServiceVisible == nil disables the -// org-service gate (catalog not wired — tests/legacy), keeping the others. -func depsAllDeployed(task *models.ComponentTask, statusByComponent, statusByConnection map[string]string, orgServiceVisible map[string]bool) bool { +// depsAllDeployed returns true when every same-project blocker the task lists +// is satisfied: each DependsOnComponents name maps to a task whose Status == +// deployed, AND each DependsOnConnections name maps to a config-collection +// task whose Status == deployed (i.e. the connection's values were collected + +// the OC Resource model provisioned), AND each DependsOnResources name maps +// to a resource-provisioning task whose Status == deployed (i.e. the platform +// resource has been provisioned and is Ready). +// +// NOTE(A2c): The org-service (cross-project) gate has been retired. Under the +// block-at-proceed model (A2b) a consumer task can only reach dispatch after +// its org-service deps are `resolved` (namespace-visible in the catalog), so +// the On-Hold gate was a redundant double-check. DependsOnOrgServices is still +// stored on the task row and used for workload.yaml dep-comment rendering +// (resolveConsumerDependenciesYAML) — it just no longer gates dispatch. +// +// Unknown component/connection/resource names return false (fail closed; the +// persist-time validator in task_stream.go is the upstream guard). +func depsAllDeployed(task *models.ComponentTask, statusByComponent, statusByConnection, statusByResource map[string]string) bool { for _, depComponent := range task.DependsOnComponents { if statusByComponent[depComponent] != string(models.TaskStatusDeployed) { return false @@ -457,11 +449,9 @@ func depsAllDeployed(task *models.ComponentTask, statusByComponent, statusByConn return false } } - if orgServiceVisible != nil { - for _, depOrg := range task.DependsOnOrgServices { - if !orgServiceVisible[depOrg] { - return false - } + for _, depRes := range task.DependsOnResources { + if statusByResource[depRes] != string(models.TaskStatusDeployed) { + return false } } return true @@ -1137,17 +1127,21 @@ func (s *dispatchService) resolveConsumerDependenciesYAML( ctx context.Context, task *models.ComponentTask, ) (string, error) { - comp, err := artifacts.ResolveDesignComponent(ctx, s.store, task) - if err != nil { - return "", fmt.Errorf("resolve design component: %w", err) - } - var deps workloadDeps - // org-service endpoints (cross-project, visibility namespace). Skip any - // whose provider hasn't published namespace-visible yet — the cascade - // re-drives, and the agent can add it later. + // org-service endpoints (cross-project, visibility namespace) and same-project + // component siblings both require the design component to enumerate dep names. + // Resolve lazily — only when the org-service resolver is wired — so the + // platform-resource branch can run without a store in unit tests. if s.orgServiceResolver != nil { + comp, err := artifacts.ResolveDesignComponent(ctx, s.store, task) + if err != nil { + return "", fmt.Errorf("resolve design component: %w", err) + } + + // org-service endpoints (cross-project, visibility namespace). Skip any + // whose provider hasn't published namespace-visible yet — the cascade + // re-drives, and the agent can add it later. for _, name := range comp.OrgServiceDependsOn() { target, ok, rerr := s.orgServiceResolver.ResolveNamespaceVisible(ctx, task.OrgID, name) if rerr != nil { @@ -1164,16 +1158,14 @@ func (s *dispatchService) resolveConsumerDependenciesYAML( EnvBindings: map[string]string{"address": connections.OrgServiceURLEnv(name)}, }) } - } - // same-project `component` siblings (visibility project). The sibling's OC - // component/owner name is `-`; the gate already held - // this consumer until every same-project dep was deployed, so the sibling's - // Workload is in the catalog. project visibility is implicit, so this uses - // ResolveProjectEndpoint (NOT the namespace-visible lookup). The env var keys - // on the LOGICAL dep name (e.g. `todo-api` → `TODO_API_URL`), matching the - // ReleaseBinding `_URL` convention. Project is omitted (same project). - if s.orgServiceResolver != nil { + // same-project `component` siblings (visibility project). The sibling's OC + // component/owner name is `-`; the gate already held + // this consumer until every same-project dep was deployed, so the sibling's + // Workload is in the catalog. project visibility is implicit, so this uses + // ResolveProjectEndpoint (NOT the namespace-visible lookup). The env var keys + // on the LOGICAL dep name (e.g. `todo-api` → `TODO_API_URL`), matching the + // ReleaseBinding `_URL` convention. Project is omitted (same project). for _, depName := range comp.ComponentDependsOn() { ocComponent := task.ProjectID + "-" + depName target, ok, rerr := s.orgServiceResolver.ResolveProjectEndpoint(ctx, task.OrgID, task.ProjectID, ocComponent) @@ -1193,10 +1185,12 @@ func (s *dispatchService) resolveConsumerDependenciesYAML( } } - // external connection resources. Read each connection's development binding - // for its resolved outputs (== env var names; mirror WireConsumer's - // output.Name → output.Name mapping). Skip any not provisioned yet. if s.connBindingReader != nil { + // external connection resources. Read each connection's development binding + // for its resolved outputs. Connection outputs are pre-namespaced by the + // connection schema, so the env-var name == output name verbatim (mirrors + // WireConsumer's output.Name → output.Name mapping). Skip any not + // provisioned yet — the cascade re-drives. for _, conn := range task.DependsOnConnections { b, berr := s.connBindingReader(ctx, task.OrgID, connections.ConnectionBindingName(task.ProjectID, conn, "development")) if berr != nil || b == nil || b.Status == nil || len(b.Status.Outputs) == 0 { @@ -1212,6 +1206,37 @@ func (s *dispatchService) resolveConsumerDependenciesYAML( EnvBindings: envBindings, }) } + + // platform-resource deps (DependsOnResources). Read the development binding + // for each dep's resolved outputs. Unlike connection outputs (which are + // pre-namespaced by the connection schema), platform-resource outputs are + // generic names (host, port, user, password), so we prefix them with the + // dep name to avoid collisions when a component binds multiple resources: + // env var = envVarName(depName, outputName) (upper-cased, kebab → C-identifier) + // e.g. dep "orders-db" output "host" → ORDERS_DB_HOST; "password" → ORDERS_DB_PASSWORD. + // The dep name is kebab-case, so hyphens MUST be sanitized to underscores — + // a k8s env-var name must be a C_IDENTIFIER ([A-Za-z_][A-Za-z0-9_]*); a + // hyphen (e.g. "ORDERS-DB_HOST") makes the pod spec invalid and deploy fails. + // This is a deliberate divergence from the connection output.Name→output.Name + // verbatim mapping and matches the todo-db scenario's DB_* env convention. + // Binding name: --development (mirrors BuildPlatformBinding). + // Ref: - (mirrors BuildPlatformResource / A2's name). + for _, depName := range task.DependsOnResources { + bindingName := task.ProjectID + "-" + depName + "-development" + b, berr := s.connBindingReader(ctx, task.OrgID, bindingName) + if berr != nil || b == nil || b.Status == nil || len(b.Status.Outputs) == 0 { + // Not provisioned / not ready yet — skip; the cascade re-drives. + continue + } + envBindings := make(map[string]string, len(b.Status.Outputs)) + for _, out := range b.Status.Outputs { + envBindings[out.Name] = envVarName(depName, out.Name) + } + deps.Resources = append(deps.Resources, workloadResourceDepYAML{ + Ref: task.ProjectID + "-" + depName, + EnvBindings: envBindings, + }) + } } if len(deps.Endpoints) == 0 && len(deps.Resources) == 0 { @@ -1225,6 +1250,24 @@ func (s *dispatchService) resolveConsumerDependenciesYAML( return string(out), nil } +// envVarName builds a valid Kubernetes/C-identifier env-var name from a +// platform-resource dep name + output name. The dep name is kebab-case (e.g. +// "orders-db") and outputs are generic (host/port/user/…), so every character +// outside [A-Za-z0-9_] is mapped to '_' and the whole thing upper-cased — +// "orders-db" + "host" → "ORDERS_DB_HOST". A k8s env-var name must be a +// C_IDENTIFIER; a hyphen would make the pod spec invalid and fail the deploy. +func envVarName(depName, outName string) string { + mapped := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + return r + default: + return '_' + } + }, depName+"_"+outName) + return strings.ToUpper(mapped) +} + // postConsumerDependencyComment renders the resolved consumer-dependency block // (if any) and posts it to the task's GitHub issue. Best-effort: a resolve or // post failure is logged and swallowed — it must never fail the dispatch. diff --git a/asdlc-service/internal/feature/codingagent/resource_watcher.go b/asdlc-service/internal/feature/codingagent/resource_watcher.go new file mode 100644 index 00000000..5a1d2255 --- /dev/null +++ b/asdlc-service/internal/feature/codingagent/resource_watcher.go @@ -0,0 +1,242 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package codingagent + +import ( + "context" + "log/slog" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// ResourceWatcher polls OpenChoreo for the status of in-flight resource-provisioning +// tasks. When the backing ResourceReleaseBinding reaches Ready=True, the task is +// transitioned to `deployed` and the cascade redispatch fires so any gated component +// tasks are unblocked. +// +// Mirrors BuildWatcher's structure (ticker Run, sweep with FOR UPDATE SKIP LOCKED, +// asServiceIdentity) — the template is codingagent/build_watcher.go. +// +// A real database (postgres-cnpg, Redis, …) can take several minutes to stand up. +// The watcher MUST NOT block the provision request path on readiness. It polls at +// the same 10s cadence as the build watcher and uses a bounded-age guard (10 min) +// to log + leave a stuck task rather than failing it spuriously. +// +// Multi-replica safe: FOR UPDATE SKIP LOCKED prevents two BFF replicas from +// double-processing the same task. +type ResourceWatcher struct { + db *gorm.DB + rc openchoreo.ResourceClient + redispatch OnHoldDispatcher + asServiceIdentity func(ctx context.Context) context.Context + tick time.Duration + staleAfter time.Duration +} + +// NewResourceWatcher constructs a ResourceWatcher. +// rc: the OC resource client (GetBinding); redispatch: the cascade adapter +// (same signature as OnHoldDispatcher); asServiceIdentity: ADR-0005 dual-mode +// OC auth (async OC-reading paths run under service-identity). +func NewResourceWatcher( + db *gorm.DB, + rc openchoreo.ResourceClient, + redispatch OnHoldDispatcher, + asServiceIdentity func(ctx context.Context) context.Context, +) *ResourceWatcher { + return &ResourceWatcher{ + db: db, + rc: rc, + redispatch: redispatch, + asServiceIdentity: asServiceIdentity, + tick: 10 * time.Second, + staleAfter: 10 * time.Minute, + } +} + +// Run blocks until ctx is cancelled, sweeping at the configured cadence. +// Spawned as a goroutine from main beside buildWatcher.Run and onHoldWatcher.Run. +func (w *ResourceWatcher) Run(ctx context.Context) { + ticker := time.NewTicker(w.tick) + defer ticker.Stop() + slog.InfoContext(ctx, "resource watcher started", "tick", w.tick, "staleAfter", w.staleAfter) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.sweep(ctx) + } + } +} + +func (w *ResourceWatcher) sweep(ctx context.Context) { + if w.asServiceIdentity != nil { + ctx = w.asServiceIdentity(ctx) + } + + // Acquire a batch of `building` resource-provisioning tasks under + // FOR UPDATE SKIP LOCKED so concurrent BFF replicas split the work. + var batch []models.ComponentTask + err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return tx.Raw(` + SELECT * FROM component_tasks + WHERE type = ? AND status = ? + ORDER BY last_event_at NULLS FIRST + LIMIT 50 + FOR UPDATE SKIP LOCKED + `, models.TaskTypeResourceProvisioning, string(models.TaskStatusBuilding)). + Scan(&batch).Error + }) + if err != nil { + slog.ErrorContext(ctx, "resource watcher: select batch failed", "error", err) + return + } + if len(batch) == 0 { + return + } + + for i := range batch { + t := &batch[i] + w.handleTask(ctx, t) + } +} + +func (w *ResourceWatcher) handleTask(ctx context.Context, t *models.ComponentTask) { + // Bounded-age guard: a stuck provision logs + leaves rather than failing + // spuriously. A real DB should not take more than staleAfter to reach Ready. + if t.LastEventAt != nil && time.Since(*t.LastEventAt) > w.staleAfter { + slog.WarnContext(ctx, "resource watcher: task stale, skipping", + "task", t.ID, "resource", t.ResourceName, + "age", time.Since(*t.LastEventAt).Round(time.Second)) + return + } + + // Derive the binding name (mirrors BuildPlatformBinding: project-resource-env). + // The single-env assumption (dispatch_service.go:807) uses "development". + bindingName := t.ProjectID + "-" + t.ResourceName + "-development" + + binding, err := w.rc.GetBinding(ctx, t.OrgID, bindingName) + if err != nil { + // Transient — try again on the next tick. + slog.WarnContext(ctx, "resource watcher: GetBinding failed", + "task", t.ID, "binding", bindingName, "error", err) + return + } + // GetBinding returns (nil, nil) on 404 — the binding may not exist yet. + // Treat a nil binding as not-ready; leave for the next tick. + + done, failed := classifyBinding(binding) + switch { + case done: + if err := w.db.WithContext(ctx).Model(&models.ComponentTask{}). + Where("id = ?", t.ID). + Update("status", string(models.TaskStatusDeployed)).Error; err != nil { + slog.ErrorContext(ctx, "resource watcher: mark deployed failed", + "task", t.ID, "error", err) + return + } + slog.InfoContext(ctx, "resource watcher: resource ready → deployed", + "task", t.ID, "resource", t.ResourceName) + // Cascade: unblock component tasks gated on this resource. + if w.redispatch != nil { + count, rerr := w.redispatch(ctx, t.OrgID, t.ProjectID) + if rerr != nil { + slog.WarnContext(ctx, "resource watcher: redispatch failed", + "task", t.ID, "org", t.OrgID, "project", t.ProjectID, "error", rerr) + } else if count > 0 { + slog.InfoContext(ctx, "resource watcher: cascaded dispatch after resource ready", + "task", t.ID, "org", t.OrgID, "project", t.ProjectID, "dispatched", count) + } + } + case failed: + if err := w.db.WithContext(ctx).Model(&models.ComponentTask{}). + Where("id = ?", t.ID). + Update("status", string(models.TaskStatusFailed)).Error; err != nil { + slog.ErrorContext(ctx, "resource watcher: mark failed failed", + "task", t.ID, "error", err) + return + } + slog.WarnContext(ctx, "resource watcher: binding in terminal failed state", + "task", t.ID, "resource", t.ResourceName) + default: + // Still pending / unknown — leave for next tick. + } +} + +// classifyBinding maps a ResourceReleaseBinding to a (done, failed) pair. +// Pure function — no side effects, directly unit-testable. +// +// - Ready=True → (true, false) — provisioning complete, cascade +// - Terminal False condition (non-progressing reason) → (false, true) — hard failure +// - Pending / Unknown / nil → (false, false) — leave for next tick +func classifyBinding(b *openchoreo.ResourceReleaseBinding) (done bool, failed bool) { + if b == nil { + return false, false + } + if b.IsReady() { + return true, false + } + if b.Status == nil { + return false, false + } + // A False condition with a non-progressing reason is terminal. + // "Progressing" reasons (e.g. "Creating", "Initializing", "") mean we should wait. + for _, c := range b.Status.Conditions { + if c.Status != "False" { + continue + } + if isTerminalReason(c.Reason) { + return false, true + } + } + return false, false +} + +// progressingReasons are non-terminal False reasons — the controller is still +// working. Any False condition with a reason NOT matched here is considered +// terminal. An empty reason means "still initializing". +var progressingReasons = map[string]bool{ + "": true, // empty reason → still initializing +} + +// progressingSubstrings catch OpenChoreo's COMPOUND progressing reasons — the +// binding controller emits e.g. "ResourcesProgressing" (ResourcesReady=False) +// while the backing resource (a CNPG Postgres "Setting up primary") is still +// coming up. A bare "Progressing" whitelist missed these, so a healthy +// mid-provision binding was mis-classified as a hard failure. Matching the +// substring is deliberately lenient: a genuinely stuck binding is caught by the +// watcher's staleAfter age guard, so erring toward "still working" is safe. +var progressingSubstrings = []string{ + "Progressing", "Pending", "Waiting", "Creating", "Initializing", "Provisioning", +} + +func isTerminalReason(reason string) bool { + if progressingReasons[reason] { + return false + } + for _, s := range progressingSubstrings { + if strings.Contains(reason, s) { + return false + } + } + return true +} diff --git a/asdlc-service/internal/feature/codingagent/resource_watcher_test.go b/asdlc-service/internal/feature/codingagent/resource_watcher_test.go new file mode 100644 index 00000000..b8244cc9 --- /dev/null +++ b/asdlc-service/internal/feature/codingagent/resource_watcher_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package codingagent + +import ( + "context" + "testing" + "time" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// ---- classifyBinding unit tests (Step 1 / TDD RED → GREEN) ----------------- +// Pure function, no DB, no OC client. Exercises the three buckets: +// - Ready=True → (done=true, failed=false) +// - Terminal False → (done=false, failed=true) +// - Nil / pending → (done=false, failed=false) + +func TestClassifyBinding_Nil(t *testing.T) { + done, failed := classifyBinding(nil) + if done || failed { + t.Fatalf("nil binding: want (false,false), got (%v,%v)", done, failed) + } +} + +func TestClassifyBinding_NoStatus(t *testing.T) { + b := &openchoreo.ResourceReleaseBinding{Status: nil} + done, failed := classifyBinding(b) + if done || failed { + t.Fatalf("no-status binding: want (false,false), got (%v,%v)", done, failed) + } +} + +func TestClassifyBinding_Ready(t *testing.T) { + b := &openchoreo.ResourceReleaseBinding{ + Status: &openchoreo.ResourceReleaseBindingStatus{ + Conditions: []openchoreo.OCCondition{ + {Type: "Ready", Status: "True"}, + }, + }, + } + done, failed := classifyBinding(b) + if !done || failed { + t.Fatalf("Ready=True: want (true,false), got (%v,%v)", done, failed) + } +} + +func TestClassifyBinding_ReadyFalse_TerminalReason(t *testing.T) { + // A False condition with a non-progressing reason is terminal → failed. + b := &openchoreo.ResourceReleaseBinding{ + Status: &openchoreo.ResourceReleaseBindingStatus{ + Conditions: []openchoreo.OCCondition{ + {Type: "ResourcesReady", Status: "False", Reason: "Failed"}, + }, + }, + } + done, failed := classifyBinding(b) + if done || !failed { + t.Fatalf("terminal-False condition: want (false,true), got (%v,%v)", done, failed) + } +} + +func TestClassifyBinding_ReadyFalse_ProgressingReason(t *testing.T) { + // A False condition with a progressing reason is NOT terminal — still waiting. + // Includes OpenChoreo's COMPOUND reasons (e.g. "ResourcesProgressing", emitted + // while a CNPG Postgres is "Setting up primary") — the live-E2E regression. + for _, reason := range []string{"Creating", "Initializing", "Progressing", "Pending", "", "ResourcesProgressing", "ResourcesProvisioning"} { + b := &openchoreo.ResourceReleaseBinding{ + Status: &openchoreo.ResourceReleaseBindingStatus{ + Conditions: []openchoreo.OCCondition{ + {Type: "ResourcesReady", Status: "False", Reason: reason}, + }, + }, + } + done, failed := classifyBinding(b) + if done || failed { + t.Fatalf("progressing reason %q: want (false,false), got (%v,%v)", reason, done, failed) + } + } +} + +func TestClassifyBinding_ReadyUnknown(t *testing.T) { + b := &openchoreo.ResourceReleaseBinding{ + Status: &openchoreo.ResourceReleaseBindingStatus{ + Conditions: []openchoreo.OCCondition{ + {Type: "Ready", Status: "Unknown"}, + }, + }, + } + done, failed := classifyBinding(b) + if done || failed { + t.Fatalf("Ready=Unknown: want (false,false), got (%v,%v)", done, failed) + } +} + +// ---- sweep integration test (Step 4) ---------------------------------------- +// Requires the deployments Postgres (docker-compose). Uses the dbtest harness +// pattern: skipped when the DB is unreachable so `go test ./...` is safe without +// a local stack. Build tag: dbtest. +// +// The test inserts a `building` resource-provisioning task, runs sweep once with +// a fake ResourceClient that reports the binding as ready, and asserts: +// - The task row reaches `deployed`. +// - The redispatch callback fires exactly once for the task's (org, project). +// - A second sweep with a nil binding leaves a fresh `building` task unchanged. + +// fakeResourceClient is a minimal openchoreo.ResourceClient stub for sweep tests. +// It returns the configured binding for any GetBinding call; all other methods +// are no-ops that return nil. +type fakeResourceClient struct { + binding *openchoreo.ResourceReleaseBinding + getErr error +} + +func (f *fakeResourceClient) EnsureResourceType(_ context.Context, _ string, rt *openchoreo.ResourceType) (*openchoreo.ResourceType, error) { + return rt, nil +} +func (f *fakeResourceClient) ApplyResource(_ context.Context, _ string, r *openchoreo.Resource) (*openchoreo.Resource, error) { + return r, nil +} +func (f *fakeResourceClient) GetResource(_ context.Context, _, _ string) (*openchoreo.Resource, error) { + return nil, nil +} +func (f *fakeResourceClient) EnsureBinding(_ context.Context, _ string, b *openchoreo.ResourceReleaseBinding) (*openchoreo.ResourceReleaseBinding, error) { + return b, nil +} +func (f *fakeResourceClient) GetBinding(_ context.Context, _, _ string) (*openchoreo.ResourceReleaseBinding, error) { + return f.binding, f.getErr +} +func (f *fakeResourceClient) DeleteBinding(_ context.Context, _, _ string) error { return nil } +func (f *fakeResourceClient) DeleteResource(_ context.Context, _, _ string) error { return nil } +func (f *fakeResourceClient) ListClusterResourceTypes(_ context.Context) ([]openchoreo.ResourceType, error) { + return nil, nil +} +func (f *fakeResourceClient) PatchWorkloadResourceDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadResourceDep) error { + return nil +} +func (f *fakeResourceClient) PatchWorkloadEndpointDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadEndpointDep) error { + return nil +} +func (f *fakeResourceClient) ListWorkloadEndpoints(_ context.Context, _ string) ([]openchoreo.WorkloadEndpointInfo, error) { + return nil, nil +} + +// readyBinding returns a ResourceReleaseBinding with Ready=True. +func readyBinding() *openchoreo.ResourceReleaseBinding { + return &openchoreo.ResourceReleaseBinding{ + Status: &openchoreo.ResourceReleaseBindingStatus{ + Conditions: []openchoreo.OCCondition{ + {Type: "Ready", Status: "True"}, + }, + }, + } +} + +// TestResourceWatcher_Sweep_ReadyBinding_DeploysTask is a placeholder for a +// future Postgres-backed test that verifies the full sweep path: it would insert +// a `building` resource-provisioning task, run sweep with a ready-binding fake, +// and confirm the task reaches `deployed` + redispatch is called once. The test +// requires the deployments Postgres (docker-compose on :5433) and is not yet +// implemented; remove this Skip and add assertions when that harness is wired. +func TestResourceWatcher_Sweep_ReadyBinding_DeploysTask(t *testing.T) { + t.Skip("placeholder: requires a live Postgres (deployments Postgres on :5433) — no assertions yet") +} + +// TestResourceWatcher_Sweep_NilBinding_LeavesTask is a no-DB unit test that +// exercises the real handleTask path when GetBinding returns (nil, nil) — the +// in-flight case where the binding has not been created yet. handleTask must +// return without touching w.db or calling redispatch (the nil-binding branch +// hits classifyBinding → (false,false) → default case → leave for next tick). +func TestResourceWatcher_Sweep_NilBinding_LeavesTask(t *testing.T) { + redispatchCalled := 0 + w := &ResourceWatcher{ + // db is nil intentionally: the not-ready branch of handleTask never + // reaches any DB call, so a nil *gorm.DB must not cause a panic. + db: nil, + rc: &fakeResourceClient{binding: nil}, + redispatch: func(_ context.Context, _, _ string) (int, error) { + redispatchCalled++ + return 1, nil + }, + asServiceIdentity: nil, + tick: 10 * time.Second, + staleAfter: 10 * time.Minute, + } + + task := models.ComponentTask{ + ID: "test-task-nil-binding", + OrgID: "test-org", + ProjectID: "test-project", + ResourceName: "my-db", + Status: string(models.TaskStatusBuilding), + // LastEventAt is nil → stale guard is skipped. + } + + // Call the real per-task method — must not panic even with db==nil. + w.handleTask(context.Background(), &task) + + if redispatchCalled != 0 { + t.Fatalf("nil binding must not trigger redispatch, got %d calls", redispatchCalled) + } + // Status is a local copy; the real status remains unchanged because no DB + // write is issued on the not-ready branch. + if task.Status != string(models.TaskStatusBuilding) { + t.Fatalf("nil binding must leave task status unchanged; got %q", task.Status) + } +} + +// TestClassifyBinding_ReadyBinding_ReturnsDone asserts that classifyBinding +// returns (true, false) for a binding whose Ready condition is True. +// The full handleTask path (DB update + redispatch) is exercised separately +// by the Postgres-backed stub above once that harness is wired. +func TestClassifyBinding_ReadyBinding_ReturnsDone(t *testing.T) { + done, failed := classifyBinding(readyBinding()) + if !done || failed { + t.Fatalf("ready binding classify: want (true,false), got (%v,%v)", done, failed) + } +} diff --git a/asdlc-service/internal/feature/connections/mcp_server.go b/asdlc-service/internal/feature/connections/mcp_server.go index a1a23f53..f2004b7b 100644 --- a/asdlc-service/internal/feature/connections/mcp_server.go +++ b/asdlc-service/internal/feature/connections/mcp_server.go @@ -22,6 +22,7 @@ import ( "log/slog" "net/http" + "github.com/wso2/asdlc/asdlc-service/internal/feature/resources" "github.com/wso2/asdlc/asdlc-service/models" ) @@ -96,12 +97,22 @@ type orgEndpointView struct { NamespaceVisible bool `json:"namespaceVisible"` // consumable cross-project as an org-service } +// mcpHandler holds the dependencies for the JSON-RPC MCP server. +type mcpHandler struct { + registry *Registry + catalog *OrgEndpointCatalog + resourceTypes *resources.ResourceTypeCatalog +} + // NewMCPHandler returns the JSON-RPC MCP handler for the connection registry + -// the org endpoint catalog. orgHandle is read from the request path. catalog may -// be nil (the list_org_endpoints tool then reports an empty catalog). -func NewMCPHandler(registry *Registry, catalog *OrgEndpointCatalog) http.Handler { +// the org endpoint catalog + the platform-resource-type catalog. orgHandle is +// read from the request path. catalog may be nil (the list_org_endpoints tool +// then reports an empty catalog). resourceTypes may be nil (the +// list_platform_resource_types tool then reports an empty list). +func NewMCPHandler(registry *Registry, catalog *OrgEndpointCatalog, resourceTypes *resources.ResourceTypeCatalog) http.Handler { + h := &mcpHandler{registry: registry, catalog: catalog, resourceTypes: resourceTypes} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if registry == nil { + if h.registry == nil { http.Error(w, "connections registry not configured", http.StatusServiceUnavailable) return } @@ -131,7 +142,7 @@ func NewMCPHandler(registry *Registry, catalog *OrgEndpointCatalog) http.Handler case "tools/list": writeRPCResult(w, req.ID, map[string]any{"tools": mcpTools()}) case "tools/call": - handleToolCall(w, r, registry, catalog, orgHandle, req) + handleToolCall(w, r, h, orgHandle, req) default: writeRPCError(w, req.ID, -32601, "method not found: "+req.Method) } @@ -169,10 +180,18 @@ func mcpTools() []mcpTool { "published it cross-project, so it cannot be consumed yet.", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, }, + { + Name: "list_platform_resource_types", + Description: "List the platform-provisioned resource types (databases, caches, queues) installed " + + "on the cluster. Each entry is a resourceType you can reference in a platform-resource " + + "dependency, with its provisioning parameters and the outputs it exposes. Read-only — you " + + "never author these.", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }, } } -func handleToolCall(w http.ResponseWriter, r *http.Request, registry *Registry, catalog *OrgEndpointCatalog, orgHandle string, req jsonrpcRequest) { +func handleToolCall(w http.ResponseWriter, r *http.Request, h *mcpHandler, orgHandle string, req jsonrpcRequest) { var call struct { Name string `json:"name"` Arguments struct { @@ -187,7 +206,7 @@ func handleToolCall(w http.ResponseWriter, r *http.Request, registry *Registry, switch call.Name { case "list_connections": - conns, err := registry.List(r.Context(), orgHandle) + conns, err := h.registry.List(r.Context(), orgHandle) if err != nil { writeToolError(w, req.ID, fmt.Sprintf("list connections: %v", err)) return @@ -202,7 +221,7 @@ func handleToolCall(w http.ResponseWriter, r *http.Request, registry *Registry, writeToolError(w, req.ID, "missing required argument: name") return } - c, err := registry.Get(r.Context(), orgHandle, call.Arguments.Name) + c, err := h.registry.Get(r.Context(), orgHandle, call.Arguments.Name) if err != nil { writeToolError(w, req.ID, fmt.Sprintf("get connection: %v", err)) return @@ -213,7 +232,11 @@ func handleToolCall(w http.ResponseWriter, r *http.Request, registry *Registry, } writeToolText(w, req.ID, mustJSON(map[string]any{"found": true, "connection": toConnectionView(c)})) case "list_org_endpoints": - infos, err := catalog.List(r.Context(), orgHandle) + if h.catalog == nil { + writeToolText(w, req.ID, mustJSON(map[string]any{"endpoints": []any{}})) + return + } + infos, err := h.catalog.List(r.Context(), orgHandle) if err != nil { writeToolError(w, req.ID, fmt.Sprintf("list org endpoints: %v", err)) return @@ -229,6 +252,17 @@ func handleToolCall(w http.ResponseWriter, r *http.Request, registry *Registry, }) } writeToolText(w, req.ID, mustJSON(map[string]any{"endpoints": views})) + case "list_platform_resource_types": + if h.resourceTypes == nil { + writeToolText(w, req.ID, mustJSON(map[string]any{"resourceTypes": []any{}})) + return + } + types, err := h.resourceTypes.List(r.Context()) + if err != nil { + writeToolError(w, req.ID, fmt.Sprintf("list platform resource types: %v", err)) + return + } + writeToolText(w, req.ID, mustJSON(map[string]any{"resourceTypes": types})) default: writeRPCError(w, req.ID, -32602, "unknown tool: "+call.Name) } diff --git a/asdlc-service/internal/feature/connections/mcp_server_test.go b/asdlc-service/internal/feature/connections/mcp_server_test.go new file mode 100644 index 00000000..9f65b73e --- /dev/null +++ b/asdlc-service/internal/feature/connections/mcp_server_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package connections + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestMCPHandler_NilResourceTypes verifies that NewMCPHandler with a nil +// resourceTypes does NOT panic and returns a success response with an empty +// resourceTypes list when list_platform_resource_types is called. +func TestMCPHandler_NilResourceTypes(t *testing.T) { + // Registry must be non-nil (the handler's first check), but we never call + // any DB method here — passing a zero-value Registry is sufficient. + reg := &Registry{} + h := NewMCPHandler(reg, nil, nil) // nil catalog + nil resourceTypes + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_platform_resource_types","arguments":{}}}` + req := httptest.NewRequest(http.MethodPost, "/internal/organizations/default/mcp", strings.NewReader(body)) + req.SetPathValue("orgHandle", "default") + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp jsonrpcResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Error != nil { + t.Fatalf("unexpected JSON-RPC error: %+v", resp.Error) + } + + // The result must have content[0].text that is a JSON object with resourceTypes=[]. + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("result is not an object: %T %+v", resp.Result, resp.Result) + } + content, ok := result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("expected non-empty content array, got %+v", result["content"]) + } + textBlock, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("content[0] is not an object: %T", content[0]) + } + textStr, ok := textBlock["text"].(string) + if !ok { + t.Fatalf("content[0].text is not a string: %T", textBlock["text"]) + } + + var payload map[string]any + if err := json.Unmarshal([]byte(textStr), &payload); err != nil { + t.Fatalf("unmarshal text payload: %v", err) + } + rtRaw, ok := payload["resourceTypes"] + if !ok { + t.Fatalf("payload missing resourceTypes key: %+v", payload) + } + rtList, ok := rtRaw.([]any) + if !ok { + t.Fatalf("resourceTypes is not an array: %T", rtRaw) + } + if len(rtList) != 0 { + t.Fatalf("expected empty resourceTypes, got %v", rtList) + } +} diff --git a/asdlc-service/internal/feature/connections/org_endpoint_catalog.go b/asdlc-service/internal/feature/connections/org_endpoint_catalog.go index d159d2aa..a6d42e63 100644 --- a/asdlc-service/internal/feature/connections/org_endpoint_catalog.go +++ b/asdlc-service/internal/feature/connections/org_endpoint_catalog.go @@ -122,3 +122,54 @@ func (c *OrgEndpointCatalog) IsNamespaceVisible(ctx context.Context, orgHandle, _, ok, err := c.ResolveNamespaceVisible(ctx, orgHandle, name) return ok, err } + +// FindByComponent returns the catalog row owned by a component named `name`, +// regardless of visibility — the P3.5 provider lookup. The org-service name a +// consumer references is the OC component name (catalog key); this resolves it +// to the provider row so the request flow can derive the provider project and +// the component's app path. When the component publishes several endpoints, an +// HTTP one wins (services expose a single API endpoint in practice); otherwise +// the first match is the fallback. ok=false ⇒ no component with that name (the +// `not-found` case — not requestable). +func (c *OrgEndpointCatalog) FindByComponent(ctx context.Context, orgHandle, name string) (openchoreo.WorkloadEndpointInfo, bool, error) { + infos, err := c.List(ctx, orgHandle) + if err != nil { + return openchoreo.WorkloadEndpointInfo{}, false, err + } + var fallback *openchoreo.WorkloadEndpointInfo + for i := range infos { + e := &infos[i] + if e.Component != name { + continue + } + if e.Type == "HTTP" { + return *e, true, nil + } + if fallback == nil { + fallback = e + } + } + if fallback != nil { + return *fallback, true, nil + } + return openchoreo.WorkloadEndpointInfo{}, false, nil +} + +// ExistsAnyVisibility reports whether ANY endpoint in the catalog is owned by a +// component named `name`, regardless of visibility. It distinguishes "published +// only project-only" (exists → requestable, P3.5 `blocked`/`access-required`) from "no such +// component at all" (P3.5 `not-found`). Used by the ArtifactStore to compute the +// `reason` for an unresolved org-service dependency. Errors are surfaced so the +// caller can degrade (best-effort: leave the reason empty). +func (c *OrgEndpointCatalog) ExistsAnyVisibility(ctx context.Context, orgHandle, name string) (bool, error) { + infos, err := c.List(ctx, orgHandle) + if err != nil { + return false, err + } + for i := range infos { + if infos[i].Component == name { + return true, nil + } + } + return false, nil +} diff --git a/asdlc-service/internal/feature/connections/org_endpoint_catalog_test.go b/asdlc-service/internal/feature/connections/org_endpoint_catalog_test.go index d2684bf6..cc55ea31 100644 --- a/asdlc-service/internal/feature/connections/org_endpoint_catalog_test.go +++ b/asdlc-service/internal/feature/connections/org_endpoint_catalog_test.go @@ -82,6 +82,53 @@ func TestOrgEndpointCatalog_ResolveNamespaceVisible(t *testing.T) { } } +func TestOrgEndpointCatalog_ExistsAnyVisibility(t *testing.T) { + cat := NewOrgEndpointCatalog(&fakeRC{workloadEndpoints: sampleEndpoints()}) + + // A project-only component (NOT namespace-visible) still exists in the + // catalog → ExistsAnyVisibility true (the P3.5 `blocked`/`access-required` case). + got, err := cat.ExistsAnyVisibility(context.Background(), "ns", "org-roster-todo-api") + if err != nil { + t.Fatalf("org-roster-todo-api: unexpected error: %v", err) + } + if !got { + t.Fatalf("org-roster-todo-api: want exists=true (project-only but present)") + } + + // An unknown component does not exist at any visibility → the `not-found` case. + got, err = cat.ExistsAnyVisibility(context.Background(), "ns", "nope") + if err != nil { + t.Fatalf("nope: unexpected error: %v", err) + } + if got { + t.Fatalf("nope: want exists=false") + } +} + +func TestOrgEndpointCatalog_FindByComponent(t *testing.T) { + cat := NewOrgEndpointCatalog(&fakeRC{workloadEndpoints: sampleEndpoints()}) + + // A project-only component (NOT namespace-visible) is still found — P3.5 + // resolves the provider row regardless of visibility to derive its project. + got, ok, err := cat.FindByComponent(context.Background(), "ns", "payroll-internal") + if err != nil || !ok { + t.Fatalf("payroll-internal: want found, got ok=%v err=%v", ok, err) + } + if got.Project != "hr" || got.Name != "http" || got.Type != "HTTP" { + t.Fatalf("resolved wrong row: %+v", got) + } + + // An org-published component is also found. + if _, ok, _ := cat.FindByComponent(context.Background(), "ns", "employee-api"); !ok { + t.Fatalf("employee-api: want found") + } + + // Unknown component must not resolve (the `not-found` case). + if _, ok, _ := cat.FindByComponent(context.Background(), "ns", "nope"); ok { + t.Fatalf("unknown component must not resolve") + } +} + func TestOrgServiceURLEnv(t *testing.T) { cases := map[string]string{ "employee-api": "EMPLOYEE_API_URL", diff --git a/asdlc-service/internal/feature/design/design_huma.go b/asdlc-service/internal/feature/design/design_huma.go index dc5c3595..bcd47b17 100644 --- a/asdlc-service/internal/feature/design/design_huma.go +++ b/asdlc-service/internal/feature/design/design_huma.go @@ -20,6 +20,7 @@ import ( "context" "errors" "net/http" + "strings" "github.com/danielgtaylor/huma/v2" @@ -74,6 +75,24 @@ type designOutput struct{ Body *models.Design } type designBundleOutput struct{ Body *DesignBundle } type designVersionsOutput struct{ Body []models.ArtifactVersion } +type collectSpecInput struct { + humakit.OrgScopedInput + ProjectName string `path:"projectName" doc:"Project name (DNS-label slug)"` + ComponentName string `path:"componentName" doc:"Component name"` + DepName string `path:"depName" doc:"Dependency name"` + Body struct { + RawSpec string `json:"rawSpec,omitempty" doc:"Raw OpenAPI YAML/JSON (paste path)"` + SpecURL string `json:"specUrl,omitempty" doc:"HTTPS URL to fetch the OpenAPI spec from"` + } +} + +type collectSpecOutput struct { + Body struct { + SpecPath string `json:"specPath" doc:"Component-relative path where the spec was stored"` + OperationCount int `json:"operationCount" doc:"Number of HTTP operations in the stored spec"` + } +} + // RegisterDesign registers the design feature's NON-STREAMING HTTP operations // on the Huma API. It is the code-first replacement for registerDesignRoutes // (api/design_routes.go): same paths, same auth posture, with the spec @@ -185,6 +204,9 @@ func RegisterDesign(api huma.API, svc DesignService) { if errors.Is(err, ErrSpecNotApproved) { return nil, huma.Error409Conflict("save requirements first — no v baseline tag") } + if errors.Is(err, ErrUnresolvedDependency) { + return nil, huma.Error409Conflict(err.Error()) + } return nil, huma.Error500InternalServerError("failed to save and proceed design") } return &designOutput{Body: design}, nil @@ -289,6 +311,39 @@ func RegisterDesign(api huma.API, svc DesignService) { _ = svc.StreamGenerateDesign(hctx.Context(), in.OrgHandle, in.ProjectName, w, flush) }}, nil }) + + huma.Register(api, huma.Operation{ + OperationID: "collect-dependency-spec", + Method: http.MethodPost, + Path: "/api/v1/organizations/{orgHandle}/projects/{projectName}/components/{componentName}/dependencies/{depName}/spec", + Summary: "Store a consumed OpenAPI spec for an external dependency", + Tags: []string{"Design"}, + Security: humakit.SecurityUserJWT, + DefaultStatus: http.StatusOK, + }, func(ctx context.Context, in *collectSpecInput) (*collectSpecOutput, error) { + rawSpec := strings.TrimSpace(in.Body.RawSpec) + specURL := strings.TrimSpace(in.Body.SpecURL) + if rawSpec == "" && specURL == "" { + return nil, huma.Error400BadRequest("provide exactly one of rawSpec or specUrl") + } + if rawSpec != "" && specURL != "" { + return nil, huma.Error400BadRequest("provide only one of rawSpec or specUrl") + } + specPath, opCount, err := svc.CollectSpec(ctx, in.OrgHandle, in.ProjectName, in.ComponentName, in.DepName, rawSpec, specURL) + if err != nil { + if errors.Is(err, ErrSpecFetchFailed) { + return nil, huma.Error502BadGateway("failed to fetch spec from URL", err) + } + if errors.Is(err, ErrInvalidSpec) { + return nil, huma.Error400BadRequest("invalid spec: " + err.Error()) + } + return nil, huma.Error500InternalServerError("failed to store spec") + } + out := &collectSpecOutput{} + out.Body.SpecPath = specPath + out.Body.OperationCount = opCount + return out, nil + }) } // mapDesignError translates the design feature's sentinel errors into RFC 9457 diff --git a/asdlc-service/internal/feature/design/design_huma_test.go b/asdlc-service/internal/feature/design/design_huma_test.go new file mode 100644 index 00000000..155246e5 --- /dev/null +++ b/asdlc-service/internal/feature/design/design_huma_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package design + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + + "github.com/wso2/asdlc/asdlc-service/internal/platform/humakit" + "github.com/wso2/asdlc/asdlc-service/internal/platform/tenant" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// emptyBody is passed to humatest.Post for operations that take no request body +// (the save-and-proceed handler has no body). +var emptyBody = struct{}{} + +// stubDesignService is a minimal DesignService stub for handler-level tests. +// Only SaveAndProceed has interesting behaviour; all other methods return +// sensible zero values. +type stubDesignService struct { + saveAndProceedErr error +} + +func (s *stubDesignService) GetDesign(_ context.Context, _, _ string) (*models.Design, error) { + return &models.Design{}, nil +} +func (s *stubDesignService) GetDesignBundle(_ context.Context, _, _ string) (*DesignBundle, error) { + return &DesignBundle{Files: map[string]string{}, Design: &models.Design{}}, nil +} +func (s *stubDesignService) GetDesignAtTag(_ context.Context, _, _, _ string) (*models.Design, error) { + return &models.Design{}, nil +} +func (s *stubDesignService) GetDesignBundleAtTag(_ context.Context, _, _, _ string) (*DesignBundle, error) { + return &DesignBundle{Files: map[string]string{}, Design: &models.Design{}}, nil +} +func (s *stubDesignService) StreamGenerateDesign(_ context.Context, _, _ string, _ io.Writer, _ func()) error { + return nil +} +func (s *stubDesignService) UpdateDesignFile(_ context.Context, _, _, _, _ string) (*DesignBundle, error) { + return &DesignBundle{Files: map[string]string{}, Design: &models.Design{}}, nil +} +func (s *stubDesignService) DeleteDesignFile(_ context.Context, _, _, _ string) (*DesignBundle, error) { + return &DesignBundle{Files: map[string]string{}, Design: &models.Design{}}, nil +} +func (s *stubDesignService) DeleteComponent(_ context.Context, _, _, _ string) (*DesignBundle, error) { + return &DesignBundle{Files: map[string]string{}, Design: &models.Design{}}, nil +} +func (s *stubDesignService) SaveAndProceed(_ context.Context, _, _ string) (*models.Design, error) { + if s.saveAndProceedErr != nil { + return nil, s.saveAndProceedErr + } + return &models.Design{}, nil +} +func (s *stubDesignService) DiscardChanges(_ context.Context, _, _ string) (*models.Design, error) { + return &models.Design{}, nil +} +func (s *stubDesignService) ListDesignVersions(_ context.Context, _, _ string) ([]models.ArtifactVersion, error) { + return nil, nil +} +func (s *stubDesignService) CollectSpec(_ context.Context, _, _, _, _, _, _ string) (string, int, error) { + return "", 0, nil +} + +// TestSaveAndProceedHandler_UnresolvedDependency_Returns409 asserts that when +// the service returns ErrUnresolvedDependency, the HTTP handler maps it to 409 +// Conflict (not 500) and the response body carries the gate message. +func TestSaveAndProceedHandler_UnresolvedDependency_Returns409(t *testing.T) { + humakit.SetGateMode(tenant.GateModeLog) + t.Cleanup(func() { humakit.SetGateMode(tenant.GateModeEnforce) }) + + svcErr := fmt.Errorf("%w: component %q dep %q (status: %s)", + ErrUnresolvedDependency, "my-component", "openweather", "unresolved") + svc := &stubDesignService{saveAndProceedErr: svcErr} + + _, api := humatest.New(t) + RegisterDesign(api, svc) + + resp := api.Post( + "/api/v1/organizations/acme/projects/my-project/design/save", + emptyBody, + ) + if resp.Code != 409 { + t.Fatalf("want HTTP 409 for ErrUnresolvedDependency, got %d; body=%s", resp.Code, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "unresolved") { + t.Errorf("want gate message in response body, got: %s", resp.Body.String()) + } +} + +// TestSaveAndProceedHandler_SpecNotApproved_Returns409 asserts that the +// existing ErrSpecNotApproved mapping still returns 409 (regression guard). +func TestSaveAndProceedHandler_SpecNotApproved_Returns409(t *testing.T) { + humakit.SetGateMode(tenant.GateModeLog) + t.Cleanup(func() { humakit.SetGateMode(tenant.GateModeEnforce) }) + + svc := &stubDesignService{saveAndProceedErr: ErrSpecNotApproved} + + _, api := humatest.New(t) + RegisterDesign(api, svc) + + resp := api.Post( + "/api/v1/organizations/acme/projects/my-project/design/save", + emptyBody, + ) + if resp.Code != 409 { + t.Fatalf("want HTTP 409 for ErrSpecNotApproved, got %d; body=%s", resp.Code, resp.Body.String()) + } +} diff --git a/asdlc-service/internal/feature/design/design_service.go b/asdlc-service/internal/feature/design/design_service.go index 77bf9142..d9f11cb1 100644 --- a/asdlc-service/internal/feature/design/design_service.go +++ b/asdlc-service/internal/feature/design/design_service.go @@ -41,6 +41,25 @@ import ( // consumer. var ErrSpecNotApproved = errors.New("spec must be saved (tagged) before generating a design") +// ErrUnresolvedDependency is the design-domain sentinel surfaced (as 409 by +// the controller) when a design save is attempted while one or more +// dependencies are still unresolved (e.g. an external dep that declares +// needsSpec but has no specPath yet). The caller must resolve the dependency +// before the design can be approved. +var ErrUnresolvedDependency = errors.New("design has unresolved dependencies — resolve them before saving") + +// ErrSpecFetchFailed is the design-domain sentinel surfaced (as 502 by the +// HTTP handler) when FetchSpecFromURL fails to retrieve the spec from the +// user-supplied URL. +var ErrSpecFetchFailed = errors.New("failed to fetch spec from URL") + +// ErrInvalidSpec is the design-domain sentinel surfaced (as 400 by the HTTP +// handler) when the supplied OpenAPI document fails content validation — +// depName path-traversal rejection or an invalid/incomplete OpenAPI 3.x doc. +// Storage and git/commit failures are intentionally NOT wrapped with this +// sentinel so the handler can distinguish them and return 500. +var ErrInvalidSpec = errors.New("invalid OpenAPI spec") + // toK8sName is a thin in-package shim over k8sname.ToK8sName. func toK8sName(name string) string { return k8sname.ToK8sName(name) } @@ -74,6 +93,10 @@ type DesignService interface { SaveAndProceed(ctx context.Context, orgID, projectID string) (*models.Design, error) DiscardChanges(ctx context.Context, orgID, projectID string) (*models.Design, error) ListDesignVersions(ctx context.Context, orgID, projectID string) ([]models.ArtifactVersion, error) + // CollectSpec stores a consumed OpenAPI spec for an external dependency and + // records its specPath on the component design (clearing the needsSpec gate). + // Exactly one of rawSpec or specURL must be non-empty. + CollectSpec(ctx context.Context, orgID, projectID, component, depName, rawSpec, specURL string) (specPath string, operationCount int, err error) } type designService struct { @@ -94,6 +117,10 @@ type designService struct { // design is saved (the catalog "definition" layer, plan §3). Optional in // tests; nil → no registration (values/wiring happen later regardless). connReg connectionRegistrar + // fetchSpec is the SSRF-guarded HTTP fetch function used by the auto-fetch + // path in SaveAndProceed (A6). Defaults to artifacts.FetchSpecFromURL; + // injectable for tests so no real HTTP calls are made. + fetchSpec func(ctx context.Context, url string) (string, error) } // traitSyncReconciler is design_service's narrow consumer port for the @@ -160,6 +187,7 @@ func NewDesignService( store: store, agentsClient: agentsClient, artifactSvc: artifactSvc, + fetchSpec: artifacts.FetchSpecFromURL, } } @@ -656,6 +684,72 @@ func (s *designService) SaveAndProceed(ctx context.Context, orgID, projectID str return nil, artifacts.ErrDesignNotFound } + // Auto-fetch phase (A6): for each external dep that the architect flagged with + // a specUrl hint but no specPath yet, attempt a fetch+store before the + // unresolved-dep gate runs. A successful fetch clears the specUrl hint and + // records the specPath via CollectSpec → store.SetDependencySpecPath, so the + // gate sees the dep as resolved. A failed fetch is non-fatal — we log a + // warning and leave the dep unresolved (the user can supply the spec manually + // or the gate will block the save as usual). After any fetch attempt (success + // or failure), re-read the design so the gate operates on the updated state. + needsReread := false + fetchFn := s.fetchSpec + if fetchFn == nil { + fetchFn = artifacts.FetchSpecFromURL + } + for _, c := range designFile.Components { + for _, dep := range c.Dependencies { + if dep.Kind != models.DependencyKindExternal { + continue + } + if !dep.NeedsSpec || dep.SpecPath != "" || strings.TrimSpace(dep.SpecUrl) == "" { + continue + } + // Attempt auto-fetch via the injectable seam. + rawSpec, fetchErr := fetchFn(ctx, dep.SpecUrl) + if fetchErr != nil { + slog.WarnContext(ctx, "auto-fetch spec failed — dep left unresolved", + "org", orgID, "project", projectID, + "component", c.Name, "dep", dep.Name, + "specUrl", dep.SpecUrl, "error", fetchErr) + needsReread = true + continue + } + // Store the spec + record specPath on the dependency (commits both + // the spec blob and the component design.md frontmatter). Pass the + // raw spec directly (rawSpec path) to avoid a second HTTP round-trip; + // CollectSpec handles validate + normalize + commit + SetDependencySpecPath. + if _, _, collectErr := s.CollectSpec(ctx, orgID, projectID, c.Name, dep.Name, rawSpec, ""); collectErr != nil { + slog.WarnContext(ctx, "auto-fetch: CollectSpec failed — dep left unresolved", + "org", orgID, "project", projectID, + "component", c.Name, "dep", dep.Name, "error", collectErr) + } + needsReread = true + } + } + // Re-read the design so the gate below sees the updated specPath values. + if needsReread { + updated, rerr := s.store.ReadDesign(ctx, orgID, projectID) + if rerr == nil && updated != nil { + designFile = updated + } + } + + // Block save when any dependency is in a non-actionable state — unresolved + // (e.g. external needsSpec with no specPath, or absent org-service), + // ambiguous, or blocked (project-only org-service awaiting access grant). + // Status is computed at read time (assembleDependencies + resolveOrgServices) + // so we check the assembled view. Draft autosave (UpdateDesignFile) is NOT + // gated — only this SaveAndProceed path blocks. + for _, c := range designFile.Components { + for _, dep := range c.Dependencies { + if dep.Status == "ambiguous" || dep.Status == "unresolved" || dep.Status == "blocked" { + return nil, fmt.Errorf("%w: component %q dep %q (status: %s)", + ErrUnresolvedDependency, c.Name, dep.Name, dep.Status) + } + } + } + res, err := s.artifactSvc.SaveDesign(ctx, orgID, projectID, artifacts.SaveRequest{ Message: "Update design", }) @@ -719,6 +813,42 @@ func (s *designService) ListDesignVersions(ctx context.Context, orgID, projectID return mapDesignVersions(v), nil } +// CollectSpec stores a consumed OpenAPI spec for an external dependency and +// records its specPath on the component design (clearing the needsSpec gate). +// Exactly one of rawSpec or specURL must be non-empty. When specURL is +// provided, FetchSpecFromURL is called first; a fetch failure is wrapped in +// ErrSpecFetchFailed so the HTTP handler can map it to a 502. +func (s *designService) CollectSpec(ctx context.Context, orgID, projectID, component, depName, rawSpec, specURL string) (string, int, error) { + if rawSpec == "" && specURL == "" { + return "", 0, fmt.Errorf("provide rawSpec or specUrl") + } + if rawSpec != "" && specURL != "" { + return "", 0, fmt.Errorf("provide only one of rawSpec or specUrl") + } + if rawSpec == "" { + fetched, err := artifacts.FetchSpecFromURL(ctx, specURL) + if err != nil { + return "", 0, fmt.Errorf("%w: %v", ErrSpecFetchFailed, err) + } + rawSpec = fetched + } + specPath, n, err := s.store.StoreConsumedSpec(ctx, orgID, projectID, component, depName, rawSpec) + if err != nil { + // Re-wrap validation-class errors (depName traversal + invalid OpenAPI) as + // ErrInvalidSpec so the HTTP handler maps them to 400. Infrastructure + // failures (normalize/commit/git) are not wrapped with ErrInvalidSpecContent + // and fall through as plain errors → handler maps them to 500. + if errors.Is(err, artifacts.ErrInvalidSpecContent) { + return "", 0, fmt.Errorf("%w: %v", ErrInvalidSpec, err) + } + return "", 0, err + } + if err := s.store.SetDependencySpecPath(ctx, orgID, projectID, component, depName, specPath); err != nil { + return "", 0, err + } + return specPath, n, nil +} + // extractWireframeDsls picks `.dsl` files from the requirements bundle and // returns them keyed by canvas name (filename without the .dsl extension). // These are passed to the architect agent so it can fetch them on demand diff --git a/asdlc-service/internal/feature/design/design_service_test.go b/asdlc-service/internal/feature/design/design_service_test.go index 6a31de03..0a99b8ed 100644 --- a/asdlc-service/internal/feature/design/design_service_test.go +++ b/asdlc-service/internal/feature/design/design_service_test.go @@ -16,7 +16,242 @@ package design -import "testing" +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" +) + +// stubArtifactService is a minimal ArtifactService stub for design_service +// unit tests. All methods return sensible zero values unless overridden by +// the test via the hook fields. +type stubArtifactService struct { + listDesignFilesFunc func(ctx context.Context, orgID, projectID string) (map[string]string, error) + putFileFunc func(ctx context.Context, orgID, projectID, path, content, sha string) (*artifacts.PutResult, error) + commitDesignFileFunc func(ctx context.Context, orgID, projectID, subPath, content, msg string) (string, error) + commitDesignFileCalls []commitDesignFileCall +} + +type commitDesignFileCall struct { + subPath string + content string + msg string +} + +func (s *stubArtifactService) GetFile(_ context.Context, _, _, _ string) (*artifacts.FileResult, error) { + return nil, errors.New("stub: GetFile not implemented") +} +func (s *stubArtifactService) PutFile(ctx context.Context, orgID, projectID, path, content, sha string) (*artifacts.PutResult, error) { + if s.putFileFunc != nil { + return s.putFileFunc(ctx, orgID, projectID, path, content, sha) + } + return nil, errors.New("stub: PutFile not implemented") +} +func (s *stubArtifactService) ListRequirementFiles(_ context.Context, _, _ string) (map[string]string, error) { + return nil, nil +} +func (s *stubArtifactService) DeleteRequirementFile(_ context.Context, _, _, _ string) error { + return nil +} +func (s *stubArtifactService) ListDesignFiles(ctx context.Context, orgID, projectID string) (map[string]string, error) { + if s.listDesignFilesFunc != nil { + return s.listDesignFilesFunc(ctx, orgID, projectID) + } + return nil, nil +} +func (s *stubArtifactService) DeleteDesignFile(_ context.Context, _, _, _ string) error { return nil } +func (s *stubArtifactService) DeleteDesignDirectory(_ context.Context, _, _, _ string) error { + return nil +} +func (s *stubArtifactService) CommitDesignFile(ctx context.Context, orgID, projectID, subPath, content, msg string) (string, error) { + s.commitDesignFileCalls = append(s.commitDesignFileCalls, commitDesignFileCall{ + subPath: subPath, + content: content, + msg: msg, + }) + if s.commitDesignFileFunc != nil { + return s.commitDesignFileFunc(ctx, orgID, projectID, subPath, content, msg) + } + return "", nil +} +func (s *stubArtifactService) SaveRequirements(_ context.Context, _, _ string, _ artifacts.SaveRequest) (*artifacts.RequirementsSaveResult, error) { + return nil, errors.New("stub: SaveRequirements not implemented") +} +func (s *stubArtifactService) SaveDesign(_ context.Context, _, _ string, _ artifacts.SaveRequest) (*artifacts.DesignSaveResult, error) { + return nil, errors.New("stub: SaveDesign not implemented") +} +func (s *stubArtifactService) DiscardRequirements(_ context.Context, _, _ string) (map[string]string, error) { + return nil, nil +} +func (s *stubArtifactService) DiscardDesign(_ context.Context, _, _ string) (map[string]string, error) { + return nil, nil +} +func (s *stubArtifactService) CaptureRequirementsSnapshot(_ context.Context, _, _, _ string) (map[string]string, error) { + return nil, nil +} +func (s *stubArtifactService) RestoreRequirementsSnapshot(_ context.Context, _, _, _ string) (map[string]string, error) { + return nil, nil +} +func (s *stubArtifactService) DeleteRequirementsSnapshot(_ context.Context, _, _, _ string) error { + return nil +} +func (s *stubArtifactService) ReadFileFromRequirementsSnapshot(_ context.Context, _, _, _, _ string) (string, bool, error) { + return "", false, nil +} +func (s *stubArtifactService) ListRequirementsVersions(_ context.Context, _, _ string) ([]artifacts.RequirementsVersionInfo, error) { + return nil, nil +} +func (s *stubArtifactService) ListDesignVersions(_ context.Context, _, _ string) ([]artifacts.DesignVersionInfo, error) { + return nil, nil +} +func (s *stubArtifactService) GetRequirementsAtTag(_ context.Context, _, _, _ string) (map[string]string, error) { + return nil, nil +} +func (s *stubArtifactService) GetDesignAtTag(_ context.Context, _, _, _ string) (map[string]string, error) { + return nil, nil +} + +// TestSaveAndProceedBlockedByUnresolvedExternalDep asserts that SaveAndProceed +// returns ErrUnresolvedDependency when the design contains an external +// dependency with needsSpec=true and no specPath (computed as unresolved at +// read time by assembleDependencies). This covers the broadened save-gate: +// ANY unresolved dep blocks save, not just org-service. +func TestSaveAndProceedBlockedByUnresolvedExternalDep(t *testing.T) { + // Build the file map directly so we control the frontmatter precisely. + compDesignMd := `--- +type: service +language: go +dependencies: + - kind: external + name: openweather + description: weather api + needsSpec: true +--- +Build a weather service. +` + files := map[string]string{ + "design.md": "System overview.\n", + "components/weather-api/design.md": compDesignMd, + } + + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return files, nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, saveErr := svc.SaveAndProceed(context.Background(), "org1", "proj1") + if saveErr == nil { + t.Fatal("want error from SaveAndProceed, got nil") + } + if !errors.Is(saveErr, ErrUnresolvedDependency) { + t.Fatalf("want ErrUnresolvedDependency, got: %v", saveErr) + } +} + +// blockedDepFiles returns a design file map with one component having a +// blocked org-service dependency (status embedded in frontmatter for test +// isolation — no live resolver needed). +func blockedDepFiles() map[string]string { + compDesignMd := `--- +type: service +language: go +dependencies: + - kind: org-service + name: payroll-internal + status: blocked + reason: access-required +--- +A consumer that needs access to payroll-internal. +` + return map[string]string{ + "design.md": "System overview.\n", + "components/consumer-app/design.md": compDesignMd, + } +} + +// TestSaveAndProceedBlockedByBlockedDep (test d) asserts that SaveAndProceed +// returns ErrUnresolvedDependency when the design contains a dep with +// status="blocked" (project-only org-service, access not yet granted). +func TestSaveAndProceedBlockedByBlockedDep(t *testing.T) { + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return blockedDepFiles(), nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, saveErr := svc.SaveAndProceed(context.Background(), "org1", "proj1") + if saveErr == nil { + t.Fatal("want error from SaveAndProceed for blocked dep, got nil") + } + if !errors.Is(saveErr, ErrUnresolvedDependency) { + t.Fatalf("want ErrUnresolvedDependency for blocked dep, got: %v", saveErr) + } +} + +// TestSaveAndProceedBlockedByAmbiguousDep (test e) asserts that SaveAndProceed +// returns ErrUnresolvedDependency when the design contains a dep with +// status="ambiguous". +func TestSaveAndProceedBlockedByAmbiguousDep(t *testing.T) { + compDesignMd := `--- +type: service +language: go +dependencies: + - kind: org-service + name: maybe-svc + status: ambiguous +--- +Ambiguous dependency. +` + files := map[string]string{ + "design.md": "System overview.\n", + "components/consumer-app/design.md": compDesignMd, + } + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return files, nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, saveErr := svc.SaveAndProceed(context.Background(), "org1", "proj1") + if saveErr == nil { + t.Fatal("want error from SaveAndProceed for ambiguous dep, got nil") + } + if !errors.Is(saveErr, ErrUnresolvedDependency) { + t.Fatalf("want ErrUnresolvedDependency for ambiguous dep, got: %v", saveErr) + } +} + +// TestUpdateDesignFileSucceedsWithBlockedDep (test f) asserts that the draft +// autosave path (UpdateDesignFile) does NOT gate on dep status — a design +// with a blocked dep can still be draft-saved. +func TestUpdateDesignFileSucceedsWithBlockedDep(t *testing.T) { + files := blockedDepFiles() + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return files, nil + }, + putFileFunc: func(_ context.Context, _, _, _, _, _ string) (*artifacts.PutResult, error) { + return &artifacts.PutResult{SHA: "abc123"}, nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, err := svc.UpdateDesignFile(context.Background(), "org1", "proj1", "design.md", "Updated overview.\n") + if err != nil { + t.Fatalf("UpdateDesignFile must succeed for blocked dep (draft autosave not gated), got: %v", err) + } +} // TestComponentNameFromDesignPath — only `components//design.md` // triggers trait_sync; root design.md and openapi.yaml are ignored. Gate @@ -44,3 +279,412 @@ func TestComponentNameFromDesignPath(t *testing.T) { } } } + +// sampleOpenAPISpec is a minimal 3-operation OpenAPI spec used by +// TestCollectSpec tests below. +const sampleOpenAPISpec = `openapi: 3.0.3 +info: + title: OpenWeather API + version: "1.0" +paths: + /weather: + get: + summary: Current weather + responses: + "200": + description: OK + /forecast: + get: + summary: Forecast + responses: + "200": + description: OK + post: + summary: Request forecast + responses: + "200": + description: OK +` + +// TestCollectSpec_RawSpecReturnsSpecPathAndOpCount asserts that CollectSpec +// with a rawSpec argument stores the spec (via StoreConsumedSpec), sets the +// specPath on the dependency (via SetDependencySpecPath), and returns the +// component-relative specPath + operation count. Both operations write to the +// working-tree draft via PutFile (NOT CommitDesignFile), so the spec and the +// updated design.md are committed atomically by the subsequent SaveDesign. +func TestCollectSpec_RawSpecReturnsSpecPathAndOpCount(t *testing.T) { + compDesignMd := `--- +type: service +language: go +dependencies: + - kind: external + name: openweather + description: weather API + needsSpec: true +--- +Build a weather service. +` + // Use a mutable map so that PutFile writes are visible to subsequent + // ListDesignFiles calls (SetDependencySpecPath reads the design after + // StoreConsumedSpec writes the spec blob). + files := map[string]string{ + "design.md": "System overview.\n", + "components/weather-api/design.md": compDesignMd, + } + + var putCalls []struct{ relPath, content string } + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + // Return a copy so mutations inside WriteDesignFile don't race. + cp := make(map[string]string, len(files)) + for k, v := range files { + cp[k] = v + } + return cp, nil + }, + putFileFunc: func(_ context.Context, _, _, relPath, content, _ string) (*artifacts.PutResult, error) { + putCalls = append(putCalls, struct{ relPath, content string }{relPath, content}) + // Reflect the write back into files so SetDependencySpecPath's + // ReadDesign sees the updated design.md written by itself. + const prefix = "specs/design/" + if key := strings.TrimPrefix(relPath, prefix); key != relPath { + files[key] = content + } + return &artifacts.PutResult{SHA: "abc"}, nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + specPath, opCount, err := svc.CollectSpec( + context.Background(), "org1", "proj1", + "weather-api", "openweather", + sampleOpenAPISpec, "", + ) + if err != nil { + t.Fatalf("CollectSpec returned unexpected error: %v", err) + } + wantSpecPath := "dependencies/openweather.openapi.yaml" + if specPath != wantSpecPath { + t.Errorf("specPath = %q, want %q", specPath, wantSpecPath) + } + if opCount != 3 { + t.Errorf("operationCount = %d, want 3", opCount) + } + // Two PutFile calls: spec blob + design.md with specPath. + if len(putCalls) != 2 { + t.Fatalf("expected 2 PutFile calls (spec blob + design.md), got %d", len(putCalls)) + } + // First call: spec blob. + wantSpecRelPath := "specs/design/components/weather-api/dependencies/openweather.openapi.yaml" + if putCalls[0].relPath != wantSpecRelPath { + t.Errorf("first PutFile relPath = %q, want %q", putCalls[0].relPath, wantSpecRelPath) + } + // Second call: design.md with specPath. + wantDesignRelPath := "specs/design/components/weather-api/design.md" + if putCalls[1].relPath != wantDesignRelPath { + t.Errorf("second PutFile relPath = %q, want %q", putCalls[1].relPath, wantDesignRelPath) + } + if !strings.Contains(putCalls[1].content, "specPath:") { + t.Errorf("updated design.md should contain specPath, got:\n%s", putCalls[1].content) + } + // REGRESSION: neither StoreConsumedSpec nor SetDependencySpecPath must + // call CommitDesignFile — both must write to the draft only. + if len(stub.commitDesignFileCalls) != 0 { + t.Errorf("neither CollectSpec sub-call must use CommitDesignFile (regression guard): got %d calls", len(stub.commitDesignFileCalls)) + } +} + +// TestCollectSpec_BothFieldsReturns400 asserts that providing both rawSpec +// and specURL returns an error (validation: exactly one must be set). +func TestCollectSpec_BothFieldsReturns400(t *testing.T) { + stub := &stubArtifactService{} + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, _, err := svc.CollectSpec( + context.Background(), "org1", "proj1", + "comp", "dep", + sampleOpenAPISpec, "https://example.com/openapi.yaml", + ) + if err == nil { + t.Fatal("expected error when both rawSpec and specURL provided, got nil") + } +} + +// TestCollectSpec_NeitherFieldReturns400 asserts that providing neither +// rawSpec nor specURL returns an error. +func TestCollectSpec_NeitherFieldReturns400(t *testing.T) { + stub := &stubArtifactService{} + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, _, err := svc.CollectSpec( + context.Background(), "org1", "proj1", + "comp", "dep", + "", "", + ) + if err == nil { + t.Fatal("expected error when neither rawSpec nor specURL provided, got nil") + } +} + +// TestCollectSpec_InvalidOpenAPIDocMapsToErrInvalidSpec asserts that supplying +// a rawSpec that is not a valid OpenAPI 3.x document surfaces ErrInvalidSpec +// (which the HTTP handler maps to 400). This tests the validation-class error +// path from StoreConsumedSpec → CollectSpec. +func TestCollectSpec_InvalidOpenAPIDocMapsToErrInvalidSpec(t *testing.T) { + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return map[string]string{"design.md": "overview\n"}, nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, _, err := svc.CollectSpec( + context.Background(), "org1", "proj1", + "comp", "dep", + "not openapi yaml at all", "", + ) + if err == nil { + t.Fatal("expected error for invalid OpenAPI doc, got nil") + } + if !errors.Is(err, ErrInvalidSpec) { + t.Errorf("want errors.Is(err, ErrInvalidSpec)=true for invalid OpenAPI doc, got: %v", err) + } + // Must NOT be misclassified as a fetch failure. + if errors.Is(err, ErrSpecFetchFailed) { + t.Errorf("invalid OpenAPI doc must not be classified as ErrSpecFetchFailed") + } +} + +// TestCollectSpec_WriteFailureMapsToInfraError asserts that a storage/write +// failure from StoreConsumedSpec (e.g. git-service PutFile error) surfaces as a +// plain error — NOT ErrInvalidSpec and NOT ErrSpecFetchFailed — so the HTTP handler +// maps it to 500 (not 400 or 502). This covers the infra-error classification +// fix (code review finding: all non-502 errors were previously mapped to 400). +// Previously this tested CommitDesignFile failure; now that StoreConsumedSpec +// uses WriteDesignFile (→ PutFile) for draft writes, the infra error comes from +// PutFile. The semantic contract is unchanged: storage failures → 500. +func TestCollectSpec_WriteFailureMapsToInfraError(t *testing.T) { + writeErr := errors.New("git commit failed: remote unreachable") + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return map[string]string{"design.md": "overview\n"}, nil + }, + putFileFunc: func(_ context.Context, _, _, _, _, _ string) (*artifacts.PutResult, error) { + return nil, writeErr + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub) + + _, _, err := svc.CollectSpec( + context.Background(), "org1", "proj1", + "comp", "dep", + sampleOpenAPISpec, "", + ) + if err == nil { + t.Fatal("expected error for write failure, got nil") + } + // Infra failure must NOT be misclassified as a client (400) or gateway (502) error. + if errors.Is(err, ErrInvalidSpec) { + t.Errorf("write failure must not be classified as ErrInvalidSpec (400)") + } + if errors.Is(err, ErrSpecFetchFailed) { + t.Errorf("write failure must not be classified as ErrSpecFetchFailed (502)") + } + // Verify the underlying cause is preserved (unwrappable). + if !strings.Contains(err.Error(), "git commit failed") { + t.Errorf("expected underlying write error to be preserved, got: %v", err) + } +} + +// ---- A6: auto-fetch specUrl at SaveAndProceed ---------------------------- + +// sampleOpenAPISpecA6 is the minimal valid OpenAPI spec returned by the +// stub fetch function in the A6 auto-fetch tests. +const sampleOpenAPISpecA6 = `openapi: 3.0.3 +info: + title: External API + version: "1.0" +paths: + /items: + get: + summary: List items + responses: + "200": + description: OK +` + +// TestSaveAndProceed_AutoFetch_Success asserts that when a component has an +// external dep with needsSpec:true, no specPath, and a specUrl hint, SaveAndProceed +// auto-fetches the spec via the injectable fetchSpec func, stores it via +// CollectSpec, and then the dep is no longer unresolved — so SaveAndProceed +// proceeds to SaveDesign (and eventually succeeds). The dep's specUrl should be +// cleared from the written design.md after the auto-fetch. +// +// CollectSpec now uses PutFile (working-tree draft) for both writes: +// 1. StoreConsumedSpec → PutFile for the spec blob +// 2. SetDependencySpecPath → PutFile for the component design.md +// +// The ListDesignFiles stub reflects PutFile writes back into the files map so +// the second read (inside SetDependencySpecPath) sees the spec blob already present. +func TestSaveAndProceed_AutoFetch_Success(t *testing.T) { + // Initial design: external dep with needsSpec + specUrl, no specPath. + initialCompDesignMd := `--- +type: service +language: go +dependencies: + - kind: external + name: external-api + description: some external API + needsSpec: true + specUrl: "https://api.example.com/openapi.yaml" +--- +Build a service that calls an external API. +` + // Mutable files map — PutFile writes are reflected here so subsequent + // ListDesignFiles calls see the updated content. + files := map[string]string{ + "design.md": "System overview.\n", + "components/my-service/design.md": initialCompDesignMd, + } + + type putCall struct{ relPath, content string } + var putCalls []putCall + + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + cp := make(map[string]string, len(files)) + for k, v := range files { + cp[k] = v + } + return cp, nil + }, + putFileFunc: func(_ context.Context, _, _, relPath, content, _ string) (*artifacts.PutResult, error) { + putCalls = append(putCalls, putCall{relPath, content}) + // Reflect writes back into the mutable files map so that a + // subsequent ListDesignFiles (inside SetDependencySpecPath's + // ReadDesign) sees the updated content. + const prefix = "specs/design/" + if key := strings.TrimPrefix(relPath, prefix); key != relPath { + files[key] = content + } + return &artifacts.PutResult{SHA: "abc"}, nil + }, + } + // Override SaveDesign to succeed (simulate a tagged version). + stub2 := &saveDesignStub{stubArtifactService: stub} + + store := artifacts.NewArtifactStore(stub2) + svc := NewDesignService(store, nil, stub2).(*designService) + + // Inject a stub fetch func that returns a valid OpenAPI spec. + svc.fetchSpec = func(_ context.Context, url string) (string, error) { + if url != "https://api.example.com/openapi.yaml" { + return "", errors.New("unexpected URL: " + url) + } + return sampleOpenAPISpecA6, nil + } + + design, err := svc.SaveAndProceed(context.Background(), "org1", "proj1") + if err != nil { + t.Fatalf("SaveAndProceed must succeed after auto-fetch, got: %v", err) + } + if design == nil { + t.Fatal("expected non-nil design after SaveAndProceed") + } + + // Verify that PutFile was called at least twice: + // 1) StoreConsumedSpec: spec blob + // 2) SetDependencySpecPath: design.md with specPath recorded + if len(putCalls) < 2 { + t.Errorf("expected at least 2 PutFile calls (spec blob + specPath), got %d", len(putCalls)) + } + + // Find the PutFile call for design.md and verify it has specPath set (no specUrl). + var lastDesignMdPut *putCall + for i := range putCalls { + c := &putCalls[i] + if strings.HasSuffix(c.relPath, "/design.md") { + lastDesignMdPut = c + } + } + if lastDesignMdPut == nil { + t.Fatal("no PutFile call for design.md found") + } + if !strings.Contains(lastDesignMdPut.content, "specPath:") { + t.Errorf("written design.md should contain specPath, got:\n%s", lastDesignMdPut.content) + } + if strings.Contains(lastDesignMdPut.content, "specUrl:") { + t.Errorf("written design.md must NOT contain specUrl after auto-fetch, got:\n%s", lastDesignMdPut.content) + } + // REGRESSION: CommitDesignFile must NOT be called for consumer spec writes. + if len(stub.commitDesignFileCalls) != 0 { + t.Errorf("auto-fetch CollectSpec must not use CommitDesignFile (regression guard): got %d calls", len(stub.commitDesignFileCalls)) + } +} + +// TestSaveAndProceed_AutoFetch_FetchFailure asserts that when the fetch stub +// returns an error, SaveAndProceed does NOT fail the whole save attempt due to +// the fetch error itself, but the dep remains unresolved so ErrUnresolvedDependency +// is still returned by the proceed-gate — consistent with the user needing to +// supply the spec manually. +func TestSaveAndProceed_AutoFetch_FetchFailure(t *testing.T) { + compDesignMd := `--- +type: service +language: go +dependencies: + - kind: external + name: external-api + description: some external API + needsSpec: true + specUrl: "https://api.example.com/openapi.yaml" +--- +Build a service. +` + files := map[string]string{ + "design.md": "System overview.\n", + "components/my-service/design.md": compDesignMd, + } + stub := &stubArtifactService{ + listDesignFilesFunc: func(_ context.Context, _, _ string) (map[string]string, error) { + return files, nil + }, + } + store := artifacts.NewArtifactStore(stub) + svc := NewDesignService(store, nil, stub).(*designService) + + // Inject a fetch func that always fails. + svc.fetchSpec = func(_ context.Context, _ string) (string, error) { + return "", errors.New("network unreachable") + } + + _, saveErr := svc.SaveAndProceed(context.Background(), "org1", "proj1") + if saveErr == nil { + t.Fatal("want error from SaveAndProceed when fetch fails + dep unresolved, got nil") + } + // The error must be ErrUnresolvedDependency (the gate caught the still-unresolved dep), + // NOT an infra/fetch error bubbling up. + if !errors.Is(saveErr, ErrUnresolvedDependency) { + t.Fatalf("want ErrUnresolvedDependency after failed fetch, got: %v", saveErr) + } +} + +// saveDesignStub wraps stubArtifactService to override SaveDesign so that +// SaveAndProceed can proceed past the git-save step in auto-fetch success tests. +// All other ArtifactService methods are promoted from the embedded stub. +type saveDesignStub struct { + *stubArtifactService +} + +func (s *saveDesignStub) SaveDesign(_ context.Context, _, _ string, _ artifacts.SaveRequest) (*artifacts.DesignSaveResult, error) { + return &artifacts.DesignSaveResult{ + Tag: "v1-1", + RequirementsVersion: 1, + DesignRevision: 1, + Status: "tagged", + }, nil +} diff --git a/asdlc-service/internal/feature/gitrepo/issue_body.go b/asdlc-service/internal/feature/gitrepo/issue_body.go index 4450e83e..1923a055 100644 --- a/asdlc-service/internal/feature/gitrepo/issue_body.go +++ b/asdlc-service/internal/feature/gitrepo/issue_body.go @@ -106,6 +106,17 @@ func BuildIssueBody(task *models.ComponentTask, comp *models.DesignComponent, _r sb.WriteString(fmt.Sprintf("- **Contract:** `specs/design/components/%s/openapi.yaml`\n", task.ComponentName)) } sb.WriteString("- **System overview:** `specs/design/design.md`\n") + + // External API contracts: for each external dependency with a stored + // OpenAPI spec, surface the contract path so the coding agent implements + // the client against the exact operations rather than guessing endpoints. + for _, dep := range comp.Dependencies { + if dep.Kind == models.DependencyKindExternal && dep.SpecPath != "" { + sb.WriteString(fmt.Sprintf( + "- **API contract — %s:** `specs/design/components/%s/%s` — implement the client against these exact operations; do not invent endpoints.\n", + dep.Name, comp.Name, dep.SpecPath)) + } + } } sb.WriteString("\n") @@ -126,6 +137,52 @@ func BuildIssueBody(task *models.ComponentTask, comp *models.DesignComponent, _r return sb.String() } +// BuildOrgPublishIssueBody produces the markdown body for a cross-project +// publish task's GitHub issue (marketplace P3.5). Unlike BuildIssueBody this is +// NOT a build-from-design task: the provider component is already built and +// deployed, and the requested change is a single targeted edit — add `namespace` +// to its HTTP endpoint's `visibility` list in `workload.yaml` so OpenChoreo +// resolves it for cross-project consumers. The body is purpose-built (it does +// not depend on the design's `orgPublished` flag being set yet) and imperative, +// matching the tone of BuildIssueBody. +// +// The trailing `Closes #` reminder is restated for the +// human-on-GitHub audience and as a fail-safe if the `asdlc` skill isn't loaded. +// The issue number isn't known until GitHub mints it, so the body references +// "this issue" rather than a literal number (the agent reads the live number +// via `gh issue view`). +func BuildOrgPublishIssueBody(providerComponentName, appPath, requesterProject string) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("> Access request from project `%s`: publish the **%s** service org-wide so it can be consumed across projects.\n\n", + requesterProject, providerComponentName)) + + sb.WriteString("## What to do\n") + sb.WriteString("This is a **targeted change to an already-built component** — do NOT regenerate or re-implement anything.\n\n") + sb.WriteString("1. Clone the repository.\n") + if appPath != "" { + sb.WriteString(fmt.Sprintf("2. Open `%s/workload.yaml`.\n", normalizeAppPath(appPath))) + } else { + sb.WriteString("2. Open the component's `workload.yaml`.\n") + } + sb.WriteString("3. Add `namespace` to the HTTP endpoint's `visibility` list — e.g. it should read `visibility: [external, namespace]`. ") + sb.WriteString("`namespace` visibility is what lets OpenChoreo resolve this endpoint for cross-project consumers.\n") + sb.WriteString("4. Do **NOT** change anything else — no code, no other config, no other endpoints.\n") + sb.WriteString("5. Open a PR for this single-line change.\n\n") + + sb.WriteString("## Component Reference\n") + sb.WriteString(fmt.Sprintf("- **Name:** %s\n", providerComponentName)) + if appPath != "" { + sb.WriteString(fmt.Sprintf("- **App Path (within repo):** `%s`\n", normalizeAppPath(appPath))) + } + sb.WriteString("\n") + + sb.WriteString("---\n") + sb.WriteString("When you open the PR, include `Closes #` in its body so the platform links the PR back to this task. The full workflow, constraints, and deny-list are in the `asdlc` skill loaded in your Claude Code session.\n") + + return sb.String() +} + // normalizeAppPath trims a single leading slash so the rendered Component // Reference shows e.g. `hello-api` instead of `/hello-api`. func normalizeAppPath(appPath string) string { diff --git a/asdlc-service/internal/feature/gitrepo/issue_body_test.go b/asdlc-service/internal/feature/gitrepo/issue_body_test.go new file mode 100644 index 00000000..9d3f8ba7 --- /dev/null +++ b/asdlc-service/internal/feature/gitrepo/issue_body_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package gitrepo + +import ( + "strings" + "testing" + + "github.com/wso2/asdlc/asdlc-service/models" +) + +func TestBuildIssueBody_ExternalDepContractLine(t *testing.T) { + const compName = "weather-frontend" + const depName = "openweather" + const specPath = "dependencies/openweather.openapi.yaml" + const wantLine = "- **API contract — openweather:** `specs/design/components/weather-frontend/dependencies/openweather.openapi.yaml` — implement the client against these exact operations; do not invent endpoints." + + task := &models.ComponentTask{ + ComponentName: compName, + IssueNumber: 42, + Title: "Implement weather-frontend", + } + comp := &models.DesignComponent{ + Name: compName, + ComponentType: "webApp", + Language: "TypeScript", + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindExternal, + Name: depName, + SpecPath: specPath, + }, + }, + } + + body := BuildIssueBody(task, comp, "", "") + + if !strings.Contains(body, wantLine) { + t.Errorf("expected body to contain contract line:\n %q\n\ngot body:\n%s", wantLine, body) + } +} + +func TestBuildIssueBody_ExternalDepNoSpecPath_NoContractLine(t *testing.T) { + const compName = "payment-service" + const depName = "stripe" + + task := &models.ComponentTask{ + ComponentName: compName, + IssueNumber: 7, + Title: "Implement payment-service", + } + comp := &models.DesignComponent{ + Name: compName, + ComponentType: "service", + Language: "Go", + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindExternal, + Name: depName, + SpecPath: "", // no spec stored + }, + }, + } + + body := BuildIssueBody(task, comp, "", "") + + if strings.Contains(body, "**API contract —") { + t.Errorf("expected body NOT to contain a contract line when SpecPath is empty, but got:\n%s", body) + } +} + +func TestBuildIssueBody_NonExternalDepNoContractLine(t *testing.T) { + const compName = "order-service" + + task := &models.ComponentTask{ + ComponentName: compName, + IssueNumber: 3, + Title: "Implement order-service", + } + comp := &models.DesignComponent{ + Name: compName, + ComponentType: "service", + Language: "Go", + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindComponent, + Name: "inventory-service", + // SpecPath deliberately absent — and it's not external either + SpecPath: "dependencies/something.openapi.yaml", + }, + }, + } + + body := BuildIssueBody(task, comp, "", "") + + if strings.Contains(body, "**API contract —") { + t.Errorf("expected body NOT to contain a contract line for non-external dep, but got:\n%s", body) + } +} diff --git a/asdlc-service/internal/feature/gitrepo/issue_service.go b/asdlc-service/internal/feature/gitrepo/issue_service.go index 532e5283..c6d7d5c0 100644 --- a/asdlc-service/internal/feature/gitrepo/issue_service.go +++ b/asdlc-service/internal/feature/gitrepo/issue_service.go @@ -21,6 +21,7 @@ import ( "fmt" "log/slog" "strings" + "sync" "github.com/wso2/asdlc/asdlc-service/internal/credentials" "github.com/wso2/asdlc/asdlc-service/models" @@ -46,6 +47,12 @@ type issueService struct { github GitHubClient githubV2 GitHubV2Client resolver credentials.Resolver + // boardMu serializes lazy GitHub Project board creation per project (keyed by + // projectID). Task generation creates several issues concurrently; without + // this, each goroutine sees an empty GithubProjectID and creates its own + // board (a check-then-act race), splitting the project's issues across two + // boards. Requires issueService to be a singleton (it is — built once in main). + boardMu sync.Map } func NewIssueService(repo repositories.RepoRepository, github GitHubClient, githubV2 GitHubV2Client, resolver credentials.Resolver) IssueService { @@ -93,12 +100,19 @@ func (s *issueService) CreateIssue(ctx context.Context, orgID, projectID string, slog.WarnContext(ctx, "fetch credential token for board ops failed", "project", projectID, "error", tokenErr) } else { if gitRepo.GithubProjectID == "" { - if boardID, err := s.ensureBoard(ctx, gitRepo, owner, repoName, token); err == nil { + // Serialize board creation per project + re-read under the lock: a + // concurrent CreateIssue may have just created and persisted the board, + // in which case we reuse it instead of creating a second (orphan) board. + unlock := s.lockProjectBoard(projectID) + if fresh, freshErr := s.repo.GetByOrgAndProjectID(ctx, orgID, projectID); freshErr == nil && fresh != nil && fresh.GithubProjectID != "" { + gitRepo.GithubProjectID = fresh.GithubProjectID + } else if boardID, err := s.ensureBoard(ctx, gitRepo, owner, repoName, token); err == nil { gitRepo.GithubProjectID = boardID if updateErr := s.repo.Update(ctx, gitRepo); updateErr != nil { slog.WarnContext(ctx, "failed to persist github project id after lazy creation", "project", projectID, "error", updateErr) } } + unlock() } s.addIssueToProject(ctx, gitRepo.GithubProjectID, issue, token) } @@ -126,6 +140,15 @@ func (s *issueService) ensureBoard(ctx context.Context, gitRepo *models.GitRepos return githubProjectID, nil } +// lockProjectBoard acquires the per-project board-creation mutex and returns a +// release function. Serializes the check-then-act around lazy board creation. +func (s *issueService) lockProjectBoard(projectID string) func() { + muAny, _ := s.boardMu.LoadOrStore(projectID, &sync.Mutex{}) + mu := muAny.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + func (s *issueService) addIssueToProject(ctx context.Context, githubProjectID string, issue *IssueResult, token string) { if issue.NodeID == "" || s.githubV2 == nil || githubProjectID == "" { slog.WarnContext(ctx, "skipping board add: missing project id or issue node id", "issue", issue.URL) diff --git a/asdlc-service/internal/feature/gitrepo/issue_service_board_test.go b/asdlc-service/internal/feature/gitrepo/issue_service_board_test.go new file mode 100644 index 00000000..b7fa3bbd --- /dev/null +++ b/asdlc-service/internal/feature/gitrepo/issue_service_board_test.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package gitrepo + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestLockProjectBoard_SerializesPerProject is the regression guard for the +// two-board race: lazy board creation is guarded by a per-project mutex so +// concurrent CreateIssue calls cannot each create their own board. This asserts +// the primitive grants at most one holder per project at a time. +func TestLockProjectBoard_SerializesPerProject(t *testing.T) { + s := &issueService{} + var active, maxActive int32 + var wg sync.WaitGroup + for i := 0; i < 25; i++ { + wg.Add(1) + go func() { + defer wg.Done() + unlock := s.lockProjectBoard("proj-A") + defer unlock() + cur := atomic.AddInt32(&active, 1) + for { + old := atomic.LoadInt32(&maxActive) + if cur <= old || atomic.CompareAndSwapInt32(&maxActive, old, cur) { + break + } + } + time.Sleep(time.Millisecond) + atomic.AddInt32(&active, -1) + }() + } + wg.Wait() + if maxActive != 1 { + t.Fatalf("same-project lock allowed %d concurrent holders, want 1", maxActive) + } +} + +// TestLockProjectBoard_DifferentProjectsDoNotBlock asserts the lock is +// per-project — holding project A's lock must not block project B. +func TestLockProjectBoard_DifferentProjectsDoNotBlock(t *testing.T) { + s := &issueService{} + unlockA := s.lockProjectBoard("A") + defer unlockA() + + done := make(chan struct{}) + go func() { + unlockB := s.lockProjectBoard("B") + unlockB() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("lock for project B blocked on project A's lock — not per-project") + } +} diff --git a/asdlc-service/internal/feature/project/project_service.go b/asdlc-service/internal/feature/project/project_service.go index 8304a915..80527e55 100644 --- a/asdlc-service/internal/feature/project/project_service.go +++ b/asdlc-service/internal/feature/project/project_service.go @@ -49,13 +49,21 @@ type ProjectService interface { } type projectService struct { - client openchoreo.ProjectClient - repoSvc gitrepo.RepoService - webhookSvc gitrepo.WebhookService - artifactSvc artifacts.ArtifactService - store *artifacts.ArtifactStore - taskRepo repositories.TaskRepository - skillsProv skillsProvisioner + client openchoreo.ProjectClient + repoSvc gitrepo.RepoService + webhookSvc gitrepo.WebhookService + artifactSvc artifacts.ArtifactService + store *artifacts.ArtifactStore + taskRepo repositories.TaskRepository + skillsProv skillsProvisioner + resourceProv resourceDeprovisioner +} + +// resourceDeprovisioner is the narrow port for tearing down a project's +// platform-resource databases (P5) on delete. *resources.OCNativeProvisioner +// satisfies it. Defined here so project doesn't import the resources package. +type resourceDeprovisioner interface { + Deprovision(ctx context.Context, orgHandle, projectName, depName string, envs []string) error } // skillsProvisioner is the narrow port for eagerly provisioning the org's @@ -75,6 +83,16 @@ func (s *projectService) SetSkillsProvisioner(p skillsProvisioner) { s.skillsPro var _ ProjectServiceWithSkills = (*projectService)(nil) +// ProjectServiceWithResourceProvisioner surfaces a setter so main can wire the +// platform-resource deprovisioner without widening the constructor. +type ProjectServiceWithResourceProvisioner interface { + SetResourceDeprovisioner(p resourceDeprovisioner) +} + +func (s *projectService) SetResourceDeprovisioner(p resourceDeprovisioner) { s.resourceProv = p } + +var _ ProjectServiceWithResourceProvisioner = (*projectService)(nil) + func NewProjectService( client openchoreo.ProjectClient, repoSvc gitrepo.RepoService, @@ -171,6 +189,28 @@ func (s *projectService) DeleteProject(ctx context.Context, orgName, projectName } } + // Tear down the project's platform-resource databases (P5) BEFORE deleting the + // tasks that name them. The OC Project delete above does NOT cascade to these: + // the Resource/binding carry only a logical spec.owner.projectName, not a k8s + // ownerReference, so without this the OC Resource + ResourceReleaseBinding and + // the provisioned CNPG Postgres orphan on the cluster. Best-effort — the OC + // project + repo are already gone; a stuck DB must not block the delete. + if s.resourceProv != nil && s.taskRepo != nil { + if tasks, err := s.taskRepo.ListByProjectID(ctx, orgName, projectName); err == nil { + for _, t := range tasks { + if t.Type == models.TaskTypeResourceProvisioning && t.ResourceName != "" { + if derr := s.resourceProv.Deprovision(ctx, orgName, projectName, t.ResourceName, []string{"development"}); derr != nil { + slog.ErrorContext(ctx, "failed to deprovision platform-resource on project delete", + "org", orgName, "project", projectName, "resource", t.ResourceName, "error", derr) + } + } + } + } else { + slog.ErrorContext(ctx, "failed to list tasks for platform-resource teardown on project delete", + "org", orgName, "project", projectName, "error", err) + } + } + // Clean up the project's ComponentTask rows. Without this they orphan in the // DB and keep an org-level connection looking "in use" (its consumers query // counts these tasks), blocking deletion of a connection whose only projects diff --git a/asdlc-service/internal/feature/resources/catalog.go b/asdlc-service/internal/feature/resources/catalog.go new file mode 100644 index 00000000..980c9554 --- /dev/null +++ b/asdlc-service/internal/feature/resources/catalog.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package resources + +import ( + "context" + "sort" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" +) + +// PlatformResourceType is the architect-facing view of an installed +// ClusterResourceType: its name (the open-string resourceType), the +// provisioning parameter schema, and the output names a consumer can bind. +type PlatformResourceType struct { + Name string `json:"name"` + Parameters map[string]any `json:"parameters,omitempty"` // openAPIV3Schema.properties + Outputs []string `json:"outputs,omitempty"` +} + +// ResourceTypeCatalog lists installed cluster-scoped ClusterResourceTypes +// (read-only). app-factory NEVER authors these — it only discovers them. +type ResourceTypeCatalog struct{ rc openchoreo.ResourceClient } + +func NewResourceTypeCatalog(rc openchoreo.ResourceClient) *ResourceTypeCatalog { + return &ResourceTypeCatalog{rc: rc} +} + +func (c *ResourceTypeCatalog) List(ctx context.Context) ([]PlatformResourceType, error) { + cts, err := c.rc.ListClusterResourceTypes(ctx) + if err != nil { + return nil, err + } + out := make([]PlatformResourceType, 0, len(cts)) + for _, ct := range cts { + prt := PlatformResourceType{Name: ct.Metadata.Name} + if ct.Spec.Parameters != nil { + if props, ok := ct.Spec.Parameters.OpenAPIV3Schema["properties"].(map[string]any); ok { + prt.Parameters = props + } + } + for _, o := range ct.Spec.Outputs { + prt.Outputs = append(prt.Outputs, o.Name) + } + out = append(out, prt) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} diff --git a/asdlc-service/internal/feature/resources/catalog_test.go b/asdlc-service/internal/feature/resources/catalog_test.go new file mode 100644 index 00000000..a51ba168 --- /dev/null +++ b/asdlc-service/internal/feature/resources/catalog_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package resources_test + +import ( + "context" + "reflect" + "testing" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/internal/feature/resources" +) + +// fakeResourceClient implements just enough of openchoreo.ResourceClient to +// drive the catalog under test. +type fakeResourceClient struct { + cts []openchoreo.ResourceType +} + +func (f *fakeResourceClient) EnsureResourceType(_ context.Context, _ string, rt *openchoreo.ResourceType) (*openchoreo.ResourceType, error) { + return rt, nil +} +func (f *fakeResourceClient) ApplyResource(_ context.Context, _ string, r *openchoreo.Resource) (*openchoreo.Resource, error) { + return r, nil +} +func (f *fakeResourceClient) GetResource(_ context.Context, _, _ string) (*openchoreo.Resource, error) { + return nil, nil +} +func (f *fakeResourceClient) EnsureBinding(_ context.Context, _ string, b *openchoreo.ResourceReleaseBinding) (*openchoreo.ResourceReleaseBinding, error) { + return b, nil +} +func (f *fakeResourceClient) GetBinding(_ context.Context, _, _ string) (*openchoreo.ResourceReleaseBinding, error) { + return nil, nil +} +func (f *fakeResourceClient) DeleteBinding(_ context.Context, _, _ string) error { return nil } +func (f *fakeResourceClient) DeleteResource(_ context.Context, _, _ string) error { return nil } +func (f *fakeResourceClient) ListClusterResourceTypes(_ context.Context) ([]openchoreo.ResourceType, error) { + return f.cts, nil +} +func (f *fakeResourceClient) PatchWorkloadResourceDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadResourceDep) error { + return nil +} +func (f *fakeResourceClient) PatchWorkloadEndpointDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadEndpointDep) error { + return nil +} +func (f *fakeResourceClient) ListWorkloadEndpoints(_ context.Context, _ string) ([]openchoreo.WorkloadEndpointInfo, error) { + return nil, nil +} + +func TestResourceTypeCatalog_List(t *testing.T) { + rc := &fakeResourceClient{cts: []openchoreo.ResourceType{ + {Metadata: openchoreo.OCObjectMeta{Name: "redis-cnpg"}}, + {Metadata: openchoreo.OCObjectMeta{Name: "postgres-cnpg"}, + Spec: openchoreo.ResourceTypeSpec{ + Parameters: &openchoreo.SchemaSection{OpenAPIV3Schema: map[string]any{ + "properties": map[string]any{"version": map[string]any{"type": "string"}}}}, + Outputs: []openchoreo.ResourceTypeOutput{{Name: "host"}, {Name: "port"}, {Name: "password"}}, + }}, + }} + got, err := resources.NewResourceTypeCatalog(rc).List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Name != "postgres-cnpg" { + t.Fatalf("want postgres-cnpg first, got %+v", got) + } + if !reflect.DeepEqual(got[0].Outputs, []string{"host", "port", "password"}) { + t.Fatalf("outputs: %+v", got[0].Outputs) + } + // got[1] is redis-cnpg: no Parameters spec and no Outputs (zero-value path). + if got[1].Name != "redis-cnpg" { + t.Fatalf("want redis-cnpg at index 1, got %q", got[1].Name) + } + if got[1].Parameters != nil { + t.Fatalf("want nil Parameters for redis-cnpg, got %+v", got[1].Parameters) + } + if len(got[1].Outputs) != 0 { + t.Fatalf("want empty Outputs for redis-cnpg, got %+v", got[1].Outputs) + } +} diff --git a/asdlc-service/internal/feature/resources/provision_huma.go b/asdlc-service/internal/feature/resources/provision_huma.go new file mode 100644 index 00000000..90a7c294 --- /dev/null +++ b/asdlc-service/internal/feature/resources/provision_huma.go @@ -0,0 +1,308 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package resources + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/danielgtaylor/huma/v2" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" + "github.com/wso2/asdlc/asdlc-service/internal/platform/humakit" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// Sentinel errors for mapping to HTTP status codes. +var ( + // ErrDepNotFound is returned when the named dependency is absent from the + // component's design or is not a platform-resource kind. + ErrDepNotFound = errors.New("resources: platform-resource dependency not found") + + // ErrProvisionFailed is returned when the ResourceProvisioner call fails. + ErrProvisionFailed = errors.New("resources: provisioner failed") +) + +// DesignReader is the slice of ArtifactStore the ResourceService reads. +// *artifacts.ArtifactStore satisfies it. +type DesignReader interface { + ReadDesign(ctx context.Context, orgID, projectID string) (*artifacts.DesignFile, error) +} + +// ResourceTaskStore is the slice of the task repo the ResourceService uses to +// mark the resource-provisioning task in-flight. *repositories.TaskRepository +// satisfies it. +type ResourceTaskStore interface { + ListByProjectID(ctx context.Context, orgID, projectID string) ([]models.ComponentTask, error) + Update(ctx context.Context, t *models.ComponentTask) error +} + +// ResourceService handles the async provision flow for platform-resource +// dependencies: +// 1. Reads the component's design to find the dep's resourceType. +// 2. Calls the ResourceProvisioner to author the OC Resource model. +// 3. Marks the resource-provisioning task in-flight (status = building). +// It does NOT wait for readiness — the watcher (A5) observes that. +// It does NOT call redispatch — the watcher cascades on readiness. +type ResourceService struct { + store DesignReader + prov ResourceProvisioner + taskRepo ResourceTaskStore +} + +func NewResourceService(store DesignReader, prov ResourceProvisioner, taskRepo ResourceTaskStore) *ResourceService { + return &ResourceService{store: store, prov: prov, taskRepo: taskRepo} +} + +// Provision authors the OC Resource model for `depName` on `componentName` and +// marks the matching resource-provisioning task `building`. It is intentionally +// async: it returns as soon as the OC CRs are authored and the task is marked +// in-flight, without waiting for the binding's Ready condition. +// +// SINGLE-PARAMS-MAP NOTE: A2's Provision signature takes a single params map +// applied to all envs. This endpoint therefore accepts one unified params map +// for all envs (not per-env params). If per-env params are needed in the future, +// A2's signature must be updated — this is a documented limitation. +func (s *ResourceService) Provision( + ctx context.Context, + orgHandle, projectName, componentName, depName string, + params map[string]string, + envs []string, +) error { + // 1. Read the design and find the platform-resource dep. + design, err := s.store.ReadDesign(ctx, orgHandle, projectName) + if err != nil { + return fmt.Errorf("resources: read design: %w", err) + } + if design == nil { + return fmt.Errorf("%w: no design found for project %q", ErrDepNotFound, projectName) + } + + resourceType, err := findPlatformResourceType(design, componentName, depName) + if err != nil { + return err + } + + // 2. Call the provisioner (async — authors OC CRs and returns immediately). + _, err = s.prov.Provision(ctx, orgHandle, projectName, depName, resourceType, params, envs) + if err != nil { + return fmt.Errorf("%w: %w", ErrProvisionFailed, err) + } + + // 3. Mark the resource-provisioning task `building` (the in-flight marker + // the watcher sweeps). Best-effort: provisioning already succeeded. + if err := s.markTaskBuilding(ctx, orgHandle, projectName, depName); err != nil { + slog.WarnContext(ctx, "resources: failed to mark resource-provisioning task building", + "dep", depName, "project", projectName, "error", err) + } + + return nil +} + +// findPlatformResourceType walks the design to find a platform-resource dep +// named depName on componentName and returns its resourceType. +func findPlatformResourceType(design *artifacts.DesignFile, componentName, depName string) (string, error) { + for _, comp := range design.Components { + if comp.Name != componentName { + continue + } + for _, dep := range comp.Dependencies { + if dep.Name != depName { + continue + } + if dep.Kind != models.DependencyKindPlatformResource { + return "", fmt.Errorf("%w: dep %q on component %q has kind %q, not %q", + ErrDepNotFound, depName, componentName, dep.Kind, models.DependencyKindPlatformResource) + } + if dep.ResourceType == "" { + return "", fmt.Errorf("%w: dep %q on component %q has no resourceType", + ErrDepNotFound, depName, componentName) + } + return dep.ResourceType, nil + } + // Component found but dep not found. + return "", fmt.Errorf("%w: dep %q not found on component %q", ErrDepNotFound, depName, componentName) + } + return "", fmt.Errorf("%w: component %q not found in design", ErrDepNotFound, componentName) +} + +// markTaskBuilding finds the resource-provisioning task for depName and sets +// its status to `building` (the in-flight marker). Does nothing if no matching +// task is found. +func (s *ResourceService) markTaskBuilding(ctx context.Context, orgHandle, projectName, depName string) error { + if s.taskRepo == nil { + return nil + } + tasks, err := s.taskRepo.ListByProjectID(ctx, orgHandle, projectName) + if err != nil { + return err + } + for i := range tasks { + t := &tasks[i] + if t.Type == models.TaskTypeResourceProvisioning && t.ResourceName == depName && + t.Status != string(models.TaskStatusBuilding) { + t.Status = string(models.TaskStatusBuilding) + return s.taskRepo.Update(ctx, t) + } + } + return nil +} + +// ---- huma routes ------------------------------------------------------------ + +type provisionResourceInput struct { + humakit.OrgScopedInput + ProjectName string `path:"projectName" doc:"Project name (DNS-label slug)"` + ComponentName string `path:"componentName" doc:"Component name"` + DepName string `path:"depName" doc:"Platform-resource dependency name"` + Body struct { + // Params is the unified provisioning parameters map applied to all envs. + // A2's Provisioner takes a single params map (not per-env) — see the + // single-params-map limitation in ResourceService.Provision docs. + Params map[string]string `json:"params,omitempty" doc:"Provisioning parameters (applied to all envs)"` + // Environments is the list of environments to provision the resource for. + Environments []string `json:"environments" doc:"Environment names to bind the resource to (e.g. [\"development\"])"` + } +} + +type provisionResourceOutput struct { + Body struct { + Status string `json:"status"` + } +} + +type getResourceStatusInput struct { + humakit.OrgScopedInput + ProjectName string `path:"projectName" doc:"Project name (DNS-label slug)"` + ComponentName string `path:"componentName" doc:"Component name"` + DepName string `path:"depName" doc:"Platform-resource dependency name"` + Environment string `query:"environment" doc:"Environment name (default: development)" required:"false"` +} + +type resourceOutput struct { + Name string `json:"name"` +} + +type getResourceStatusOutput struct { + Body struct { + // Status mirrors the resource-provisioning task status (pending, building, deployed, failed…). + Status string `json:"status"` + // Ready reports whether the OC binding's native Ready condition is True. + Ready bool `json:"ready"` + // Outputs lists binding outputs with secret values MASKED (name only, never value). + Outputs []resourceOutput `json:"outputs,omitempty"` + } +} + +// RegisterResources registers the async provision + status routes for +// platform-resource dependencies. Call next to RegisterConnections in +// RegisterAllHuma. +func RegisterResources(api huma.API, svc *ResourceService, rc openchoreo.ResourceClient) { + huma.Register(api, huma.Operation{ + OperationID: "provision-resource", + Method: http.MethodPost, + Path: "/api/v1/organizations/{orgHandle}/projects/{projectName}/components/{componentName}/dependencies/{depName}/provision", + Summary: "Author the OC Resource model for a platform-resource dep and mark it in-flight (async)", + Tags: []string{"Resources"}, + Security: humakit.SecurityUserJWT, + DefaultStatus: http.StatusAccepted, + }, func(ctx context.Context, in *provisionResourceInput) (*provisionResourceOutput, error) { + if svc == nil { + return nil, huma.Error503ServiceUnavailable("resource provisioning is not configured") + } + envs := in.Body.Environments + if len(envs) == 0 { + envs = []string{"development"} + } + if err := svc.Provision(ctx, in.OrgHandle, in.ProjectName, in.ComponentName, in.DepName, + in.Body.Params, envs); err != nil { + switch { + case errors.Is(err, ErrDepNotFound): + return nil, huma.Error404NotFound("platform-resource dependency not found", err) + case errors.Is(err, ErrProvisionFailed): + return nil, huma.Error502BadGateway("platform provisioner failed", err) + default: + return nil, huma.Error500InternalServerError("failed to provision resource", err) + } + } + out := &provisionResourceOutput{} + out.Body.Status = "provisioning" + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "get-resource-status", + Method: http.MethodGet, + Path: "/api/v1/organizations/{orgHandle}/projects/{projectName}/components/{componentName}/dependencies/{depName}/status", + Summary: "Get the provisioning status and masked outputs for a platform-resource dependency", + Tags: []string{"Resources"}, + Security: humakit.SecurityUserJWT, + }, func(ctx context.Context, in *getResourceStatusInput) (*getResourceStatusOutput, error) { + if rc == nil { + return nil, huma.Error503ServiceUnavailable("resource client is not configured") + } + if svc == nil { + return nil, huma.Error503ServiceUnavailable("resource provisioning is not configured") + } + env := in.Environment + if env == "" { + env = "development" + } + // Derive binding name (mirrors BuildPlatformBinding: project-depName-env). + bindingName := in.ProjectName + "-" + in.DepName + "-" + env + + binding, err := rc.GetBinding(ctx, in.OrgHandle, bindingName) + if err != nil { + return nil, huma.Error500InternalServerError("failed to read resource binding", err) + } + + // Derive task status from the resource-provisioning task. + taskStatus := "pending" + if svc.taskRepo != nil { + tasks, terr := svc.taskRepo.ListByProjectID(ctx, in.OrgHandle, in.ProjectName) + if terr != nil { + slog.WarnContext(ctx, "resources: failed to list tasks for status; defaulting to pending", + "org", in.OrgHandle, "project", in.ProjectName, "dep", in.DepName, "error", terr) + } else { + for _, t := range tasks { + if t.Type == models.TaskTypeResourceProvisioning && t.ResourceName == in.DepName { + taskStatus = t.Status + break + } + } + } + } + + out := &getResourceStatusOutput{} + out.Body.Status = taskStatus + if binding != nil { + out.Body.Ready = binding.IsReady() + if binding.Status != nil { + // Mask outputs: emit name only, never value (secrets must not + // enter API responses — they live only in the OC-rendered Secret). + for _, o := range binding.Status.Outputs { + out.Body.Outputs = append(out.Body.Outputs, resourceOutput{Name: o.Name}) + } + } + } + return out, nil + }) +} diff --git a/asdlc-service/internal/feature/resources/provision_huma_test.go b/asdlc-service/internal/feature/resources/provision_huma_test.go new file mode 100644 index 00000000..27aa2908 --- /dev/null +++ b/asdlc-service/internal/feature/resources/provision_huma_test.go @@ -0,0 +1,321 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package resources + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" + "github.com/wso2/asdlc/asdlc-service/internal/platform/humakit" + "github.com/wso2/asdlc/asdlc-service/internal/platform/tenant" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// ---- fakes ------------------------------------------------------------------ + +type fakeDesignStore struct { + design *artifacts.DesignFile + err error +} + +func (f *fakeDesignStore) ReadDesign(_ context.Context, _, _ string) (*artifacts.DesignFile, error) { + return f.design, f.err +} + +type fakeProvisioner struct { + called int + deprovisioned int + result *ProvisionResult + err error +} + +func (f *fakeProvisioner) Provision(_ context.Context, _, _, _, _ string, _ map[string]string, _ []string) (*ProvisionResult, error) { + f.called++ + return f.result, f.err +} + +func (f *fakeProvisioner) Deprovision(_ context.Context, _, _, _ string, _ []string) error { + f.deprovisioned++ + return f.err +} + +type fakeTaskRepo struct { + tasks []models.ComponentTask + listErr error + updated *models.ComponentTask +} + +func (f *fakeTaskRepo) ListByProjectID(_ context.Context, _, _ string) ([]models.ComponentTask, error) { + return f.tasks, f.listErr +} +func (f *fakeTaskRepo) Update(_ context.Context, t *models.ComponentTask) error { + cp := *t + f.updated = &cp + return nil +} + +// fakeResourceClient is a minimal stub for openchoreo.ResourceClient used in +// handler-level tests. Only GetBinding is exercised by the GetStatus tests. +type fakeResourceClient struct { + binding *openchoreo.ResourceReleaseBinding + bindErr error + deletedBindings []string + deletedResource []string +} + +func (f *fakeResourceClient) EnsureResourceType(_ context.Context, _ string, rt *openchoreo.ResourceType) (*openchoreo.ResourceType, error) { + return rt, nil +} +func (f *fakeResourceClient) ApplyResource(_ context.Context, _ string, r *openchoreo.Resource) (*openchoreo.Resource, error) { + return r, nil +} +func (f *fakeResourceClient) GetResource(_ context.Context, _, _ string) (*openchoreo.Resource, error) { + return &openchoreo.Resource{}, nil +} +func (f *fakeResourceClient) EnsureBinding(_ context.Context, _ string, b *openchoreo.ResourceReleaseBinding) (*openchoreo.ResourceReleaseBinding, error) { + return b, nil +} +func (f *fakeResourceClient) GetBinding(_ context.Context, _, _ string) (*openchoreo.ResourceReleaseBinding, error) { + return f.binding, f.bindErr +} +func (f *fakeResourceClient) DeleteBinding(_ context.Context, _, name string) error { + f.deletedBindings = append(f.deletedBindings, name) + return nil +} +func (f *fakeResourceClient) DeleteResource(_ context.Context, _, name string) error { + f.deletedResource = append(f.deletedResource, name) + return nil +} +func (f *fakeResourceClient) ListClusterResourceTypes(_ context.Context) ([]openchoreo.ResourceType, error) { + return nil, nil +} +func (f *fakeResourceClient) PatchWorkloadResourceDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadResourceDep) error { + return nil +} +func (f *fakeResourceClient) PatchWorkloadEndpointDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadEndpointDep) error { + return nil +} +func (f *fakeResourceClient) ListWorkloadEndpoints(_ context.Context, _ string) ([]openchoreo.WorkloadEndpointInfo, error) { + return nil, nil +} + +// TestOCNativeProvisioner_Deprovision asserts the teardown deletes each per-env +// binding (--) and then the per-project Resource +// (-) — the cleanup project deletion invokes to avoid orphans. +func TestOCNativeProvisioner_Deprovision(t *testing.T) { + rc := &fakeResourceClient{} + p := NewOCNativeProvisioner(rc) + if err := p.Deprovision(context.Background(), "default", "storefront-3", "orders-db", []string{"development", "production"}); err != nil { + t.Fatalf("Deprovision: %v", err) + } + if got, want := strings.Join(rc.deletedBindings, ","), "storefront-3-orders-db-development,storefront-3-orders-db-production"; got != want { + t.Errorf("deleted bindings = %q, want %q", got, want) + } + if got, want := strings.Join(rc.deletedResource, ","), "storefront-3-orders-db"; got != want { + t.Errorf("deleted resource = %q, want %q", got, want) + } +} + +// ---- helpers ---------------------------------------------------------------- + +func designWithPlatformResource(compName, depName, resourceType string) *artifacts.DesignFile { + return &artifacts.DesignFile{ + Components: []models.DesignComponent{ + { + Name: compName, + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindPlatformResource, + Name: depName, + ResourceType: resourceType, + }, + }, + }, + }, + } +} + +// ---- tests ------------------------------------------------------------------ + +// TestResourceService_Provision_SetsBuildingNotDeployed is the TDD RED test: +// calling Provision marks the matching resource-provisioning task to `building` +// (the in-flight marker the watcher sweeps), NOT `deployed`. It calls +// ResourceProvisioner.Provision exactly once and does NOT call redispatch. +func TestResourceService_Provision_SetsBuildingNotDeployed(t *testing.T) { + prov := &fakeProvisioner{result: &ProvisionResult{ResourceName: "proj-db", BindingByEnv: map[string]string{"development": "proj-db-development"}}} + store := &fakeDesignStore{ + design: designWithPlatformResource("api", "db", "postgres-cnpg"), + } + taskRepo := &fakeTaskRepo{ + tasks: []models.ComponentTask{ + {Type: models.TaskTypeResourceProvisioning, ResourceName: "db", Status: string(models.TaskStatusPending)}, + {Type: models.TaskTypeComponent, ComponentName: "api", Status: string(models.TaskStatusOnHold)}, + }, + } + + svc := NewResourceService(store, prov, taskRepo) + + err := svc.Provision(context.Background(), "default", "proj", "api", "db", + map[string]string{"storage": "10Gi"}, []string{"development"}) + if err != nil { + t.Fatalf("Provision: %v", err) + } + + // Provisioner must be called exactly once. + if prov.called != 1 { + t.Errorf("Provisioner.Provision called %d times, want 1", prov.called) + } + + // Task must be set to `building` (the in-flight marker), NOT `deployed`. + if taskRepo.updated == nil { + t.Fatal("task was not updated") + } + if taskRepo.updated.Status != string(models.TaskStatusBuilding) { + t.Errorf("task status = %q, want %q", taskRepo.updated.Status, models.TaskStatusBuilding) + } + if taskRepo.updated.Status == string(models.TaskStatusDeployed) { + t.Error("task must NOT be set to deployed — that is the watcher's job (async)") + } +} + +// TestResourceService_Provision_MissingDep returns an error when the dependency +// is not found in the component's design. +func TestResourceService_Provision_MissingDep(t *testing.T) { + store := &fakeDesignStore{ + design: designWithPlatformResource("api", "cache", "redis"), + } + svc := NewResourceService(store, &fakeProvisioner{}, &fakeTaskRepo{}) + + err := svc.Provision(context.Background(), "default", "proj", "api", "db", + nil, []string{"development"}) + if err == nil { + t.Fatal("expected error for missing dep, got nil") + } + if !errors.Is(err, ErrDepNotFound) { + t.Errorf("expected ErrDepNotFound, got %v", err) + } +} + +// TestResourceService_Provision_WrongKind returns an error when the dep exists +// but is not a platform-resource kind. +func TestResourceService_Provision_WrongKind(t *testing.T) { + store := &fakeDesignStore{ + design: &artifacts.DesignFile{ + Components: []models.DesignComponent{ + { + Name: "api", + Dependencies: []models.Dependency{ + {Kind: models.DependencyKindExternal, Name: "db"}, + }, + }, + }, + }, + } + svc := NewResourceService(store, &fakeProvisioner{}, &fakeTaskRepo{}) + + err := svc.Provision(context.Background(), "default", "proj", "api", "db", + nil, []string{"development"}) + if err == nil { + t.Fatal("expected error for wrong dep kind, got nil") + } + if !errors.Is(err, ErrDepNotFound) { + t.Errorf("expected ErrDepNotFound, got %v", err) + } +} + +// TestResourceService_Provision_ProvisionerFail returns ErrProvisionFailed when +// the provisioner returns an error. +func TestResourceService_Provision_ProvisionerFail(t *testing.T) { + store := &fakeDesignStore{ + design: designWithPlatformResource("api", "db", "postgres-cnpg"), + } + prov := &fakeProvisioner{err: errors.New("OC 503")} + svc := NewResourceService(store, prov, &fakeTaskRepo{}) + + err := svc.Provision(context.Background(), "default", "proj", "api", "db", + nil, []string{"development"}) + if err == nil { + t.Fatal("expected ErrProvisionFailed, got nil") + } + if !errors.Is(err, ErrProvisionFailed) { + t.Errorf("expected ErrProvisionFailed, got %v", err) + } +} + +// TestGetStatus_BindingNotYetCreated asserts that GetStatus returns HTTP 200 +// (not 500) when GetBinding returns (nil, nil) — the not-yet-created case that +// occurs between POST-provision and the first watcher sweep. The response must +// carry ready=false and the task status from the DB (here: "building"). +func TestGetStatus_BindingNotYetCreated(t *testing.T) { + humakit.SetGateMode(tenant.GateModeLog) + t.Cleanup(func() { humakit.SetGateMode(tenant.GateModeEnforce) }) + + rc := &fakeResourceClient{binding: nil, bindErr: nil} // GetBinding returns (nil, nil) + taskRepo := &fakeTaskRepo{ + tasks: []models.ComponentTask{ + {Type: models.TaskTypeResourceProvisioning, ResourceName: "db", Status: string(models.TaskStatusBuilding)}, + }, + } + store := &fakeDesignStore{design: designWithPlatformResource("api", "db", "postgres-cnpg")} + svc := NewResourceService(store, &fakeProvisioner{result: &ProvisionResult{}}, taskRepo) + + _, api := humatest.New(t) + RegisterResources(api, svc, rc) + + resp := api.Get("/api/v1/organizations/default/projects/proj/components/api/dependencies/db/status") + if resp.Code != 200 { + t.Fatalf("want HTTP 200 when binding not yet created, got %d; body=%s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + if !strings.Contains(body, `"ready":false`) { + t.Errorf("want ready=false in response, got: %s", body) + } + if !strings.Contains(body, `"status":"building"`) { + t.Errorf("want status=building from DB task in response, got: %s", body) + } +} + +// TestGetStatus_ListByProjectIDError asserts that when ListByProjectID errors, +// GetStatus still returns HTTP 200 with status "pending" (polling-resilient +// fallback) rather than 500 — the error is logged but not surfaced. +func TestGetStatus_ListByProjectIDError(t *testing.T) { + humakit.SetGateMode(tenant.GateModeLog) + t.Cleanup(func() { humakit.SetGateMode(tenant.GateModeEnforce) }) + + rc := &fakeResourceClient{binding: nil, bindErr: nil} + taskRepo := &fakeTaskRepo{listErr: errors.New("db transient")} + store := &fakeDesignStore{design: designWithPlatformResource("api", "db", "postgres-cnpg")} + svc := NewResourceService(store, &fakeProvisioner{result: &ProvisionResult{}}, taskRepo) + + _, api := humatest.New(t) + RegisterResources(api, svc, rc) + + resp := api.Get("/api/v1/organizations/default/projects/proj/components/api/dependencies/db/status") + if resp.Code != 200 { + t.Fatalf("want HTTP 200 on ListByProjectID error, got %d; body=%s", resp.Code, resp.Body.String()) + } + body := resp.Body.String() + if !strings.Contains(body, `"status":"pending"`) { + t.Errorf("want status=pending fallback, got: %s", body) + } +} diff --git a/asdlc-service/internal/feature/resources/provisioner.go b/asdlc-service/internal/feature/resources/provisioner.go new file mode 100644 index 00000000..db2b7933 --- /dev/null +++ b/asdlc-service/internal/feature/resources/provisioner.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package resources + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" +) + +// ResourceProvisioner is the narrow swap-point of P5: it authors the OpenChoreo +// Resource model for a platform-resource dependency and returns immediately — +// provisioning a real backing instance (a database, a broker) is asynchronous, +// so Provision MUST NOT block on readiness. It authors the Resource + per-env +// binding and returns; the resource watcher (a later task) observes the +// binding's native Ready condition out-of-band. +// +// This is the ONLY abstraction that varies by environment. The OC-native +// implementation below authors directly against openchoreo-api in both local +// and cloud. PlatformAPIProvisioner (reserved, see below) is the future cloud +// swap-in. Everything else in P5 — discovery, the task graph, the watcher, the +// drawer, consumption — is provisioner-agnostic. +type ResourceProvisioner interface { + // Provision authors (idempotently) the OC Resource model for a platform- + // resource dep and returns immediately — it does NOT wait for readiness + // (a real provisioner is async; the resource watcher observes the binding's + // native Ready condition). resourceType is a DISCOVERED ClusterResourceType + // name. params are user-supplied provisioning parameters (spec.parameters). + Provision(ctx context.Context, orgHandle, projectName, depName, resourceType string, + params map[string]string, envs []string) (*ProvisionResult, error) + + // Deprovision tears down what Provision authored: the per-env bindings (their + // retainPolicy cascades the rendered backing instance — for postgres-cnpg the + // CNPG Cluster + its generated Secret/PVC), then the Resource. Best-effort and + // 404-tolerant — used by project deletion, where the Resource/binding would + // otherwise orphan (the OC Project delete does NOT cascade them; they carry a + // logical spec.owner.projectName, not a k8s ownerReference). + Deprovision(ctx context.Context, orgHandle, projectName, depName string, envs []string) error +} + +// ProvisionResult reports what was authored: the per-project Resource name and +// the per-env binding names. It carries NO outputs — outputs (incl. secret +// references) are resolved later from the binding's status by the readiness +// watcher, never returned synchronously from the request path. +type ProvisionResult struct { + ResourceName string + BindingByEnv map[string]string +} + +// PlatformAPIProvisioner is the RESERVED cloud implementation (wso2cloud #638). +// When that provisioning API ships, this impl calls it to mint the backing +// instance, then materializes the returned outputs into the SAME OC binding so +// consumption stays identical. NOT IMPLEMENTED in P5 — the OCNativeProvisioner +// is the only registered impl; this comment marks the seam. + +// OCNativeProvisioner authors the OC Resource model directly against +// openchoreo-api (identical local + cloud). It references a DISCOVERED +// ClusterResourceType — it NEVER authors the type (the cluster PE installs it +// via the deployments/ scripts) and so makes no EnsureResourceType call. +// +// Flow (async): ApplyResource → poll status.latestRelease (cutting a release is +// fast) → author the per-env binding pinned to that release → return. It does +// NOT wait for the binding's Ready condition: standing up a real DB takes +// minutes; the resource watcher observes Ready out-of-band. +type OCNativeProvisioner struct { + rc openchoreo.ResourceClient + + // pollInterval / pollTimeout bound ONLY the wait for the controller to cut + // the ResourceRelease (Resources have no AutoDeploy — the BFF pins it). + // This is NOT a readiness wait — it is the fast "did a release name appear" + // poll, just long enough to pin the binding. + pollInterval time.Duration + pollTimeout time.Duration +} + +func NewOCNativeProvisioner(rc openchoreo.ResourceClient) *OCNativeProvisioner { + return &OCNativeProvisioner{ + rc: rc, + pollInterval: 2 * time.Second, + pollTimeout: 60 * time.Second, + } +} + +// Provision authors the Resource + per-env bindings and returns immediately. It +// references the discovered ClusterResourceType `resourceType` (no +// EnsureResourceType), writes user params to spec.parameters, polls only for +// status.latestRelease to pin, and returns success even when the binding is not +// yet Ready (async — the watcher observes readiness later). +func (p *OCNativeProvisioner) Provision( + ctx context.Context, + orgHandle, projectName, depName, resourceType string, + params map[string]string, + envs []string, +) (*ProvisionResult, error) { + if orgHandle == "" || projectName == "" || depName == "" { + return nil, fmt.Errorf("resources: orgHandle, projectName and depName required") + } + if resourceType == "" { + return nil, fmt.Errorf("resources: resourceType (ClusterResourceType name) required") + } + + // 1. Resource (one per project; references the discovered ClusterResourceType). + // NO EnsureResourceType — app-factory never authors the type. + res := BuildPlatformResource(projectName, depName, resourceType, params) + if _, err := p.rc.ApplyResource(ctx, orgHandle, res); err != nil { + return nil, fmt.Errorf("resources: apply resource: %w", err) + } + + // 2. Wait for status.latestRelease (no AutoDeploy → BFF pins it). This is a + // fast poll for the release NAME, not a readiness wait. + latest, err := p.waitForLatestRelease(ctx, orgHandle, res.Metadata.Name) + if err != nil { + return nil, err + } + + // 3. Per env: author the binding pinned to latestRelease. Return without + // waiting on the binding Ready condition — a real DB takes minutes. + result := &ProvisionResult{ResourceName: res.Metadata.Name, BindingByEnv: map[string]string{}} + for _, env := range envs { + binding, berr := BuildPlatformBinding(projectName, depName, env, latest, params) + if berr != nil { + return nil, berr + } + if _, err := p.rc.EnsureBinding(ctx, orgHandle, binding); err != nil { + return nil, fmt.Errorf("resources: ensure binding for env %q: %w", env, err) + } + result.BindingByEnv[env] = binding.Metadata.Name + } + return result, nil +} + +// waitForLatestRelease polls the Resource for status.latestRelease — the release +// name the per-env bindings pin to. Reused from the connections provisioner: it +// bounds ONLY the (fast) release-cut, never readiness of the backing instance. +func (p *OCNativeProvisioner) waitForLatestRelease(ctx context.Context, namespace, resourceName string) (string, error) { + deadline := time.Now().Add(p.pollTimeout) + for { + got, err := p.rc.GetResource(ctx, namespace, resourceName) + if err != nil { + return "", fmt.Errorf("resources: poll resource %q: %w", resourceName, err) + } + if got.Status != nil && got.Status.LatestRelease != nil && got.Status.LatestRelease.Name != "" { + return got.Status.LatestRelease.Name, nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf("resources: resource %q produced no ResourceRelease within %s", resourceName, p.pollTimeout) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(p.pollInterval): + } + } +} + +// Deprovision deletes the per-env bindings (their retainPolicy cascades the +// rendered CNPG Cluster + generated Secret/PVC), then the Resource. Best-effort: +// DeleteBinding/DeleteResource are 404-tolerant, and errors are collected rather +// than short-circuited so one failed env doesn't leave the Resource behind. +func (p *OCNativeProvisioner) Deprovision(ctx context.Context, orgHandle, projectName, depName string, envs []string) error { + resourceName := platformResourceName(projectName, depName) + var errs []error + for _, env := range envs { + if err := p.rc.DeleteBinding(ctx, orgHandle, resourceName+"-"+env); err != nil { + errs = append(errs, fmt.Errorf("delete binding (%s/%s): %w", depName, env, err)) + } + } + if err := p.rc.DeleteResource(ctx, orgHandle, resourceName); err != nil { + errs = append(errs, fmt.Errorf("delete resource (%s): %w", depName, err)) + } + return errors.Join(errs...) +} + +// ---- pure builders (unit-tested) ------------------------------------------- + +// platformResourceName is the per-project Resource name (metadata.name is +// namespace-unique; owner.projectName does NOT scope it). +func platformResourceName(project, depName string) string { return project + "-" + depName } + +// BuildPlatformResource references a DISCOVERED ClusterResourceType (Type.Kind = +// "ClusterResourceType") — unlike the connections provisioner's namespaced +// per-connection ResourceType, app-factory never authors this type. User +// provisioning params go to spec.parameters as JSON. +func BuildPlatformResource(project, depName, rtName string, params map[string]string) *openchoreo.Resource { + res := &openchoreo.Resource{ + Metadata: openchoreo.OCObjectMeta{Name: platformResourceName(project, depName)}, + Spec: openchoreo.ResourceSpec{ + Owner: openchoreo.ResourceOwner{ProjectName: project}, + Type: openchoreo.ResourceTypeRef{Kind: "ClusterResourceType", Name: rtName}, + }, + } + if len(params) > 0 { + if raw, err := json.Marshal(params); err == nil { + res.Spec.Parameters = raw + } + } + return res +} + +// BuildPlatformBinding authors a per-env binding pinned to latestRelease. Per-env +// params go to spec.resourceTypeEnvironmentConfigs as JSON. +func BuildPlatformBinding(project, depName, env, latestRelease string, params map[string]string) (*openchoreo.ResourceReleaseBinding, error) { + resourceName := platformResourceName(project, depName) + b := &openchoreo.ResourceReleaseBinding{ + Metadata: openchoreo.OCObjectMeta{Name: resourceName + "-" + env}, + Spec: openchoreo.ResourceReleaseBindingSpec{ + Owner: openchoreo.ResourceReleaseBindingOwner{ProjectName: project, ResourceName: resourceName}, + Environment: env, + ResourceRelease: latestRelease, + }, + } + if len(params) > 0 { + raw, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("resources: marshal env configs: %w", err) + } + b.Spec.ResourceTypeEnvironmentConfigs = json.RawMessage(raw) + } + return b, nil +} + +// OCNativeProvisioner is the only registered ResourceProvisioner impl in P5. +var _ ResourceProvisioner = (*OCNativeProvisioner)(nil) diff --git a/asdlc-service/internal/feature/resources/provisioner_test.go b/asdlc-service/internal/feature/resources/provisioner_test.go new file mode 100644 index 00000000..a0373e3f --- /dev/null +++ b/asdlc-service/internal/feature/resources/provisioner_test.go @@ -0,0 +1,174 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package resources_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/wso2/asdlc/asdlc-service/clients/openchoreo" + "github.com/wso2/asdlc/asdlc-service/internal/feature/resources" +) + +func TestBuildPlatformResource_ReferencesClusterResourceType(t *testing.T) { + r := resources.BuildPlatformResource("shop", "maindb", "postgres-cnpg", map[string]string{"version": "16"}) + if r.Spec.Type.Kind != "ClusterResourceType" || r.Spec.Type.Name != "postgres-cnpg" { + t.Fatalf("type ref: %+v", r.Spec.Type) + } + if r.Metadata.Name != "shop-maindb" { + t.Fatalf("name: %s", r.Metadata.Name) + } + if r.Spec.Owner.ProjectName != "shop" { + t.Fatalf("owner: %+v", r.Spec.Owner) + } + var got map[string]string + _ = json.Unmarshal(r.Spec.Parameters, &got) + if got["version"] != "16" { + t.Fatalf("params: %s", r.Spec.Parameters) + } +} + +func TestBuildPlatformBinding_PinsRelease(t *testing.T) { + b, err := resources.BuildPlatformBinding("shop", "maindb", "development", "shop-maindb-r1", map[string]string{"size": "small"}) + if err != nil { + t.Fatal(err) + } + if b.Spec.ResourceRelease != "shop-maindb-r1" || b.Spec.Environment != "development" { + t.Fatalf("binding: %+v", b.Spec) + } + if b.Metadata.Name != "shop-maindb-development" { + t.Fatalf("name: %s", b.Metadata.Name) + } +} + +// recordingRC records calls and returns a Resource that already has a +// latestRelease but a binding that is NOT ready — proving Provision returns +// success without waiting on the binding Ready condition. (catalog_test.go owns +// the minimal fakeResourceClient; this one records the author flow.) +type recordingRC struct { + ensureRTCalls int + appliedRes *openchoreo.Resource + bindings []*openchoreo.ResourceReleaseBinding + latest string + getResource int +} + +func (f *recordingRC) EnsureResourceType(_ context.Context, _ string, rt *openchoreo.ResourceType) (*openchoreo.ResourceType, error) { + f.ensureRTCalls++ + return rt, nil +} +func (f *recordingRC) ApplyResource(_ context.Context, _ string, r *openchoreo.Resource) (*openchoreo.Resource, error) { + f.appliedRes = r + return r, nil +} +func (f *recordingRC) GetResource(_ context.Context, _, name string) (*openchoreo.Resource, error) { + f.getResource++ + return &openchoreo.Resource{ + Metadata: openchoreo.OCObjectMeta{Name: name}, + Status: &openchoreo.ResourceStatus{LatestRelease: &openchoreo.ResourceLatestRelease{Name: f.latest}}, + }, nil +} +func (f *recordingRC) EnsureBinding(_ context.Context, _ string, b *openchoreo.ResourceReleaseBinding) (*openchoreo.ResourceReleaseBinding, error) { + f.bindings = append(f.bindings, b) + return b, nil +} +func (f *recordingRC) GetBinding(_ context.Context, _, name string) (*openchoreo.ResourceReleaseBinding, error) { + // NOT ready — Provision must not block on this. + return &openchoreo.ResourceReleaseBinding{ + Metadata: openchoreo.OCObjectMeta{Name: name}, + Status: &openchoreo.ResourceReleaseBindingStatus{Conditions: []openchoreo.OCCondition{{Type: "Ready", Status: "False"}}}, + }, nil +} +func (f *recordingRC) DeleteBinding(_ context.Context, _, _ string) error { return nil } +func (f *recordingRC) DeleteResource(_ context.Context, _, _ string) error { return nil } +func (f *recordingRC) ListClusterResourceTypes(_ context.Context) ([]openchoreo.ResourceType, error) { + return nil, nil +} +func (f *recordingRC) PatchWorkloadResourceDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadResourceDep) error { + return nil +} +func (f *recordingRC) PatchWorkloadEndpointDeps(_ context.Context, _, _ string, _ []openchoreo.WorkloadEndpointDep) error { + return nil +} +func (f *recordingRC) ListWorkloadEndpoints(_ context.Context, _ string) ([]openchoreo.WorkloadEndpointInfo, error) { + return nil, nil +} + +func TestProvision_AuthorsResourceModel_Async(t *testing.T) { + rc := &recordingRC{latest: "shop-maindb-r1"} + p := resources.NewOCNativeProvisioner(rc) + + res, err := p.Provision( + context.Background(), + "default", "shop", "maindb", "postgres-cnpg", + map[string]string{"version": "16"}, + []string{"development", "production"}, + ) + if err != nil { + t.Fatalf("provision: %v", err) + } + + // Never authors the type — it's discovered, not authored. + if rc.ensureRTCalls != 0 { + t.Fatalf("EnsureResourceType must NEVER be called, got %d", rc.ensureRTCalls) + } + + // Authored exactly one Resource against the discovered ClusterResourceType. + if rc.appliedRes == nil { + t.Fatal("Resource not applied") + } + if rc.appliedRes.Spec.Type.Kind != "ClusterResourceType" || rc.appliedRes.Spec.Type.Name != "postgres-cnpg" { + t.Fatalf("type ref: %+v", rc.appliedRes.Spec.Type) + } + if rc.appliedRes.Metadata.Name != "shop-maindb" { + t.Fatalf("resource name: %s", rc.appliedRes.Metadata.Name) + } + if rc.getResource == 0 { + t.Fatal("GetResource (poll for latestRelease) never called") + } + + // One binding per env, pinned to the polled release. + if len(rc.bindings) != 2 { + t.Fatalf("want 2 bindings, got %d", len(rc.bindings)) + } + for _, b := range rc.bindings { + if b.Spec.ResourceRelease != "shop-maindb-r1" { + t.Errorf("binding not pinned: %q", b.Spec.ResourceRelease) + } + } + + // Result reports the resource + per-env binding names — returned success even + // though the binding is NOT Ready (a real DB takes minutes; the watcher + // observes readiness later). + if res.ResourceName != "shop-maindb" { + t.Errorf("result resource name: %s", res.ResourceName) + } + if res.BindingByEnv["development"] != "shop-maindb-development" || res.BindingByEnv["production"] != "shop-maindb-production" { + t.Errorf("result bindings: %+v", res.BindingByEnv) + } +} + +func TestProvision_RequiresArgs(t *testing.T) { + rc := &recordingRC{latest: "r1"} + p := resources.NewOCNativeProvisioner(rc) + if _, err := p.Provision(context.Background(), "", "shop", "maindb", "postgres-cnpg", nil, []string{"development"}); err == nil { + t.Fatal("expected error for empty orgHandle") + } + _ = time.Second +} diff --git a/asdlc-service/internal/feature/task/board_service.go b/asdlc-service/internal/feature/task/board_service.go index d4b45628..770258fd 100644 --- a/asdlc-service/internal/feature/task/board_service.go +++ b/asdlc-service/internal/feature/task/board_service.go @@ -57,6 +57,24 @@ type BoardTask struct { // it can dispatch. Populated for every task; the On Hold column // reads it to show "Waiting for: …". DependsOnComponents []string `json:"dependsOnComponents,omitempty"` + // DependsOnResources / DependsOnOrgServices / DependsOnConnections mirror + // the same-named ComponentTask fields — the resource (platform-provisioned + // db/cache), cross-project org-service, and external-connection gates a + // component task waits on. Surfaced so the On Hold column can explain the + // full reason a task is held (not just component gates). + DependsOnResources []string `json:"dependsOnResources,omitempty"` + DependsOnOrgServices []string `json:"dependsOnOrgServices,omitempty"` + DependsOnConnections []string `json:"dependsOnConnections,omitempty"` + // Type mirrors ComponentTask.Type (component, resource-provisioning, + // config-collection). The frontend uses it to route the row's action — + // resource-provisioning/config-collection tasks are resolved in the + // architecture-page drawer, not via the (no-op) per-task exec endpoint. + Type string `json:"type,omitempty"` + // ResourceName / ConnectionName mirror the same-named ComponentTask fields + // (the dependency this SYSTEM task resolves). The frontend deep-links the + // architecture drawer to this dep name (?dep=). + ResourceName string `json:"resourceName,omitempty"` + ConnectionName string `json:"connectionName,omitempty"` // ComponentName mirrors ComponentTask.ComponentName so the frontend // can resolve dep -> task lookups (e.g. "what is component `todo-api`'s // task currently doing while we wait?"). @@ -110,18 +128,8 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* return nil, fmt.Errorf("get board: %w", err) } - // Load DB tasks for enrichment. - type taskDBMeta struct { - id string - lifecycleStatus string - status string - dispatchedAt *time.Time - execType string - dependsOnComponents []string - componentName string - errorMessage string - } - issueURLToMeta := map[string]taskDBMeta{} + // Load DB tasks for enrichment, keyed by issue URL. + issueURLToCT := map[string]models.ComponentTask{} var allComponentTasks []models.ComponentTask // unissuedTasks are tasks with no IssueURL (gh_issue_waiting or gh_issue_failed). // They never appear on the GitHub Project board and must be surfaced separately. @@ -131,16 +139,7 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* allComponentTasks = componentTasks for _, ct := range componentTasks { if ct.IssueURL != "" { - issueURLToMeta[ct.IssueURL] = taskDBMeta{ - id: ct.ID, - lifecycleStatus: ct.LifecycleStatus, - status: ct.Status, - dispatchedAt: ct.DispatchedAt, - execType: ct.ExecType, - dependsOnComponents: []string(ct.DependsOnComponents), - componentName: ct.ComponentName, - errorMessage: ct.ErrorMessage, - } + issueURLToCT[ct.IssueURL] = ct } else { unissuedTasks = append(unissuedTasks, ct) } @@ -148,7 +147,11 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* } } + // Track which DB tasks (by issue URL) the canonical board actually renders, + // so the reconcile pass below can surface any issued task the board omits. + renderedIssueURLs := map[string]bool{} for _, item := range result.Items { + renderedIssueURLs[item.URL] = true task := BoardTask{ ID: item.ID, Title: item.Title, @@ -158,15 +161,8 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* Labels: item.Labels, LifecycleStatus: string(models.TaskLifecycleGhIssueCreated), } - if meta, ok := issueURLToMeta[item.URL]; ok { - task.ComponentTaskID = meta.id - task.LifecycleStatus = meta.lifecycleStatus - task.Status = meta.status - task.DispatchedAt = meta.dispatchedAt - task.ExecType = meta.execType - task.DependsOnComponents = meta.dependsOnComponents - task.ComponentName = meta.componentName - task.ErrorMessage = meta.errorMessage + if ct, ok := issueURLToCT[item.URL]; ok { + applyCTFields(&task, ct) } // The BFF's ComponentTask.Status is authoritative for kanban routing // of `on_hold` (dep-gated), terminal failure states, and @@ -202,32 +198,12 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* // Fallback: when the GitHub board has no items, show all component tasks from DB. if len(result.Items) == 0 && len(allComponentTasks) > 0 { for _, ct := range allComponentTasks { - labels := make([]gitrepo.LabelInfo, 0, len(ct.Labels)) - for _, l := range ct.Labels { - labels = append(labels, gitrepo.LabelInfo{Name: l}) - } - // Board has 0 items — GitHub Project hasn't synced yet. - // Override gh_issue_created → gh_issue_syncing in the response so - // the frontend shows a skeleton instead of a labelless task card. - // This value is never written to DB. - lifecycleStatus := ct.LifecycleStatus - if lifecycleStatus == string(models.TaskLifecycleGhIssueCreated) { - lifecycleStatus = string(models.TaskLifecycleGhIssueSyncing) - } - task := BoardTask{ - ID: ct.ID, - Title: ct.Title, - URL: ct.IssueURL, - Description: ct.Body, - ComponentTaskID: ct.ID, - Labels: labels, - LifecycleStatus: lifecycleStatus, - Status: ct.Status, - DispatchedAt: ct.DispatchedAt, - ExecType: ct.ExecType, - DependsOnComponents: []string(ct.DependsOnComponents), - ComponentName: ct.ComponentName, - ErrorMessage: ct.ErrorMessage, + task := boardTaskFromCT(ct) + // Board has 0 items — GitHub Project hasn't synced yet. Override + // gh_issue_created → gh_issue_syncing so the frontend shows a skeleton + // instead of a labelless task card. Never written to DB. + if task.LifecycleStatus == string(models.TaskLifecycleGhIssueCreated) { + task.LifecycleStatus = string(models.TaskLifecycleGhIssueSyncing) } switch ct.Status { case "on_hold": @@ -245,25 +221,52 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* return board, nil } - // Always surface unissued tasks (gh_issue_waiting / gh_issue_failed) even - // when the primary path is active. These have no IssueURL and are invisible - // to the GitHub Project board. + // Always surface unissued tasks (no IssueURL — either a coding task still + // awaiting its GitHub issue, or a SYSTEM resource-provisioning / + // config-collection task that never gets an issue by design). These are + // invisible to the GitHub Project board, so route them by their own status: + // a SYSTEM task moves Todo → In Progress → Done as it provisions, rather than + // sitting in Todo with a "deployed" label forever. for _, ct := range unissuedTasks { - task := BoardTask{ - ID: ct.ID, - Title: ct.Title, - ComponentTaskID: ct.ID, - LifecycleStatus: ct.LifecycleStatus, - Status: ct.Status, - DispatchedAt: ct.DispatchedAt, - ExecType: ct.ExecType, - DependsOnComponents: []string(ct.DependsOnComponents), - ComponentName: ct.ComponentName, - ErrorMessage: ct.ErrorMessage, + task := boardTaskFromCT(ct) + switch { + case ct.LifecycleStatus == string(models.TaskLifecycleGhIssueFailed): + board.Failed = append(board.Failed, task) + case ct.Status == "deployed": + board.Done = append(board.Done, task) + case ct.Status == "building" || ct.Status == "in_progress": + board.InProgress = append(board.InProgress, task) + case ct.Status == "failed" || ct.Status == "rejected" || ct.Status == "verification_failed": + board.Failed = append(board.Failed, task) + case ct.Status == "on_hold": + board.OnHold = append(board.OnHold, task) + default: // pending / empty — needs action, or a coding task awaiting its issue + board.Todo = append(board.Todo, task) + } + } + + // Reconcile: a task may carry an IssueURL yet be absent from the canonical + // GitHub Project board — e.g. a board-provisioning race split the project's + // issues across two boards, or a project-item add failed. Such a task is + // matched by neither the primary loop (its issue is not in result.Items) nor + // the unissued path (it HAS a URL), so it would silently vanish. Surface any + // issued task the board omitted, routed by its authoritative ComponentTask + // status (the same mapping the zero-items fallback uses). + for _, ct := range allComponentTasks { + if ct.IssueURL == "" || renderedIssueURLs[ct.IssueURL] { + continue } - if ct.LifecycleStatus == string(models.TaskLifecycleGhIssueFailed) { + task := boardTaskFromCT(ct) + switch ct.Status { + case "on_hold": + board.OnHold = append(board.OnHold, task) + case "in_progress": + board.InProgress = append(board.InProgress, task) + case "ready_for_review", "merged", "building", "deployed": + board.Done = append(board.Done, task) + case "failed", "rejected", "verification_failed", "abandoned": board.Failed = append(board.Failed, task) - } else { + default: board.Todo = append(board.Todo, task) } } @@ -274,3 +277,40 @@ func (s *boardService) GetBoard(ctx context.Context, orgID, projectID string) (* func normalizeStatus(s string) string { return strings.ToLower(strings.TrimSpace(s)) } + +// applyCTFields copies the ComponentTask-derived status / dependency / type +// fields onto a BoardTask, leaving any GitHub-sourced fields (id / title / url / +// body / labels / assignee) intact so the primary path keeps the board item's copy. +func applyCTFields(task *BoardTask, ct models.ComponentTask) { + task.ComponentTaskID = ct.ID + task.LifecycleStatus = ct.LifecycleStatus + task.Status = ct.Status + task.DispatchedAt = ct.DispatchedAt + task.ExecType = ct.ExecType + task.Type = ct.Type + task.ResourceName = ct.ResourceName + task.ConnectionName = ct.ConnectionName + task.DependsOnComponents = []string(ct.DependsOnComponents) + task.DependsOnResources = []string(ct.DependsOnResources) + task.DependsOnOrgServices = []string(ct.DependsOnOrgServices) + task.DependsOnConnections = []string(ct.DependsOnConnections) + task.ComponentName = ct.ComponentName + task.ErrorMessage = ct.ErrorMessage +} + +// boardTaskFromCT builds a BoardTask entirely from a ComponentTask — used by the +// zero-items fallback, the reconcile pass, and the unissued pass, none of which +// have a GitHub board item to source from. +func boardTaskFromCT(ct models.ComponentTask) BoardTask { + task := BoardTask{ + ID: ct.ID, + Title: ct.Title, + URL: ct.IssueURL, + Description: ct.Body, + } + for _, l := range ct.Labels { + task.Labels = append(task.Labels, gitrepo.LabelInfo{Name: l}) + } + applyCTFields(&task, ct) + return task +} diff --git a/asdlc-service/internal/feature/task/board_service_test.go b/asdlc-service/internal/feature/task/board_service_test.go new file mode 100644 index 00000000..32e3a447 --- /dev/null +++ b/asdlc-service/internal/feature/task/board_service_test.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package task + +import ( + "context" + "testing" + + "github.com/wso2/asdlc/asdlc-service/internal/feature/gitrepo" + "github.com/wso2/asdlc/asdlc-service/models" +) + +// stubRepoBoard returns a fixed GitHub Project board result. +type stubRepoBoard struct{ result *gitrepo.ProjectBoardResult } + +func (s *stubRepoBoard) GetBoard(_ context.Context, _, _ string) (*gitrepo.ProjectBoardResult, error) { + return s.result, nil +} +func (s *stubRepoBoard) MoveIssueToStatus(_ context.Context, _, _, _, _ string) error { return nil } + +// TestGetBoard_ReconcilesIssuedTaskMissingFromBoard is the regression guard for +// the two-board race: a component task that HAS a GitHub issue but whose issue +// is absent from the canonical Project board (it landed on an orphaned second +// board, or a project-item add failed) must still be surfaced — matched by +// neither the primary loop nor the unissued path, it would otherwise vanish. +func TestGetBoard_ReconcilesIssuedTaskMissingFromBoard(t *testing.T) { + const webURL = "https://github.com/o/r/issues/1" + const apiURL = "https://github.com/o/r/issues/2" + + // Canonical board holds only storefront-web (#1); order-api (#2) is off-board. + repoBoard := &stubRepoBoard{result: &gitrepo.ProjectBoardResult{ + URL: "https://github.com/orgs/o/projects/85", + Items: []gitrepo.BoardItem{{ID: "PVTI_web", Title: "storefront-web", URL: webURL, Status: "On Hold"}}, + }} + taskRepo := &stubTaskRepo{tasks: []models.ComponentTask{ + {ID: "t-web", ComponentName: "storefront-web", IssueURL: webURL, Status: "on_hold"}, + {ID: "t-api", ComponentName: "order-api", IssueURL: apiURL, Status: "on_hold"}, // issued, off-board + {ID: "t-db", ComponentName: "orders-db", Type: "resource-provisioning", Status: "pending"}, // unissued + }} + + board, err := NewBoardService(repoBoard, taskRepo).GetBoard(context.Background(), "org", "proj") + if err != nil { + t.Fatalf("GetBoard: %v", err) + } + + onHold := map[string]int{} + for _, tk := range board.OnHold { + onHold[tk.ComponentName]++ + } + if onHold["storefront-web"] != 1 { + t.Errorf("storefront-web: OnHold count = %d, want 1", onHold["storefront-web"]) + } + if onHold["order-api"] != 1 { + t.Errorf("order-api (issued but off canonical board): OnHold count = %d, want 1 — reconcile failed", onHold["order-api"]) + } +} + +// TestGetBoard_NoDoubleCountForOnBoardTask asserts the reconcile pass does not +// re-add a task the primary loop already rendered. +func TestGetBoard_NoDoubleCountForOnBoardTask(t *testing.T) { + const url = "https://github.com/o/r/issues/1" + repoBoard := &stubRepoBoard{result: &gitrepo.ProjectBoardResult{ + Items: []gitrepo.BoardItem{{ID: "PVTI_1", Title: "api", URL: url, Status: "On Hold"}}, + }} + taskRepo := &stubTaskRepo{tasks: []models.ComponentTask{ + {ID: "t-1", ComponentName: "api", IssueURL: url, Status: "on_hold"}, + }} + board, err := NewBoardService(repoBoard, taskRepo).GetBoard(context.Background(), "org", "proj") + if err != nil { + t.Fatalf("GetBoard: %v", err) + } + if len(board.OnHold) != 1 { + t.Fatalf("OnHold has %d tasks, want 1 (no double-count)", len(board.OnHold)) + } +} + +// TestGetBoard_SurfacesGateKindsAndSystemTaskFields asserts the payload carries +// the full set of gate kinds (so the On Hold reason is complete) and the type + +// dep name of SYSTEM tasks (so the frontend can deep-link the drawer). +func TestGetBoard_SurfacesGateKindsAndSystemTaskFields(t *testing.T) { + repoBoard := &stubRepoBoard{result: &gitrepo.ProjectBoardResult{}} // 0 items → whole-DB fallback + taskRepo := &stubTaskRepo{tasks: []models.ComponentTask{ + {ID: "t-api", ComponentName: "order-api", Type: "component", Status: "on_hold", + DependsOnResources: models.StringSlice{"orders-db"}, + DependsOnConnections: models.StringSlice{"exchangerate"}, + DependsOnOrgServices: models.StringSlice{"catalog-product-api"}}, + {ID: "t-db", ComponentName: "orders-db", Type: "resource-provisioning", Status: "pending", ResourceName: "orders-db"}, + {ID: "t-cfg", ComponentName: "exchangerate", Type: "config-collection", Status: "pending", ConnectionName: "exchangerate"}, + }} + board, err := NewBoardService(repoBoard, taskRepo).GetBoard(context.Background(), "o", "p") + if err != nil { + t.Fatalf("GetBoard: %v", err) + } + + var api *BoardTask + for i := range board.OnHold { + if board.OnHold[i].ComponentName == "order-api" { + api = &board.OnHold[i] + } + } + if api == nil { + t.Fatal("order-api not in OnHold") + } + if len(api.DependsOnResources) != 1 || len(api.DependsOnConnections) != 1 || len(api.DependsOnOrgServices) != 1 { + t.Errorf("gate kinds not surfaced: res=%v conn=%v org=%v", api.DependsOnResources, api.DependsOnConnections, api.DependsOnOrgServices) + } + + find := func(name string) *BoardTask { + for i := range board.Todo { + if board.Todo[i].ComponentName == name { + return &board.Todo[i] + } + } + return nil + } + if db := find("orders-db"); db == nil || db.Type != "resource-provisioning" || db.ResourceName != "orders-db" { + t.Errorf("orders-db SYSTEM fields wrong: %+v", db) + } + if cfg := find("exchangerate"); cfg == nil || cfg.Type != "config-collection" || cfg.ConnectionName != "exchangerate" { + t.Errorf("exchangerate SYSTEM fields wrong: %+v", cfg) + } +} + +// TestGetBoard_UnissuedSystemTaskRoutedByStatus asserts a resource-provisioning / +// config-collection task (no issue → the unissued path) is routed by its own +// status: deployed → Done (not pinned to Todo with a "deployed" label), pending +// → Todo. +func TestGetBoard_UnissuedSystemTaskRoutedByStatus(t *testing.T) { + // One board item that matches no DB task keeps result.Items > 0 so the + // whole-DB fallback does not fire; the DB-only tasks flow through unissued. + repoBoard := &stubRepoBoard{result: &gitrepo.ProjectBoardResult{ + Items: []gitrepo.BoardItem{{ID: "PVTI_x", URL: "https://github.com/o/r/issues/9", Status: "Todo"}}, + }} + taskRepo := &stubTaskRepo{tasks: []models.ComponentTask{ + {ID: "t-db", ComponentName: "orders-db", Type: "resource-provisioning", ResourceName: "orders-db", Status: "deployed"}, + {ID: "t-cfg", ComponentName: "exchangerate", Type: "config-collection", ConnectionName: "exchangerate", Status: "pending"}, + }} + board, err := NewBoardService(repoBoard, taskRepo).GetBoard(context.Background(), "o", "p") + if err != nil { + t.Fatalf("GetBoard: %v", err) + } + has := func(list []BoardTask, name string) bool { + for _, tk := range list { + if tk.ComponentName == name { + return true + } + } + return false + } + if !has(board.Done, "orders-db") { + t.Errorf("deployed resource-provisioning task not in Done (Todo=%v)", has(board.Todo, "orders-db")) + } + if !has(board.Todo, "exchangerate") { + t.Errorf("pending config-collection task should be in Todo") + } +} diff --git a/asdlc-service/internal/feature/task/handlers.go b/asdlc-service/internal/feature/task/handlers.go index 4ee054a6..a7b4afeb 100644 --- a/asdlc-service/internal/feature/task/handlers.go +++ b/asdlc-service/internal/feature/task/handlers.go @@ -39,6 +39,16 @@ type BuildDispatcher interface { DispatchTaskBuild(ctx context.Context, task *models.ComponentTask, sha string) (runName string, err error) } +// AccessRejector is the P3.5 close-out port for a provider's `org-publish` task +// that is rejected (PR closed unmerged → task `rejected`). The handler invokes +// it after the rejected transition so every consumer AccessRequest riding on +// the provider task flips to `rejected`. Satisfied structurally by +// access.AccessService; wired at the composition root so the task feature +// needn't import access. Optional — nil disables the step. +type AccessRejector interface { + RejectByProviderTask(ctx context.Context, providerTaskID string) error +} + // RegisterHandlers builds the transition handlers and installs them via the // supplied register func (the composition root adapts it onto the webhook // Router) — so the task feature imports nothing from the webhook package. @@ -52,11 +62,13 @@ func RegisterHandlers( db *gorm.DB, projector *Projector, builds BuildDispatcher, + accessRejector AccessRejector, ) { h := &Handler{ - db: db, - projector: projector, - wfService: builds, + db: db, + projector: projector, + wfService: builds, + accessRejector: accessRejector, } register("pull_request", "opened", h.PullRequestOpened) register("pull_request", "edited", h.PullRequestEdited) @@ -68,9 +80,10 @@ func RegisterHandlers( } type Handler struct { - db *gorm.DB - projector *Projector - wfService BuildDispatcher + db *gorm.DB + projector *Projector + wfService BuildDispatcher + accessRejector AccessRejector } // pull_request payload subset. @@ -242,7 +255,15 @@ func (h *Handler) PullRequestClosed(ctx context.Context, event, action string, b if IsTaskNotFound(err) { return nil } - return err + if err != nil { + return err + } + // P3.5: if the rejected task is a provider `org-publish` task, flip every + // consumer AccessRequest riding on it to `rejected`. Best-effort — a sync + // failure here must never fail webhook processing. The task transition + // above has already committed. + h.rejectAccessRequestsForPR(ctx, p.Repository.FullName, p.PullRequest.Number) + return nil } // Merged: record the merge SHA and advance. @@ -327,3 +348,32 @@ func (h *Handler) IssueComment(ctx context.Context, event, action string, body [ // Persisted in webhook_payloads for audit; no state effect. return nil } + +// rejectAccessRequestsForPR is the P3.5 reject close-out. After a PR closes +// unmerged (the task already transitioned to `rejected`), it looks up the task +// by (repo, PR number) and — if it is a provider `org-publish` task — flips +// every consumer AccessRequest riding on that provider task to `rejected`. +// Entirely best-effort: any lookup/flip failure is logged and swallowed so it +// never fails webhook processing. +func (h *Handler) rejectAccessRequestsForPR(ctx context.Context, repoFullName string, prNumber int) { + if h.accessRejector == nil { + return + } + orgID, projectID, err := repositories.LookupOrgProjectByRepoURL(h.db.WithContext(ctx), repoFullName) + if err != nil || projectID == "" { + return + } + var task models.ComponentTask + if err := h.db.WithContext(ctx). + Where("org_id = ? AND project_id = ? AND pull_request_number = ?", orgID, projectID, prNumber). + First(&task).Error; err != nil { + return + } + if task.Type != models.TaskTypeOrgPublish { + return + } + if rerr := h.accessRejector.RejectByProviderTask(ctx, task.ID); rerr != nil { + slog.WarnContext(ctx, "access reject: RejectByProviderTask failed", + "task", task.ID, "error", rerr) + } +} diff --git a/asdlc-service/internal/feature/task/task_stream.go b/asdlc-service/internal/feature/task/task_stream.go index 712193be..d229b3a5 100644 --- a/asdlc-service/internal/feature/task/task_stream.go +++ b/asdlc-service/internal/feature/task/task_stream.go @@ -442,7 +442,8 @@ func (s *taskService) persistAndIssue( // authored, not LLM-authored) and validated against the component set // at persist time. The LLM's `PlanItem.dependsOn` is context-only. rows := make([]persistedItem, len(plan)) - batchConns := map[string]struct{}{} // distinct `external` connections the batch binds + batchConns := map[string]struct{}{} // distinct `external` connections the batch binds + batchResources := map[string]string{} // distinct `platform-resource` deps: name → resourceType for i, p := range plan { comp := byName[strings.ToLower(p.ComponentName)] deps := comp.ComponentDependsOn() @@ -463,6 +464,15 @@ func (s *taskService) persistAndIssue( batchConns[dep.Name] = struct{}{} } } + // Platform-resource deps gate the component task until their + // resource-provisioning task is provisioned (plan §4, P5). + var resDeps []string + for _, dep := range comp.Dependencies { + if dep.Kind == models.DependencyKindPlatformResource { + resDeps = append(resDeps, dep.Name) + batchResources[dep.Name] = dep.ResourceType // distinct platform-resources the batch binds + } + } task := &models.ComponentTask{ ProjectID: projectID, OrgID: orgID, @@ -473,6 +483,7 @@ func (s *taskService) persistAndIssue( DependsOnComponents: models.StringSlice(deps), DependsOnConnections: models.StringSlice(connDeps), DependsOnOrgServices: models.StringSlice(comp.OrgServiceDependsOn()), + DependsOnResources: models.StringSlice(resDeps), BatchID: ptrString(batchID), SourceSpecVersion: specVersion, SourceDesignVersion: designVersion, @@ -537,6 +548,52 @@ func (s *taskService) persistAndIssue( } } + // resource-provisioning tasks: one per distinct `platform-resource` dep the + // batch binds, deduped against existing resource-provisioning tasks for the + // project (a re-generation or a later batch binding an already-provisioned + // resource adds none). No GitHub issue — created outside `rows`, so the issue + // loop skips them; LifecycleStatus is gh_issue_created so reconcilers skip too. + // NOTE(§4): No GitHub issue is created here because P5 resource provisioning + // commits no repo artifacts (the OC Resource + binding are authored directly + // against the OC API, not committed to git). A future resourceType that does + // commit repo artifacts would re-introduce issue creation — keep the no-issue + // behavior keyed on "commits artifacts", do not treat it as universal. + if len(batchResources) > 0 { + existing, lerr := s.taskRepo.ListByProjectID(ctx, orgID, projectID) + if lerr != nil { + return nil, fmt.Errorf("list tasks for resource-provisioning dedup: %w", lerr) + } + haveRes := map[string]struct{}{} + for _, t := range existing { + if t.Type == models.TaskTypeResourceProvisioning && t.ResourceName != "" { + haveRes[t.ResourceName] = struct{}{} + } + } + order := len(rows) + for depName := range batchResources { + if _, ok := haveRes[depName]; ok { + continue + } + order++ + rp := &models.ComponentTask{ + ProjectID: projectID, + OrgID: orgID, + Type: models.TaskTypeResourceProvisioning, + ResourceName: depName, + ComponentName: depName, // surfaces the resource on the board + Title: "Provision resource: " + depName, + BatchID: ptrString(batchID), + Order: order, + Status: string(models.TaskStatusPending), + LifecycleStatus: string(models.TaskLifecycleGhIssueCreated), + ExecType: "SYSTEM", + } + if err := s.taskRepo.Create(ctx, rp); err != nil { + return nil, fmt.Errorf("create resource-provisioning task for %q: %w", depName, err) + } + } + } + // Parallel issue creation, bounded by ghCreateConcurrency. sem := make(chan struct{}, ghCreateConcurrency) var wg sync.WaitGroup diff --git a/asdlc-service/internal/feature/task/task_stream_resource_test.go b/asdlc-service/internal/feature/task/task_stream_resource_test.go new file mode 100644 index 00000000..e624434e --- /dev/null +++ b/asdlc-service/internal/feature/task/task_stream_resource_test.go @@ -0,0 +1,241 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package task — resource-provisioning task generation tests. +// +// These tests assert the A3 invariants for persistAndIssue: +// - A component task with a platform-resource dep has DependsOnResources +// populated (platform-authored, not LLM-authored). +// - Exactly one resource-provisioning task is created per distinct +// platform-resource dep, with ResourceName set and status pending. +// - Re-generation does not duplicate existing resource-provisioning tasks +// (dedup against existing tasks by ResourceName). +package task + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/wso2/asdlc/asdlc-service/internal/feature/artifacts" + "github.com/wso2/asdlc/asdlc-service/models" + "github.com/wso2/asdlc/asdlc-service/repositories" +) + +// stubTaskRepo is an in-memory task repository for unit tests. +// Only Create and ListByProjectID are wired — the test assertions query the +// stored slice directly. +type stubTaskRepo struct { + tasks []models.ComponentTask +} + +func (r *stubTaskRepo) Create(_ context.Context, task *models.ComponentTask) error { + r.tasks = append(r.tasks, *task) + return nil +} + +func (r *stubTaskRepo) ListByProjectID(_ context.Context, _, _ string) ([]models.ComponentTask, error) { + return r.tasks, nil +} + +// Implement the full TaskRepository interface with panicking stubs. +func (r *stubTaskRepo) GetByID(_ context.Context, _ string) (*models.ComponentTask, error) { + panic("not implemented") +} +func (r *stubTaskRepo) GetByIDScoped(_ context.Context, _, _ string) (*models.ComponentTask, error) { + panic("not implemented") +} +func (r *stubTaskRepo) GetByComponentName(_ context.Context, _, _, _ string) (*models.ComponentTask, error) { + panic("not implemented") +} +func (r *stubTaskRepo) ListNonTerminalByOrgID(_ context.Context, _ string) ([]models.ComponentTask, error) { + panic("not implemented") +} +func (r *stubTaskRepo) ListByOrgID(_ context.Context, _ string, _ repositories.ListByOrgFilter) ([]models.ComponentTask, error) { + panic("not implemented") +} +func (r *stubTaskRepo) GetBaselineBatch(_ context.Context, _, _ string) (string, string, string, error) { + panic("not implemented") +} +func (r *stubTaskRepo) Update(_ context.Context, _ *models.ComponentTask) error { + // issue creation updates task status to failed — ignore in tests. + return nil +} +func (r *stubTaskRepo) DeleteByProjectID(_ context.Context, _, _ string) error { + panic("not implemented") +} +func (r *stubTaskRepo) DeleteAll(_ context.Context) error { + panic("not implemented") +} + +// newTestTaskService builds a minimal taskService for unit testing persistAndIssue. +// issueSvc is nil — issue creation will fail for component tasks (expected in these +// tests; we assert on the task rows, not issue outcomes). +func newTestTaskService(repo *stubTaskRepo) *taskService { + return &taskService{ + taskRepo: repo, + store: nil, + } +} + +// sseDiscard is an io.Writer that discards all SSE frames during testing. +type sseDiscard struct{} + +func (sseDiscard) Write(p []byte) (int, error) { return len(p), nil } + +// TestPersistAndIssue_PlatformResourceDep asserts that a design with a +// platform-resource dependency: +// +// (a) produces a component task with DependsOnResources populated, and +// (b) produces exactly one resource-provisioning task with ResourceName +// set and Status == pending. +func TestPersistAndIssue_PlatformResourceDep(t *testing.T) { + repo := &stubTaskRepo{} + svc := newTestTaskService(repo) + + design := &artifacts.DesignFile{ + Components: []models.DesignComponent{ + { + Name: "api", + ComponentType: "service", + Language: "Go", + Dependencies: []models.Dependency{ + { + Kind: models.DependencyKindPlatformResource, + Name: "maindb", + ResourceType: "postgres-cnpg", + }, + }, + }, + }, + } + + plan := []planItemFrame{ + {TempID: "t1", ComponentName: "api", Title: "Implement api"}, + } + + w := &sseWriter{out: sseDiscard{}, flush: func() {}} + + // persistAndIssue will fail at issue creation (issueSvc == nil), but all + // task rows are persisted before issue creation begins — ignore the error. + //nolint:errcheck + _, _ = svc.persistAndIssue(context.Background(), w, + "org1", "proj1", "batch1", + "v1", "v1-1", + plan, design, + "https://github.com/org/repo", "org/repo", + ) + + // (a) component task must have DependsOnResources = ["maindb"] + var compTask *models.ComponentTask + for i := range repo.tasks { + if repo.tasks[i].Type == models.TaskTypeComponent || repo.tasks[i].Type == "" { + compTask = &repo.tasks[i] + break + } + } + if compTask == nil { + t.Fatal("no component task created") + } + if len(compTask.DependsOnResources) != 1 || compTask.DependsOnResources[0] != "maindb" { + t.Fatalf("want DependsOnResources=[maindb], got %v", compTask.DependsOnResources) + } + + // (b) exactly one resource-provisioning task for "maindb", status pending + var resTasks []models.ComponentTask + for _, task := range repo.tasks { + if task.Type == models.TaskTypeResourceProvisioning { + resTasks = append(resTasks, task) + } + } + if len(resTasks) != 1 { + t.Fatalf("want exactly 1 resource-provisioning task, got %d", len(resTasks)) + } + rt := resTasks[0] + if rt.ResourceName != "maindb" { + t.Errorf("want ResourceName=maindb, got %q", rt.ResourceName) + } + if rt.Status != string(models.TaskStatusPending) { + t.Errorf("want Status=pending, got %q", rt.Status) + } + if rt.LifecycleStatus != string(models.TaskLifecycleGhIssueCreated) { + t.Errorf("want LifecycleStatus=gh_issue_created, got %q", rt.LifecycleStatus) + } +} + +// TestPersistAndIssue_PlatformResourceDep_Dedup asserts that a second +// generation call does not duplicate a resource-provisioning task that already +// exists for the same resource name (mirroring config-collection dedup). +func TestPersistAndIssue_PlatformResourceDep_Dedup(t *testing.T) { + // Pre-seed with an existing resource-provisioning task for "maindb". + existing := models.ComponentTask{ + Type: models.TaskTypeResourceProvisioning, + ResourceName: "maindb", + ComponentName: "maindb", + Title: "Provision resource: maindb", + Status: string(models.TaskStatusPending), + LifecycleStatus: string(models.TaskLifecycleGhIssueCreated), + CreatedAt: time.Now(), + } + repo := &stubTaskRepo{tasks: []models.ComponentTask{existing}} + svc := newTestTaskService(repo) + + design := &artifacts.DesignFile{ + Components: []models.DesignComponent{ + { + Name: "api", + ComponentType: "service", + Language: "Go", + Dependencies: []models.Dependency{ + {Kind: models.DependencyKindPlatformResource, Name: "maindb", ResourceType: "postgres-cnpg"}, + }, + }, + }, + } + plan := []planItemFrame{{TempID: "t1", ComponentName: "api", Title: "Implement api"}} + w := &sseWriter{out: sseDiscard{}, flush: func() {}} + + //nolint:errcheck + _, _ = svc.persistAndIssue(context.Background(), w, + "org1", "proj1", "batch2", + "v1", "v1-2", + plan, design, + "https://github.com/org/repo", "org/repo", + ) + + // Only the original resource-provisioning task should exist (no duplicate). + var resCount int + for _, task := range repo.tasks { + if task.Type == models.TaskTypeResourceProvisioning { + resCount++ + } + } + if resCount != 1 { + t.Fatalf("dedup: want exactly 1 resource-provisioning task after re-generation, got %d", resCount) + } +} + +// sseWriter duplicate for test — the real sseWriter is unexported so we can't +// import it; since we're in the same package this is fine. Confirm the type +// exists by compiling. +var _ *sseWriter = (*sseWriter)(nil) + +// Ensure sseDiscard implements io.Writer (compile-time assertion). +var _ interface{ Write([]byte) (int, error) } = sseDiscard{} + +// Ensure bytes.Buffer satisfies write — just to keep the bytes import used. +var _ = (*bytes.Buffer)(nil) diff --git a/asdlc-service/models/access_request.go b/asdlc-service/models/access_request.go new file mode 100644 index 00000000..3e86b3d8 --- /dev/null +++ b/asdlc-service/models/access_request.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package models + +import "time" + +// AccessRequest is the tracking/UX record for a cross-project request to publish +// an org service (marketplace P3.5). A consumer component depends on an +// `org-service` that exists but is published only project-only (dep status: +// `blocked`, reason: `access-required`); requesting access creates a publish +// ComponentTask on the provider's project + repo and writes this row. The +// functional resume is the existing org-service gate (the provider going +// namespace-visible), not this row — the AccessRequest is the tracking layer +// that drives the consumer-side chip and lets many consumers fan out from one +// provider publish task. +// +// One row per (OrgID, ConsumerProjectID, ConsumerComponentName, OrgServiceName). +type AccessRequest struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + OrgID string `gorm:"index;not null" json:"-"` + + // Consumer side — who is asking. OrgServiceName is the requested provider + // component name (the catalog key the consumer's dependency references). + ConsumerProjectID string `gorm:"index;not null" json:"consumerProjectId"` + ConsumerComponentName string `gorm:"not null" json:"consumerComponentName"` + OrgServiceName string `gorm:"not null" json:"orgServiceName"` // = provider OC component name + + // Provider side — the target whose visibility must widen. Resolved from the + // catalog row at request time. The publish ComponentTask + its GitHub issue + // live on the provider's project/repo; many consumer requests dedupe onto one + // provider task (ProviderTaskID). + ProviderProjectID string `json:"providerProjectId,omitempty"` + ProviderComponentName string `json:"providerComponentName,omitempty"` + ProviderTaskID string `gorm:"index" json:"providerTaskId,omitempty"` + ProviderIssueNumber int `json:"providerIssueNumber,omitempty"` + ProviderIssueURL string `gorm:"type:text" json:"providerIssueUrl,omitempty"` + + // Status is the request lifecycle (P3.5 §3/§5). Driven off existing signals: + // dispatch of the provider task → in_progress; provider component deployed + + // catalog namespace-visible → granted; provider issue closed unmerged → + // rejected. + Status string `gorm:"index;not null;default:requested" json:"status"` // requested|in_progress|granted|rejected + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// TableName pins the table name (GORM's default pluralization would also yield +// "access_requests", but the explicit name keeps it stable). +func (AccessRequest) TableName() string { return "access_requests" } + +// AccessRequest lifecycle status values (P3.5). Mirrors the TaskStatus* style. +const ( + AccessRequestStatusRequested = "requested" + AccessRequestStatusInProgress = "in_progress" + AccessRequestStatusGranted = "granted" + AccessRequestStatusRejected = "rejected" +) diff --git a/asdlc-service/models/component_task.go b/asdlc-service/models/component_task.go index d277e6dd..55da615e 100644 --- a/asdlc-service/models/component_task.go +++ b/asdlc-service/models/component_task.go @@ -74,6 +74,13 @@ const ( TaskTypeComponent = "component" TaskTypeConfigCollection = "config-collection" TaskTypeResourceProvisioning = "resource-provisioning" + // TaskTypeOrgPublish is a cross-project publish task (marketplace P3.5): a + // TARGETED modification of an already-built provider component, created on + // the PROVIDER's project/repo when a consumer requests access to a + // project-only org-service. It behaves like a normal component task for + // dispatch + lifecycle; only its issue body differs (BuildOrgPublishIssueBody) + // — the agent adds `namespace` visibility to the component's workload.yaml. + TaskTypeOrgPublish = "org-publish" ) // ComponentTask is one implementation task targeting a single component. @@ -138,6 +145,17 @@ type ComponentTask struct { // collects. Empty for component tasks. ConnectionName string `gorm:"column:connection_name;index" json:"connectionName,omitempty"` + // DependsOnResources lists the `platform-resource` dependency names this + // component binds. Gates the component task until each resource's + // resource-provisioning task is `deployed` (provisioned + Ready). Platform- + // authored from the design's platform-resource dependencies (plan §4, P5). + DependsOnResources StringSlice `gorm:"column:depends_on_resources;type:jsonb;serializer:json" json:"dependsOnResources,omitempty"` + + // ResourceName is set only on resource-provisioning tasks (Type == + // resource-provisioning): the platform-resource dependency this task + // provisions. Empty for other task types. (Mirrors ConnectionName.) + ResourceName string `gorm:"column:resource_name;index" json:"resourceName,omitempty"` + // Lineage — set at generation time, immutable thereafter. BatchID *string `gorm:"type:uuid;index" json:"batchId,omitempty"` SourceDesignVersion string `gorm:"type:text" json:"sourceDesignVersion,omitempty"` diff --git a/asdlc-service/models/design.go b/asdlc-service/models/design.go index 4d7612ec..c34f6417 100644 --- a/asdlc-service/models/design.go +++ b/asdlc-service/models/design.go @@ -56,7 +56,42 @@ type Dependency struct { Kind string `json:"kind" yaml:"kind"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` - Status string `json:"status,omitempty" yaml:"status,omitempty"` // resolved|ambiguous|unresolved + Status string `json:"status,omitempty" yaml:"status,omitempty"` // resolved|ambiguous|unresolved|blocked + // Reason refines the dependency status (4-state model, P3.5 / A2b). + // + // Platform-computed (emitted by resolveOrgServices / assembleDependencies): + // "access-required" — org-service exists but is project-only; consumer must + // request access (status: blocked). + // "not-found" — no component with that name in the org catalog + // (status: unresolved). + // "needs-spec" — external dep with needsSpec=true but no specPath yet + // (status: unresolved). + // + // Client-derived (NOT emitted by the platform; the console derives these + // from auxiliary queries): + // "access-pending" — the console derives this from an in-flight + // AccessRequest query; the platform never sets it. + // "needs-input" — reserved for future client-side gating. + // + // "" (empty) — n/a or resolved. + // + // Like Status, Reason is a READ-TIME computed field (recomputed against the + // live catalog on every design read); it is NOT persisted to frontmatter and + // NOT part of the agents Zod schema — the platform computes it, the architect + // never sets it. + Reason string `json:"reason,omitempty" yaml:"reason,omitempty"` + // external (REST/GraphQL): the architect sets NeedsSpec when the agent must + // call the API by specific endpoints; SpecPath points at the stored contract + // (relative to the component dir: dependencies/.openapi.yaml). Unlike + // Status/Reason these ARE persisted to frontmatter. NeedsSpec && SpecPath=="" + // ⇒ Status unresolved (gates save). + NeedsSpec bool `json:"needsSpec,omitempty" yaml:"needsSpec,omitempty"` + SpecPath string `json:"specPath,omitempty" yaml:"specPath,omitempty"` + // SpecUrl is a TRANSIENT hint from the architect: a published OpenAPI URL + // to auto-fetch at design save. Persisted transiently through the + // generate→save hop (yaml/json serialised) but cleared immediately after a + // successful auto-fetch so it is never stored long-term. + SpecUrl string `json:"specUrl,omitempty" yaml:"specUrl,omitempty"` // external: the config key schema the consuming component codes against. Config []ConfigKey `json:"config,omitempty" yaml:"config,omitempty"` // platform-resource: the registered (Cluster)ResourceType + provisioning params. diff --git a/asdlc-service/skills/builtin/api-management/SKILL.md b/asdlc-service/skills/builtin/api-management/SKILL.md index c7e0ee61..7a516496 100644 --- a/asdlc-service/skills/builtin/api-management/SKILL.md +++ b/asdlc-service/skills/builtin/api-management/SKILL.md @@ -1,6 +1,6 @@ --- name: api-management -description: How the platform's API gateway validates JWTs and injects X-User-Id from the sub claim, and how to design + write services and consumers that match. Apply to any service with exposesAPI.auth set, and to any consumer (a `kind: component` sibling, a `kind: org-service`, or a `kind: external` dependency) that calls a protected API. +description: "How the platform's API gateway validates JWTs and injects X-User-Id from the sub claim, and how to design + write services and consumers that match. Apply to any service with exposesAPI.auth set, and to any consumer (a `kind: component` sibling, a `kind: org-service`, or a `kind: external` dependency) that calls a protected API." metadata: asdlc.version: "2" --- diff --git a/asdlc-service/skills/builtin/go/SKILL.md b/asdlc-service/skills/builtin/go/SKILL.md index ddc7b74d..8f67ea91 100644 --- a/asdlc-service/skills/builtin/go/SKILL.md +++ b/asdlc-service/skills/builtin/go/SKILL.md @@ -1,6 +1,6 @@ --- name: go -description: How to build a Go service on the platform — pinned golang:1.25-alpine builder (the build pod runs with GOTOOLCHAIN=local), pure-Go modernc.org/sqlite driver (CGO times out under the build pod's CPU throttle), suggested layout, port 9090, GET /health liveness, multi-stage Dockerfile → slim runtime, embedded SQLite for per-user data inside the owning service. Apply to every Go component. +description: How to build a Go service on the platform — pinned golang:1.25-alpine builder (the build pod runs with GOTOOLCHAIN=local), suggested layout, port 9090, GET /health liveness, multi-stage Dockerfile → slim runtime, and persistence via a platform-provisioned database (a `platform-resource` dependency) — embedded modernc.org/sqlite ONLY when the design explicitly asks for local storage. Apply to every Go component. metadata: asdlc.version: "2" --- @@ -11,10 +11,11 @@ metadata: The platform's coding-agent + build pipeline have specific constraints on the Go toolchain (no network-installed newer Go), on CGO (build pods -are CPU-throttled), and on persistence (embedded only — there is no -external Postgres for v1). This skill tells the agent to pin its -Dockerfile base image, use the pure-Go SQLite driver, and follow a -production-shaped project layout. +are CPU-throttled), and on persistence (prefer the platform-provisioned +database from a `platform-resource` dependency; embedded SQLite only when +the design explicitly requests local storage). This skill tells the agent +to pin its Dockerfile base image, choose the right persistence backend, +and follow a production-shaped project layout. ## Platform facts @@ -32,8 +33,13 @@ production-shaped project layout. budget. The CPU-throttled build pod compiling the SQLite amalgamation (`sqlite3-binding.c`, ~3 MB of C) takes 10–20 minutes and frequently times out. -- The pure-Go `modernc.org/sqlite` driver compiles in ~30 seconds and - has the same `database/sql` interface. Use it everywhere. +- When embedded storage IS explicitly called for, use the pure-Go + `modernc.org/sqlite` driver (compiles in ~30 seconds, same + `database/sql` interface) — never the CGO `mattn/go-sqlite3`. +- Persistence default: a real datastore is a platform-provisioned + database, declared as a `platform-resource` dependency and reached via + the injected `_HOST/PORT/DBNAME/USER/PASSWORD` env vars + (`database/sql` + `lib/pq`/`pgx`). Do NOT default to embedded SQLite. - Default backend port is 9090. - Every service exposes `GET /health` returning 200 (the platform's readiness probe hits this). @@ -44,9 +50,11 @@ production-shaped project layout. ### Architect - Default new backend services to Go + `net/http` on port 9090. -- Prefer fewer components: a single Go service owns its API + its - embedded SQLite database. Do NOT spin off a separate `storage` / - `database` / `persistence` component. +- Prefer fewer components: a single Go service owns its API + its data. + Do NOT spin off a separate `storage` / `database` / `persistence` + component. For a real datastore, declare a `platform-resource` + dependency ON the service (the platform provisions a managed database) + rather than embedding SQLite. - Do NOT create scheduled-task / cronjob components in Go (or anywhere else). Fold periodic work into the owning service as a background goroutine kicked off at startup. Call this out in @@ -56,11 +64,14 @@ production-shaped project layout. (`github.com/go-chi/chi/v5`) is a fine choice. Avoid framework-heavy options (Gin, Echo, Fiber) for v1 — they pull large dep trees and add little for the platform's typical 5–20-endpoint services. -- Suggest the embedded `modernc.org/sqlite` driver in - `componentAgentInstructions` when the component owns per-user data - (e.g. todos, drafts, notes, profile-extension data). Include a - short note: "Use `modernc.org/sqlite` (pure-Go); driver name is - `\"sqlite\"`. Store the DB under `/data/.db`." +- When the component has a `platform-resource` database dependency, + persist to THAT (Postgres via the injected `_HOST/PORT/DBNAME/ + USER/PASSWORD` env vars) — never SQLite. Suggest embedded + `modernc.org/sqlite` ONLY when the design explicitly asks for + lightweight local/embedded storage (or the data is trivial/ephemeral + and no `platform-resource` DB is declared); then note in + `componentAgentInstructions`: "Use `modernc.org/sqlite` (pure-Go); + driver name is `\"sqlite\"`. Store the DB under `/data/.db`." ### Tech-lead — issue body bullets @@ -73,8 +84,16 @@ For every Go service task, include this Scope bullet (HARD requirement): `go mod download` to fail with `go.mod requires go >= X.Y` at build time even when the local `go build` verification succeeded." -For every Go service task whose component is expected to persist -per-user data, include this Scope bullet: +For a Go service task whose component has a `platform-resource` database +dependency, include this Scope bullet: + +- "Persistence: connect to the platform-provisioned database via the + injected env vars (`_HOST`, `_PORT`, `_DBNAME`, + `_USER`, `_PASSWORD`) using `database/sql` with `lib/pq` or + `pgx`. Do NOT use SQLite or any embedded/local database." + +Only when the design EXPLICITLY calls for embedded/local storage (no +`platform-resource` DB is declared), include the SQLite bullet instead: - "Persistence: use the pure-Go `modernc.org/sqlite` driver (import `_ \"modernc.org/sqlite\"`; `sql.Open(\"sqlite\", ...)` — note the diff --git a/console/package.json b/console/package.json index d0b199a5..07b877a0 100644 --- a/console/package.json +++ b/console/package.json @@ -10,12 +10,16 @@ "gen:api": "npx --yes openapi-typescript@7 openapi.yaml -o src/services/api/openapi.gen.ts" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/js-yaml": "^4", "@types/react": "19.1.6", "@types/react-dom": "19.0.2", "@vitejs/plugin-react-swc": "4.2.0", + "jsdom": "^29.1.1", "typescript": "5.9.3", - "vite": "7.2.2" + "vite": "7.2.2", + "vitest": "^4.1.9" }, "dependencies": { "@asdlc/cell-diagram-view": "workspace:*", diff --git a/console/src/App.tsx b/console/src/App.tsx index bd394542..e7a070ee 100644 --- a/console/src/App.tsx +++ b/console/src/App.tsx @@ -26,7 +26,6 @@ import OrgOverviewPage from './pages/OrgOverviewPage'; import ProjectCreatePage from './pages/ProjectCreatePage'; import ProjectArchitecturePage from './pages/ProjectArchitecturePage'; import ProjectTasksPage from './pages/ProjectTasksPage'; -import ProjectDependenciesPage from './pages/ProjectDependenciesPage'; import TaskDetailPage from './pages/TaskDetailPage'; import ProjectRequirementsPage from './pages/ProjectRequirementsPage'; import ProjectOverviewPage from './pages/ProjectOverviewPage'; @@ -47,6 +46,9 @@ import NoOrganizationPage from './pages/NoOrganizationPage'; import { setOrgGithubTokenAccessor } from './services/api/orgGithub'; import { setOrgAnthropicTokenAccessor } from './services/api/orgAnthropic'; import { setConnectionsTokenAccessor } from './services/api/connections'; +import { setAccessRequestsTokenAccessor } from './services/api/accessRequests'; +import { setSpecsTokenAccessor } from './services/api/specs'; +import { setResourcesTokenAccessor } from './services/api/provisioning'; import { setOrgIDPTokenAccessor } from './services/api/orgIDP'; import { setOrgSkillsTokenAccessor } from './services/api/orgSkills'; import { useBillingOrg } from './hooks/useBillingOrg'; @@ -83,6 +85,9 @@ export function App() { setOrgGithubTokenAccessor(getAccessToken); setOrgAnthropicTokenAccessor(getAccessToken); setConnectionsTokenAccessor(getAccessToken); + setAccessRequestsTokenAccessor(getAccessToken); + setSpecsTokenAccessor(getAccessToken); + setResourcesTokenAccessor(getAccessToken); setOrgIDPTokenAccessor(getAccessToken); setOrgSkillsTokenAccessor(getAccessToken); }, [getAccessToken]); @@ -134,7 +139,6 @@ export function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/console/src/components/tasks/TaskDetailPanel.tsx b/console/src/components/tasks/TaskDetailPanel.tsx index 82593e9b..9f4106e9 100644 --- a/console/src/components/tasks/TaskDetailPanel.tsx +++ b/console/src/components/tasks/TaskDetailPanel.tsx @@ -26,9 +26,11 @@ import { Typography, } from '@wso2/oxygen-ui'; import ReactMarkdown from 'react-markdown'; -import { Play, RotateCcw } from '@wso2/oxygen-ui-icons-react'; +import { Play, RotateCcw, ExternalLink } from '@wso2/oxygen-ui-icons-react'; import { api } from '../../services/api'; import type { Task } from '../../services/api'; +import { DependencyDrawer } from '../../pages/architecture/DependencyDrawer'; +import type { DepRef } from '../../pages/architecture/DependenciesSection'; import { AssigneeChip } from './AssigneeChip'; interface TaskDetailPanelProps { @@ -41,6 +43,61 @@ interface TaskDetailPanelProps { export function TaskDetailPanel({ task, orgId, projectId, onClose }: TaskDetailPanelProps) { const [isExecuting, setIsExecuting] = useState(false); const [isRetrying, setIsRetrying] = useState(false); + const [drawerDepRef, setDrawerDepRef] = useState(null); + const [isOpeningDrawer, setIsOpeningDrawer] = useState(false); + + // resource-provisioning / config-collection tasks are resolved in the + // dependency drawer (Provision / Provide configuration), NOT the per-task + // exec endpoint (a no-op). The drawer opens as an overlay ON the tasks page — + // no navigation to the architecture view. + const drawerDep = + task.type === 'resource-provisioning' ? task.resourceName + : task.type === 'config-collection' ? task.connectionName + : undefined; + const drawerCtaLabel = + task.type === 'resource-provisioning' ? 'Provision resource' + : task.type === 'config-collection' ? 'Provide configuration' + : ''; + const needsDrawerAction = + !!drawerDep && (!task.status || ['pending', 'on_hold', 'failed'].includes(task.status)); + + // Resolve the task's dep NAME → the full dependency object the drawer needs. + // NB: a resource-provisioning / config-collection task's componentName is the + // resource/connection name, NOT the owning component — so we search for the dep + // by name across every component (the owner is whichever component declares it). + const openDrawer = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!drawerDep) return; + setIsOpeningDrawer(true); + try { + const design = await api.getDesign(orgId, projectId); + for (const comp of design?.components ?? []) { + const dep = comp.dependencies?.find((d) => d.name === drawerDep); + if (dep) { + setDrawerDepRef({ component: comp.name, dependency: dep }); + break; + } + } + } catch { + // swallow — the button stays; the user can retry + } finally { + setIsOpeningDrawer(false); + } + }; + + // Re-read the design after a drawer action so the open drawer reflects the + // fresh dependency (provisioning kicked off / values saved). + const refreshDrawerDep = async () => { + if (!drawerDep) return; + const design = await api.getDesign(orgId, projectId); + for (const comp of design?.components ?? []) { + const dep = comp.dependencies?.find((d) => d.name === drawerDep); + if (dep) { + setDrawerDepRef({ component: comp.name, dependency: dep }); + break; + } + } + }; const handleExecute = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -71,6 +128,7 @@ export function TaskDetailPanel({ task, orgId, projectId, onClose }: TaskDetailP }; return ( + <> e.stopPropagation()} sx={{ @@ -181,14 +239,26 @@ export function TaskDetailPanel({ task, orgId, projectId, onClose }: TaskDetailP )} - {/* Execute Now only fires the per-task /tasks/{id}/exec endpoint, - which does meaningful work only for SYSTEM tasks (DB / infra - provisioning). WORKER (coding-agent) tasks must go through the - batch dispatch path — hide the button for them so users don't - see a no-op affordance. Pre-dispatch states only; once - dispatched, the row's Live progress button is the primary - affordance. */} - {task.componentTaskId && task.execType === 'SYSTEM' && (!task.status || task.status === 'pending' || task.status === 'on_hold') && ( + {/* resource-provisioning / config-collection tasks are resolved in the + architecture-page drawer — deep-link there (Provision / Provide + configuration) instead of the no-op exec endpoint. */} + {needsDrawerAction && ( + + )} + + {/* Execute Now fires the per-task /tasks/{id}/exec endpoint. It is + gated to SYSTEM tasks, but excludes the drawer-resolved types above + (resource-provisioning / config-collection) which have a dedicated + CTA. Pre-dispatch states only. */} + {task.componentTaskId && task.execType === 'SYSTEM' && !drawerDep && (!task.status || task.status === 'pending' || task.status === 'on_hold') && ( task.status === 'on_hold' ? ( @@ -216,5 +286,14 @@ export function TaskDetailPanel({ task, orgId, projectId, onClose }: TaskDetailP )} + setDrawerDepRef(null)} + onChanged={refreshDrawerDep} + /> + ); } diff --git a/console/src/components/tasks/TaskRow.tsx b/console/src/components/tasks/TaskRow.tsx index 7dd2d7e5..6e384a4f 100644 --- a/console/src/components/tasks/TaskRow.tsx +++ b/console/src/components/tasks/TaskRow.tsx @@ -51,6 +51,19 @@ export function TaskRow({ task, section, orgId, projectId, index }: TaskRowProps const animationDelay = `${index * 0.045}s`; + // Full set of gates an on_hold task is waiting on, across every dep kind — + // not just sibling components. Resource/connection gates carry the action + // needed so the reason doubles as a hint (provision / configure). + const waitingFor = + task.status === 'on_hold' + ? [ + ...(task.dependsOnComponents ?? []), + ...(task.dependsOnResources ?? []).map((r) => `${r} (needs provisioning)`), + ...(task.dependsOnConnections ?? []).map((c) => `${c} (needs configuration)`), + ...(task.dependsOnOrgServices ?? []).map((o) => `${o} (org-service)`), + ] + : []; + if (isWaiting || isSyncing) { return ( {task.title} - {task.status === 'on_hold' && task.dependsOnComponents && task.dependsOnComponents.length > 0 && ( + {waitingFor.length > 0 && ( - Waiting for: {task.dependsOnComponents.join(', ')} + Waiting for: {waitingFor.join(', ')} )} {task.status === 'verification_failed' && task.errorMessage && ( diff --git a/console/src/layouts/AsdlcLayout.tsx b/console/src/layouts/AsdlcLayout.tsx index 191d3763..e4b5ac8e 100644 --- a/console/src/layouts/AsdlcLayout.tsx +++ b/console/src/layouts/AsdlcLayout.tsx @@ -48,7 +48,6 @@ import { Sparkles, X, ClipboardList, - Plug, Settings, } from '@wso2/oxygen-ui-icons-react'; import { useUserClaims } from '../auth'; @@ -62,7 +61,6 @@ import { projectRequirementsPath, projectArchitecturePath, projectTasksPath, - projectDependenciesPath, componentDetailPath, componentBuildPath, componentDeployPath, @@ -232,11 +230,6 @@ export default function AsdlcLayout() { ) { return 'tasks'; } - if ( - matchPath('/organizations/:orgId/projects/:projectId/dependencies', location.pathname) - ) { - return 'dependencies'; - } return 'overview'; })(); @@ -289,9 +282,6 @@ export default function AsdlcLayout() { case 'tasks': navigate(projectTasksPath(routeOrgId, projectId)); break; - case 'dependencies': - navigate(projectDependenciesPath(routeOrgId, projectId)); - break; default: break; } @@ -669,12 +659,6 @@ export default function AsdlcLayout() { Implementation - - - - - Dependencies - )} diff --git a/console/src/lib/paths.ts b/console/src/lib/paths.ts index b8a6b2c1..c122c96d 100644 --- a/console/src/lib/paths.ts +++ b/console/src/lib/paths.ts @@ -49,10 +49,6 @@ export function projectTasksPath(orgId: string, projectId: string): string { return `/organizations/${orgId}/projects/${projectId}/tasks`; } -export function projectDependenciesPath(orgId: string, projectId: string): string { - return `/organizations/${orgId}/projects/${projectId}/dependencies`; -} - export function projectTaskDetailPath(orgId: string, projectId: string, taskId: string): string { return `/organizations/${orgId}/projects/${projectId}/tasks/${taskId}`; } diff --git a/console/src/pages/ProjectArchitecturePage.tsx b/console/src/pages/ProjectArchitecturePage.tsx index f03e62c3..9507aeaf 100644 --- a/console/src/pages/ProjectArchitecturePage.tsx +++ b/console/src/pages/ProjectArchitecturePage.tsx @@ -17,7 +17,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Box, Button, @@ -34,6 +34,8 @@ import { MdEditor } from '@asdlc/md-editor'; import { OpenApiView } from '@asdlc/openapi-view'; import { api } from '../services/api'; import type { ArtifactVersion, Design, DesignComponent } from '../services/api'; +import { DependenciesSection, type DepRef } from './architecture/DependenciesSection'; +import { DependencyDrawer } from './architecture/DependencyDrawer'; import { projectTasksPath } from '../lib/paths'; import VersionSelector from '../components/VersionSelector'; import LineageLabel from '../components/LineageLabel'; @@ -85,6 +87,7 @@ function getArchitectureFileLabel(path: string): string | undefined { export default function ProjectArchitecturePage() { const navigate = useNavigate(); const { orgId, projectId } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); const routeOrgId = orgId ?? 'default'; const [loading, setLoading] = useState(true); @@ -99,6 +102,7 @@ export default function ProjectArchitecturePage() { const [generating, setGenerating] = useState(false); const [publishing, setPublishing] = useState(false); const [publishError, setPublishError] = useState(null); + const [activeDep, setActiveDep] = useState(null); // Streaming state populated from architect SSE events while `generating`. // The cell diagram reads from `streamingComponents` and the file tree shows @@ -339,6 +343,42 @@ export default function ProjectArchitecturePage() { // finalize fires and the bundle refreshes, fall back to design.components. const effectiveComponents = generating ? streamingComponents : (design?.components ?? []); + // Deep-link: /architecture?dep= opens that dependency's drawer directly + // (the tasks board's Provision / Provide configuration CTAs link here). Runs + // once the design is loaded, then clears the param so closing the drawer sticks. + useEffect(() => { + const depName = searchParams.get('dep'); + if (!depName || activeDep || effectiveComponents.length === 0) return; + for (const comp of effectiveComponents) { + const dep = comp.dependencies?.find((d) => d.name === depName); + if (dep) { + setActiveDep({ component: comp.name, dependency: dep }); + break; + } + } + const next = new URLSearchParams(searchParams); + next.delete('dep'); + setSearchParams(next, { replace: true }); + }, [searchParams, effectiveComponents, activeDep, setSearchParams]); + + // When effectiveComponents changes (e.g. after onChanged refetches the design), + // re-derive activeDep from the fresh component list so the open drawer always + // shows the up-to-date dependency (e.g. org-service status flips after a + // request is created). If the dep is no longer found, leave activeDep as-is. + useEffect(() => { + if (!activeDep) return; + for (const comp of effectiveComponents) { + if (comp.name === activeDep.component) { + const freshDep = comp.dependencies?.find((d) => d.name === activeDep.dependency.name); + if (freshDep) { + setActiveDep({ component: activeDep.component, dependency: freshDep }); + } + return; + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveComponents]); + const designMdContent = liveContents[DESIGN_ROOT_FILE] ?? ''; const designReadOnly = viewingHistorical || generating; const handleDesignMdChange = useCallback( @@ -437,6 +477,14 @@ export default function ProjectArchitecturePage() { placeholder="System architecture overview…" /> + {/* Dependencies section — one row per (component × dependency). + Clicking a row opens the DependencyDrawer for resolution. */} + + + ), }, @@ -579,6 +627,15 @@ export default function ProjectArchitecturePage() { {DESIGN_DOCUMENT_TYPES.length > 0 && null /* keep import used for tree-shake stability */} + + setActiveDep(null)} + onChanged={refreshBundle} + /> ); } diff --git a/console/src/pages/ProjectDependenciesPage.tsx b/console/src/pages/ProjectDependenciesPage.tsx deleted file mode 100644 index f42130a7..00000000 --- a/console/src/pages/ProjectDependenciesPage.tsx +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -/** - * Project Dependencies tab — the user-facing surface for the marketplace - * external-connection flow. It reads the published design, lists every `external` - * connection a component depends on as a card, and lets the user provide that - * connection's values (the config keys the architect declared). Saving posts to - * the value-save endpoint, which provisions the OpenChoreo Resource model and - * completes the gating config-collection task so the BFF cascade can dispatch the - * dependent component build. Mirrors the API-driven flow but in the console. - */ - -import { useCallback, useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { - Alert, - Box, - Button, - Card, - CardContent, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Stack, - TextField, - Typography, -} from '@wso2/oxygen-ui'; -import { Plug, Lock, CheckCircle } from '@wso2/oxygen-ui-icons-react'; - -import { restApi } from '../services/api/rest'; -import type { ConfigKey, Dependency } from '../services/api/types'; -import { saveConnectionValues } from '../services/api/connections'; -import { ApiError } from '../services/api/rest'; - -interface ExternalConnection { - name: string; - description?: string; - config: ConfigKey[]; - consumers: string[]; // component names that depend on it -} - -/** Collapse the design's components into the unique set of external connections. */ -function collectExternalConnections( - components: { name: string; dependencies?: Dependency[] }[], -): ExternalConnection[] { - const byName = new Map(); - for (const comp of components) { - for (const dep of comp.dependencies ?? []) { - if (dep.kind !== 'external' || !dep.name) continue; - const existing = byName.get(dep.name); - if (existing) { - if (!existing.consumers.includes(comp.name)) existing.consumers.push(comp.name); - if (existing.config.length === 0 && dep.config) existing.config = dep.config; - } else { - byName.set(dep.name, { - name: dep.name, - description: dep.description, - config: dep.config ?? [], - consumers: [comp.name], - }); - } - } - } - return Array.from(byName.values()); -} - -export default function ProjectDependenciesPage(): React.ReactElement { - const { orgId, projectId } = useParams(); - const orgHandle = orgId ?? 'default'; - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [connections, setConnections] = useState([]); - const [active, setActive] = useState(null); - const [saved, setSaved] = useState>(new Set()); - - const load = useCallback(async () => { - if (!projectId) return; - setLoading(true); - setError(null); - try { - const design = await restApi.getDesign(orgHandle, projectId); - setConnections(collectExternalConnections(design?.components ?? [])); - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load the design.'); - } finally { - setLoading(false); - } - }, [orgHandle, projectId]); - - useEffect(() => { - void load(); - }, [load]); - - if (loading) { - return ( - - Loading dependencies… - - ); - } - if (error) { - return ( - - void load()}>Retry}> - {error} - - - ); - } - - return ( - - - - Dependencies - - - External connections this project depends on. Provide each connection's values to - provision it — the dependent components build once their connections are configured. - - - {connections.length === 0 ? ( - - No external connections in the published design. Publish a design that declares an - external dependency to manage connections here. - - ) : ( - - {connections.map((c) => ( - - - - - - {c.name} - {saved.has(c.name) && ( - } - label="Provisioned" - /> - )} - - {c.description && ( - - {c.description} - - )} - - {c.config.map((k) => ( - : undefined} - label={k.key} - /> - ))} - - {c.consumers.length > 0 && ( - - Used by: {c.consumers.join(', ')} - - )} - - - - - - ))} - - )} - - {active && ( - setActive(null)} - onSaved={() => { - setSaved((s) => new Set(s).add(active.name)); - setActive(null); - }} - /> - )} - - ); -} - -interface DialogProps { - orgHandle: string; - projectId: string; - connection: ExternalConnection; - onClose: () => void; - onSaved: () => void; -} - -function ConnectionValuesDialog({ - orgHandle, - projectId, - connection, - onClose, - onSaved, -}: DialogProps): React.ReactElement { - const [values, setValues] = useState>({}); - const [saving, setSaving] = useState(false); - const [err, setErr] = useState(null); - - const allFilled = connection.config.every((k) => (values[k.key] ?? '').trim() !== ''); - - const submit = async () => { - setSaving(true); - setErr(null); - try { - await saveConnectionValues(orgHandle, projectId, connection.name, { - development: values, - }); - onSaved(); - } catch (e) { - const msg = - e instanceof ApiError ? `${e.message} (HTTP ${e.status})` : e instanceof Error ? e.message : 'Save failed'; - setErr(msg); - } finally { - setSaving(false); - } - }; - - return ( - - Configure “{connection.name}” - - - Provide the values for the development environment. Secret values are stored encrypted - and injected into the component at runtime. - - - {connection.config.map((k) => ( - setValues((v) => ({ ...v, [k.key]: e.target.value }))} - fullWidth - helperText={k.secret ? 'Secret — stored in the secret manager' : 'Plain value'} - autoComplete="off" - /> - ))} - - {err && ( - - {err} - - )} - - - - - - - ); -} diff --git a/console/src/pages/architecture/ConnectionValues.test.tsx b/console/src/pages/architecture/ConnectionValues.test.tsx new file mode 100644 index 00000000..d54e4780 --- /dev/null +++ b/console/src/pages/architecture/ConnectionValues.test.tsx @@ -0,0 +1,251 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; + +// Mock the connections module before importing ConnectionValues +vi.mock('../../services/api/connections', () => ({ + saveConnectionValues: vi.fn(), +})); + +import * as connectionsModule from '../../services/api/connections'; +import { ConnectionValues } from './ConnectionValues'; + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +const externalDepWithConfig: Dependency = { + kind: 'external', + name: 'payment-gateway', + config: [ + { key: 'API_URL', secret: false }, + { key: 'API_SECRET', secret: true }, + ], +}; + +const externalDepSingleKey: Dependency = { + kind: 'external', + name: 'email-service', + config: [{ key: 'SMTP_PASSWORD', secret: true }], +}; + +describe('ConnectionValues', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('renders one input per dep.config key', () => { + it('shows a field for each config key', () => { + render( + , + { wrapper }, + ); + + // Should have a field for each key + expect(screen.getByLabelText(/API_URL/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/API_SECRET/i)).toBeInTheDocument(); + }); + + it('renders secret fields as type=password', () => { + render( + , + { wrapper }, + ); + + const secretInput = screen.getByLabelText(/API_SECRET/i); + expect(secretInput).toHaveAttribute('type', 'password'); + + const plainInput = screen.getByLabelText(/API_URL/i); + expect(plainInput).toHaveAttribute('type', 'text'); + }); + }); + + describe('Save button disabled state', () => { + it('is disabled when all fields are empty', () => { + render( + , + { wrapper }, + ); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + expect(saveBtn).toBeDisabled(); + }); + + it('is disabled when only some fields are filled', () => { + render( + , + { wrapper }, + ); + + // Fill only one field + fireEvent.change(screen.getByLabelText(/API_URL/i), { + target: { value: 'https://api.example.com' }, + }); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + expect(saveBtn).toBeDisabled(); + }); + + it('is enabled when all fields are filled', () => { + render( + , + { wrapper }, + ); + + fireEvent.change(screen.getByLabelText(/API_URL/i), { + target: { value: 'https://api.example.com' }, + }); + fireEvent.change(screen.getByLabelText(/API_SECRET/i), { + target: { value: 'super-secret-key' }, + }); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + expect(saveBtn).not.toBeDisabled(); + }); + }); + + describe('submit calls saveConnectionValues with correct args', () => { + it('calls saveConnectionValues(orgHandle, projectId, dep.name, { development: {...} }) and shows hint + calls onSaved', async () => { + vi.mocked(connectionsModule.saveConnectionValues).mockResolvedValue(undefined); + + const onSaved = vi.fn(); + + render( + , + { wrapper }, + ); + + fireEvent.change(screen.getByLabelText(/API_URL/i), { + target: { value: 'https://api.example.com' }, + }); + fireEvent.change(screen.getByLabelText(/API_SECRET/i), { + target: { value: 'super-secret-key' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(connectionsModule.saveConnectionValues).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'payment-gateway', + { + development: { + API_URL: 'https://api.example.com', + API_SECRET: 'super-secret-key', + }, + }, + ); + }); + + // Should show the rotation/propagation hint + expect(await screen.findByText(/redeploy consumers to apply/i)).toBeInTheDocument(); + + // Should call onSaved + expect(onSaved).toHaveBeenCalled(); + }); + + it('shows server error on failure', async () => { + const { ApiError } = await import('../../services/api/rest'); + vi.mocked(connectionsModule.saveConnectionValues).mockRejectedValue( + new ApiError(500, 'Internal Server Error'), + ); + + render( + , + { wrapper }, + ); + + fireEvent.change(screen.getByLabelText(/SMTP_PASSWORD/i), { + target: { value: 'password123' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + expect(await screen.findByText(/Internal Server Error/i)).toBeInTheDocument(); + }); + }); + + describe('env tabs', () => { + it('renders a "development" tab that is enabled', () => { + render( + , + { wrapper }, + ); + + // development tab should be present and accessible + expect(screen.getByRole('tab', { name: /development/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /development/i })).not.toBeDisabled(); + }); + }); +}); diff --git a/console/src/pages/architecture/ConnectionValues.tsx b/console/src/pages/architecture/ConnectionValues.tsx new file mode 100644 index 00000000..ee845195 --- /dev/null +++ b/console/src/pages/architecture/ConnectionValues.tsx @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * ConnectionValues — per-environment value entry form for an external + * dependency. Adapted from the retired ProjectDependenciesPage for + * drawer-embedded use (no dialog wrapper). Supports: + * - An environment Tabs row (only `development` active today; higher envs + * are rendered as disabled tabs for forward-compatibility). + * - One TextField per config key; `type="password"` + `autoComplete="off"` + * for secret keys, plain text otherwise. + * - All-filled gate: Save is disabled until every key has a non-empty value. + * - On success: shows a "Saved — redeploy consumers to apply" inline hint + * and calls `onSaved()`. Rotation is implicit: the form starts blank on + * every open (secrets are write-only) and a second submit replaces the + * values. + */ + +import type { JSX } from 'react'; +import { useState } from 'react'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import { Alert, Box, Button, Stack, TextField, Typography } from '@wso2/oxygen-ui'; +import { useMutation } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; +import { saveConnectionValues } from '../../services/api/connections'; +import { ApiError } from '../../services/api/rest'; + +// --------------------------------------------------------------------------- +// Environment list — `development` is the only active env today. Others are +// rendered as disabled placeholders so the multi-env affordance is visible +// from day 1. +// --------------------------------------------------------------------------- + +type Env = 'development' | 'staging' | 'production'; + +const ENVS: Array<{ id: Env; label: string; disabled: boolean }> = [ + { id: 'development', label: 'Development', disabled: false }, + { id: 'staging', label: 'Staging', disabled: true }, + { id: 'production', label: 'Production', disabled: true }, +]; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface ConnectionValuesProps { + orgHandle: string; + projectId: string; + dep: Dependency; + onSaved: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ConnectionValues({ + orgHandle, + projectId, + dep, + onSaved, +}: ConnectionValuesProps): JSX.Element { + const [selectedEnv, setSelectedEnv] = useState('development'); + const [values, setValues] = useState>({}); + const [saved, setSaved] = useState(false); + + const config = dep.config ?? []; + const allFilled = config.every((k) => (values[k.key] ?? '').trim() !== ''); + + const mutation = useMutation({ + mutationFn: () => + saveConnectionValues(orgHandle, projectId, dep.name, { + [selectedEnv]: values, + }), + onSuccess: () => { + setSaved(true); + onSaved(); + }, + }); + + const errorMsg = mutation.isError + ? mutation.error instanceof ApiError + ? `${mutation.error.message} (HTTP ${mutation.error.status})` + : mutation.error instanceof Error + ? mutation.error.message + : 'Save failed' + : null; + + return ( + + {/* Section heading */} + + Connection values + + + {/* Environment tabs */} + { + setSelectedEnv(v); + // Reset form state when the env changes. + setValues({}); + setSaved(false); + mutation.reset(); + }} + variant="scrollable" + scrollButtons="auto" + sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }} + > + {ENVS.map(({ id, label, disabled }) => ( + + ))} + + + {/* Intro text */} + + Provide values for the {selectedEnv} environment. Secret values are stored + encrypted and injected into the component at runtime. + + + + {config.map((k) => ( + + setValues((prev) => ({ ...prev, [k.key]: e.target.value })) + } + fullWidth + size="small" + helperText={k.secret ? 'Secret — stored in the secret manager' : 'Plain value'} + autoComplete="off" + inputProps={{ + 'aria-label': k.key, + }} + /> + ))} + + + {/* Error */} + {errorMsg && ( + + {errorMsg} + + )} + + {/* Success / rotation hint */} + {saved && ( + + Saved — redeploy consumers to apply + + )} + + + + + + ); +} diff --git a/console/src/pages/architecture/DependenciesSection.test.tsx b/console/src/pages/architecture/DependenciesSection.test.tsx new file mode 100644 index 00000000..da4252c6 --- /dev/null +++ b/console/src/pages/architecture/DependenciesSection.test.tsx @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { DesignComponent } from '../../services/api/types'; +import { DependenciesSection, type DepRef } from './DependenciesSection'; + +const mockComponents: DesignComponent[] = [ + { + name: 'web', + componentType: 'web-app', + language: 'typescript', + entrypoint: 'deployment/service', + buildpack: 'docker', + appPath: './web', + componentAgentInstructions: '', + dependencies: [ + { + kind: 'external', + name: 'openweather', + status: 'unresolved', + reason: 'not-found', + needsSpec: true, + }, + ], + }, + { + name: 'api', + componentType: 'service', + language: 'go', + entrypoint: 'deployment/service', + buildpack: 'docker', + appPath: './api', + componentAgentInstructions: '', + dependencies: [ + { + kind: 'org-service', + name: 'payment-service', + status: 'ambiguous', + reason: '', + }, + ], + }, +]; + +describe('DependenciesSection', () => { + it('renders a row per dependency with dep names and status chips', () => { + const onOpen = vi.fn(); + render(); + + expect(screen.getByText('openweather')).toBeInTheDocument(); + expect(screen.getByText('payment-service')).toBeInTheDocument(); + }); + + it('calls onOpen with the correct DepRef when a row is clicked', () => { + const onOpen = vi.fn(); + render(); + + fireEvent.click(screen.getByText('openweather')); + + expect(onOpen).toHaveBeenCalledOnce(); + const callArg: DepRef = onOpen.mock.calls[0][0]; + expect(callArg.component).toBe('web'); + expect(callArg.dependency.name).toBe('openweather'); + }); + + it('calls onOpen with the correct component when the second dep row is clicked', () => { + const onOpen = vi.fn(); + render(); + + fireEvent.click(screen.getByText('payment-service')); + + expect(onOpen).toHaveBeenCalledOnce(); + const callArg: DepRef = onOpen.mock.calls[0][0]; + expect(callArg.component).toBe('api'); + expect(callArg.dependency.name).toBe('payment-service'); + }); + + it('shows empty state when no dependencies exist', () => { + const onOpen = vi.fn(); + const emptyComponents: DesignComponent[] = [ + { + name: 'web', + componentType: 'web-app', + language: 'typescript', + entrypoint: 'deployment/service', + buildpack: 'docker', + appPath: './web', + componentAgentInstructions: '', + dependencies: [], + }, + ]; + render(); + + expect(screen.getByText('No dependencies.')).toBeInTheDocument(); + }); + + it('shows empty state when components array is empty', () => { + const onOpen = vi.fn(); + render(); + + expect(screen.getByText('No dependencies.')).toBeInTheDocument(); + }); +}); diff --git a/console/src/pages/architecture/DependenciesSection.tsx b/console/src/pages/architecture/DependenciesSection.tsx new file mode 100644 index 00000000..efc17efc --- /dev/null +++ b/console/src/pages/architecture/DependenciesSection.tsx @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { JSX } from 'react'; +import { Box, Chip, Stack, Typography } from '@wso2/oxygen-ui'; +import type { DesignComponent, Dependency } from '../../services/api/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type DepRef = { + component: string; + dependency: Dependency; +}; + +// --------------------------------------------------------------------------- +// Row +// --------------------------------------------------------------------------- + +function statusColor( + status: Dependency['status'], +): 'success' | 'warning' | 'error' | 'default' { + switch (status) { + case 'resolved': + return 'success'; + case 'ambiguous': + case 'unresolved': + return 'warning'; + case 'blocked': + return 'error'; + default: + return 'default'; + } +} + +function statusLabel(status: Dependency['status']): string { + return status ?? 'unknown'; +} + +interface DepRowProps { + componentName: string; + dependency: Dependency; + onOpen: (ref: DepRef) => void; +} + +function DepRow({ componentName, dependency, onOpen }: DepRowProps) { + return ( + onOpen({ component: componentName, dependency })} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen({ component: componentName, dependency }); + } + }} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 2, + px: 2, + py: 1.25, + borderRadius: 1, + cursor: 'pointer', + border: 1, + borderColor: 'divider', + bgcolor: 'background.paper', + '&:hover': { + bgcolor: 'action.hover', + }, + }} + > + + + {dependency.name} + + + + {dependency.kind} + + + + used by {componentName} + + + ); +} + +// --------------------------------------------------------------------------- +// Section +// --------------------------------------------------------------------------- + +interface DependenciesSectionProps { + components: DesignComponent[]; + onOpen: (ref: DepRef) => void; +} + +export function DependenciesSection({ + components, + onOpen, +}: DependenciesSectionProps): JSX.Element { + const rows: Array<{ componentName: string; dependency: Dependency }> = components.flatMap( + (component) => + (component.dependencies ?? []).map((dep) => ({ + componentName: component.name, + dependency: dep, + })), + ); + + return ( + + + Dependencies + + {rows.length === 0 ? ( + + No dependencies. + + ) : ( + + {rows.map(({ componentName, dependency }) => ( + + ))} + + )} + + ); +} diff --git a/console/src/pages/architecture/DependencyDrawer.tsx b/console/src/pages/architecture/DependencyDrawer.tsx new file mode 100644 index 00000000..fbb63529 --- /dev/null +++ b/console/src/pages/architecture/DependencyDrawer.tsx @@ -0,0 +1,183 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { JSX } from 'react'; +import Drawer from '@mui/material/Drawer'; +import { Box, Chip, Divider, IconButton, Stack, Typography } from '@wso2/oxygen-ui'; +import { X } from '@wso2/oxygen-ui-icons-react'; +import type { DepRef } from './DependenciesSection'; +import { ConnectionValues } from './ConnectionValues'; +import { OrgServiceResolution } from './OrgServiceResolution'; +import { PlatformResourcePanel } from './PlatformResourcePanel'; +import { ProvideSpec } from './ProvideSpec'; + +const DRAWER_WIDTH = 480; + +interface DependencyDrawerProps { + open: boolean; + depRef: DepRef | null; + orgHandle: string; + projectId: string; + onClose: () => void; + onChanged: () => void; +} + +export function DependencyDrawer({ + open, + depRef, + orgHandle, + projectId, + onClose, + onChanged, +}: DependencyDrawerProps): JSX.Element { + const dep = depRef?.dependency ?? null; + + return ( + (theme.palette.mode === 'dark' ? '#1e1e1e' : '#ffffff'), + backdropFilter: 'none', + WebkitBackdropFilter: 'none', + }, + }} + > + {/* Header */} + + + + + {dep?.name ?? '—'} + + {dep && ( + + )} + + {dep?.status && ( + + Status: {dep.status} + {dep.reason ? ` · ${dep.reason}` : ''} + + )} + + + + + + + + + {/* Body — resolution UI. B2 fills org-service; B3/B4 fill other kinds. */} + + {!dep ? ( + + Select a dependency to see details. + + ) : dep.kind === 'org-service' ? ( + + ) : dep.kind === 'external' ? ( + + {/* B3: spec attachment — shown only when a spec is needed */} + {dep.needsSpec && !dep.specPath ? ( + + ) : dep.specPath ? ( + + + Spec attached ✓ + + + {dep.specPath} + + + ) : null} + + {/* B4: per-environment value entry + rotation — shown when the dep + declares config keys. Rendered regardless of spec state so + users can enter values both during initial setup and later + for rotation. */} + {dep.config && dep.config.length > 0 && ( + + )} + + {/* Fallback: no spec and no config — placeholder */} + {!dep.needsSpec && !(dep.config && dep.config.length > 0) && ( + + No additional configuration required for this dependency. + + )} + + ) : dep.kind === 'platform-resource' ? ( + + ) : ( + + This dependency is resolved by the platform — no action needed here. + + )} + + + ); +} diff --git a/console/src/pages/architecture/OrgServiceResolution.test.tsx b/console/src/pages/architecture/OrgServiceResolution.test.tsx new file mode 100644 index 00000000..a5530372 --- /dev/null +++ b/console/src/pages/architecture/OrgServiceResolution.test.tsx @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; +import type { AccessRequest } from '../../services/api/accessRequests'; + +// Mock the accessRequests module before importing OrgServiceResolution +vi.mock('../../services/api/accessRequests', () => ({ + listAccessRequests: vi.fn(), + requestAccess: vi.fn(), +})); + +import * as accessRequestsModule from '../../services/api/accessRequests'; +import { OrgServiceResolution } from './OrgServiceResolution'; + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +const blockedDep: Dependency = { + kind: 'org-service', + name: 'payment-svc', + status: 'blocked', + reason: 'access-required', +}; + +const unresolvedDep: Dependency = { + kind: 'org-service', + name: 'missing-svc', + status: 'unresolved', + reason: 'not-found', +}; + +const accessPendingDep: Dependency = { + kind: 'org-service', + name: 'payment-svc', + status: 'blocked', + reason: 'access-pending', +}; + +const mockAccessRequest: AccessRequest = { + id: 'ar-1', + orgID: 'org-1', + consumerProjectID: 'proj-1', + consumerComponentName: 'api', + orgServiceName: 'payment-svc', + providerProjectID: 'proj-provider', + providerComponentName: 'payment-svc', + providerTaskID: 'task-1', + providerIssueNumber: 42, + providerIssueUrl: 'https://github.com/org/repo/issues/42', + status: 'requested', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}; + +describe('OrgServiceResolution', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('blocked / access-required — no prior request', () => { + it('renders a warning and "Request access" button', async () => { + vi.mocked(accessRequestsModule.listAccessRequests).mockResolvedValue([]); + + render( + , + { wrapper }, + ); + + // Button should appear + expect(await screen.findByRole('button', { name: /request access/i })).toBeInTheDocument(); + }); + + it('calls requestAccess with (orgHandle, projectId, component, dep.name) when clicked', async () => { + vi.mocked(accessRequestsModule.listAccessRequests).mockResolvedValue([]); + vi.mocked(accessRequestsModule.requestAccess).mockResolvedValue(mockAccessRequest); + + const onChanged = vi.fn(); + + render( + , + { wrapper }, + ); + + const btn = await screen.findByRole('button', { name: /request access/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(accessRequestsModule.requestAccess).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'api', + 'payment-svc', + ); + }); + }); + }); + + describe('unresolved / not-found', () => { + it('renders a plain warning and NO "Request access" button', async () => { + vi.mocked(accessRequestsModule.listAccessRequests).mockResolvedValue([]); + + render( + , + { wrapper }, + ); + + // Wait for the query to resolve (loading → loaded) + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + // No "Request access" button + expect(screen.queryByRole('button', { name: /request access/i })).not.toBeInTheDocument(); + + // Should have some warning text + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + }); + + describe('access-pending state with an in-flight request', () => { + it('renders a status chip and a link to the provider issue', async () => { + vi.mocked(accessRequestsModule.listAccessRequests).mockResolvedValue([mockAccessRequest]); + + render( + , + { wrapper }, + ); + + // Should show a chip (not a button) + expect( + await screen.findByText(/access requested|pending/i), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /request access/i })).not.toBeInTheDocument(); + + // Should have a link to the provider issue (wait for query to resolve) + const link = await screen.findByRole('link', { name: /view request/i }); + expect(link).toHaveAttribute('href', 'https://github.com/org/repo/issues/42'); + }); + }); +}); diff --git a/console/src/pages/architecture/OrgServiceResolution.tsx b/console/src/pages/architecture/OrgServiceResolution.tsx new file mode 100644 index 00000000..a7c45a4d --- /dev/null +++ b/console/src/pages/architecture/OrgServiceResolution.tsx @@ -0,0 +1,224 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * OrgServiceResolution — the drawer body for an org-service dependency. + * + * 4-state model (P4): + * blocked / access-required — service exists but is project-only; show warning + + * "Request access" button. On success invalidates the + * design + access-requests query caches. + * blocked / access-pending — an AccessRequest is already in flight; show a status + * chip (granted→success, rejected→error, + * requested|in_progress→warning) + a link to + * providerIssueUrl when present. + * unresolved / not-found — no such component anywhere; plain warning, NO button. + * + * Ported from the retired ProjectDependenciesPage (OrgServiceCard + AccessRequestAffordance) + * and re-keyed on the P4 4-state model. + */ + +import type { JSX } from 'react'; +import { Alert, Box, Button, Chip, Link, Stack, Typography } from '@wso2/oxygen-ui'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { Dependency } from '../../services/api/types'; +import { + listAccessRequests, + requestAccess, + type AccessRequest, +} from '../../services/api/accessRequests'; +import { ApiError } from '../../services/api/rest'; + +// --------------------------------------------------------------------------- +// Stable query-key helpers — co-located here so OrgServiceResolution can +// invalidate the correct caches. They produce the same keys as the originals +// so that any existing cached queries are correctly invalidated. +// --------------------------------------------------------------------------- + +export const designQueryKey = (orgHandle: string, projectId: string | undefined) => [ + 'design', + orgHandle, + projectId, +]; + +export const accessRequestsQueryKey = (orgHandle: string, projectId: string | undefined) => [ + 'accessRequests', + orgHandle, + projectId, +]; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface OrgServiceResolutionProps { + orgHandle: string; + projectId: string; + /** The component that declares this dependency. */ + component: string; + dep: Dependency; + onChanged: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function OrgServiceResolution({ + orgHandle, + projectId, + component, + dep, + onChanged, +}: OrgServiceResolutionProps): JSX.Element { + const queryClient = useQueryClient(); + + // Fetch all access requests for this project (consumer side). + const accessRequestsQuery = useQuery({ + queryKey: accessRequestsQueryKey(orgHandle, projectId), + queryFn: () => listAccessRequests(orgHandle, projectId), + }); + + // Match the dep to its AccessRequest by orgServiceName + consumerComponentName. + const accessRequest = (accessRequestsQuery.data ?? []).find( + (r) => r.orgServiceName === dep.name && r.consumerComponentName === component, + ); + + // Mutation to create an access request (blocked / access-required path). + const mutation = useMutation({ + mutationFn: () => requestAccess(orgHandle, projectId, component, dep.name), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: accessRequestsQueryKey(orgHandle, projectId) }); + void queryClient.invalidateQueries({ queryKey: designQueryKey(orgHandle, projectId) }); + onChanged(); + }, + }); + + const errorMsg = mutation.isError + ? mutation.error instanceof ApiError + ? `${mutation.error.message} (HTTP ${mutation.error.status})` + : mutation.error instanceof Error + ? mutation.error.message + : 'Failed to request access.' + : null; + + // ------ unresolved / not-found ------------------------------------------------ + if (dep.status === 'unresolved' && dep.reason === 'not-found') { + return ( + + + Unknown org service "{dep.name}" — not found in this organization. Check + the dependency name in the design. + + {dep.description && ( + + {dep.description} + + )} + + ); + } + + // ------ blocked / access-pending (in-flight request) ------------------------- + if (dep.reason === 'access-pending' || (dep.status === 'blocked' && accessRequest)) { + // Use the matched AccessRequest if available; fall through to access-required + // affordance only when there is genuinely no request row. + const req = accessRequest ?? null; + + let label: string; + let color: 'default' | 'success' | 'error' | 'warning'; + + if (req) { + switch (req.status) { + case 'granted': + label = 'Access granted'; + color = 'success'; + break; + case 'rejected': + label = 'Access denied'; + color = 'error'; + break; + default: + // requested | in_progress + label = 'Access requested · pending'; + color = 'warning'; + break; + } + } else { + label = 'Access requested · pending'; + color = 'warning'; + } + + return ( + + + An access request has been submitted. The provider project must publish this + service org-wide for your components to connect. + + {dep.description && ( + + {dep.description} + + )} + + + {req?.providerIssueUrl && ( + + View request + + )} + + + ); + } + + // ------ blocked / access-required (requestable) -------------------------------- + // Default: dep.status === 'blocked' && dep.reason === 'access-required' (no request yet). + return ( + + + This service exists but is not published for cross-project use. Request the + owning project to publish it org-wide. + + {dep.description && ( + + {dep.description} + + )} + {errorMsg && ( + + {errorMsg} + + )} + + + ); +} diff --git a/console/src/pages/architecture/PlatformResourcePanel.test.tsx b/console/src/pages/architecture/PlatformResourcePanel.test.tsx new file mode 100644 index 00000000..630f1ad4 --- /dev/null +++ b/console/src/pages/architecture/PlatformResourcePanel.test.tsx @@ -0,0 +1,451 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; + +// Mock the provisioning module before importing the panel +vi.mock('../../services/api/provisioning', () => ({ + provisionResource: vi.fn(), + getResourceStatus: vi.fn(), +})); + +import * as provisioningModule from '../../services/api/provisioning'; +import { PlatformResourcePanel } from './PlatformResourcePanel'; + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +const BASE_PROPS = { + orgHandle: 'my-org', + projectId: 'my-project', + component: 'api-service', + onChanged: vi.fn(), +}; + +// -- Dependency fixtures ----------------------------------------------------- + +const unresolvedDep: Dependency = { + kind: 'platform-resource', + name: 'postgres', + resourceType: 'postgres-cnpg', + parameters: { + version: '16', + storage: '10Gi', + }, +}; + +const unresolvedDepNoParams: Dependency = { + kind: 'platform-resource', + name: 'redis', + resourceType: 'redis-standalone', +}; + +const provisioningDep: Dependency = { + kind: 'platform-resource', + name: 'postgres', + resourceType: 'postgres-cnpg', + status: 'provisioning', +}; + +const resolvedDep: Dependency = { + kind: 'platform-resource', + name: 'postgres', + resourceType: 'postgres-cnpg', + status: 'resolved', + outputs: [ + { name: 'DATABASE_URL', secret: true }, + { name: 'DB_HOST', secret: false }, + { name: 'DB_PORT', secret: false }, + { name: 'DB_PASSWORD', secret: true }, + ], +}; + +// ---------------------------------------------------------------------------- + +describe('PlatformResourcePanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // (a) unresolved dep — param form + Provision button, clicking calls + // provisionResource + // ------------------------------------------------------------------------- + describe('unresolved dep', () => { + it('renders the parameter form with inputs from dep.parameters', () => { + render( + , + { wrapper }, + ); + + expect(screen.getByRole('button', { name: /provision/i })).toBeInTheDocument(); + expect(screen.getByLabelText('version')).toBeInTheDocument(); + expect(screen.getByLabelText('storage')).toBeInTheDocument(); + }); + + it('pre-fills param fields with dep.parameters defaults', () => { + render( + , + { wrapper }, + ); + + expect(screen.getByLabelText('version')).toHaveValue('16'); + expect(screen.getByLabelText('storage')).toHaveValue('10Gi'); + }); + + it('renders no param fields and shows defaults-note when dep.parameters is empty', () => { + render( + , + { wrapper }, + ); + + expect(screen.getByRole('button', { name: /provision/i })).toBeInTheDocument(); + expect(screen.getByText(/defaults will be used/i)).toBeInTheDocument(); + }); + + it('clicking Provision calls provisionResource with correct args', async () => { + vi.mocked(provisioningModule.provisionResource).mockResolvedValue(undefined); + const onChanged = vi.fn(); + + render( + , + { wrapper }, + ); + + // Modify one param + fireEvent.change(screen.getByLabelText('version'), { + target: { value: '15' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /provision/i })); + + await waitFor(() => { + expect(provisioningModule.provisionResource).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'api-service', + 'postgres', + { + params: { version: '15', storage: '10Gi' }, + environments: ['development'], + }, + ); + }); + + expect(onChanged).toHaveBeenCalled(); + }); + + it('calls provisionResource with no params field when dep has no parameters', async () => { + vi.mocked(provisioningModule.provisionResource).mockResolvedValue(undefined); + const onChanged = vi.fn(); + + render( + , + { wrapper }, + ); + + fireEvent.click(screen.getByRole('button', { name: /provision/i })); + + await waitFor(() => { + expect(provisioningModule.provisionResource).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'api-service', + 'redis', + { + params: undefined, + environments: ['development'], + }, + ); + }); + }); + }); + + // ------------------------------------------------------------------------- + // (b) provisioning status — progress indicator, polling enabled + // ------------------------------------------------------------------------- + describe('provisioning status', () => { + beforeEach(() => { + // getResourceStatus returns still-in-progress + vi.mocked(provisioningModule.getResourceStatus).mockResolvedValue({ + status: 'provisioning', + ready: false, + outputs: [], + }); + }); + + it('renders progress text when dep.status is provisioning', () => { + render( + , + { wrapper }, + ); + + expect( + screen.getByText(/Provisioning…/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/a database can take a few minutes/i), + ).toBeInTheDocument(); + }); + + it('does not render the Provision button while provisioning', () => { + render( + , + { wrapper }, + ); + + // Should not show a "Provision" or "Re-provision" button in this state + expect( + screen.queryByRole('button', { name: /^provision$/i }), + ).not.toBeInTheDocument(); + }); + + it('calls getResourceStatus to poll when status is provisioning', async () => { + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(provisioningModule.getResourceStatus).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'api-service', + 'postgres', + 'development', + ); + }); + }); + }); + + // ------------------------------------------------------------------------- + // (c) ready / resolved + outputs — output NAMES rendered, no values, + // Re-provision affordance present + // ------------------------------------------------------------------------- + describe('resolved / ready state', () => { + beforeEach(() => { + vi.mocked(provisioningModule.getResourceStatus).mockResolvedValue({ + status: 'deployed', + ready: true, + outputs: [ + { name: 'DATABASE_URL' }, + { name: 'DB_HOST' }, + { name: 'DB_PASSWORD' }, + ], + }); + }); + + it('shows "Provisioned ✓" heading', () => { + render( + , + { wrapper }, + ); + + expect(screen.getByText(/Provisioned ✓/)).toBeInTheDocument(); + }); + + it('renders output names from dep.outputs', () => { + render( + , + { wrapper }, + ); + + // Names from the dep fixture (dep.outputs used as fallback before query loads) + expect(screen.getByText('DATABASE_URL')).toBeInTheDocument(); + expect(screen.getByText('DB_HOST')).toBeInTheDocument(); + expect(screen.getByText('DB_PASSWORD')).toBeInTheDocument(); + }); + + it('does NOT render any output value strings (secret safety)', () => { + // Render with outputs that have both a name and a (hypothetical) value field. + // The component must render ONLY the name strings — never values. + const depWithOutputs: Dependency = { + kind: 'platform-resource', + name: 'postgres', + resourceType: 'postgres-cnpg', + status: 'resolved', + outputs: [ + { name: 'DB_HOST', secret: false }, + { name: 'DB_PASSWORD', secret: true }, + ], + }; + + // getResourceStatus is mocked to return deployed/ready (see beforeEach) + render( + , + { wrapper }, + ); + + // Positive: both output names must be present + expect(screen.getByText('DB_HOST')).toBeInTheDocument(); + expect(screen.getByText('DB_PASSWORD')).toBeInTheDocument(); + + // Exact count: the output list should contain exactly 2 name items. + // Use the monospace typography elements rendered inside the outputs stack. + const outputNames = screen + .getAllByText(/^DB_/) + .filter((el) => el.tagName !== 'INPUT'); + expect(outputNames).toHaveLength(2); + + // Negative: no secret values must leak into the render tree + expect(screen.queryByText(/password123/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/secretvalue/i)).not.toBeInTheDocument(); + }); + + it('renders the Re-provision affordance', () => { + render( + , + { wrapper }, + ); + + expect(screen.getByRole('button', { name: /re-provision/i })).toBeInTheDocument(); + }); + + it('renders provisioned view (not spinner) when getResourceStatus returns deployed+ready for a resolved dep', async () => { + // This is the critical robustness scenario: dep.status='resolved', and + // the status query comes back with { status: 'deployed', ready: true }. + // The panel must show the provisioned view driven by the QUERY result — + // NOT fall through to the fallback spinner. + vi.mocked(provisioningModule.getResourceStatus).mockResolvedValue({ + status: 'deployed', + ready: true, + outputs: [ + { name: 'REDIS_URL' }, + { name: 'REDIS_PORT' }, + ], + }); + + const resolvedDepWithQuery: Dependency = { + kind: 'platform-resource', + name: 'redis', + resourceType: 'redis-standalone', + status: 'resolved', + outputs: [], + }; + + render( + , + { wrapper }, + ); + + // Provisioned heading must appear (not the fallback spinner) + await waitFor(() => { + expect(screen.getByText(/Provisioned ✓/)).toBeInTheDocument(); + }); + + // Output names from the query result must be rendered once the query resolves + await waitFor(() => { + expect(screen.getByText('REDIS_URL')).toBeInTheDocument(); + }); + expect(screen.getByText('REDIS_PORT')).toBeInTheDocument(); + + // Re-provision button must be present + expect(screen.getByRole('button', { name: /re-provision/i })).toBeInTheDocument(); + + // Spinner must NOT be present + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + it('clicking Re-provision calls provisionResource again', async () => { + vi.mocked(provisioningModule.provisionResource).mockResolvedValue(undefined); + const onChanged = vi.fn(); + + render( + , + { wrapper }, + ); + + fireEvent.click(screen.getByRole('button', { name: /re-provision/i })); + + await waitFor(() => { + expect(provisioningModule.provisionResource).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'api-service', + 'postgres', + expect.objectContaining({ + environments: ['development'], + }), + ); + }); + + expect(onChanged).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // (d) failed state — error message + retry button + // ------------------------------------------------------------------------- + describe('failed state', () => { + const failedDep: Dependency = { + kind: 'platform-resource', + name: 'postgres', + resourceType: 'postgres-cnpg', + status: 'provisioning', + }; + + beforeEach(() => { + vi.mocked(provisioningModule.getResourceStatus).mockResolvedValue({ + status: 'failed', + ready: false, + outputs: [], + }); + }); + + it('shows error alert and Retry button when status is failed', async () => { + render( + , + { wrapper }, + ); + + // Wait for the status query to resolve to failed + await waitFor(() => { + expect(screen.getByText(/Provisioning failed/i)).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/console/src/pages/architecture/PlatformResourcePanel.tsx b/console/src/pages/architecture/PlatformResourcePanel.tsx new file mode 100644 index 00000000..65022f60 --- /dev/null +++ b/console/src/pages/architecture/PlatformResourcePanel.tsx @@ -0,0 +1,319 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * PlatformResourcePanel — provision form, async status polling, name-only + * outputs, and re-provision (R6 rotation) for `platform-resource` dependencies. + * + * Security: outputs are name-only (values masked at the BFF; secret values + * are never surfaced by the status endpoint and are never rendered here). + * + * Polling: stops once the task reaches a terminal state (deployed / failed) or + * the OC binding's Ready condition is True. Tab-visibility gating is global + * (QueryClient refetchIntervalInBackground:false set in main.tsx). + */ + +import type { JSX } from 'react'; +import { useState } from 'react'; +import { Alert, Box, Button, CircularProgress, Stack, TextField, Typography } from '@wso2/oxygen-ui'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; +import { + provisionResource, + getResourceStatus, +} from '../../services/api/provisioning'; +import { ApiError } from '../../services/api/rest'; + +// --------------------------------------------------------------------------- +// Terminal states — polling stops when any of these is reached or ready===true +// --------------------------------------------------------------------------- +const TERMINAL_STATUSES = new Set(['deployed', 'resolved', 'failed']); + +function isTerminal(status: string | undefined, ready: boolean | undefined): boolean { + if (ready) return true; + if (!status) return false; + return TERMINAL_STATUSES.has(status); +} + +const POLL_INTERVAL_MS = 5_000; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- +interface PlatformResourcePanelProps { + dep: Dependency; + orgHandle: string; + projectId: string; + component: string; + onChanged: () => void; +} + +// --------------------------------------------------------------------------- +// Param form — one TextField per dep.parameters key +// --------------------------------------------------------------------------- +function ParamForm({ + dep, + orgHandle, + projectId, + component, + onChanged, + submitLabel = 'Provision', +}: PlatformResourcePanelProps & { submitLabel?: string }): JSX.Element { + const params = dep.parameters ?? {}; + const paramKeys = Object.keys(params); + + const [values, setValues] = useState>( + // Pre-fill from dep.parameters (architect defaults) + Object.fromEntries(paramKeys.map((k) => [k, params[k] ?? ''])), + ); + + const mutation = useMutation({ + mutationFn: () => + provisionResource(orgHandle, projectId, component, dep.name, { + params: paramKeys.length > 0 ? values : undefined, + environments: ['development'], + }), + onSuccess: () => { + onChanged(); + }, + }); + + const errorMsg = mutation.isError + ? mutation.error instanceof ApiError + ? `${mutation.error.message} (HTTP ${mutation.error.status})` + : mutation.error instanceof Error + ? mutation.error.message + : 'Provision failed' + : null; + + return ( + + + Provisioning parameters + + + {paramKeys.length === 0 ? ( + + No parameters required — the resource type defaults will be used. + + ) : ( + + {paramKeys.map((k) => ( + setValues((prev) => ({ ...prev, [k]: e.target.value }))} + fullWidth + size="small" + /> + ))} + + )} + + {errorMsg && ( + + {errorMsg} + + )} + + + + ); +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- +export function PlatformResourcePanel({ + dep, + orgHandle, + projectId, + component, + onChanged, +}: PlatformResourcePanelProps): JSX.Element { + const statusQuery = useQuery({ + queryKey: ['resourceStatus', orgHandle, projectId, component, dep.name], + queryFn: () => getResourceStatus(orgHandle, projectId, component, dep.name, 'development'), + // Only fetch when provisioning is in-flight or resource was previously provisioned. + // 'deployed' is a task-status, not a valid dep.status, but included defensively so + // that if the BFF ever surfaces it on a dep, the query still fires and the ready/ + // provisioned branch renders correctly (driven by the query result, not dep.status). + enabled: + dep.status === 'provisioning' || + dep.status === 'resolved' || + dep.status === 'blocked' || + (dep.status as string) === 'deployed', + refetchInterval: (q) => { + const data = q.state.data; + if (isTerminal(data?.status, data?.ready)) return false; + return POLL_INTERVAL_MS; + }, + }); + + // Provisioning status is authoritative from the status query (the + // resource-provisioning task status + binding readiness) — NOT dep.status. + // dep.status === 'resolved' only means the architect matched a resource type; + // it does NOT mean the resource has been provisioned. + const effectiveStatus = statusQuery.data?.status ?? dep.status; + const effectiveReady = statusQuery.data?.ready === true; + const outputs = statusQuery.data?.outputs ?? dep.outputs ?? []; + + // ------------------------------------------------------------------------- + // State: unresolved — show param form + // ------------------------------------------------------------------------- + if (!dep.status || dep.status === 'unresolved' || dep.status === 'ambiguous') { + return ( + + ); + } + + // ------------------------------------------------------------------------- + // While the real provisioning status loads, don't guess — otherwise a + // resolved-but-unprovisioned resource briefly flashes "Provisioned ✓". + // ------------------------------------------------------------------------- + if (statusQuery.isLoading) { + return ( + + + + Checking provisioning status… + + + ); + } + + // ------------------------------------------------------------------------- + // State: provisioning / building (in-flight) + // ------------------------------------------------------------------------- + if ( + (effectiveStatus === 'building' || effectiveStatus === 'provisioning') && + !isTerminal(effectiveStatus, effectiveReady) + ) { + return ( + + + + + Provisioning… (a database can take a few minutes) + + + {statusQuery.data && ( + + Status: {statusQuery.data.status} + + )} + + ); + } + + // ------------------------------------------------------------------------- + // State: failed + // ------------------------------------------------------------------------- + if (effectiveStatus === 'failed') { + return ( + + + Provisioning failed. Review the task logs and retry below. + + + + ); + } + + // ------------------------------------------------------------------------- + // State: deployed / ready — show outputs + re-provision. Only a genuinely + // provisioned resource (binding Ready, or the task deployed) qualifies — + // NOT merely architect-resolved. + // ------------------------------------------------------------------------- + if (effectiveReady || effectiveStatus === 'deployed') { + return ( + + + Provisioned ✓ + + + {outputs.length > 0 && ( + + + The following outputs are injected into your component at runtime. + Values are stored in the OC-rendered Secret and are not displayed + here. + + + {outputs.map((o) => ( + + {o.name} + + ))} + + + )} + + {/* Re-provision (R6 rotation) */} + + + Re-provision to rotate credentials or apply updated parameters. + + + + + ); + } + + // ------------------------------------------------------------------------- + // Fallback: resolved by the architect but NOT yet provisioned (task pending) + // — show the provision form so the user can kick it off. + // ------------------------------------------------------------------------- + return ( + + ); +} diff --git a/console/src/pages/architecture/ProvideSpec.test.tsx b/console/src/pages/architecture/ProvideSpec.test.tsx new file mode 100644 index 00000000..8d660749 --- /dev/null +++ b/console/src/pages/architecture/ProvideSpec.test.tsx @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; + +// Mock the specs module before importing ProvideSpec +vi.mock('../../services/api/specs', () => ({ + collectSpec: vi.fn(), +})); + +import * as specsModule from '../../services/api/specs'; +import { ProvideSpec } from './ProvideSpec'; + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +const externalDep: Dependency = { + kind: 'external', + name: 'payment-gateway', + needsSpec: true, +}; + +describe('ProvideSpec', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('submit button disabled state', () => { + it('is disabled when all inputs are empty', () => { + render( + , + { wrapper }, + ); + + const submitBtn = screen.getByRole('button', { name: /attach spec/i }); + expect(submitBtn).toBeDisabled(); + }); + }); + + describe('paste spec text + submit', () => { + it('calls collectSpec with { rawSpec } when paste field is filled', async () => { + const sampleSpec = 'openapi: 3.0.3\ninfo:\n title: Test\n version: 1.0.0\npaths: {}'; + vi.mocked(specsModule.collectSpec).mockResolvedValue({ + specPath: 'components/api/payment-gateway.yaml', + operationCount: 7, + }); + + const onChanged = vi.fn(); + + render( + , + { wrapper }, + ); + + // Type into the paste textarea + const pasteField = screen.getByRole('textbox', { name: /paste spec/i }); + fireEvent.change(pasteField, { target: { value: sampleSpec } }); + + // Submit button should be enabled now + const submitBtn = screen.getByRole('button', { name: /attach spec/i }); + expect(submitBtn).not.toBeDisabled(); + + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(specsModule.collectSpec).toHaveBeenCalledWith( + 'my-org', + 'my-project', + 'api', + 'payment-gateway', + { rawSpec: sampleSpec }, + ); + }); + + // Should show success message with operation count + expect(await screen.findByText(/7 operations/i)).toBeInTheDocument(); + + // Should call onChanged + expect(onChanged).toHaveBeenCalled(); + }); + }); +}); diff --git a/console/src/pages/architecture/ProvideSpec.tsx b/console/src/pages/architecture/ProvideSpec.tsx new file mode 100644 index 00000000..6e28616c --- /dev/null +++ b/console/src/pages/architecture/ProvideSpec.tsx @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * ProvideSpec — the drawer body for an external dependency that has + * `needsSpec: true` and no `specPath` yet. The user can either paste raw + * OpenAPI YAML/JSON, upload a .yaml/.yml/.json file (its text is loaded into + * the paste field), or supply a publicly reachable URL. Submitting calls the + * A4 endpoint (collectSpec) and on success shows the returned operationCount, + * then calls `onChanged()` so the parent can refresh the design query. + */ + +import type { JSX } from 'react'; +import { useRef, useState } from 'react'; +import { Alert, Box, Button, Stack, TextField, Typography } from '@wso2/oxygen-ui'; +import { useMutation } from '@tanstack/react-query'; +import type { Dependency } from '../../services/api/types'; +import { collectSpec } from '../../services/api/specs'; +import { ApiError } from '../../services/api/rest'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface ProvideSpecProps { + orgHandle: string; + projectId: string; + /** The component that declares this dependency. */ + component: string; + dep: Dependency; + onChanged: () => void; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ProvideSpec({ + orgHandle, + projectId, + component, + dep, + onChanged, +}: ProvideSpecProps): JSX.Element { + const [pasteText, setPasteText] = useState(''); + const [specUrl, setSpecUrl] = useState(''); + const fileInputRef = useRef(null); + + const mutation = useMutation({ + mutationFn: () => { + const body: { rawSpec: string } | { specUrl: string } = pasteText.trim() + ? { rawSpec: pasteText.trim() } + : { specUrl: specUrl.trim() }; + return collectSpec(orgHandle, projectId, component, dep.name, body); + }, + onSuccess: () => { + onChanged(); + }, + }); + + const canSubmit = pasteText.trim().length > 0 || specUrl.trim().length > 0; + + const errorMsg = mutation.isError + ? mutation.error instanceof ApiError + ? `${mutation.error.message} (HTTP ${mutation.error.status})` + : mutation.error instanceof Error + ? mutation.error.message + : 'Failed to attach spec.' + : null; + + // Handle file selection — read file text into the paste field. + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (evt) => { + const text = evt.target?.result; + if (typeof text === 'string') { + setPasteText(text); + } + }; + reader.readAsText(file); + // Reset the input so the same file can be re-selected if needed. + e.target.value = ''; + } + + if (mutation.isSuccess && mutation.data) { + return ( + + + Spec attached ✓ ({mutation.data.operationCount} operations) + + + {mutation.data.specPath} + + + ); + } + + return ( + + {dep.description && ( + + {dep.description} + + )} + + + {/* Paste textarea */} + setPasteText(e.target.value)} + fullWidth + size="small" + inputProps={{ 'aria-label': 'Paste spec' }} + /> + + {/* Hidden file input — triggered by "Upload file" button */} + + + + {/* URL field */} + setSpecUrl(e.target.value)} + fullWidth + size="small" + inputProps={{ 'aria-label': 'Spec URL' }} + /> + + {errorMsg && ( + {errorMsg} + )} + + + + + ); +} diff --git a/console/src/services/api/accessRequests.ts b/console/src/services/api/accessRequests.ts new file mode 100644 index 00000000..cd90ebe7 --- /dev/null +++ b/console/src/services/api/accessRequests.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Typed client for the marketplace P3.5 cross-project access-request flow. A + * consumer whose design references an org service published only at `project` + * visibility (dep status `unresolved`, reason `unpublished`) can request that + * the provider publish it org-wide. The request creates a publish ComponentTask + * + GitHub issue on the provider's repo; an `AccessRequest` row tracks it. + * See docs/design/marketplace/access-requests.md (§2, §6) and + * asdlc-service/internal/feature/accessrequests. + */ + +import { env } from '../../config/env'; +import { ApiError } from './rest'; + +const BASE = env.VITE_CORE_API_BASE_URL; + +let _getAccessToken: (() => Promise) | null = null; + +export function setAccessRequestsTokenAccessor(fn: (() => Promise) | null): void { + _getAccessToken = fn; +} + +/** Lifecycle of a cross-project access request. */ +export type AccessRequestStatus = 'requested' | 'in_progress' | 'granted' | 'rejected'; + +/** A consumer's request for a provider to publish an org service cross-project. */ +export interface AccessRequest { + id: string; + orgID: string; + consumerProjectID: string; + consumerComponentName: string; + orgServiceName: string; + providerProjectID: string; + providerComponentName: string; + providerTaskID: string; + providerIssueNumber: number; + providerIssueUrl: string; + status: AccessRequestStatus; + createdAt: string; + updatedAt: string; +} + +async function authHeaders(extra?: Record): Promise> { + const headers: Record = { 'Content-Type': 'application/json', ...extra }; + if (_getAccessToken) { + const token = await _getAccessToken(); + if (token) headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function parseError(res: Response): Promise { + const body = await res.text(); + let message = body; + try { + const parsed = JSON.parse(body); + message = parsed.detail || parsed.message || parsed.error || body; + } catch { + /* raw body */ + } + return new ApiError(res.status, message); +} + +/** + * Request cross-project access to an org service. Creates the provider publish + * task + GitHub issue (idempotent server-side) and returns the AccessRequest + * row tracking it. + */ +export async function requestAccess( + orgHandle: string, + projectName: string, + componentName: string, + orgServiceName: string, +): Promise { + const path = `/api/v1/organizations/${encodeURIComponent(orgHandle)}/projects/${encodeURIComponent( + projectName, + )}/components/${encodeURIComponent(componentName)}/access-requests`; + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: await authHeaders(), + body: JSON.stringify({ orgServiceName }), + }); + if (!res.ok) throw await parseError(res); + return (await res.json()) as AccessRequest; +} + +/** List the project's cross-project access requests (consumer side). */ +export async function listAccessRequests( + orgHandle: string, + projectName: string, +): Promise { + const path = `/api/v1/organizations/${encodeURIComponent(orgHandle)}/projects/${encodeURIComponent( + projectName, + )}/access-requests`; + const res = await fetch(`${BASE}${path}`, { headers: await authHeaders() }); + if (!res.ok) throw await parseError(res); + const body = (await res.json()) as { accessRequests?: AccessRequest[] } | AccessRequest[]; + // Tolerate either a bare array or a wrapped { accessRequests } envelope. + return Array.isArray(body) ? body : body.accessRequests ?? []; +} diff --git a/console/src/services/api/provisioning.ts b/console/src/services/api/provisioning.ts new file mode 100644 index 00000000..3c52db14 --- /dev/null +++ b/console/src/services/api/provisioning.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Typed client for the platform-resource provisioning endpoints. + * + * - `provisionResource` — POST …/provision: authors the OC Resource model for + * a platform-resource dependency and marks the matching task in-flight. + * Async: returns as soon as the CRs are authored; readiness is polled via + * `getResourceStatus`. + * - `getResourceStatus` — GET …/status: returns task status, OC binding + * readiness, and MASKED output names (values are NEVER surfaced). + * + * See asdlc-service/internal/feature/resources/provision_huma.go (A4). + * + * SECURITY: secret output values must never be requested, logged, or rendered. + * The status endpoint intentionally emits name-only outputs; this client does + * not add any mechanism to retrieve values. + */ + +import { env } from '../../config/env'; +import { ApiError } from './rest'; + +const BASE = env.VITE_CORE_API_BASE_URL; + +let _getAccessToken: (() => Promise) | null = null; + +export function setResourcesTokenAccessor(fn: (() => Promise) | null): void { + _getAccessToken = fn; +} + +async function authHeaders(extra?: Record): Promise> { + const headers: Record = { 'Content-Type': 'application/json', ...extra }; + if (_getAccessToken) { + const token = await _getAccessToken(); + if (token) headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function parseError(res: Response): Promise { + const body = await res.text(); + let message = body; + try { + const parsed = JSON.parse(body); + message = parsed.detail || parsed.message || parsed.error || body; + } catch { + /* raw body */ + } + return new ApiError(res.status, message); +} + +// -- Request / response shapes (mirrors provision_huma.go) ------------------- + +export interface ProvisionResourceBody { + /** Provisioning parameters applied to all environments. May be omitted if + * the resource type requires no parameters. */ + params?: Record; + /** Environment names to bind the resource to (e.g. ["development"]). */ + environments: string[]; +} + +/** Masked output from the OC resource binding. Value is never present. */ +export interface ResourceOutput { + /** Output name (e.g. "DATABASE_URL"). */ + name: string; +} + +export interface ResourceStatusResponse { + /** Mirrors the resource-provisioning task status (pending, building, + * deployed, failed, …). */ + status: string; + /** Whether the OC binding's native Ready condition is True. */ + ready: boolean; + /** Masked outputs — name only; secret values are never surfaced. */ + outputs: ResourceOutput[]; +} + +// -- Public API -------------------------------------------------------------- + +/** + * Author the OC Resource model for `depName` on `componentName` and mark + * the matching resource-provisioning task in-flight. Returns when the CRs + * are authored; readiness must be polled via `getResourceStatus`. + * + * Route: POST /api/v1/organizations/{orgHandle}/projects/{projectName}/ + * components/{componentName}/dependencies/{depName}/provision + * + * Body: { params?, environments } + * Response: 202 { status: "provisioning" } + */ +export async function provisionResource( + orgHandle: string, + projectId: string, + component: string, + depName: string, + body: ProvisionResourceBody, +): Promise { + const path = + `/api/v1/organizations/${encodeURIComponent(orgHandle)}` + + `/projects/${encodeURIComponent(projectId)}` + + `/components/${encodeURIComponent(component)}` + + `/dependencies/${encodeURIComponent(depName)}/provision`; + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: await authHeaders(), + body: JSON.stringify(body), + }); + if (!res.ok) throw await parseError(res); + // 202 body contains { status: "provisioning" } — caller does not need it; + // readiness is observed via getResourceStatus. +} + +/** + * Get the provisioning status and masked outputs for `depName` on + * `componentName`. + * + * Route: GET /api/v1/organizations/{orgHandle}/projects/{projectName}/ + * components/{componentName}/dependencies/{depName}/status + * + * Response: { status, ready, outputs: [{ name }] } + * + * SECURITY: outputs are name-only (values masked at the BFF; see + * provision_huma.go). This client must not attempt to read secret values. + */ +export async function getResourceStatus( + orgHandle: string, + projectId: string, + component: string, + depName: string, + environment?: string, +): Promise { + const params = environment ? `?environment=${encodeURIComponent(environment)}` : ''; + const path = + `/api/v1/organizations/${encodeURIComponent(orgHandle)}` + + `/projects/${encodeURIComponent(projectId)}` + + `/components/${encodeURIComponent(component)}` + + `/dependencies/${encodeURIComponent(depName)}/status${params}`; + const res = await fetch(`${BASE}${path}`, { + method: 'GET', + headers: await authHeaders(), + }); + if (!res.ok) throw await parseError(res); + return (await res.json()) as ResourceStatusResponse; +} diff --git a/console/src/services/api/specs.ts b/console/src/services/api/specs.ts new file mode 100644 index 00000000..8ff84bc6 --- /dev/null +++ b/console/src/services/api/specs.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Typed client for the external-dependency spec-collection endpoint. When a + * component has an external REST dependency with `needsSpec: true` and no + * `specPath`, the user can attach an OpenAPI spec by pasting raw YAML/JSON or + * providing a public URL. The BFF validates, stores, and commits the spec then + * returns the relative path and operation count. + * See asdlc-service/internal/feature/design/design_huma.go (A4). + */ + +import { env } from '../../config/env'; +import { ApiError } from './rest'; + +const BASE = env.VITE_CORE_API_BASE_URL; + +let _getAccessToken: (() => Promise) | null = null; + +export function setSpecsTokenAccessor(fn: (() => Promise) | null): void { + _getAccessToken = fn; +} + +async function authHeaders(extra?: Record): Promise> { + const headers: Record = { 'Content-Type': 'application/json', ...extra }; + if (_getAccessToken) { + const token = await _getAccessToken(); + if (token) headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function parseError(res: Response): Promise { + const body = await res.text(); + let message = body; + try { + const parsed = JSON.parse(body); + message = parsed.detail || parsed.message || parsed.error || body; + } catch { + /* raw body */ + } + return new ApiError(res.status, message); +} + +/** + * Attach an OpenAPI spec to an external dependency. Supply exactly one of + * `rawSpec` (pasted/uploaded YAML or JSON text) or `specUrl` (a publicly + * reachable URL the BFF will fetch). On success returns the spec file path + * (relative to `specs/design/`) and the number of operations parsed. + * + * Throws `ApiError` on 400 (invalid spec / missing fields) or 502 (BFF + * could not fetch the URL). + */ +export async function collectSpec( + orgHandle: string, + projectId: string, + component: string, + depName: string, + body: { rawSpec: string } | { specUrl: string }, +): Promise<{ specPath: string; operationCount: number }> { + const path = `/api/v1/organizations/${encodeURIComponent(orgHandle)}/projects/${encodeURIComponent( + projectId, + )}/components/${encodeURIComponent(component)}/dependencies/${encodeURIComponent(depName)}/spec`; + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: await authHeaders(), + body: JSON.stringify(body), + }); + if (!res.ok) throw await parseError(res); + return (await res.json()) as { specPath: string; operationCount: number }; +} diff --git a/console/src/services/api/types.ts b/console/src/services/api/types.ts index e9a9e690..cede6288 100644 --- a/console/src/services/api/types.ts +++ b/console/src/services/api/types.ts @@ -119,7 +119,21 @@ export interface Dependency { kind: DependencyKind; name: string; description?: string; - status?: "resolved" | "ambiguous" | "unresolved"; + // P4 4-state resolution model + P5 provisioning state. + status?: "resolved" | "ambiguous" | "unresolved" | "blocked" | "provisioning"; + // P5: masked outputs from the OC resource binding (name only; values are + // never surfaced — they live in OC-rendered Secrets via ESO). + outputs?: { name: string; secret: boolean }[]; + // Why a dep is not yet resolved. `needs-spec` = external dep has no spec + // yet; `needs-input` = value entry required; `not-found` = no match found; + // `access-required` = org-service exists but is not accessible (requestable + // via P3.5 access requests); `access-pending` = access request in flight. + // `""` for resolved deps. + reason?: "" | "needs-spec" | "needs-input" | "not-found" | "access-required" | "access-pending" | "unpublished"; + // Whether the architect requires a spec file to be provided for this dep. + needsSpec?: boolean; + // Path (relative to `specs/design/`) where the dep spec file lives. + specPath?: string; config?: ConfigKey[]; resourceType?: string; parameters?: Record; @@ -419,6 +433,21 @@ export interface Task { // The Pending Deps column renders "Waiting for: …" from this. Empty // for unblocked tasks. dependsOnComponents?: string[]; + // The other gate kinds a component task waits on — platform-resource + // provisions, cross-project org-services, and external connections. The + // On Hold row explains the full reason (not just component gates) from these. + dependsOnResources?: string[]; + dependsOnOrgServices?: string[]; + dependsOnConnections?: string[]; + // Task type: "component" (coding-agent), "resource-provisioning", or + // "config-collection". Routes the row's action — the last two are resolved + // in the architecture-page drawer, not the (no-op) exec endpoint. + type?: string; + // The dependency this SYSTEM task resolves (resource-provisioning → + // resourceName; config-collection → connectionName). Used to deep-link the + // architecture drawer (?dep=). + resourceName?: string; + connectionName?: string; // F3c — diagnostic surface for `verification_failed` tasks. Shown on // the card so the operator can decide whether to retry. errorMessage?: string; diff --git a/console/src/test-setup.ts b/console/src/test-setup.ts new file mode 100644 index 00000000..6942e895 --- /dev/null +++ b/console/src/test-setup.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import '@testing-library/jest-dom'; diff --git a/console/vite.config.ts b/console/vite.config.ts index c2dafdad..41d8d73c 100644 --- a/console/vite.config.ts +++ b/console/vite.config.ts @@ -16,6 +16,7 @@ * under the License. */ +/// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react-swc'; @@ -23,6 +24,19 @@ const apiTarget = process.env.API_PROXY_TARGET || 'http://localhost:9090'; export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test-setup.ts'], + server: { + deps: { + // @wso2/oxygen-ui uses prismjs with CJS extension-less imports that + // fail in vitest's native ESM mode. Inline-transform it so vitest + // can resolve those imports correctly. + inline: ['@wso2/oxygen-ui', 'prismjs'], + }, + }, + }, resolve: { dedupe: ['react', 'react-dom'], }, diff --git a/deployments/scripts/env.sh b/deployments/scripts/env.sh index 6b66ebdc..91c9809e 100755 --- a/deployments/scripts/env.sh +++ b/deployments/scripts/env.sh @@ -17,5 +17,6 @@ # Shared cluster environment variables — sourced by all scripts in this directory. OPENCHOREO_VERSION="1.1.1" THUNDER_VERSION="0.34.0" +CNPG_VERSION="0.29.0" CLUSTER_NAME="openchoreo" CLUSTER_CONTEXT="k3d-${CLUSTER_NAME}" diff --git a/deployments/scripts/setup-asdlc.sh b/deployments/scripts/setup-asdlc.sh index bfd581d3..31a435ab 100755 --- a/deployments/scripts/setup-asdlc.sh +++ b/deployments/scripts/setup-asdlc.sh @@ -543,6 +543,16 @@ spec: OCEOF echo "✅ ClusterComponentType 'deployment/web-application' created" +# ── Sample platform-resource: postgres-cnpg ClusterResourceType (P5) ──────── +# The cluster PE installs the platform-resource catalog; app-factory's BFF only +# DISCOVERS and REFERENCES these types (it never authors a ClusterResourceType). +# postgres-cnpg renders a CloudNativePG `Cluster`; its RBAC grant lets OC's +# data-plane agent apply that foreign CRD into the `dp-*` namespace (without it, +# provisioning fails with a `clusters.postgresql.cnpg.io is forbidden` denial). +kubectl apply -f "${SCRIPT_DIR}/../single-cluster/postgres-cnpg-rbac.yaml" +kubectl apply -f "${SCRIPT_DIR}/../single-cluster/postgres-cnpg-resourcetype.yaml" +echo "✅ ClusterResourceType 'postgres-cnpg' + CNPG data-plane RBAC created" + # ── Per-org NAMESPACED ComponentTypes (local stand-in for cloud's # platform-api ProvisionOrgUnit) ──────────────────────────────────────── # The BFF references the per-org namespaced ComponentType (kind=ComponentType), diff --git a/deployments/scripts/setup-prerequisites.sh b/deployments/scripts/setup-prerequisites.sh index cf0447c4..5e44baf3 100755 --- a/deployments/scripts/setup-prerequisites.sh +++ b/deployments/scripts/setup-prerequisites.sh @@ -187,5 +187,12 @@ echo "✅ API Platform RBAC applied" kubectl --context ${CLUSTER_CONTEXT} apply -f "${SCRIPT_DIR}/../manifests/api-platform/api-gateway.yaml" echo "✅ APIGateway CR applied — operator will deploy the gateway runtime" +echo "" +echo "7️⃣ CloudNativePG operator (platform-resource sample provisioner)" +helm_install_if_not_exists "cnpg" "cnpg-system" \ + "oci://ghcr.io/cloudnative-pg/charts/cloudnative-pg" --version "${CNPG_VERSION}" +kubectl wait --for=condition=available deployment --all -n cnpg-system --context ${CLUSTER_CONTEXT} --timeout=120s +echo "✅ CloudNativePG operator ready" + echo "" echo "✅ All prerequisites installed!" diff --git a/deployments/single-cluster/postgres-cnpg-rbac.yaml b/deployments/single-cluster/postgres-cnpg-rbac.yaml new file mode 100644 index 00000000..aba7209e --- /dev/null +++ b/deployments/single-cluster/postgres-cnpg-rbac.yaml @@ -0,0 +1,45 @@ +# RBAC for OpenChoreo to render the postgres-cnpg ClusterResourceType. +# +# OC's RenderedRelease controller applies a ResourceType's rendered resources[] +# into the consuming project's data-plane namespace (`dp-<...>`) — but it does so +# as the DATA-PLANE agent SA `cluster-agent-dataplane` (namespace +# `openchoreo-data-plane`), NOT the control-plane controller-manager. For +# postgres-cnpg the rendered object is a `postgresql.cnpg.io/v1 Cluster`, a CRD +# neither of that agent's roles +# (`cluster-agent-dataplane-openchoreo-data-plane`, `wso2-api-platform-gateway-module`) +# covers. Without this grant provisioning fails with: +# clusters.postgresql.cnpg.io "" is forbidden: User +# "system:serviceaccount:openchoreo-data-plane:cluster-agent-dataplane" +# cannot patch resource "clusters" in API group "postgresql.cnpg.io" ... +# +# Least privilege: only the CNPG Cluster lifecycle verbs the data-plane applier +# needs (create/patch/update to apply, get/list/watch to copy the live Cluster +# status into applied..status — which the ResourceType's readyWhen gates on +# (`status.phase == "Cluster in healthy state"`) — delete for retainPolicy: Delete). The +# CNPG-generated `-app` Secret is read for outputs via the agent's +# EXISTING secrets grant, so secrets are intentionally NOT re-granted here. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: openchoreo-dataplane-cnpg + labels: + app.kubernetes.io/part-of: asdlc +rules: + - apiGroups: ["postgresql.cnpg.io"] + resources: ["clusters"] + verbs: ["create", "get", "list", "watch", "update", "patch", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: openchoreo-dataplane-cnpg + labels: + app.kubernetes.io/part-of: asdlc +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: openchoreo-dataplane-cnpg +subjects: + - kind: ServiceAccount + name: cluster-agent-dataplane + namespace: openchoreo-data-plane diff --git a/deployments/single-cluster/postgres-cnpg-resourcetype.yaml b/deployments/single-cluster/postgres-cnpg-resourcetype.yaml new file mode 100644 index 00000000..b4919bd8 --- /dev/null +++ b/deployments/single-cluster/postgres-cnpg-resourcetype.yaml @@ -0,0 +1,100 @@ +# postgres-cnpg — sample platform-resource ClusterResourceType (P5). +# +# Installed by the local deployments/ scripts (acting as the cluster PE would); +# app-factory's BFF only DISCOVERS and REFERENCES this type — it never authors a +# ClusterResourceType (see p5-constraints "app-factory authors only the Resource + +# binding"). This is the concrete `resourceType` an architect's platform-resource +# dependency names (resourceType == ClusterResourceType.metadata.name == "postgres-cnpg"). +# +# It renders a CloudNativePG `Cluster` (postgresql.cnpg.io/v1). CNPG auto-generates +# a `-app` Secret with keys: host, port, dbname, username, password, user, +# uri (+ pgpass / *-jdbc-uri variants). The Resource model resolves `${metadata.name}` +# to a hashed render name (e.g. `r--`); CNPG names the app secret and +# the `-rw` Service off that same name, so the `${metadata.name}-...` refs line up. +# +# CEL context (per OC 1.1.1 ResourceType CRD docs): readyWhen/outputs are evaluated +# against metadata.*, parameters.*, environmentConfigs.*, dataplane.*, and — once the +# manifest is applied — applied..status.* (the rendered object's live status). +--- +apiVersion: openchoreo.dev/v1alpha1 +kind: ClusterResourceType +metadata: + name: postgres-cnpg +spec: + retainPolicy: Delete + parameters: + openAPIV3Schema: + type: object + properties: + version: + type: string + default: "16" + # Bound the image tag to a small allowlist so a consumer can't request an + # arbitrary `ghcr.io/cloudnative-pg/postgresql:` image. + enum: ["16", "15"] + storage: + type: string + default: "1Gi" + # Fixed-size allowlist — closes the arbitrary-PVC-size over-provision surface. + enum: ["1Gi", "5Gi", "10Gi"] + instances: + type: integer + default: 1 + minimum: 1 + # Cap replica count so a consumer can't over-provision the data plane. + maximum: 3 + resources: + - id: cluster + # readyWhen gates the binding's ResourcesReady/Ready on the CNPG Cluster + # actually SERVING — not merely on a successful apply. This is load-bearing + # for P5's async-provisioning safety: OC 1.1.1's getUnknownResourceHealth + # returns `Healthy` UNCONDITIONALLY for unknown Kinds (a foreign CRD like the + # CNPG `Cluster`), so WITHOUT this gate the binding flips Ready=True at APPLY + # time — before Postgres serves — and the A5 watcher would mark the consumer + # task `deployed` against a non-serving DB. + # + # OC 1.1.1 DOES copy the foreign CRD's FULL live `status` into + # `applied..status` every reconcile, so `applied.cluster.status.phase` is + # available once CNPG writes it. The `has()` chain makes both the brief + # pre-APPLY window (the `applied.cluster` key does not exist yet) and the + # pre-PHASE window (status object exists but `.phase` not yet written) + # evaluate to `false` cleanly — fail-closed until the real CNPG healthy phase + # "Cluster in healthy state" appears. LIVE-VERIFIED 2026-06-30: the binding + # held Ready=False through "Setting up primary" / "Waiting for the instances + # to become active" and flipped Ready=True ONLY after phase reached "Cluster + # in healthy state" (gated transition, never an early flip). + readyWhen: '${has(applied.cluster) && has(applied.cluster.status) && has(applied.cluster.status.phase) && applied.cluster.status.phase == "Cluster in healthy state"}' + template: + apiVersion: postgresql.cnpg.io/v1 + kind: Cluster + metadata: + name: ${metadata.name} + namespace: ${metadata.namespace} + labels: ${metadata.labels} + spec: + instances: ${parameters.instances} + imageName: ghcr.io/cloudnative-pg/postgresql:${parameters.version} + storage: + size: ${parameters.storage} + bootstrap: + initdb: + database: appdb + owner: appuser + outputs: + # Plain coordinates — exposed as values (the BFF surfaces them un-masked). + - name: host + value: ${metadata.name}-rw.${metadata.namespace}.svc.cluster.local + - name: port + value: "5432" + - name: dbname + value: appdb + # Credentials — resolved from the CNPG-generated `-app` Secret as + # references only (the BFF reads the secretKeyRef, never the value). + - name: user + secretKeyRef: + name: ${metadata.name}-app + key: username + - name: password + secretKeyRef: + name: ${metadata.name}-app + key: password diff --git a/docs/adr/0007-platform-resource-via-direct-oc-resource-model.md b/docs/adr/0007-platform-resource-via-direct-oc-resource-model.md new file mode 100644 index 00000000..7ab1a151 --- /dev/null +++ b/docs/adr/0007-platform-resource-via-direct-oc-resource-model.md @@ -0,0 +1,65 @@ +# Platform resources via direct OpenChoreo Resource authoring; wso2cloud #638 reserved + +## Status + +accepted (2026-06; marketplace P5) + +## Context + +P5 lets a design-phase `platform-resource` dependency (a database / cache / +queue) be provisioned and consumed by a deployed component. In wso2cloud, +minting a real backing instance is expected to go through a central +**platform / provisioning API** — but that API does not exist yet: +`wso2-enterprise/wso2cloud#638` ("Platform Services Provisioning") is an +**unbuilt tracking stub** (the Aiven-migration successor), and wso2cloud uses +**none** of OpenChoreo's Resource model today. Meanwhile app-factory already +authors OC Resource CRs directly against `openchoreo-api` for P4 external +connections (`connections.Provisioner` + `clients/openchoreo/resource_client.go`), +and the OC Resource mechanism (the controller fills a binding's +`status.outputs`) is installed and proven live on the local cluster. + +## Decision + +P5 provisions platform-resources on the **same OC Resource model as P4**, +authored **directly** by the BFF: discover a cluster-installed +`ClusterResourceType` (read-only — app-factory **never authors the type**), +author a per-project `Resource` + per-env `ResourceReleaseBinding` that +references it, and let the cluster's real provisioner fill the outputs. The one +point that genuinely differs between local and cloud — *who fills the outputs* — +is a narrow `ResourceProvisioner` seam: `OCNativeProvisioner` is the only +implementation built; a `PlatformProvisioner` for wso2cloud#638 is **reserved** +(interface boundary + doc comment, no code). Discovery, the typed task graph, +the readiness watcher, the drawer, and consumption +(`workload.spec.dependencies.resources[]`) are all provisioner-agnostic. +Locally the sample type is `postgres-cnpg` (CloudNativePG operator), installed +by the `deployments/` scripts standing in for the cluster platform-engineer. + +## Why + +- There is **no wso2cloud provisioning API to integrate with today** (#638 is a + stub), so building one inside app-factory would fork platform responsibility. +- The OC Resource model already works and is OC-native, so consumption is + **identical** whether an in-cluster controller or a future #638 API fills the + outputs — when #638 ships, only the "fill outputs" step swaps. +- A wide seam (abstracting provisioning *and* wiring) would fork the + P4 `Resource`/binding/`dependencies.resources[]` chain that already works, + for no benefit. + +## Consequences + +- **R1 (async):** a real DB takes minutes; provisioning is async — a readiness + watcher (modeled on `build_watcher.go`) completes the `resource-provisioning` + task on the binding's native `Ready` condition. No synchronous wait in the + request path. +- **R2 (cloud controllers):** wso2cloud's DP cluster must have the OC Resource + controllers enabled and a real `ClusterResourceType` + provisioner installed + before P5 works there — neither exists today. Local-only until then; engage + #638 rather than fork. +- **R4 (quota):** app-factory authors OC CRs bypassing platform-api + (wso2cloud#549), so the billing / entitlement gate does not fire on Resource + authoring — a governance gap to close when platform-api routing / #638 lands. +- **R6 (generated creds):** the provisioner *mints* credentials (unlike P4's + user-supplied values); rotation = re-provision. Secret values never leave the + OC-rendered Secret — the BFF reads only output *references*. +- Resource authoring runs under service-identity + `X-Impersonate-Org` + ([[adr-dual-mode-oc-auth-impersonation]]). diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..e328ea30 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,12 @@ +# Architecture Decision Records + +This branch (`feat/dependency-management`) carries **ADR-0007** (P5 platform-resource +provisioning). + +Earlier records **ADR-0001 … ADR-0006** — coding-agent placement off the workflow +plane, the local sm-api in-repo stub, coding-agent via cluster-gateway-proxy, +namespaced ComponentType local provisioning, dual-mode OpenChoreo auth / +impersonation, and build git-secret via OpenChoreo — live on the +**`feature/marketplace`** branch. ADR-0007's `[[adr-…]]` links (e.g. +`[[adr-dual-mode-oc-auth-impersonation]]`) resolve there. Bring them over if/when +the branches reconcile. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a792c449..46091805 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,12 @@ importers: specifier: ^13.6.30 version: 13.6.30 devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.0.2(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/js-yaml': specifier: ^4 version: 4.0.9 @@ -120,12 +126,18 @@ importers: '@vitejs/plugin-react-swc': specifier: 4.2.0 version: 4.2.0(vite@7.2.2) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 typescript: specifier: 5.9.3 version: 5.9.3 vite: specifier: 7.2.2 version: 7.2.2 + vitest: + specifier: ^4.1.9 + version: 4.1.9(jsdom@29.1.1)(vite@7.2.2) ui-components/cell-diagram-view: dependencies: @@ -425,6 +437,21 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asgardeo/browser@0.5.12': resolution: {integrity: sha512-IzgDA3pOPJ4L3gsUqw9p+Y+C5hNe5m9ZrTpwPbwgHm/kRKX+QknU2ymt/Y+BV1legseCC0LxhNbIZY3vQn3ugQ==} @@ -531,6 +558,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -561,6 +592,42 @@ packages: '@chevrotain/utils@12.0.0': resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -952,6 +1019,15 @@ packages: resolution: {integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==} engines: {node: '>=10'} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1072,7 +1148,7 @@ packages: '@mui/base@5.0.0-beta.6': resolution: {integrity: sha512-jcHy6HwOX7KzRhRtL8nvIvUlxvLx2Fl6NMRCyUSQSvMTyfou9kndekz0H4HJaXvG1Y4WEifk23RYedOlrD1kEQ==} engines: {node: '>=12.0.0'} - deprecated: This package has been replaced by @base-ui-components/react + deprecated: This package has been replaced by @base-ui/react peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 @@ -1814,6 +1890,9 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@storybook/addon-docs@10.3.5': resolution: {integrity: sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==} peerDependencies: @@ -2097,6 +2176,21 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.6.1': resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} engines: {node: '>=12', npm: '>=6'} @@ -2509,15 +2603,44 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -2644,6 +2767,9 @@ packages: bezier-easing@2.1.0: resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2729,6 +2855,10 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2861,6 +2991,10 @@ packages: resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==} engines: {node: '>= 0.10'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-vendor@2.0.8: resolution: {integrity: sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==} @@ -3032,6 +3166,10 @@ packages: dagre@0.8.5: resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -3047,6 +3185,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3142,6 +3283,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3153,6 +3298,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3194,6 +3342,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3201,6 +3352,10 @@ packages: evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3366,6 +3521,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3472,6 +3631,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-typed-array@1.1.15: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} @@ -3526,6 +3688,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3683,6 +3854,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mermaid@11.14.0: resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} @@ -3833,6 +4007,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + open-color@1.9.1: resolution: {integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==} @@ -3872,6 +4050,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -3890,6 +4071,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathfinding@0.4.18: resolution: {integrity: sha512-R0TGEQ9GRcFCDvAWlJAWC+KGJ9SLbW4c0nuZRcioVlXVTlw+F5RvXQ8SQgSqI9KXWC1ew95vgmIiyaWTlCe9Ag==} @@ -4018,6 +4202,10 @@ packages: public-encrypt@4.0.3: resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pwacompat@2.0.17: resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} @@ -4236,6 +4424,10 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -4300,6 +4492,10 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4343,8 +4539,12 @@ packages: resolution: {integrity: sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg==} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sliced@1.0.1: resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} + deprecated: Unsupported source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -4364,6 +4564,12 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + storybook@10.3.5: resolution: {integrity: sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==} hasBin: true @@ -4423,6 +4629,9 @@ packages: react: '>=16.8.0 <20' react-dom: '>=16.8.0 <20' + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -4432,6 +4641,9 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.1.2: resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} @@ -4444,10 +4656,21 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.4.5: + resolution: {integrity: sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==} + + tldts@7.4.5: + resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} + hasBin: true + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -4459,6 +4682,14 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-sitter-json@0.24.8: resolution: {integrity: sha512-Tc9ZZYwHyWZ3Tt1VEw7Pa2scu1YO7/d2BCBbKTx5hXwig3UfdQjsOPkPyLpDJOn/m1UBEWYAtSdGAwCSyagBqQ==} peerDependencies: @@ -4523,6 +4754,10 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4635,6 +4870,47 @@ packages: yaml: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -4661,15 +4937,31 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-tree-sitter@0.24.5: resolution: {integrity: sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} webworkify@1.5.0: resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -4679,6 +4971,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -4698,9 +4995,16 @@ packages: xml-but-prettier@1.0.1: resolution: {integrity: sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-protocols@1.0.7: resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -4754,6 +5058,26 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@asgardeo/browser@0.5.12': dependencies: '@asgardeo/javascript': 0.15.0 @@ -4918,6 +5242,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -4950,6 +5278,30 @@ snapshots: '@chevrotain/utils@12.0.0': {} + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emotion/babel-plugin@11.13.5': dependencies: '@babel/helper-module-imports': 7.28.6 @@ -5289,6 +5641,8 @@ snapshots: '@excalidraw/random-username@1.1.0': {} + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -6171,6 +6525,8 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@standard-schema/spec@1.1.0': {} + '@storybook/addon-docs@10.3.5(@types/react@19.1.6)(esbuild@0.27.7)(rollup@4.60.2)(storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.2)': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.1.6)(react@19.2.3) @@ -6776,6 +7132,16 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.0.2(@types/react@19.1.6))(@types/react@19.1.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.1.6 + '@types/react-dom': 19.0.2(@types/react@19.1.6) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -7234,20 +7600,61 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@7.2.2)': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2 + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.1.9': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@webcontainer/env@1.1.1': {} '@wso2/cell-diagram@0.2.0(@types/react@19.1.6)(pathfinding@0.4.18)(paths-js@0.4.11)(resize-observer-polyfill@1.5.1)': @@ -7409,6 +7816,10 @@ snapshots: bezier-easing@2.1.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bn.js@4.12.3: {} @@ -7519,6 +7930,8 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7675,6 +8088,11 @@ snapshots: randombytes: 2.1.0 randomfill: 1.0.4 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + css-vendor@2.0.8: dependencies: '@babel/runtime': 7.29.2 @@ -7875,6 +8293,13 @@ snapshots: graphlib: 2.1.8 lodash: 4.18.1 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + date-fns@4.1.0: {} dayjs@1.11.20: {} @@ -7883,6 +8308,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -7978,6 +8405,8 @@ snapshots: empathic@2.0.0: {} + entities@8.0.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -7986,6 +8415,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.2.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8067,6 +8498,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} evp_bytestokey@1.0.3: @@ -8074,6 +8509,8 @@ snapshots: md5.js: 1.3.5 safe-buffer: 5.2.1 + expect-type@1.4.0: {} + extend@3.0.2: {} fast-equals@5.4.0: {} @@ -8250,6 +8687,12 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} hyphenate-style-name@1.1.0: {} @@ -8330,6 +8773,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-typed-array@1.1.15: dependencies: which-typed-array: 1.1.20 @@ -8368,6 +8813,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -8595,6 +9066,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + mermaid@11.14.0: dependencies: '@braintree/sanitize-url': 7.1.2 @@ -8810,6 +9283,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.3: {} + open-color@1.9.1: {} open@10.2.0: @@ -8862,6 +9337,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-data-parser@0.1.0: {} path-key@3.1.1: {} @@ -8875,6 +9354,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + pathfinding@0.4.18: dependencies: heap: 0.2.5 @@ -9040,6 +9521,8 @@ snapshots: randombytes: 2.1.0 safe-buffer: 5.2.1 + punycode@2.3.1: {} + pwacompat@2.0.17: {} querystringify@2.2.0: {} @@ -9306,6 +9789,8 @@ snapshots: repeat-string@1.6.1: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} reselect@5.1.1: {} @@ -9393,6 +9878,10 @@ snapshots: immutable: 4.3.8 source-map-js: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -9432,6 +9921,8 @@ snapshots: short-unique-id@5.3.2: {} + siginfo@2.0.0: {} + sliced@1.0.1: {} source-map-js@1.2.1: {} @@ -9444,6 +9935,10 @@ snapshots: sprintf-js@1.0.3: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + storybook@10.3.5(@testing-library/dom@10.4.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@storybook/global': 5.0.0 @@ -9571,12 +10066,16 @@ snapshots: - '@types/react' - debug + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tiny-invariant@1.3.3: {} tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + tinyexec@1.1.2: {} tinyglobby@0.2.16: @@ -9586,8 +10085,16 @@ snapshots: tinyrainbow@2.0.0: {} + tinyrainbow@3.1.0: {} + tinyspy@4.0.4: {} + tldts-core@7.4.5: {} + + tldts@7.4.5: + dependencies: + tldts-core: 7.4.5 + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -9600,6 +10107,14 @@ snapshots: toggle-selection@1.0.6: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.5 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-sitter-json@0.24.8(tree-sitter@0.21.1): dependencies: node-addon-api: 8.7.0 @@ -9664,6 +10179,8 @@ snapshots: undici-types@5.26.5: {} + undici@7.28.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -9761,6 +10278,33 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@4.1.9(jsdom@29.1.1)(vite@7.2.2): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@7.2.2) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.2.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.2.2 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -9782,13 +10326,29 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-tree-sitter@0.24.5: optional: true + webidl-conversions@8.0.1: {} + webpack-virtual-modules@0.6.2: {} webworkify@1.5.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -9803,6 +10363,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.20.0: {} wsl-utils@0.1.0: @@ -9813,8 +10378,12 @@ snapshots: dependencies: repeat-string: 1.6.1 + xml-name-validator@5.0.0: {} + xml@1.0.1: {} + xmlchars@2.2.0: {} + y-protocols@1.0.7(yjs@13.6.30): dependencies: lib0: 0.2.117 diff --git a/remote-worker/plugin/skills/asdlc/SKILL.md b/remote-worker/plugin/skills/asdlc/SKILL.md index ae640129..d01526ef 100644 --- a/remote-worker/plugin/skills/asdlc/SKILL.md +++ b/remote-worker/plugin/skills/asdlc/SKILL.md @@ -295,3 +295,10 @@ comment, your component has no consumer-side dependencies — add no `dependencies:` block. The build's `generate-workload-cr` step propagates this block into the OpenChoreo `Workload` CR, and OpenChoreo resolves + injects the addresses; you never hardcode an upstream URL. + +**Consumed API contracts.** If your issue lists an "API contract" for an external +dependency with a `specPath`, read that OpenAPI file in the repo and generate the +client strictly from its operations, parameters, and schemas — do not invent +endpoints or guess shapes. Authenticate using the injected config env-var NAMES +only (never hardcode or echo secret values; the pre-push guard scans for leaked +values). diff --git a/ui-components/cell-diagram-view/src/buildProjectModel.ts b/ui-components/cell-diagram-view/src/buildProjectModel.ts index 6c1b1c3f..b05cf8dc 100644 --- a/ui-components/cell-diagram-view/src/buildProjectModel.ts +++ b/ui-components/cell-diagram-view/src/buildProjectModel.ts @@ -111,10 +111,18 @@ export function buildProjectModel(components: CellDiagramComponent[]): Project { label: depName, onPlatform: true, })); - // External nodes: unified external + org-service deps, else legacy dependentApis. + // External nodes: unified external + org-service + platform-resource deps + // (each is a backing dependency outside the component's own code), else + // legacy dependentApis. platform-resource (a provisioned db/cache/queue) + // renders as a chain-link node just like an external API or org-service. const externalConnections: Connection[] = Array.isArray(comp.dependencies) ? comp.dependencies - .filter((d) => d.kind === 'external' || d.kind === 'org-service') + .filter( + (d) => + d.kind === 'external' || + d.kind === 'org-service' || + d.kind === 'platform-resource', + ) .map(externalDependencyConnection) : (comp.dependentApis || []).map(dependentApiConnection);