diff --git a/.github/workflows/apip-api-control-plane-release.yml b/.github/workflows/apip-api-control-plane-release.yml new file mode 100644 index 000000000..775824484 --- /dev/null +++ b/.github/workflows/apip-api-control-plane-release.yml @@ -0,0 +1,116 @@ +name: API Control Plane Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0)' + required: true + type: string + next_dev_version: + description: 'Next development version (with -SNAPSHOT suffix, e.g. 0.2.0-SNAPSHOT)' + required: true + type: string + +env: + DOCKER_REGISTRY: ghcr.io/wso2/api-platform + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set version + env: + VERSION: ${{ inputs.version }} + run: | + if [ -z "$(printf '%s' "${VERSION}" | tr -d '[:space:]')" ] || [[ "${VERSION}" == *-SNAPSHOT ]]; then + echo "Error: version must be a non-snapshot release version" + exit 1 + fi + make -C portals/api-control-plane version-set "VERSION=${VERSION}" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26.5' + cache: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver: docker-container + + - name: Install frontend dependencies + working-directory: portals/api-control-plane + run: npm ci + + - name: Run tests + run: make test-api-control-plane + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.API_PLATFORM_BOT_TOKEN }} + + - name: Build and push multi arch Docker images + run: make build-and-push-api-control-plane-multiarch + + - name: Create and push tag + env: + VERSION: ${{ inputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "Release api-control-plane version ${VERSION}" + git tag -a "apip-api-control-plane/v${VERSION}" -m "API Control Plane ${VERSION}" + git push origin "apip-api-control-plane/v${VERSION}" + + - name: Set next dev version + env: + NEXT_DEV_VERSION: ${{ inputs.next_dev_version }} + run: | + if [ -z "${NEXT_DEV_VERSION}" ]; then + echo "Error: next_dev_version input is required" + exit 1 + fi + if [[ "${NEXT_DEV_VERSION}" != *-SNAPSHOT ]]; then + echo "Error: next_dev_version must end with -SNAPSHOT (e.g. 0.2.0-SNAPSHOT)" + exit 1 + fi + echo "Setting api-control-plane to next dev version: ${NEXT_DEV_VERSION}" + make -C portals/api-control-plane version-set "VERSION=${NEXT_DEV_VERSION}" + + - name: Commit version bump + run: | + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + git commit -am "Bump api-control-plane to next dev version" + else + echo "No changes to commit" + fi + + - name: Create PR for version bump + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + commit-message: "Bump api-control-plane to next dev version" + title: "chore: Bump api-control-plane to next dev version" + body: | + Automated version bump after release `apip-api-control-plane/v${{ inputs.version }}` + + This PR bumps the api-control-plane version to the next development version `${{ inputs.next_dev_version }}`. + branch: api-control-plane-version-bump-${{ github.run_id }} + base: main + delete-branch: true + labels: | + automated + version-bump diff --git a/Makefile b/Makefile index 8e544bc80..92536a3eb 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,7 @@ EVENT_GATEWAY_VERSION := $(shell cat event-gateway/VERSION) PLATFORM_API_VERSION := $(shell cat platform-api/VERSION) CLI_VERSION := $(shell cat cli/VERSION) API_PORTAL_VERSION := $(shell cat portals/api-portal/VERSION) +API_CONTROL_PLANE_VERSION := $(shell cat portals/api-control-plane/VERSION) # Docker registry configuration DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform @@ -57,6 +58,8 @@ help: ## Show this help message @echo ' make test-cli - Run CLI tests' @echo ' make test-api-portal - Run API Portal integration tests' @echo ' make test-ai-workspace - Run AI Workspace E2E tests' + @echo ' make build-and-push-api-control-plane-multiarch - Build and push API Control Plane image for multiple architectures' + @echo ' make test-api-control-plane - Run API Control Plane tests' @echo '' @echo 'Push Targets:' @echo ' make push-gateway - Push gateway images to registry' @@ -75,6 +78,7 @@ version: ## Display current versions @echo "Platform API Version: $(PLATFORM_API_VERSION)" @echo "CLI Version: $(CLI_VERSION)" @echo "API Portal Version: $(API_PORTAL_VERSION)" + @echo "API Control Plane Version: $(API_CONTROL_PLANE_VERSION)" # Build Targets @@ -114,6 +118,12 @@ build-and-push-api-portal-multiarch: ## Build and push API Portal Docker image f $(MAKE) -C portals/api-portal build-and-push-multiarch @echo "Successfully built and pushed multi-arch API Portal" +.PHONY: build-and-push-api-control-plane-multiarch +build-and-push-api-control-plane-multiarch: ## Build and push API Control Plane Docker image for multiple architectures (amd64, arm64) + @echo "Building and pushing multi-arch API Control Plane ($(API_CONTROL_PLANE_VERSION))..." + $(MAKE) -C portals/api-control-plane build-and-push-multiarch VERSION=$(API_CONTROL_PLANE_VERSION) + @echo "Successfully built and pushed multi-arch API Control Plane" + # Package Targets .PHONY: package-event-gateway package-event-gateway: ## Package event gateway as a self-contained zip (wso2apip-event-gateway-.zip) @@ -162,6 +172,11 @@ test-ai-workspace: ## Run AI Workspace E2E tests @echo "Running AI Workspace E2E tests..." $(MAKE) -C portals/ai-workspace e2e-ci +.PHONY: test-api-control-plane +test-api-control-plane: ## Run API Control Plane tests + @echo "Running API Control Plane tests..." + $(MAKE) -C portals/api-control-plane test + .PHONY: build-cli build-cli: ## Build CLI binaries for all platforms @echo "Building CLI ($(CLI_VERSION))..." diff --git a/portals/api-control-plane/.gitignore b/portals/api-control-plane/.gitignore index a586006cb..5bb327ac2 100644 --- a/portals/api-control-plane/.gitignore +++ b/portals/api-control-plane/.gitignore @@ -1,4 +1,5 @@ /build/ /coverage/ /node_modules/ +/target/ .env*.local diff --git a/portals/api-control-plane/Makefile b/portals/api-control-plane/Makefile index e15b4bf57..7932f79da 100644 --- a/portals/api-control-plane/Makefile +++ b/portals/api-control-plane/Makefile @@ -21,7 +21,10 @@ SHELL := /bin/bash VERSION ?= $(shell cat VERSION 2>/dev/null || echo "0.0.0-SNAPSHOT") -.PHONY: help run build bff-build bff-run bff-test bff-tidy +DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform +IMAGE_NAME := $(DOCKER_REGISTRY)/api-control-plane + +.PHONY: help run build bff-build bff-run bff-test bff-tidy test build-and-push-multiarch version-set version-bump-patch version-bump-minor version-bump-major version-bump-next-dev version-get-release # BFF (Backend-for-Frontend) — Go server that serves the SPA, proxies all # browser->backend traffic, and owns authentication. Local dev runs it over @@ -75,5 +78,47 @@ bff-test: ## Run BFF unit tests bff-tidy: ## Tidy the BFF module cd $(BFF_DIR) && GOWORK=off go mod tidy +test: ## Run frontend and BFF tests + npm test + $(MAKE) bff-test + build: ## Build the production Docker image - docker build -t api-control-plane:$(VERSION) . + docker build -t $(IMAGE_NAME):$(VERSION) -t api-control-plane:$(VERSION) . + +build-and-push-multiarch: ## Build and push multi-architecture Docker image (linux/amd64, linux/arm64) + @echo "Building and pushing multi-arch api-control-plane Docker image: $(IMAGE_NAME):$(VERSION)" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=$(VERSION) \ + -t $(IMAGE_NAME):$(VERSION) \ + --push \ + . + +version-set: ## Set api-control-plane version + @if [ -z "$(VERSION)" ]; then \ + echo "Error: VERSION required"; \ + echo "Usage: make version-set VERSION=1.0.0"; \ + exit 1; \ + fi + @echo "$(VERSION)" > VERSION + @echo "✓ Set api-control-plane version to $(VERSION)" + +version-bump-patch: ## Bump patch version + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh patch portals/api-control-plane) + +version-bump-minor: ## Bump minor version + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh minor portals/api-control-plane) + +version-bump-major: ## Bump major version + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh major portals/api-control-plane) + +version-bump-next-dev: ## Bump to next minor dev version with SNAPSHOT suffix + @$(MAKE) version-set VERSION=$$(cd ../.. && bash scripts/next-version.sh next-dev portals/api-control-plane) + +version-get-release: ## Get release version (strips SNAPSHOT suffix) + @VERSION=$$(cat VERSION 2>/dev/null | tr -d '[:space:]' | sed 's/-SNAPSHOT//'); \ + if [ -z "$$VERSION" ]; then \ + echo "Error: VERSION is empty or contains only whitespace. Check portals/api-control-plane/VERSION file." >&2; \ + exit 1; \ + fi; \ + echo "$$VERSION" diff --git a/portals/api-control-plane/README.md b/portals/api-control-plane/README.md index 82ba0ce63..ca945dee8 100644 --- a/portals/api-control-plane/README.md +++ b/portals/api-control-plane/README.md @@ -28,13 +28,21 @@ supports two auth modes (`[auth] mode` in `configs/config.toml`): ## Development -Two processes, run side by side: +Three processes, run side by side: Platform API, the BFF, then the portal's +own dev server. ```bash -# Terminal 1 — the BFF, proxying to a running Platform API +# Terminal 1 — Platform API (one-time setup, then run it) +cd /platform-api +./scripts/setup-local-dev.sh # first time only — generates local certs/keys/admin creds +make run-local # or: make setup-local-dev && make run-local + +# Terminal 2 — the BFF, proxying to the running Platform API +cd /portals/api-control-plane CONTROL_PLANE_URL=https://localhost:9243 make bff-run -# Terminal 2 — the Vite dev server, proxying same-origin BFF paths to it +# Terminal 3 — the Vite dev server, proxying same-origin BFF paths to it +cd /portals/api-control-plane npm install npm run dev ``` diff --git a/portals/api-control-plane/src/App.tsx b/portals/api-control-plane/src/App.tsx index 9a88eeda3..cde14ab45 100644 --- a/portals/api-control-plane/src/App.tsx +++ b/portals/api-control-plane/src/App.tsx @@ -27,6 +27,10 @@ import { runtimeConfig } from './config/runtime'; import { AuthProvider } from './features/auth/AuthProvider'; import { ProductActivation } from './features/billing/ProductActivation'; import { AppRoutes } from './routes/AppRoutes'; +import { + ExtensionsProvider, + type ApiControlPlaneExtension, +} from './extensions'; const isProduction = import.meta.env.PROD; @@ -39,7 +43,11 @@ const queryClient = new QueryClient({ }, }); -export default function App() { +export type AppProps = { + extensions?: readonly ApiControlPlaneExtension[]; +}; + +export default function App({ extensions = [] }: AppProps) { return ( @@ -49,7 +57,9 @@ export default function App() { - + + + diff --git a/portals/api-control-plane/src/cloud/index.ts b/portals/api-control-plane/src/cloud/index.ts new file mode 100644 index 000000000..fee98f3ef --- /dev/null +++ b/portals/api-control-plane/src/cloud/index.ts @@ -0,0 +1,27 @@ +/* + * 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. + * + * Injection seam for cloud-only extensions. `main.tsx` imports + * `cloudExtensions` from here unconditionally, so this file must always + * exist and export a valid (possibly empty) array — this is what lets a + * downstream build overlay just this one file/directory with real cloud + * features, without ever touching App.tsx/main.tsx/extensions.tsx. + */ + +import type { ApiControlPlaneExtension } from '../extensions'; + +export const cloudExtensions: ApiControlPlaneExtension[] = []; diff --git a/portals/api-control-plane/src/extensions.tsx b/portals/api-control-plane/src/extensions.tsx new file mode 100644 index 000000000..0ed969124 --- /dev/null +++ b/portals/api-control-plane/src/extensions.tsx @@ -0,0 +1,85 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; + +import type { ConsoleScope } from './scope/ConsoleScopeProvider'; +import type { NavigationLevel } from './navigation/navigationTypes'; + +/** + * A host-injected feature: a route plus its sidebar entry. `routePath` is + * relative to the same route group the built-in nav items live in (e.g. + * `"billing"`, not `"/organizations/:orgHandle/billing"`), and `level` + * decides which sidebar section it's grouped under — mirrors + * `NavigationDefinition` so it can be merged straight into the existing + * nav pipeline in `navigation/useNavigationItems.ts`. + */ +export type ApiControlPlaneExtension = { + id: string; + routePath: string; + element: ReactNode; + label: string; + icon?: ReactNode; + level: NavigationLevel; + /** Sidebar section heading. Defaults to the level's own section (e.g. "Organization"). */ + group?: string; + order: number; + isVisible?: (scope: ConsoleScope) => boolean; +}; + +const ExtensionsContext = createContext( + [] +); + +export function ExtensionsProvider({ + extensions, + children, +}: { + extensions: readonly ApiControlPlaneExtension[]; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useExtensions(): readonly ApiControlPlaneExtension[] { + return useContext(ExtensionsContext); +} + +/** + * Prefixes an extension's `routePath` with the URL shape for its `level` + * (organization/project/api), so both `AppRoutes` (route patterns, `orgHandle` + * etc. as `:param` placeholders) and the nav pipeline (concrete scope values) + * build the same URL shape from one place. + */ +export function buildScopedExtensionPath( + level: NavigationLevel, + routeSuffix: string, + params: { orgHandle: string; projectHandler?: string; apiHandler?: string } +): string { + if (level === 'organization') { + return `/organizations/${params.orgHandle}/${routeSuffix}`; + } + if (level === 'project') { + return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/${routeSuffix}`; + } + return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/apis/${params.apiHandler}/${routeSuffix}`; +} diff --git a/portals/api-control-plane/src/index.ts b/portals/api-control-plane/src/index.ts index 5e5abfd10..f9ccdeac5 100644 --- a/portals/api-control-plane/src/index.ts +++ b/portals/api-control-plane/src/index.ts @@ -23,4 +23,7 @@ // hot-reloading edits across both apps in a single dev server, with no // separate "build api-control-plane, then run the host" step. export { default as App } from './App'; +export type { AppProps } from './App'; export { loadRuntimeConfigScripts } from './config/loadRuntimeConfigScripts'; +export type { ApiControlPlaneExtension } from './extensions'; +export { buildScopedExtensionPath } from './extensions'; diff --git a/portals/api-control-plane/src/main.tsx b/portals/api-control-plane/src/main.tsx index 756e760cb..fdc1d8067 100644 --- a/portals/api-control-plane/src/main.tsx +++ b/portals/api-control-plane/src/main.tsx @@ -29,10 +29,16 @@ loadRuntimeConfigScripts() }) .finally(async () => { const { default: App } = await import('./App'); + const cloudExtensions = await import('./cloud') + .then((module) => module.cloudExtensions) + .catch((error) => { + console.warn('Cloud extensions could not be loaded.', error); + return []; + }); root.render( - + ); }); diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.ts b/portals/api-control-plane/src/navigation/useNavigationItems.ts index f9fb37a97..31bf338ba 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.ts +++ b/portals/api-control-plane/src/navigation/useNavigationItems.ts @@ -21,6 +21,7 @@ import { useLocation } from 'react-router-dom'; import { runtimeConfig } from '../config/runtime'; import { useConsoleScope } from '../scope/ConsoleScopeProvider'; +import { buildScopedExtensionPath, useExtensions } from '../extensions'; import { navigationRegistry } from './navigationRegistry'; import { NAVIGATION_GROUP_BY_LEVEL, @@ -45,37 +46,81 @@ const isFeatureEnabled = (definition: NavigationDefinition) => export const useNavigationItems = (): NavigationItem[] => { const scope = useConsoleScope(); const location = useLocation(); + const extensions = useExtensions(); - return useMemo( - () => - navigationRegistry - .filter((definition) => isLevelAvailable(definition, scope)) - .filter(isFeatureEnabled) - .filter((definition) => definition.isVisible?.(scope) ?? true) - .map((definition) => { - const to = definition.to(scope); - if (!to) return undefined; - return { - group: definition.group ?? NAVIGATION_GROUP_BY_LEVEL[definition.level], - icon: definition.icon, - id: definition.id, - isActive: definition.match - ? definition.match(location.pathname) - : location.pathname === to, - label: definition.label, - to, - }; - }) - .filter(Boolean) - .sort((left, right) => { - const leftOrder = - navigationRegistry.find((item) => item.id === left?.id)?.order ?? 0; - const rightOrder = - navigationRegistry.find((item) => item.id === right?.id)?.order ?? 0; - return leftOrder - rightOrder; - }) as NavigationItem[], - [location.pathname, scope] - ); + return useMemo(() => { + // Host-injected extensions are converted to the same NavigationDefinition + // shape the built-in registry uses, so they run through one filter/sort + // pipeline instead of a parallel "Cloud category" implementation. + const extensionDefinitions: NavigationDefinition[] = extensions.map( + (extension) => { + const isDescendantRoute = extension.routePath.endsWith('/*'); + const routeSuffix = extension.routePath.replace(/\/\*$/, ''); + const routeSegment = `/${routeSuffix}`; + return { + group: extension.group, + icon: extension.icon, + id: extension.id, + isVisible: extension.isVisible, + label: extension.label, + level: extension.level, + match: (pathname) => { + const index = pathname.indexOf(routeSegment); + if (index === -1) return false; + const charAfter = pathname[index + routeSegment.length]; + // Match only a complete path segment: nothing after it, or (for + // a `/*` route) a further `/` continuing into a descendant path. + return charAfter === undefined || (isDescendantRoute && charAfter === '/'); + }, + order: extension.order, + to: (navScope) => { + const { orgHandle, projectHandler, apiHandler } = navScope.params; + if (!orgHandle) return undefined; + if (extension.level === 'project' && !projectHandler) return undefined; + if ( + extension.level === 'api' && + (!projectHandler || !apiHandler) + ) { + return undefined; + } + return buildScopedExtensionPath(extension.level, routeSuffix, { + apiHandler, + orgHandle, + projectHandler, + }); + }, + }; + } + ); + const combinedRegistry = [...navigationRegistry, ...extensionDefinitions]; + + return combinedRegistry + .filter((definition) => isLevelAvailable(definition, scope)) + .filter(isFeatureEnabled) + .filter((definition) => definition.isVisible?.(scope) ?? true) + .map((definition) => { + const to = definition.to(scope); + if (!to) return undefined; + return { + group: definition.group ?? NAVIGATION_GROUP_BY_LEVEL[definition.level], + icon: definition.icon, + id: definition.id, + isActive: definition.match + ? definition.match(location.pathname) + : location.pathname === to, + label: definition.label, + to, + }; + }) + .filter(Boolean) + .sort((left, right) => { + const leftOrder = + combinedRegistry.find((item) => item.id === left?.id)?.order ?? 0; + const rightOrder = + combinedRegistry.find((item) => item.id === right?.id)?.order ?? 0; + return leftOrder - rightOrder; + }) as NavigationItem[]; + }, [location.pathname, scope, extensions]); }; /** diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index d2bef3afb..cffdcd5ba 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -30,6 +30,10 @@ import { } from '../features/system/SystemPages'; import { ConsoleScopeProvider } from '../scope/ConsoleScopeProvider'; import AppLayout from '../layouts/AppLayout'; +import { + buildScopedExtensionPath, + type ApiControlPlaneExtension, +} from '../extensions'; import { ProtectedRoute } from './ProtectedRoute'; import { routes } from './paths'; @@ -100,7 +104,23 @@ const SettingsPage = lazy(() => })) ); -export function AppRoutes() { +export type AppRoutesProps = { + extensions?: readonly ApiControlPlaneExtension[]; +}; + +export function AppRoutes({ extensions = [] }: AppRoutesProps) { + const extensionRoutes = extensions.map((extension) => ( + + )); + return ( } /> @@ -133,6 +153,7 @@ export function AppRoutes() { } /> } /> } /> + {extensionRoutes} } />