From 0ea2e743d0432e31b68b156dea4ae6365d3e2096 Mon Sep 17 00:00:00 2001 From: JPadovano1483 Date: Tue, 30 Jun 2026 10:08:25 -0400 Subject: [PATCH 1/4] Add Kind integration tests for management stack and backbone bootstrap. End-to-end Vitest specs against a local Kind cluster with Keycloak, cert-manager, Postgres, Skupper, and backbone site bootstrap coverage. Co-authored-by: Cursor --- .gitignore | 1 + tests/integration/helpers/api-client.js | 51 +++ tests/integration/helpers/bootstrap.js | 397 ++++++++++++++++++ tests/integration/helpers/keycloak.js | 105 +++++ tests/integration/helpers/kubectl.js | 301 +++++++++++++ tests/integration/helpers/postgres.js | 137 ++++++ tests/integration/helpers/site-api-client.js | 77 ++++ tests/integration/kind/README.md | 139 ++++++ tests/integration/kind/config.js | 86 ++++ tests/integration/kind/config.sh | 63 +++ .../fixtures/backbone/integration-seed.sql | 83 ++++ .../kind/fixtures/keycloak/README.md | 7 + .../kind/fixtures/keycloak/deployment.yaml | 79 ++++ .../fixtures/keycloak/realm-vms-test.json | 114 +++++ .../kind/fixtures/secrets/keycloak.json | 9 + tests/integration/kind/kind-config.yaml | 16 + .../integration/kind/scripts/cluster-down.sh | 22 + tests/integration/kind/scripts/cluster-up.sh | 106 +++++ .../kind/scripts/install-skupper.sh | 65 +++ .../kind/scripts/seed-integration.sh | 93 ++++ .../kind/scripts/wait-cert-manager-webhook.sh | 36 ++ tests/integration/kind/scripts/wait-ready.sh | 27 ++ .../kind/specs/backbone-bootstrap.test.js | 165 ++++++++ .../kind/specs/mgmt-auth-api.test.js | 160 +++++++ .../integration/kind/specs/mgmt-certs.test.js | 64 +++ .../kind/specs/mgmt-health.test.js | 125 ++++++ 26 files changed, 2528 insertions(+) create mode 100644 tests/integration/helpers/api-client.js create mode 100644 tests/integration/helpers/bootstrap.js create mode 100644 tests/integration/helpers/keycloak.js create mode 100644 tests/integration/helpers/kubectl.js create mode 100644 tests/integration/helpers/postgres.js create mode 100644 tests/integration/helpers/site-api-client.js create mode 100644 tests/integration/kind/README.md create mode 100644 tests/integration/kind/config.js create mode 100644 tests/integration/kind/config.sh create mode 100644 tests/integration/kind/fixtures/backbone/integration-seed.sql create mode 100644 tests/integration/kind/fixtures/keycloak/README.md create mode 100644 tests/integration/kind/fixtures/keycloak/deployment.yaml create mode 100644 tests/integration/kind/fixtures/keycloak/realm-vms-test.json create mode 100644 tests/integration/kind/fixtures/secrets/keycloak.json create mode 100644 tests/integration/kind/kind-config.yaml create mode 100755 tests/integration/kind/scripts/cluster-down.sh create mode 100755 tests/integration/kind/scripts/cluster-up.sh create mode 100755 tests/integration/kind/scripts/install-skupper.sh create mode 100755 tests/integration/kind/scripts/seed-integration.sh create mode 100755 tests/integration/kind/scripts/wait-cert-manager-webhook.sh create mode 100755 tests/integration/kind/scripts/wait-ready.sh create mode 100644 tests/integration/kind/specs/backbone-bootstrap.test.js create mode 100644 tests/integration/kind/specs/mgmt-auth-api.test.js create mode 100644 tests/integration/kind/specs/mgmt-certs.test.js create mode 100644 tests/integration/kind/specs/mgmt-health.test.js diff --git a/.gitignore b/.gitignore index 2d7af0aa..ef9d925b 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ dist # Keycloak file keycloak.json +!tests/integration/kind/fixtures/secrets/keycloak.json # js config file jsconfig.json diff --git a/tests/integration/helpers/api-client.js b/tests/integration/helpers/api-client.js new file mode 100644 index 00000000..c239398d --- /dev/null +++ b/tests/integration/helpers/api-client.js @@ -0,0 +1,51 @@ +/* + * HTTP client for management-controller via kubectl port-forward. + */ + +import { MC_LOCAL_PORT, MC_PORT, MC_SERVICE } from '../kind/config.js'; +import { startPortForward, waitForHttp } from './kubectl.js'; + +const PROBE_TIMEOUT_MS = 5_000; + +/** + * Start port-forward to the management-server Service. + * @param {number} [localPort] + * @returns {Promise<{ localPort: number, stop: () => void }>} + */ +export async function startMcPortForward(localPort = MC_LOCAL_PORT) { + const { child } = await startPortForward(localPort, MC_SERVICE, MC_PORT, { timeoutMs: 60_000 }); + + const stop = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }; + + await waitForHttp(async () => { + const res = await fetch(`http://127.0.0.1:${localPort}/api/v1alpha1/`, { + redirect: 'manual', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + // API-style unauthenticated request should get 401, not an OIDC redirect. + if (res.status === 401 || (res.status >= 200 && res.status < 500)) { + return res; + } + throw new Error(`Unexpected status ${res.status}`); + }); + + return { localPort, stop }; +} + +/** + * @param {number} localPort + * @param {string} path + * @param {RequestInit} [init] + */ +export async function mcFetch(localPort, path, init = {}) { + return fetch(`http://127.0.0.1:${localPort}${path}`, { + redirect: 'manual', + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + ...init, + }); +} diff --git a/tests/integration/helpers/bootstrap.js b/tests/integration/helpers/bootstrap.js new file mode 100644 index 00000000..4bf4a6cd --- /dev/null +++ b/tests/integration/helpers/bootstrap.js @@ -0,0 +1,397 @@ +/* + * Bootstrap YAML generation and backbone site helpers for integration tests. + */ + +import * as common from "../../../modules/src/common.js" +import { ToYaml } from "../../../modules/src/util.js" +import * as resourceTemplates from "../../../components/management-controller/src/resource-templates.js" +import { + MC_DEPLOYMENT, + ROUTER_LABEL_SELECTOR, + SC_IMAGE, + NAMESPACE, + SITE_CONTROLLER_DEPLOYMENT, + SITE_NAMESPACE, + TEST_MANAGE_AP_ID, + TEST_MANAGE_AP_TLS_SECRET, + TEST_SITE_CERT_SECRET, + TEST_SITE_ID, + TEST_SITE_NAME, +} from "../kind/config.js" +import { kubectl, getPodLogs } from "./kubectl.js" +import { psql } from "./postgres.js" + +/** + * @param {string} name + * @param {string} [namespace] + * @returns {{ data: Record }} + */ +function readSecretData(name, namespace = NAMESPACE) { + const { stdout } = kubectl(["get", "secret", name, "-o", "json"], { + namespace, + }) + const secret = JSON.parse(stdout) + return { data: secret.data } +} + +/** + * Build bootstrap YAML for initial site bring-up. + * Includes manage AP TLS secret (named skx-access-{apId}) and RouterAccess once seed has issued the cert. + * @param {string} [siteId] + * @returns {string} + */ +export function generateBootstrapYaml(siteId = TEST_SITE_ID) { + const siteSecret = readSecretData(TEST_SITE_CERT_SECRET) + const manageApSecret = readSecretData(TEST_MANAGE_AP_TLS_SECRET) + + return ToYaml([ + resourceTemplates.ServiceAccount(), + resourceTemplates.BackboneRole(), + resourceTemplates.RoleBinding(), + resourceTemplates.Deployment(siteId, true, "sk2", SC_IMAGE), + resourceTemplates.Secret( + siteSecret, + `skx-site-${siteId}`, + common.INJECT_TYPE_SITE, + `tls-site-${siteId}`, + ), + resourceTemplates.Secret( + manageApSecret, + TEST_MANAGE_AP_TLS_SECRET, + common.INJECT_TYPE_ACCESS_POINT, + `tls-server-${TEST_MANAGE_AP_ID}`, + ), + resourceTemplates.AccessPointCR(TEST_MANAGE_AP_ID, { + kind: "manage", + accessType: "local", + }), + // Network must exist before Site (multi-van). + resourceTemplates.NetworkCR("mbone"), + resourceTemplates.BackboneSite(TEST_SITE_NAME, siteId), + ]) +} + +/** + * @param {string} [namespace] + * @returns {boolean} + */ +function siteControllerDeployed(namespace = SITE_NAMESPACE) { + const { status } = kubectl( + ["get", "deployment", SITE_CONTROLLER_DEPLOYMENT, "-o", "name"], + { namespace, allowFailure: true }, + ) + return status === 0 +} + +/** + * @param {string} [namespace] + * @returns {boolean} + */ +function skupperRouterRunning(namespace = SITE_NAMESPACE) { + const { stdout, status } = kubectl( + [ + "get", + "pods", + "-l", + ROUTER_LABEL_SELECTOR, + "-o", + "jsonpath={.items[0].status.phase}", + ], + { namespace, allowFailure: true }, + ) + return status === 0 && stdout === "Running" +} + +/** + * Whether bootstrap YAML should be applied (cluster-first, not only DB ready-bootstrap). + * @param {string} [siteId] + * @param {string} [namespace] + * @returns {boolean} + */ +export function needsBackboneBootstrap( + siteId = TEST_SITE_ID, + namespace = SITE_NAMESPACE, +) { + const state = interiorSiteDeploymentState(siteId) + + if (!siteControllerDeployed(namespace)) { + return true + } + + if (state === "deployed") { + return false + } + + return !skupperRouterRunning(namespace) +} + +/** + * Remove Skupper/site-controller resources before a fresh bootstrap apply. + * @param {string} [namespace] + */ +export function cleanupBackboneSiteResources(namespace = SITE_NAMESPACE) { + kubectl( + [ + "delete", + "deployment", + "skupper-router", + SITE_CONTROLLER_DEPLOYMENT, + "--ignore-not-found=true", + ], + { namespace, allowFailure: true }, + ) + kubectl(["delete", "routeraccess", "--all", "--ignore-not-found=true"], { + namespace, + allowFailure: true, + }) + kubectl(["delete", "site,network", "--all", "--ignore-not-found=true"], { + namespace, + allowFailure: true, + }) +} + +/** + * @param {string} yamlText + * @param {string} [namespace] + */ +export function kubectlApplyYaml(yamlText, namespace = SITE_NAMESPACE) { + kubectl(["apply", "-f", "-"], { namespace, input: yamlText }) +} + +/** + * @param {string} siteId + * @returns {string} + */ +function interiorSiteDeploymentState(siteId) { + const escaped = siteId.replace(/'/g, "''") + return psql( + `SELECT deploymentstate FROM interiorsites WHERE id = '${escaped}';`, + ).trim() +} + +/** + * @param {string} apId + * @returns {{ hostname: string | null, port: string | null, lifecycle: string | null }} + */ +export function accessPointRow(apId) { + const escaped = apId.replace(/'/g, "''") + const out = psql( + `SELECT hostname, port, lifecycle FROM backboneaccesspoints WHERE id = '${escaped}';`, + ).trim() + if (!out) { + return { hostname: null, port: null, lifecycle: null } + } + const [hostname, port, lifecycle] = out.split("|") + return { + hostname: hostname || null, + port: port || null, + lifecycle: lifecycle || null, + } +} + +/** + * Wait until a label selector has at least one Running pod. + * @param {string} labelSelector + * @param {string} [namespace] + * @param {number} [timeoutMs] + */ +export async function waitForRunningPod( + labelSelector, + namespace = SITE_NAMESPACE, + timeoutMs = 300_000, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const { stdout, status } = kubectl( + [ + "get", + "pods", + "-l", + labelSelector, + "-o", + "jsonpath={.items[0].status.phase}", + ], + { namespace, allowFailure: true }, + ) + if (status === 0 && stdout === "Running") { + return + } + await new Promise((r) => setTimeout(r, 3000)) + } + const detail = describePodFailure(labelSelector, namespace) + throw new Error( + `Timed out waiting for Running pod (${labelSelector}) in ${namespace}. ${detail}`, + ) +} + +/** + * @param {string} labelSelector + * @param {string} namespace + * @returns {string} + */ +function describePodFailure(labelSelector, namespace) { + const { stdout, status } = kubectl( + ["get", "pods", "-l", labelSelector, "-o", "json"], + { namespace, allowFailure: true }, + ) + if (status !== 0 || !stdout) { + return "No pod found." + } + const pod = JSON.parse(stdout).items?.[0] + if (!pod) { + return "No pod found." + } + const init = pod.status?.initContainerStatuses?.[0] + const parts = [`pod=${pod.metadata.name}`, `phase=${pod.status?.phase}`] + if (init?.state?.waiting?.reason) { + parts.push(`init=${init.state.waiting.reason}`) + } + if (init?.state?.terminated?.reason) { + parts.push( + `init=${init.state.terminated.reason} exit=${init.state.terminated.exitCode}`, + ) + } + try { + const logs = kubectl( + ["logs", pod.metadata.name, "-c", "config-init", "--tail=20"], + { namespace, allowFailure: true }, + ).stdout + if (logs) { + parts.push( + `config-init: ${logs.trim().split("\n").slice(-3).join(" | ")}`, + ) + } + } catch { + // ignore log fetch errors + } + return parts.join("; ") +} + +/** + * Poll pod logs until all startup markers appear or timeout. + * @param {string} labelSelector + * @param {string[]} markers + * @param {string} [namespace] + * @param {number} [timeoutMs] + * @param {RegExp} [failurePattern] + * @returns {Promise} + */ +export async function waitForPodLogMarkers( + labelSelector, + markers, + namespace = SITE_NAMESPACE, + timeoutMs = 300_000, + failurePattern = /initialization failed/i, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const logs = getPodLogs(labelSelector, namespace) + if (failurePattern.test(logs)) { + throw new Error( + `Pod startup failed (${labelSelector}): matched ${failurePattern}`, + ) + } + if (markers.every((marker) => logs.includes(marker))) { + return logs + } + await new Promise((r) => setTimeout(r, 3000)) + } + throw new Error( + `Timed out waiting for log markers on ${labelSelector}: ${markers.join(", ")}`, + ) +} + +/** + * Poll MC logs until pattern matches or timeout. + * @param {RegExp} pattern + * @param {number} [timeoutMs] + */ +export async function waitForMcLog(pattern, timeoutMs = 300_000) { + const selector = `app.kubernetes.io/instance=${MC_DEPLOYMENT}` + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const logs = getPodLogs(selector) + if (pattern.test(logs)) { + return logs + } + await new Promise((r) => setTimeout(r, 5000)) + } + throw new Error(`Timed out waiting for MC log matching ${pattern}`) +} + +/** + * @param {string} name Secret metadata.name (exact match). + * @param {string} [namespace] + * @returns {boolean} + */ +export function secretExists(name, namespace = SITE_NAMESPACE) { + const { status } = kubectl(["get", "secret", name, "-o", "name"], { + namespace, + allowFailure: true, + }) + return status === 0 +} + +/** + * @param {string} name + * @param {string} [namespace] + * @returns {boolean} + */ +export function secretHasTlsCert(name, namespace = SITE_NAMESPACE) { + const { stdout, status } = kubectl( + ["get", "secret", name, "-o", String.raw`jsonpath={.data.tls\.crt}`], + { namespace, allowFailure: true }, + ) + return status === 0 && stdout.length > 0 +} + +/** + * Poll until a Secret exists or timeout. + * @param {string} name + * @param {string} [namespace] + * @param {number} [timeoutMs] + */ +export async function waitForSecret( + name, + namespace = SITE_NAMESPACE, + timeoutMs = 300_000, +) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (secretExists(name, namespace)) { + return + } + await new Promise((r) => setTimeout(r, 5000)) + } + throw new Error(`Timed out waiting for secret ${name} in ${namespace}`) +} + +/** + * Poll until a backbone access point row matches expected fields. + * @param {string} apId + * @param {{ hostname?: string, port?: string, lifecycle?: string }} [expected] + * @param {number} [timeoutMs] + */ +export async function waitForAccessPointRow( + apId, + expected = {}, + timeoutMs = 300_000, +) { + const deadline = Date.now() + timeoutMs + let last = accessPointRow(apId) + while (Date.now() < deadline) { + last = accessPointRow(apId) + const hostnameOk = + expected.hostname === undefined || last.hostname === expected.hostname + const portOk = expected.port === undefined || last.port === expected.port + const lifecycleOk = + expected.lifecycle === undefined || last.lifecycle === expected.lifecycle + if (last.hostname && last.port && hostnameOk && portOk && lifecycleOk) { + return last + } + await new Promise((r) => setTimeout(r, 5000)) + } + throw new Error( + `Timed out waiting for access point ${apId}. Last: ${JSON.stringify(last)}; expected: ${JSON.stringify(expected)}`, + ) +} diff --git a/tests/integration/helpers/keycloak.js b/tests/integration/helpers/keycloak.js new file mode 100644 index 00000000..7df97c9a --- /dev/null +++ b/tests/integration/helpers/keycloak.js @@ -0,0 +1,105 @@ +/* + * Keycloak token helper for integration tests (password grant via port-forward). + */ + +import { + KEYCLOAK_CLIENT_ID, + KEYCLOAK_CLIENT_SECRET, + KEYCLOAK_LOCAL_PORT, + KEYCLOAK_PORT, + KEYCLOAK_REALM, + KEYCLOAK_SERVICE, + KEYCLOAK_ADMIN_USER, + KEYCLOAK_ADMIN_PASSWORD, + KEYCLOAK_VIEWER_USER, + KEYCLOAK_VIEWER_PASSWORD, +} from '../kind/config.js'; +import { startPortForward, waitForHttp } from './kubectl.js'; + +const TOKEN_TIMEOUT_MS = 15_000; + +/** + * Start port-forward to the in-cluster Keycloak Service. + * @param {number} [localPort] + * @returns {Promise<{ localPort: number, stop: () => void }>} + */ +export async function startKeycloakPortForward(localPort = KEYCLOAK_LOCAL_PORT) { + const { child } = await startPortForward(localPort, KEYCLOAK_SERVICE, KEYCLOAK_PORT, { + timeoutMs: 120_000, + }); + + const stop = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }; + + await waitForHttp(async () => { + const res = await fetch( + `http://127.0.0.1:${localPort}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration`, + { signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS) }, + ); + if (!res.ok) { + throw new Error(`Keycloak discovery returned ${res.status}`); + } + return res; + }); + + return { localPort, stop }; +} + +/** + * Obtain an access token using the resource-owner password grant. + * @param {number} localPort Port-forward local port + * @param {string} username + * @param {string} password + * @returns {Promise} access_token JWT + */ +export async function fetchAccessToken(localPort, username, password) { + const params = new URLSearchParams({ + grant_type: 'password', + client_id: KEYCLOAK_CLIENT_ID, + client_secret: KEYCLOAK_CLIENT_SECRET, + username, + password, + }); + + const res = await fetch( + `http://127.0.0.1:${localPort}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS), + }, + ); + + const body = await res.text(); + if (!res.ok) { + throw new Error(`Keycloak token request failed (${res.status}): ${body}`); + } + + const json = JSON.parse(body); + if (!json.access_token) { + throw new Error(`Keycloak token response missing access_token: ${body}`); + } + return json.access_token; +} + +/** @param {number} localPort */ +export function fetchAdminAccessToken(localPort) { + return fetchAccessToken(localPort, KEYCLOAK_ADMIN_USER, KEYCLOAK_ADMIN_PASSWORD); +} + +/** @param {number} localPort */ +export function fetchViewerAccessToken(localPort) { + return fetchAccessToken(localPort, KEYCLOAK_VIEWER_USER, KEYCLOAK_VIEWER_PASSWORD); +} + +/** + * @param {string} accessToken + * @returns {{ Authorization: string }} + */ +export function bearerAuth(accessToken) { + return { Authorization: `Bearer ${accessToken}` }; +} diff --git a/tests/integration/helpers/kubectl.js b/tests/integration/helpers/kubectl.js new file mode 100644 index 00000000..31757f24 --- /dev/null +++ b/tests/integration/helpers/kubectl.js @@ -0,0 +1,301 @@ +/* + * kubectl / helmfile helpers for integration tests. + */ + +import { spawnSync, spawn } from 'node:child_process'; +import { CLUSTER_NAME, KUBECTL_CONTEXT, NAMESPACE } from '../kind/config.js'; + +/** + * @param {string[]} args + * @param {{ namespace?: string, context?: string, input?: string, allowFailure?: boolean }} [opts] + * @returns {{ stdout: string, stderr: string, status: number | null }} + */ +export function kubectl(args, opts = {}) { + const fullArgs = []; + const ctx = opts.context ?? KUBECTL_CONTEXT; + if (ctx) { + fullArgs.push('--context', ctx); + } + if (opts.namespace ?? NAMESPACE) { + fullArgs.push('-n', opts.namespace ?? NAMESPACE); + } + fullArgs.push(...args); + + const result = spawnSync('kubectl', fullArgs, { + encoding: 'utf8', + input: opts.input, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (result.status !== 0 && !opts.allowFailure) { + const err = new Error( + `kubectl ${fullArgs.join(' ')} failed (${result.status}): ${result.stderr || result.stdout}`, + ); + err.result = result; + throw err; + } + + return { + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + status: result.status, + }; +} + +/** + * @param {string} resource + * @param {string} name + * @param {string} condition + * @param {number} timeoutSec + */ +export function kubectlWait(resource, name, condition, timeoutSec = 300) { + kubectl([ + 'wait', + `--for=condition=${condition}`, + `${resource}/${name}`, + `--timeout=${timeoutSec}s`, + ]); +} + +/** + * @param {string} selector + * @param {number} timeoutSec + */ +export function waitDeploymentReady(name, timeoutSec = 300) { + kubectlWait('deployment', name, 'Available', timeoutSec); +} + +/** + * @returns {string} First Running pod name for label selector. + * @param {string} labelSelector + * @param {string} [namespace] + */ +export function getPodName(labelSelector, namespace) { + const { stdout } = kubectl( + [ + 'get', + 'pods', + '-l', + labelSelector, + '-o', + 'jsonpath={.items[0].metadata.name}', + ], + { namespace }, + ); + if (!stdout) { + throw new Error(`No pod found for selector ${labelSelector}`); + } + return stdout; +} + +/** + * @param {string} podName + * @param {string[]} containerArgs + * @param {{ namespace?: string }} [opts] + */ +export function kubectlExec(podName, containerArgs, opts = {}) { + const { stdout } = kubectl(['exec', podName, '--', ...containerArgs], opts); + return stdout; +} + +/** + * @param {string} labelSelector + * @param {string} [namespace] + * @returns {string} + */ +export function getPodLogs(labelSelector, namespace) { + const pod = getPodName(labelSelector, namespace); + const { stdout } = kubectl(['logs', pod], { namespace }); + return stdout; +} + +/** + * @param {number} localPort + * @param {string} serviceName + * @param {number} remotePort + * @param {{ namespace?: string, timeoutMs?: number }} [opts] + * @returns {Promise<{ child: import('node:child_process').ChildProcess, localPort: number }>} + */ +export function startPortForward(localPort, serviceName, remotePort, opts = {}) { + const timeoutMs = opts.timeoutMs ?? 60_000; + const ns = opts.namespace ?? NAMESPACE; + return new Promise((resolve, reject) => { + const child = spawn( + 'kubectl', + [ + '--context', + KUBECTL_CONTEXT, + '-n', + ns, + 'port-forward', + '--address', + '127.0.0.1', + `svc/${serviceName}`, + `${localPort}:${remotePort}`, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + let settled = false; + const fail = (err) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (!child.killed) { + child.kill('SIGTERM'); + } + reject(err); + }; + + const timer = setTimeout( + () => fail(new Error(`port-forward to ${serviceName} did not become ready within ${timeoutMs}ms`)), + timeoutMs, + ); + + const onOutput = (chunk) => { + const text = chunk.toString(); + if (text.includes('Forwarding from') || text.includes('Handling connection for')) { + clearTimeout(timer); + settled = true; + resolve({ child, localPort }); + } + if (text.includes('unable to forward') || text.includes('error forwarding port')) { + fail(new Error(text.trim())); + } + }; + + child.stdout.on('data', onOutput); + child.stderr.on('data', onOutput); + child.on('error', fail); + child.on('exit', (code, signal) => { + if (!settled) { + fail(new Error(`port-forward exited (code=${code}, signal=${signal})`)); + } + }); + }); +} + +/** + * Port-forward to a pod (site-controller has no Service). + * @param {number} localPort + * @param {string} podName + * @param {number} remotePort + * @param {string} [namespace] + * @param {number} [timeoutMs] + */ +export function startPodPortForward(localPort, podName, remotePort, namespace, timeoutMs = 60_000) { + return new Promise((resolve, reject) => { + const child = spawn( + 'kubectl', + [ + '--context', + KUBECTL_CONTEXT, + '-n', + namespace ?? NAMESPACE, + 'port-forward', + '--address', + '127.0.0.1', + `pod/${podName}`, + `${localPort}:${remotePort}`, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + + let settled = false; + const fail = (err) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + if (!child.killed) { + child.kill('SIGTERM'); + } + reject(err); + }; + + const timer = setTimeout( + () => fail(new Error(`port-forward to pod/${podName} did not become ready within ${timeoutMs}ms`)), + timeoutMs, + ); + + const onOutput = (chunk) => { + const text = chunk.toString(); + if (text.includes('Forwarding from') || text.includes('Handling connection for')) { + clearTimeout(timer); + settled = true; + resolve({ child, localPort }); + } + if (text.includes('unable to forward') || text.includes('error forwarding port')) { + fail(new Error(text.trim())); + } + }; + + child.stdout.on('data', onOutput); + child.stderr.on('data', onOutput); + child.on('error', fail); + child.on('exit', (code, signal) => { + if (!settled) { + fail(new Error(`port-forward exited (code=${code}, signal=${signal})`)); + } + }); + }); +} + +/** @param {unknown} err */ +function isRetryableHttpError(err) { + const codes = new Set(['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET']); + /** @param {unknown} e */ + const codeOf = (e) => (e && typeof e === 'object' && 'code' in e ? e.code : undefined); + if (codes.has(codeOf(err))) { + return true; + } + if (codes.has(codeOf(err?.cause))) { + return true; + } + const msg = String(err?.message ?? err); + return msg.includes('fetch failed') || msg.includes('ECONNRESET'); +} + +/** + * Poll until port-forward target responds or timeout. + * @param {() => Promise} probe + * @param {number} timeoutMs + */ +export async function waitForHttp(probe, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const res = await probe(); + return res; + } catch (e) { + lastError = e; + if (!isRetryableHttpError(e)) { + throw e; + } + await new Promise((r) => setTimeout(r, 500)); + } + } + throw lastError ?? new Error('HTTP probe timed out'); +} + +/** @returns {boolean} */ +export function clusterExists() { + const result = spawnSync('kind', ['get', 'clusters'], { encoding: 'utf8' }); + if (result.status !== 0) { + return false; + } + return (result.stdout || '').split(/\s+/).includes(CLUSTER_NAME); +} + +/** Fail fast when integration tests run without a cluster. */ +export function requireCluster() { + if (!clusterExists()) { + throw new Error( + `Kind cluster "${CLUSTER_NAME}" not found. Run: pnpm run test:integration:local`, + ); + } +} diff --git a/tests/integration/helpers/postgres.js b/tests/integration/helpers/postgres.js new file mode 100644 index 00000000..843d17f5 --- /dev/null +++ b/tests/integration/helpers/postgres.js @@ -0,0 +1,137 @@ +/* + * PostgreSQL assertions via kubectl exec into the Bitnami primary pod. + */ + +import { + POSTGRES_ADMIN_PASSWORD_KEY, + POSTGRES_DB, + POSTGRES_RELEASE, + POSTGRES_SECRET, + POSTGRES_USER, + TEST_SITE_ID, +} from '../kind/config.js'; +import { kubectl, kubectlExec, getPodName } from './kubectl.js'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const cache = { postgresPassword: /** @type {string | undefined} */ (undefined) }; + +/** + * Superuser password from the postgres-credentials Secret (matches cluster-up.sh). + * @returns {string} + */ +export function getPostgresPassword() { + if (!cache.postgresPassword) { + const { stdout } = kubectl([ + 'get', + 'secret', + POSTGRES_SECRET, + '-o', + `jsonpath={.data.${POSTGRES_ADMIN_PASSWORD_KEY}}`, + ]); + cache.postgresPassword = Buffer.from(stdout, 'base64').toString('utf8'); + } + return cache.postgresPassword; +} + +/** Escape a string for use inside single-quoted bash argument. */ +function shellSingleQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + +/** + * Run psql as the postgres superuser inside the primary pod. + * @param {string} sql + * @returns {string} + */ +export function psql(sql) { + const pod = postgresPodName(); + const escaped = sql.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const cmd = [ + `PGPASSWORD=${shellSingleQuote(getPostgresPassword())}`, + 'psql', + '-h', + '127.0.0.1', + '-U', + POSTGRES_USER, + '-d', + POSTGRES_DB, + '-tAc', + `"${escaped}"`, + ].join(' '); + return kubectlExec(pod, ['bash', '-lc', cmd]); +} + +/** + * @returns {string} + */ +export function postgresPodName() { + return getPodName(`app.kubernetes.io/instance=${POSTGRES_RELEASE}`); +} + +/** + * @param {string} table PascalCase name from db-setup.sql (stored lowercase in PostgreSQL). + * @returns {boolean} + */ +export function tableExists(table) { + const safe = table.replace(/'/g, "''"); + const out = psql( + `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = lower('${safe}'));`, + ); + return out.trim() === 't'; +} + +/** + * @returns {number} + */ +export function configurationRowCount() { + const out = psql('SELECT COUNT(*) FROM configuration WHERE id = 0;'); + return Number(out.trim()); +} + +/** + * @param {string} controllerName + * @returns {number} + */ +export function managementControllerCount(controllerName) { + const escaped = controllerName.replace(/'/g, "''"); + const out = psql( + `SELECT COUNT(*) FROM managementcontrollers WHERE name = '${escaped}';`, + ); + return Number(out.trim()); +} + +/** + * @param {string} siteId + * @returns {number} + */ +export function interiorSiteCount(siteId) { + const escaped = siteId.replace(/'/g, "''"); + const out = psql(`SELECT COUNT(*) FROM interiorsites WHERE id = '${escaped}';`); + return Number(out.trim()); +} + +/** + * Re-run seed-integration.sh when cluster-up seed failed or was skipped. + * @param {string} [siteId] + */ +export function ensureBackboneSiteSeeded(siteId = TEST_SITE_ID) { + if (interiorSiteCount(siteId) >= 1) { + return; + } + const script = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../kind/scripts/seed-integration.sh', + ); + const result = spawnSync(script, { + stdio: 'inherit', + env: process.env, + }); + if (result.status !== 0) { + throw new Error(`seed-integration.sh failed (exit ${result.status})`); + } + if (interiorSiteCount(siteId) < 1) { + throw new Error(`InteriorSites row ${siteId} still missing after seed-integration.sh`); + } +} diff --git a/tests/integration/helpers/site-api-client.js b/tests/integration/helpers/site-api-client.js new file mode 100644 index 00000000..cb45ef9b --- /dev/null +++ b/tests/integration/helpers/site-api-client.js @@ -0,0 +1,77 @@ +/* + * HTTP client for site-controller API via kubectl port-forward to the site pod. + */ + +import { SITE_LOCAL_PORT, SITE_NAMESPACE } from '../kind/config.js'; +import { getPodName, startPodPortForward, waitForHttp } from './kubectl.js'; + +const PROBE_TIMEOUT_MS = 5_000; +const SITE_API_PORT = 1040; + +/** + * @param {string} labelSelector + * @param {number} [localPort] + * @returns {Promise<{ localPort: number, stop: () => void }>} + */ +export async function startSitePortForward(labelSelector, localPort = SITE_LOCAL_PORT) { + const podName = getPodName(labelSelector, SITE_NAMESPACE); + const { child } = await startPodPortForward(localPort, podName, SITE_API_PORT, SITE_NAMESPACE); + + const stop = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }; + + await waitForHttp(async () => { + const res = await fetch(`http://127.0.0.1:${localPort}/healthz`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (res.ok) { + return res; + } + throw new Error(`Unexpected healthz status ${res.status}`); + }); + + return { localPort, stop }; +} + +/** + * @param {number} localPort + * @param {string} path + * @param {RequestInit} [init] + */ +export async function siteFetch(localPort, path, init = {}) { + return fetch(`http://127.0.0.1:${localPort}${path}`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + ...init, + }); +} + +/** + * Poll hostnames API until the given access point has host and port. + * @param {number} localPort + * @param {string} apId + * @param {number} [timeoutMs] + * @returns {Promise<{ body: Record, entry: { host: string, port: number | string } }>} + */ +export async function waitForHostnamesEntry(localPort, apId, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + let lastBody = {}; + while (Date.now() < deadline) { + const res = await siteFetch(localPort, '/api/v1alpha1/hostnames', { + headers: { Accept: 'application/json' }, + }); + if (res.status === 200) { + lastBody = await res.json(); + const entry = lastBody[apId]; + if (entry?.host && entry?.port) { + return { body: lastBody, entry }; + } + } + await new Promise((r) => setTimeout(r, 3000)); + } + throw new Error( + `Timed out waiting for hostnames entry ${apId}. Last body: ${JSON.stringify(lastBody)}`, + ); +} diff --git a/tests/integration/kind/README.md b/tests/integration/kind/README.md new file mode 100644 index 00000000..00746f3f --- /dev/null +++ b/tests/integration/kind/README.md @@ -0,0 +1,139 @@ +# Kind integration tests + +End-to-end tests run against a local [Kind](https://kind.sigs.k8s.io/) cluster. They exercise the **management controller** with **PostgreSQL**, **cert-manager**, and **Keycloak**; a **backbone site** in namespace `site-a` (Skupper, site-controller, bootstrap YAML, hostnames API, state-sync, AMQP manage link); and **authenticated REST** via Keycloak Bearer tokens. + +## Prerequisites + +- [kind](https://kind.sigs.k8s.io/) +- [kubectl](https://kubernetes.io/docs/tasks/tools/) +- [helm](https://helm.sh/) and [helmfile](https://github.com/helmfile/helmfile) +- [helm-diff](https://github.com/databus23/helm-diff) plugin +- Docker (build + `kind load docker-image`) +- Network access during `cluster-up` (Skupper install YAML + multi-van CRDs from GitHub) + +## Quick start + +From the repo root: + +```shell +pnpm install +pnpm run test:integration:local +``` + +This creates cluster `vms-kind`, builds MC and site-controller images, deploys the stack via `helmfile -e kind`, installs Skupper, seeds backbone rows, runs all integration specs, then deletes the cluster (`pnpm run cluster:down`). Cluster teardown runs even when tests fail. + +If the cluster is already up: + +```shell +pnpm run test:integration +``` + +Run a single spec file: + +```shell +pnpm exec vitest run --project integration tests/integration/kind/specs/backbone-bootstrap.test.js +``` + +## Manual cluster lifecycle + +```shell +pnpm run cluster:up # create cluster and deploy the full stack +pnpm run test:integration +pnpm run cluster:down # helmfile destroy + delete cluster +``` + +Port-forward the management API (optional): + +```shell +kubectl --context kind-vms-kind -n vms-test port-forward svc/management-server 18085:8085 +``` + +## Layout + +| Path | Purpose | +| --------------------------- | -------------------------------------------------------------------------------------- | +| `kind-config.yaml` | Single control-plane node; host port **8085** mapped to node **30085** | +| `helmfile/values-kind.yaml` | Overlay: `vms-test` namespace, local image, Postgres without PV | +| `fixtures/keycloak/` | Keycloak 26 dev mode + imported `vms-test` realm (in-cluster `http://keycloak:8080`) | +| `fixtures/backbone/` | SQL seed for integration backbone site | +| `scripts/` | `cluster-up`, `cluster-down`, `install-skupper`, `seed-integration`, `wait-ready` | +| `specs/` | Vitest integration specs | + +Helmfile environment **`kind`** is defined in `charts/helmfile/helmfile.yaml.gotmpl`. + +## Environment variables + +| Variable | Default | Description | +| -------------------------- | -------------------------------- | ------------------------------------------------ | +| `VMS_KIND_CLUSTER` | `vms-kind` | Kind cluster name | +| `VMS_TEST_NAMESPACE` | `vms-test` | Management stack namespace | +| `VMS_SITE_NAMESPACE` | `site-a` | Backbone site namespace | +| `VMS_MC_IMAGE` | `vms-management-controller:kind` | Local MC image tag | +| `VMS_SC_IMAGE` | `vms-site-controller:kind` | Local site-controller image | +| `VMS_MC_LOCAL_PORT` | `18085` | Local port for MC port-forward | +| `VMS_SITE_LOCAL_PORT` | `11040` | Local port for site-controller port-forward | +| `VMS_SKUPPER_NAMESPACE` | `skupper` | Skupper controller namespace | +| `VMS_POSTGRES_PASSWORD` | `integration-postgres` | Bitnami superuser password | +| `VMS_APP_USER_PASSWORD` | `integration-app-user` | `app_user` role password | +| `VMS_KEYCLOAK_LOCAL_PORT` | `18080` | Local port for Keycloak port-forward in auth tests | +| `VMS_KEYCLOAK_ADMIN_USER` | `integration-admin` | Admin test user (Bearer token in auth spec) | +| `VMS_KEYCLOAK_VIEWER_USER` | `integration-viewer` | Viewer test user (no list roles) | + +`cluster-up.sh` installs cert-manager first, waits for the validating webhook, then PostgreSQL and management-server, then builds the site-controller image, installs Skupper, and runs the SQL seed. + +If `skupper-router` init container `config-init` crash-loops, verify the controller has multi-van **both** `SKUPPER_KUBE_ADAPTOR_IMAGE` and `SKUPPER_ROUTER_IMAGE` set (stock `kube-adaptor:2.2.0` cannot initialize a multi-van router). Re-run `./tests/integration/kind/scripts/install-skupper.sh`. + +Bootstrap YAML includes the manage AP TLS secret as **`skx-access-{accessPointId}`** (same name RouterAccess expects) and a `RouterAccess` CR. cert-manager issues that secret in the MC namespace first; bootstrap copies it into the site namespace. The MC may also push the same secret via AMQP state-sync when the site connects. + +`seed-integration.sh` issues the manage AP server cert under `skx-access-${TEST_MANAGE_AP_ID}` and marks the access point **`ready`** in Postgres (TlsCertificates.ObjectName must match the cert-manager secret name). + +If the access secret is still missing after bootstrap, re-seed and restart the site-controller: + +```shell +./tests/integration/kind/scripts/seed-integration.sh +kubectl -n "${VMS_SITE_NAMESPACE:-site-a}" rollout restart deployment/skupperx-site +``` + +Reset Kubernetes site resources and re-run the backbone spec: + +```shell +kubectl -n "${VMS_SITE_NAMESPACE:-site-a}" delete deployment skupper-router skupperx-site --ignore-not-found +kubectl -n "${VMS_SITE_NAMESPACE:-site-a}" delete routeraccess --all --ignore-not-found +kubectl -n "${VMS_SITE_NAMESPACE:-site-a}" delete site,network --all --ignore-not-found +pnpm exec vitest run --project integration tests/integration/kind/specs/backbone-bootstrap.test.js +``` + +## What the specs cover + +### `mgmt-health` and `mgmt-certs` + +- Postgres tables from `db-setup.sql` exist; `Configuration` row present +- `management-server` deployment ready; pod Running/Ready +- HTTP responds on port-forward (401 for unauthenticated API request) +- cert-manager: `skupperx-root-ca` Ready, `skupperx-root` issuer present +- MC logs contain startup markers; `ManagementControllers` row created + +### `backbone-bootstrap` + +- SQL seed: backbone + interior site ready for bootstrap with manage access point +- Bootstrap YAML applied to `site-a` (generated in-test from `resource-templates`) +- `skupperx-site` deployment and `skupper-router` pod Running +- Site API `GET /api/v1alpha1/hostnames` returns JSON +- Manage access point hostname/port synced to Postgres via state-sync +- Manage AP TLS secret `skx-access-{accessPointId}` present in site namespace +- MC logs `Connecting to Access Point:` (AMQP manage link) + +### `mgmt-auth-api` + +- Keycloak realm `vms-test` with client `vms-management-controller` +- `GET /backbones` returns 401 without token, 403 for viewer without list role +- Admin Bearer token lists seeded backbone and reads `/user/profile` +- `POST /backbones` + `DELETE /backbones/:id` round-trip CRUD + +Test users (password grant via port-forward): `integration-admin` / `integration-admin`, `integration-viewer` / `integration-viewer`. + +## Out of scope + +- Browser OIDC login redirect (MC uses in-cluster `keycloak:8080`; host browser cannot resolve that DNS name) +- Member claim flow, console UI +- CI nightly Kind workflow diff --git a/tests/integration/kind/config.js b/tests/integration/kind/config.js new file mode 100644 index 00000000..7e6c8243 --- /dev/null +++ b/tests/integration/kind/config.js @@ -0,0 +1,86 @@ +/* + * Shared constants for Kind integration tests and cluster scripts. + */ + +export const CLUSTER_NAME = process.env.VMS_KIND_CLUSTER || "vms-kind" +export const NAMESPACE = process.env.VMS_TEST_NAMESPACE || "vms-test" +export const KUBECTL_CONTEXT = `kind-${CLUSTER_NAME}` + +export const MC_IMAGE = + process.env.VMS_MC_IMAGE || "vms-management-controller:kind" +export const MC_DEPLOYMENT = + process.env.VMS_MC_DEPLOYMENT || "management-server" +export const MC_SERVICE = process.env.VMS_MC_SERVICE || "management-server" +export const MC_PORT = Number(process.env.VMS_MC_PORT || 8085) +/** Local port for kubectl port-forward (avoid Kind hostPort 8085 in kind-config.yaml). */ +export const MC_LOCAL_PORT = Number(process.env.VMS_MC_LOCAL_PORT || 18085) + +export const POSTGRES_RELEASE = "postgresql" +export const POSTGRES_USER = "postgres" +export const POSTGRES_DB = process.env.VMS_POSTGRES_DB || "studiodb" +export const POSTGRES_SECRET = "postgres-credentials" +export const POSTGRES_ADMIN_PASSWORD_KEY = "postgres-password" + +export const ROOT_CA_CERT = "skupperx-root-ca" +export const ROOT_ISSUER = "skupperx-root" + +/** backbone site namespace and images. */ +export const SITE_NAMESPACE = process.env.VMS_SITE_NAMESPACE || "site-a" +export const SC_IMAGE = process.env.VMS_SC_IMAGE || "vms-site-controller:kind" +export const SITE_LOCAL_PORT = Number(process.env.VMS_SITE_LOCAL_PORT || 11040) + +export const SITE_CONTROLLER_DEPLOYMENT = "skupperx-site" +export const ROUTER_LABEL_SELECTOR = "application=skupper-router" +export const SITE_CONTROLLER_LABEL = "app.kubernetes.io/name=skupperx-site" + +/** Keycloak OIDC (in-cluster service DNS). */ +export const KEYCLOAK_SERVICE = "keycloak" +export const KEYCLOAK_PORT = 8080 +export const KEYCLOAK_LOCAL_PORT = Number(process.env.VMS_KEYCLOAK_LOCAL_PORT || 18080) +export const KEYCLOAK_REALM = "vms-test" +export const KEYCLOAK_CLIENT_ID = "vms-management-controller" +export const KEYCLOAK_CLIENT_SECRET = "integration-test-secret" +export const KEYCLOAK_ADMIN_USER = + process.env.VMS_KEYCLOAK_ADMIN_USER || "integration-admin" +export const KEYCLOAK_ADMIN_PASSWORD = + process.env.VMS_KEYCLOAK_ADMIN_PASSWORD || "integration-admin" +export const KEYCLOAK_VIEWER_USER = + process.env.VMS_KEYCLOAK_VIEWER_USER || "integration-viewer" +export const KEYCLOAK_VIEWER_PASSWORD = + process.env.VMS_KEYCLOAK_VIEWER_PASSWORD || "integration-viewer" + +/** Fixed UUIDs for deterministic SQL seed and bootstrap YAML (see integration-seed.sql). */ +export const TEST_BACKBONE_ID = "00000000-0000-4000-8000-000000000001" +export const TEST_SITE_ID = "00000000-0000-4000-8000-000000000002" +export const TEST_MANAGE_AP_ID = "00000000-0000-4000-8000-000000000004" +export const TEST_SITE_NAME = "site-a" +export const TEST_SITE_CERT_SECRET = "vms-site-cert-integration" + +/** TLS secret name RouterAccess uses: skx-access-{accessPointId}. */ +export const TEST_MANAGE_AP_TLS_SECRET = `skx-access-${TEST_MANAGE_AP_ID}` + +/** Expected manage access point endpoint after seed-integration.sh (matches integration-seed.sql). */ +export const TEST_MANAGE_AP_HOSTNAME = `skupper-router.${SITE_NAMESPACE}.svc.cluster.local` +export const TEST_MANAGE_AP_PORT = "5671" + +/** Tables created by charts/helmfile/resources/db-setup.sql (smoke subset). */ +export const EXPECTED_TABLES = [ + "Configuration", + "ManagementControllers", + "Backbones", + "InteriorSites", + "TlsCertificates", +] + +/** Log lines emitted during successful mc-main startup. */ +export const MC_STARTUP_LOG_MARKERS = [ + "[Config module starting]", + "[API Server module started]", + "[Management controller initialization completed successfully]", +] + +/** Log lines emitted during successful site-controller startup. */ +export const SC_STARTUP_LOG_MARKERS = [ + "[Ingress Skupper v2 module started]", + "[Site controller initialization completed successfully]", +] diff --git a/tests/integration/kind/config.sh b/tests/integration/kind/config.sh new file mode 100644 index 00000000..94f96ca1 --- /dev/null +++ b/tests/integration/kind/config.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Shared shell configuration for Kind integration scripts. +set -euo pipefail + +export VMS_KIND_CLUSTER="${VMS_KIND_CLUSTER:-vms-kind}" +export VMS_TEST_NAMESPACE="${VMS_TEST_NAMESPACE:-vms-test}" +export VMS_MC_IMAGE="${VMS_MC_IMAGE:-vms-management-controller:kind}" +export VMS_MC_PORT="${VMS_MC_PORT:-8085}" + +export CLUSTER_NAME="${VMS_KIND_CLUSTER}" +export NAMESPACE="${VMS_TEST_NAMESPACE}" +export KUBECTL_CONTEXT="kind-${CLUSTER_NAME}" +export MC_IMAGE="${VMS_MC_IMAGE}" +export MC_PORT="${VMS_MC_PORT}" +export MC_DEPLOYMENT="${VMS_MC_DEPLOYMENT:-management-server}" +export MC_SERVICE="${VMS_MC_SERVICE:-management-server}" +export CERT_MANAGER_NAMESPACE="${VMS_CERT_MANAGER_NAMESPACE:-cert-manager}" +export POSTGRES_STATEFULSET="${VMS_POSTGRES_STATEFULSET:-postgresql}" +export ROOT_CA_CERT="${VMS_ROOT_CA_CERT:-skupperx-root-ca}" +export ROOT_ISSUER="${VMS_ROOT_ISSUER:-skupperx-root}" +export POSTGRES_IMAGE="${VMS_POSTGRES_IMAGE:-bitnami/postgresql:latest}" +export KEYCLOAK_IMAGE="${VMS_KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.0.7}" +export KEYCLOAK_ADMIN_USER="${VMS_KEYCLOAK_ADMIN_USER:-integration-admin}" +export KEYCLOAK_ADMIN_PASSWORD="${VMS_KEYCLOAK_ADMIN_PASSWORD:-integration-admin}" +export KEYCLOAK_VIEWER_USER="${VMS_KEYCLOAK_VIEWER_USER:-integration-viewer}" +export KEYCLOAK_VIEWER_PASSWORD="${VMS_KEYCLOAK_VIEWER_PASSWORD:-integration-viewer}" + +export SITE_NAMESPACE="${VMS_SITE_NAMESPACE:-site-a}" +export SC_IMAGE="${VMS_SC_IMAGE:-vms-site-controller:kind}" +export SITE_CONTROLLER_DEPLOYMENT="${VMS_SITE_CONTROLLER_DEPLOYMENT:-skupperx-site}" +export SKUPPER_NAMESPACE="${VMS_SKUPPER_NAMESPACE:-skupper}" +export SKUPPER_INSTALL_URL="${VMS_SKUPPER_INSTALL_URL:-https://github.com/skupperproject/skupper/releases/download/2.2.0/skupper-cluster-scope.yaml}" +export SKUPPER_CRD_BASE="${VMS_SKUPPER_CRD_BASE:-https://github.com/fgiorgetti/skupper/raw/refs/heads/multi-van/config/crd/bases}" +export SKUPPER_CONTROLLER_IMAGE="${VMS_SKUPPER_CONTROLLER_IMAGE:-quay.io/fgiorgetti/controller:multi-van}" +export SKUPPER_ADAPTOR_IMAGE="${VMS_SKUPPER_ADAPTOR_IMAGE:-quay.io/fgiorgetti/kube-adaptor:multi-van}" +export SKUPPER_ROUTER_IMAGE="${VMS_SKUPPER_ROUTER_IMAGE:-quay.io/tedlross/skupper-router:multi-van}" + +export TEST_SITE_ID="${VMS_TEST_SITE_ID:-00000000-0000-4000-8000-000000000002}" +export TEST_SITE_NAME="${VMS_TEST_SITE_NAME:-site-a}" +export TEST_MANAGE_AP_ID="${VMS_TEST_MANAGE_AP_ID:-00000000-0000-4000-8000-000000000004}" +export TEST_MANAGE_AP_TLS_SECRET="skx-access-${TEST_MANAGE_AP_ID}" +export TEST_SITE_CERT_SECRET="${VMS_TEST_SITE_CERT_SECRET:-vms-site-cert-integration}" + +load_image() { + local image="$1" + if ! docker image inspect "${image}" >/dev/null 2>&1; then + echo "Pulling ${image}..." + docker pull "${image}" + fi + echo "Loading ${image} into Kind..." + if kind load docker-image "${image}" --name "${CLUSTER_NAME}" 2>/dev/null; then + return 0 + fi + echo "kind load failed for ${image}; falling back to ctr import..." + docker save "${image}" | docker exec -i "${CLUSTER_NAME}-control-plane" \ + ctr --namespace=k8s.io images import - +} + + +KIND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export KIND_DIR +export REPO_ROOT="$(cd "${KIND_DIR}/../../.." && pwd)" +export KIND_CONFIG="${KIND_DIR}/kind-config.yaml" diff --git a/tests/integration/kind/fixtures/backbone/integration-seed.sql b/tests/integration/kind/fixtures/backbone/integration-seed.sql new file mode 100644 index 00000000..7f275a80 --- /dev/null +++ b/tests/integration/kind/fixtures/backbone/integration-seed.sql @@ -0,0 +1,83 @@ +-- integration test seed: one backbone, one interior site ready for bootstrap. +-- UUIDs must match tests/integration/kind/config.js +-- +-- The Helm bootstrap root CA (secret skupperx-root-secret) is not tracked in +-- TlsCertificates by the MC; insert rows explicitly for integration tests. + +INSERT INTO TlsCertificates (Id, IsCA, ObjectName, Label, SignedBy) +VALUES ( + '00000000-0000-4000-8000-000000000000'::uuid, + true, + 'skupperx-root-secret', + 'Integration root CA (Kind bootstrap)', + NULL +) +ON CONFLICT (Id) DO NOTHING; + +INSERT INTO TlsCertificates (Id, IsCA, ObjectName, Label, SignedBy) +VALUES ( + '00000000-0000-4000-8000-000000000003'::uuid, + false, + 'vms-site-cert-integration', + 'Integration site client cert', + '00000000-0000-4000-8000-000000000000'::uuid +) +ON CONFLICT (Id) DO NOTHING; + +INSERT INTO TlsCertificates (Id, IsCA, ObjectName, Label, SignedBy) +VALUES ( + '00000000-0000-4000-8000-000000000005'::uuid, + false, + 'skx-access-00000000-0000-4000-8000-000000000004', + 'Integration manage access point cert', + '00000000-0000-4000-8000-000000000000'::uuid +) +ON CONFLICT (Id) DO NOTHING; + +INSERT INTO Backbones (Id, Name, Lifecycle, Certificate) +VALUES ( + '00000000-0000-4000-8000-000000000001'::uuid, + 'integration-backbone', + 'ready', + '00000000-0000-4000-8000-000000000000'::uuid +) +ON CONFLICT (Id) DO UPDATE SET + lifecycle = EXCLUDED.lifecycle, + certificate = EXCLUDED.certificate; + +INSERT INTO InteriorSites (Id, Name, Lifecycle, Certificate, DeploymentState, TargetPlatform, Backbone) +VALUES ( + '00000000-0000-4000-8000-000000000002'::uuid, + 'site-a', + 'ready', + '00000000-0000-4000-8000-000000000003'::uuid, + 'ready-bootstrap', + 'sk2', + '00000000-0000-4000-8000-000000000001'::uuid +) +ON CONFLICT (Id) DO UPDATE SET + lifecycle = EXCLUDED.lifecycle, + deploymentstate = EXCLUDED.deploymentstate, + certificate = EXCLUDED.certificate, + backbone = EXCLUDED.backbone; + +INSERT INTO BackboneAccessPoints (Id, Name, Kind, Lifecycle, InteriorSite, AccessType, Certificate, Hostname, Port) +VALUES ( + '00000000-0000-4000-8000-000000000004'::uuid, + 'manage', + 'manage', + 'ready', + '00000000-0000-4000-8000-000000000002'::uuid, + 'local', + '00000000-0000-4000-8000-000000000005'::uuid, + '', + '5671' +) +ON CONFLICT (Id) DO UPDATE SET + lifecycle = EXCLUDED.lifecycle, + kind = EXCLUDED.kind, + interiorsite = EXCLUDED.interiorsite, + accesstype = EXCLUDED.accesstype, + certificate = EXCLUDED.certificate, + hostname = EXCLUDED.hostname, + port = EXCLUDED.port; diff --git a/tests/integration/kind/fixtures/keycloak/README.md b/tests/integration/kind/fixtures/keycloak/README.md new file mode 100644 index 00000000..a3c25405 --- /dev/null +++ b/tests/integration/kind/fixtures/keycloak/README.md @@ -0,0 +1,7 @@ +# Keycloak integration fixture + +Deploys Keycloak in dev mode with imported **`vms-test`** realm at in-cluster **`http://keycloak:8080`**. + +- MC `keycloak.json`: `auth-server-url` → `http://keycloak:8080` +- `KC_HOSTNAME=http://keycloak:8080` so JWT issuers match in-cluster OIDC discovery +- Test users: `integration-admin` / `integration-admin`, `integration-viewer` / `integration-viewer` diff --git a/tests/integration/kind/fixtures/keycloak/deployment.yaml b/tests/integration/kind/fixtures/keycloak/deployment.yaml new file mode 100644 index 00000000..196e0d2a --- /dev/null +++ b/tests/integration/kind/fixtures/keycloak/deployment.yaml @@ -0,0 +1,79 @@ +# Keycloak for integration tests — imports vms-test realm on startup. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keycloak + labels: + app: keycloak +spec: + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: quay.io/keycloak/keycloak:26.0.7 + imagePullPolicy: IfNotPresent + args: + - start-dev + - --import-realm + env: + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_HOSTNAME_STRICT + value: "false" + - name: KC_HOSTNAME_STRICT_HTTPS + value: "false" + - name: KC_HOSTNAME + value: http://keycloak:8080 + - name: KC_HEALTH_ENABLED + value: "true" + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: admin + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + value: admin + ports: + - containerPort: 8080 + name: http + readinessProbe: + httpGet: + path: /realms/vms-test/.well-known/openid-configuration + port: http + initialDelaySeconds: 20 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 24 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + memory: 2Gi + volumeMounts: + - name: realm-import + mountPath: /opt/keycloak/data/import + readOnly: true + volumes: + - name: realm-import + configMap: + name: keycloak-realm-import +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + labels: + app: keycloak +spec: + type: ClusterIP + selector: + app: keycloak + ports: + - name: http + port: 8080 + targetPort: http diff --git a/tests/integration/kind/fixtures/keycloak/realm-vms-test.json b/tests/integration/kind/fixtures/keycloak/realm-vms-test.json new file mode 100644 index 00000000..fccc8bfb --- /dev/null +++ b/tests/integration/kind/fixtures/keycloak/realm-vms-test.json @@ -0,0 +1,114 @@ +{ + "realm": "vms-test", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "roles": { + "realm": [ + { "name": "admin", "composite": true, "composites": { "realm": [ + "backbone-owner", "van-owner", "application-owner", "application-deployer", + "certificate-manager", "can-list-backbones", "can-list-accesspoints-backbone", + "can-list-applications", "can-list-vans" + ] } }, + { "name": "backbone-owner", "composite": true, "composites": { "realm": [ + "can-list-backbones", "can-list-accesspoints-backbone" + ] } }, + { "name": "van-owner", "composite": true, "composites": { "realm": [ + "can-list-accesspoints-backbone", "can-list-backbones", "can-list-vans" + ] } }, + { "name": "application-owner", "composite": true, "composites": { "realm": [ + "can-list-applications" + ] } }, + { "name": "application-deployer", "composite": true, "composites": { "realm": [ + "can-list-applications", "can-list-vans" + ] } }, + { "name": "certificate-manager" }, + { "name": "can-list-backbones" }, + { "name": "can-list-accesspoints-backbone" }, + { "name": "can-list-applications" }, + { "name": "can-list-vans" } + ] + }, + "groups": [ + { "name": "integration-group", "path": "/integration-group" } + ], + "clients": [ + { + "clientId": "vms-management-controller", + "name": "VMS Management Controller", + "enabled": true, + "publicClient": false, + "bearerOnly": false, + "clientAuthenticatorType": "client-secret", + "secret": "integration-test-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "implicitFlowEnabled": false, + "serviceAccountsEnabled": false, + "consentRequired": false, + "fullScopeAllowed": true, + "redirectUris": [ + "http://management-server:8085/*", + "http://management-server.vms-test.svc.cluster.local:8085/*" + ], + "webOrigins": ["*"], + "protocol": "openid-connect", + "protocolMappers": [ + { + "name": "clientGroups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "false", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientGroups" + } + } + ] + } + ], + "users": [ + { + "username": "integration-admin", + "enabled": true, + "emailVerified": true, + "firstName": "Integration", + "lastName": "Admin", + "email": "integration-admin@vms-test.local", + "credentials": [ + { + "type": "password", + "value": "integration-admin", + "temporary": false + } + ], + "realmRoles": ["admin"], + "groups": ["/integration-group"] + }, + { + "username": "integration-viewer", + "enabled": true, + "emailVerified": true, + "firstName": "Integration", + "lastName": "Viewer", + "email": "integration-viewer@vms-test.local", + "credentials": [ + { + "type": "password", + "value": "integration-viewer", + "temporary": false + } + ], + "realmRoles": [] + } + ] +} diff --git a/tests/integration/kind/fixtures/secrets/keycloak.json b/tests/integration/kind/fixtures/secrets/keycloak.json new file mode 100644 index 00000000..1e538408 --- /dev/null +++ b/tests/integration/kind/fixtures/secrets/keycloak.json @@ -0,0 +1,9 @@ +{ + "realm": "vms-test", + "auth-server-url": "http://keycloak:8080", + "ssl-required": "none", + "resource": "vms-management-controller", + "credentials": { + "secret": "integration-test-secret" + } +} diff --git a/tests/integration/kind/kind-config.yaml b/tests/integration/kind/kind-config.yaml new file mode 100644 index 00000000..61d1f775 --- /dev/null +++ b/tests/integration/kind/kind-config.yaml @@ -0,0 +1,16 @@ +# Single-node Kind cluster for Skupper-X integration tests. +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: vms-kind +nodes: + - role: control-plane + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + extraPortMappings: + - containerPort: 30085 + hostPort: 8085 + protocol: TCP diff --git a/tests/integration/kind/scripts/cluster-down.sh b/tests/integration/kind/scripts/cluster-down.sh new file mode 100755 index 00000000..1e7561d6 --- /dev/null +++ b/tests/integration/kind/scripts/cluster-down.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Tear down Kind cluster and helmfile releases. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config.sh +source "${ROOT}/config.sh" + +if kind get clusters 2>/dev/null | grep -qx "${CLUSTER_NAME}"; then + if command -v helmfile >/dev/null 2>&1; then + echo "Destroying helmfile releases (-e kind)..." + ( + cd "${REPO_ROOT}/charts/helmfile" + helmfile -e kind destroy || true + ) + fi + + echo "Deleting Kind cluster ${CLUSTER_NAME}..." + kind delete cluster --name "${CLUSTER_NAME}" +else + echo "Kind cluster ${CLUSTER_NAME} does not exist; nothing to do." +fi diff --git a/tests/integration/kind/scripts/cluster-up.sh b/tests/integration/kind/scripts/cluster-up.sh new file mode 100755 index 00000000..fef02d96 --- /dev/null +++ b/tests/integration/kind/scripts/cluster-up.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Create Kind cluster, build/load MC image, deploy stack via helmfile -e kind. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config.sh +source "${ROOT}/config.sh" + +if ! command -v kind >/dev/null 2>&1; then + echo "kind is required but not installed" >&2 + exit 1 +fi +if ! command -v helm >/dev/null 2>&1; then + echo "helm is required but not installed" >&2 + exit 1 +fi +if ! command -v helmfile >/dev/null 2>&1; then + echo "helmfile is required but not installed" >&2 + exit 1 +fi +if ! command -v kubectl >/dev/null 2>&1; then + echo "kubectl is required but not installed" >&2 + exit 1 +fi +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required but not installed" >&2 + exit 1 +fi + +if kind get clusters 2>/dev/null | grep -qx "${CLUSTER_NAME}"; then + echo "Kind cluster ${CLUSTER_NAME} already exists" +else + echo "Creating Kind cluster ${CLUSTER_NAME}..." + kind create cluster --name "${CLUSTER_NAME}" --config "${KIND_CONFIG}" +fi + +echo "Building ${MC_IMAGE} from Containerfile target vms-management-controller..." +docker build -f Containerfile --target vms-management-controller -t "${MC_IMAGE}" "${REPO_ROOT}" + +echo "Loading image into Kind..." +kind load docker-image "${MC_IMAGE}" --name "${CLUSTER_NAME}" + +echo "Pre-loading dependency images into Kind (avoids Docker Hub rate limits in-cluster)..." +load_image "${POSTGRES_IMAGE}" +load_image "${KEYCLOAK_IMAGE}" + +echo "Ensuring namespace ${NAMESPACE}..." +kubectl --context "${KUBECTL_CONTEXT}" create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl --context "${KUBECTL_CONTEXT}" apply -f - + +echo "Applying Keycloak realm import configmap..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" create configmap keycloak-realm-import \ + --from-file=vms-test.json="${KIND_DIR}/fixtures/keycloak/realm-vms-test.json" \ + --dry-run=client -o yaml | kubectl --context "${KUBECTL_CONTEXT}" apply -f - + +echo "Applying Keycloak fixture..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" apply -f "${KIND_DIR}/fixtures/keycloak/deployment.yaml" +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" set image deployment/keycloak keycloak="${KEYCLOAK_IMAGE}" --record=false 2>/dev/null || true + +echo "Waiting for Keycloak before management-server..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" rollout status deployment/keycloak --timeout=300s + +POSTGRES_PASSWORD="${VMS_POSTGRES_PASSWORD:-integration-postgres}" +APP_USER_PASSWORD="${VMS_APP_USER_PASSWORD:-integration-app-user}" +APP_SYSTEM_PASSWORD="${VMS_APP_SYSTEM_PASSWORD:-integration-app-system}" + +echo "Creating postgres-credentials secret..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" create secret generic postgres-credentials \ + --from-literal=postgres-password="${POSTGRES_PASSWORD}" \ + --from-literal=app-user-password="${APP_USER_PASSWORD}" \ + --from-literal=app-system-password="${APP_SYSTEM_PASSWORD}" \ + --dry-run=client -o yaml | kubectl --context "${KUBECTL_CONTEXT}" apply -f - + +echo "Creating keycloak-config secret..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" create secret generic keycloak-config \ + --from-file=keycloak.json="${KIND_DIR}/fixtures/secrets/keycloak.json" \ + --dry-run=client -o yaml | kubectl --context "${KUBECTL_CONTEXT}" apply -f - + +echo "Running helmfile sync: cert-manager (-e kind)..." +( + cd "${REPO_ROOT}/charts/helmfile" + helmfile -e kind sync -l component=cert-manager +) + +"${ROOT}/scripts/wait-cert-manager-webhook.sh" + +echo "Running helmfile sync: postgresql + management-server (-e kind)..." +( + cd "${REPO_ROOT}/charts/helmfile" + helmfile -e kind sync +) + +"${ROOT}/scripts/wait-ready.sh" + +echo "=== Installing site-controller image, Skupper, backbone seed ===" +echo "Building ${SC_IMAGE} from Containerfile target vms-site-controller..." +docker build -f Containerfile --target vms-site-controller -t "${SC_IMAGE}" "${REPO_ROOT}" +load_image "${SC_IMAGE}" + +load_image "${SKUPPER_CONTROLLER_IMAGE}" +load_image "${SKUPPER_ADAPTOR_IMAGE}" +load_image "${SKUPPER_ROUTER_IMAGE}" + +"${ROOT}/scripts/install-skupper.sh" +"${ROOT}/scripts/seed-integration.sh" + +echo "Kind cluster is up. Run: pnpm run test:integration" diff --git a/tests/integration/kind/scripts/install-skupper.sh b/tests/integration/kind/scripts/install-skupper.sh new file mode 100755 index 00000000..3228d7f9 --- /dev/null +++ b/tests/integration/kind/scripts/install-skupper.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Install Skupper v2 controller with multi-van CRDs and images. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config.sh +source "${ROOT}/config.sh" + +echo "Ensuring namespace ${SITE_NAMESPACE}..." +kubectl --context "${KUBECTL_CONTEXT}" create namespace "${SITE_NAMESPACE}" --dry-run=client -o yaml \ + | kubectl --context "${KUBECTL_CONTEXT}" apply -f - + +echo "Applying multi-van Skupper CRDs..." +for crd in \ + skupper_network_crd.yaml \ + skupper_network_link_crd.yaml \ + skupper_inter_network_ingress_crd.yaml \ + skupper_network_access_crd.yaml \ + skupper_certificate_request_crd.yaml +do + kubectl --context "${KUBECTL_CONTEXT}" apply -f "${SKUPPER_CRD_BASE}/${crd}" +done + +echo "Installing Skupper controller (${SKUPPER_INSTALL_URL})..." +kubectl --context "${KUBECTL_CONTEXT}" apply -f "${SKUPPER_INSTALL_URL}" + +echo "Patching Skupper controller to multi-van images..." +# Skupper 2.2 controller deployment has a single "controller" container; router/adaptor +# images for site pods are driven by SKUPPER_*_IMAGE env vars on that container. +kubectl --context "${KUBECTL_CONTEXT}" -n "${SKUPPER_NAMESPACE}" set image deployment/skupper-controller \ + controller="${SKUPPER_CONTROLLER_IMAGE}" + +kubectl --context "${KUBECTL_CONTEXT}" -n "${SKUPPER_NAMESPACE}" set env deployment/skupper-controller \ + SKUPPER_KUBE_ADAPTOR_IMAGE="${SKUPPER_ADAPTOR_IMAGE}" \ + SKUPPER_ROUTER_IMAGE="${SKUPPER_ROUTER_IMAGE}" \ + SKUPPER_KUBE_ADAPTOR_IMAGE_PULL_POLICY=IfNotPresent \ + SKUPPER_ROUTER_IMAGE_PULL_POLICY=IfNotPresent \ + --containers=controller + +echo "Extending skupper-controller ClusterRole for multi-van resources..." +if ! kubectl --context "${KUBECTL_CONTEXT}" patch clusterrole skupper-controller --type=json -p='[ + {"op":"add","path":"/rules/-","value":{ + "apiGroups":["skupper.io"], + "resources":[ + "networks","networks/status", + "internetworkingresses","internetworkingresses/status", + "networklinks","networklinks/status", + "networkaccesses","networkaccesses/status", + "certificaterequests","certificaterequests/status" + ], + "verbs":["get","list","watch","create","update","patch","delete"] + }} +]'; then + echo "ClusterRole patch skipped (rules may already exist)" +fi + +echo "Waiting for skupper-controller..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${SKUPPER_NAMESPACE}" rollout status deployment/skupper-controller --timeout=300s + +echo "Removing router deployments created with stock kube-adaptor (controller will recreate them)..." +for ns in "${SITE_NAMESPACE}" "${NAMESPACE}"; do + kubectl --context "${KUBECTL_CONTEXT}" -n "${ns}" delete deployment skupper-router --ignore-not-found +done + +echo "Skupper controller is ready." diff --git a/tests/integration/kind/scripts/seed-integration.sh b/tests/integration/kind/scripts/seed-integration.sh new file mode 100755 index 00000000..352c4d74 --- /dev/null +++ b/tests/integration/kind/scripts/seed-integration.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Issue integration site client cert and seed backbone rows in Postgres. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config.sh +source "${ROOT}/config.sh" + +echo "Waiting for root CA certificate ${ROOT_CA_CERT}..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" wait "certificate/${ROOT_CA_CERT}" \ + --for=condition=Ready --timeout=300s + +echo "Updating Configuration.SiteControllerImage to ${SC_IMAGE}..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" exec statefulset/"${POSTGRES_STATEFULSET}" -- \ + bash -lc "PGPASSWORD=\$(cat /opt/bitnami/postgresql/secrets/postgres-password) psql -h 127.0.0.1 -U postgres -d studiodb -c \ + \"UPDATE configuration SET sitecontrollerimage = '${SC_IMAGE}' WHERE id = 0;\"" + +echo "Issuing site TLS certificate via cert-manager..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" apply -f - <&2 + exit 1 +fi + +ap_ready="$(kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" exec statefulset/"${POSTGRES_STATEFULSET}" -- \ + bash -lc "PGPASSWORD=\$(cat /opt/bitnami/postgresql/secrets/postgres-password) psql -h 127.0.0.1 -U postgres -d studiodb -tAc \ + \"SELECT COUNT(*) FROM backboneaccesspoints WHERE id = '${TEST_MANAGE_AP_ID}' AND lifecycle = 'ready' AND certificate IS NOT NULL;\"")" +if [[ "${ap_ready}" != "1" ]]; then + echo "ERROR: expected manage access point ready with certificate, got ${ap_ready}" >&2 + exit 1 +fi + +echo "Database seed complete: InteriorSites + manage AP verified." diff --git a/tests/integration/kind/scripts/wait-cert-manager-webhook.sh b/tests/integration/kind/scripts/wait-cert-manager-webhook.sh new file mode 100755 index 00000000..22e802ba --- /dev/null +++ b/tests/integration/kind/scripts/wait-cert-manager-webhook.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Block until cert-manager webhook Deployment is ready and accepting connections. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config.sh +source "${ROOT}/config.sh" + +echo "Waiting for cert-manager controller..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${CERT_MANAGER_NAMESPACE}" rollout status deployment/cert-manager --timeout=300s + +echo "Waiting for cert-manager webhook deployment..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${CERT_MANAGER_NAMESPACE}" rollout status deployment/cert-manager-webhook --timeout=300s + +echo "Waiting for cert-manager webhook pods..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${CERT_MANAGER_NAMESPACE}" wait pod \ + -l app.kubernetes.io/component=webhook \ + --for=condition=Ready \ + --timeout=300s + +# Endpoints can lag briefly after pods report Ready. +for attempt in $(seq 1 60); do + ready=$( + kubectl --context "${KUBECTL_CONTEXT}" -n "${CERT_MANAGER_NAMESPACE}" get endpointslices \ + -l "kubernetes.io/service-name=cert-manager-webhook" \ + -o jsonpath='{.items[0].endpoints[?(@.conditions.ready==true)].addresses[0]}' 2>/dev/null || true + ) + if [[ -n "${ready}" ]]; then + echo "cert-manager webhook endpoint is ready (${ready})" + exit 0 + fi + sleep 5 +done + +echo "cert-manager webhook Service has no ready endpoints after 300s" >&2 +exit 1 diff --git a/tests/integration/kind/scripts/wait-ready.sh b/tests/integration/kind/scripts/wait-ready.sh new file mode 100755 index 00000000..d737aa91 --- /dev/null +++ b/tests/integration/kind/scripts/wait-ready.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Wait for core cluster resources to become ready. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=../config.sh +source "${ROOT}/config.sh" + +echo "Waiting for cert-manager webhook..." +"${ROOT}/scripts/wait-cert-manager-webhook.sh" + +echo "Waiting for cert-manager cainjector..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${CERT_MANAGER_NAMESPACE}" rollout status deployment/cert-manager-cainjector --timeout=300s + +echo "Waiting for PostgreSQL in ${NAMESPACE}..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" rollout status statefulset/"${POSTGRES_STATEFULSET}" --timeout=300s + +echo "Waiting for Keycloak..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" rollout status deployment/keycloak --timeout=300s + +echo "Waiting for management-server..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" rollout status deployment/"${MC_DEPLOYMENT}" --timeout=300s + +echo "Waiting for root CA certificate..." +kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" wait certificate/"${ROOT_CA_CERT}" --for=condition=Ready --timeout=300s + +echo "Cluster is ready for integration specs." diff --git a/tests/integration/kind/specs/backbone-bootstrap.test.js b/tests/integration/kind/specs/backbone-bootstrap.test.js new file mode 100644 index 00000000..416d9f42 --- /dev/null +++ b/tests/integration/kind/specs/backbone-bootstrap.test.js @@ -0,0 +1,165 @@ +/* + * backbone site bootstrap — SQL seed, bootstrap YAML, site-controller, AMQP/state-sync smoke. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest" +import { + TEST_MANAGE_AP_ID, + TEST_MANAGE_AP_TLS_SECRET, + TEST_MANAGE_AP_HOSTNAME, + TEST_MANAGE_AP_PORT, + TEST_SITE_ID, + SITE_NAMESPACE, + SITE_CONTROLLER_DEPLOYMENT, + SITE_CONTROLLER_LABEL, + ROUTER_LABEL_SELECTOR, + SC_STARTUP_LOG_MARKERS, +} from "../config.js" +import { requireCluster, kubectl } from "../../helpers/kubectl.js" +import { psql, ensureBackboneSiteSeeded } from "../../helpers/postgres.js" +import { + generateBootstrapYaml, + kubectlApplyYaml, + needsBackboneBootstrap, + cleanupBackboneSiteResources, + waitForRunningPod, + waitForPodLogMarkers, + waitForMcLog, + secretExists, + secretHasTlsCert, + waitForSecret, + waitForAccessPointRow, +} from "../../helpers/bootstrap.js" +import { + startSitePortForward, + waitForHostnamesEntry, +} from "../../helpers/site-api-client.js" + +describe("backbone site bootstrap", () => { + beforeAll(async () => { + requireCluster() + ensureBackboneSiteSeeded(TEST_SITE_ID) + + if (needsBackboneBootstrap(TEST_SITE_ID, SITE_NAMESPACE)) { + cleanupBackboneSiteResources(SITE_NAMESPACE) + kubectlApplyYaml(generateBootstrapYaml()) + } + + await waitForRunningPod(SITE_CONTROLLER_LABEL) + await waitForRunningPod(ROUTER_LABEL_SELECTOR) + + // If the site connected before the manage AP was seeded ready, restart to pull tls-server-*. + if (!secretExists(TEST_MANAGE_AP_TLS_SECRET)) { + kubectl( + ["rollout", "restart", "deployment", SITE_CONTROLLER_DEPLOYMENT], + { + namespace: SITE_NAMESPACE, + }, + ) + await waitForRunningPod(SITE_CONTROLLER_LABEL) + } + + await waitForPodLogMarkers( + SITE_CONTROLLER_LABEL, + SC_STARTUP_LOG_MARKERS, + SITE_NAMESPACE, + ) + }, 600_000) + + it("Postgres has backbone site seeded and bootstrapped", () => { + const escaped = TEST_SITE_ID.replace(/'/g, "''") + const state = psql( + `SELECT deploymentstate FROM interiorsites WHERE id = '${escaped}' AND lifecycle = 'ready';`, + ).trim() + expect(state).toMatch( + /^ready-boot|^deployed$|^colo-automatic$|^ready-automatic$/, + ) + }) + + it("site-controller deployment is available in site-a", () => { + const { stdout } = kubectl( + [ + "get", + "deployment", + SITE_CONTROLLER_DEPLOYMENT, + "-o", + "jsonpath={.status.availableReplicas}", + ], + { namespace: SITE_NAMESPACE }, + ) + expect(Number(stdout)).toBeGreaterThanOrEqual(1) + }) + + it("skupper-router pod is running in site-a", () => { + const { stdout } = kubectl( + [ + "get", + "pods", + "-l", + ROUTER_LABEL_SELECTOR, + "-o", + "jsonpath={.items[0].status.phase}", + ], + { namespace: SITE_NAMESPACE }, + ) + expect(stdout).toBe("Running") + }) + + it("site-controller startup log markers are present", async () => { + const logs = await waitForPodLogMarkers( + SITE_CONTROLLER_LABEL, + SC_STARTUP_LOG_MARKERS, + SITE_NAMESPACE, + 30_000, + /Site controller initialization failed/i, + ) + expect(logs).not.toMatch(/Site controller initialization failed/i) + }, 60_000) + + describe("site API via port-forward", () => { + /** @type {{ localPort: number, stop: () => void } | undefined} */ + let portForward + + beforeAll(async () => { + portForward = await startSitePortForward(SITE_CONTROLLER_LABEL) + }, 120_000) + + afterAll(() => { + portForward?.stop() + }) + + it("GET /api/v1alpha1/hostnames returns manage access point ingress bundle", async () => { + const { body, entry } = await waitForHostnamesEntry( + portForward.localPort, + TEST_MANAGE_AP_ID, + ) + + expect(body).not.toBeNull() + expect(Array.isArray(body)).toBe(false) + expect(typeof entry.host).toBe("string") + expect(entry.host.length).toBeGreaterThan(0) + expect(Number(entry.port)).toBeGreaterThan(0) + }) + }) + + it("manage access point ingress appears in Postgres after state sync", async () => { + const row = await waitForAccessPointRow(TEST_MANAGE_AP_ID, { + hostname: TEST_MANAGE_AP_HOSTNAME, + port: TEST_MANAGE_AP_PORT, + lifecycle: "ready", + }) + expect(row.hostname).toBe(TEST_MANAGE_AP_HOSTNAME) + expect(row.port).toBe(TEST_MANAGE_AP_PORT) + expect(row.lifecycle).toBe("ready") + }, 300_000) + + it("manage access-point TLS secret exists in site namespace", async () => { + await waitForSecret(TEST_MANAGE_AP_TLS_SECRET) + expect(secretHasTlsCert(TEST_MANAGE_AP_TLS_SECRET)).toBe(true) + }, 300_000) + + it("management-controller logs AMQP connection to site manage access point", async () => { + const logs = await waitForMcLog(/Connecting to Access Point:/) + expect(logs).toMatch(/Connecting to Access Point:/) + }, 300_000) +}) diff --git a/tests/integration/kind/specs/mgmt-auth-api.test.js b/tests/integration/kind/specs/mgmt-auth-api.test.js new file mode 100644 index 00000000..9d0dcee7 --- /dev/null +++ b/tests/integration/kind/specs/mgmt-auth-api.test.js @@ -0,0 +1,160 @@ +/* + * authenticated REST CRUD via in-cluster Keycloak (password grant + Bearer). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { MC_DEPLOYMENT, TEST_BACKBONE_ID } from '../config.js'; +import { requireCluster, waitDeploymentReady } from '../../helpers/kubectl.js'; +import { startMcPortForward, mcFetch } from '../../helpers/api-client.js'; +import { + startKeycloakPortForward, + fetchAdminAccessToken, + fetchViewerAccessToken, + bearerAuth, +} from '../../helpers/keycloak.js'; + +describe('authenticated REST API via Keycloak', () => { + /** @type {{ localPort: number, stop: () => void } | undefined} */ + let mcPortForward; + /** @type {{ localPort: number, stop: () => void } | undefined} */ + let keycloakPortForward; + /** @type {string | undefined} */ + let adminToken; + /** @type {string | undefined} */ + let viewerToken; + + beforeAll(async () => { + requireCluster(); + waitDeploymentReady(MC_DEPLOYMENT); + keycloakPortForward = await startKeycloakPortForward(); + adminToken = await fetchAdminAccessToken(keycloakPortForward.localPort); + viewerToken = await fetchViewerAccessToken(keycloakPortForward.localPort); + mcPortForward = await startMcPortForward(); + }, 600_000); + + afterAll(() => { + mcPortForward?.stop(); + keycloakPortForward?.stop(); + }); + + it('GET /backbones returns 401 without Bearer token', async () => { + const res = await mcFetch(mcPortForward.localPort, '/api/v1alpha1/backbones', { + headers: { Accept: 'application/json' }, + }); + expect(res.status).toBe(401); + }); + + it('GET /backbones returns 403 for authenticated user without list role', async () => { + const res = await mcFetch(mcPortForward.localPort, '/api/v1alpha1/backbones', { + headers: { + Accept: 'application/json', + ...bearerAuth(viewerToken), + }, + }); + expect(res.status).toBe(403); + }); + + it('GET /backbones returns seeded backbone for admin token', async () => { + const res = await mcFetch(mcPortForward.localPort, '/api/v1alpha1/backbones', { + headers: { + Accept: 'application/json', + ...bearerAuth(adminToken), + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + const seeded = body.find((row) => row.id === TEST_BACKBONE_ID); + expect(seeded).toMatchObject({ + id: TEST_BACKBONE_ID, + name: 'integration-backbone', + lifecycle: 'ready', + }); + }); + + it('GET /backbones/:id returns a single backbone', async () => { + const res = await mcFetch( + mcPortForward.localPort, + `/api/v1alpha1/backbones/${TEST_BACKBONE_ID}`, + { + headers: { + Accept: 'application/json', + ...bearerAuth(adminToken), + }, + }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe(TEST_BACKBONE_ID); + expect(body.name).toBe('integration-backbone'); + }); + + it('POST /backbones creates a backbone and DELETE removes it', async () => { + const name = `integration-auth-${Date.now()}`; + + const createRes = await mcFetch(mcPortForward.localPort, '/api/v1alpha1/backbones', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...bearerAuth(adminToken), + }, + body: JSON.stringify({ name }), + }); + expect(createRes.status).toBe(201); + const created = await createRes.json(); + expect(created.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + + const readRes = await mcFetch( + mcPortForward.localPort, + `/api/v1alpha1/backbones/${created.id}`, + { + headers: { + Accept: 'application/json', + ...bearerAuth(adminToken), + }, + }, + ); + expect(readRes.status).toBe(200); + const row = await readRes.json(); + expect(row.name).toBe(name); + expect(row.id).toBe(created.id); + + const deleteRes = await mcFetch( + mcPortForward.localPort, + `/api/v1alpha1/backbones/${created.id}`, + { + method: 'DELETE', + headers: bearerAuth(adminToken), + }, + ); + expect(deleteRes.status).toBe(204); + + const goneRes = await mcFetch( + mcPortForward.localPort, + `/api/v1alpha1/backbones/${created.id}`, + { + headers: { + Accept: 'application/json', + ...bearerAuth(adminToken), + }, + }, + ); + expect(goneRes.status).toBe(400); + expect(await goneRes.text()).toContain('Not Found'); + }, 120_000); + + it('GET /user/profile returns authenticated admin identity', async () => { + const res = await mcFetch(mcPortForward.localPort, '/api/v1alpha1/user/profile', { + headers: { + Accept: 'application/json', + ...bearerAuth(adminToken), + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe('Integration Admin'); + }); +}); diff --git a/tests/integration/kind/specs/mgmt-certs.test.js b/tests/integration/kind/specs/mgmt-certs.test.js new file mode 100644 index 00000000..274c79d5 --- /dev/null +++ b/tests/integration/kind/specs/mgmt-certs.test.js @@ -0,0 +1,64 @@ +/* + * cert-manager bootstrap — root CA certificate and skupperx-root issuer. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { ROOT_CA_CERT, ROOT_ISSUER } from '../config.js'; +import { requireCluster, kubectl, kubectlWait } from '../../helpers/kubectl.js'; + +describe('cert-manager bootstrap', () => { + beforeAll(() => { + requireCluster(); + }); + + it('cert-manager deployments are available', () => { + for (const dep of ['cert-manager', 'cert-manager-webhook', 'cert-manager-cainjector']) { + const { stdout } = kubectl( + [ + 'get', + 'deployment', + dep, + '-o', + 'jsonpath={.status.availableReplicas}', + ], + { namespace: 'cert-manager' }, + ); + expect(Number(stdout)).toBeGreaterThanOrEqual(1); + } + }); + + it('skupperx-root-ca certificate becomes Ready', () => { + kubectlWait('certificate', ROOT_CA_CERT, 'Ready', 300); + const { stdout } = kubectl([ + 'get', + 'certificate', + ROOT_CA_CERT, + '-o', + 'jsonpath={.status.conditions[?(@.type=="Ready")].status}', + ]); + expect(stdout).toBe('True'); + }); + + it('skupperx-root issuer exists', () => { + const { stdout } = kubectl(['get', 'issuer', ROOT_ISSUER, '-o', 'name']); + expect(stdout).toBe(`issuer.cert-manager.io/${ROOT_ISSUER}`); + }); + + it('skupperx-root-secret contains a CA certificate', () => { + const { stdout } = kubectl([ + 'get', + 'secret', + 'skupperx-root-secret', + '-o', + String.raw`jsonpath={.data.ca\.crt}`, + ]); + expect(stdout.length).toBeGreaterThan(0); + const pem = Buffer.from(stdout, 'base64').toString('utf8'); + expect(pem).toContain('BEGIN CERTIFICATE'); + }); + + it('selfsigned-issuer exists', () => { + const { stdout } = kubectl(['get', 'issuer', 'selfsigned-issuer', '-o', 'name']); + expect(stdout).toBe('issuer.cert-manager.io/selfsigned-issuer'); + }); +}); diff --git a/tests/integration/kind/specs/mgmt-health.test.js b/tests/integration/kind/specs/mgmt-health.test.js new file mode 100644 index 00000000..b5467951 --- /dev/null +++ b/tests/integration/kind/specs/mgmt-health.test.js @@ -0,0 +1,125 @@ +/* + * management stack health — Postgres, MC pod, HTTP, startup logs, DB row. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + MC_DEPLOYMENT, + MC_STARTUP_LOG_MARKERS, + EXPECTED_TABLES, +} from '../config.js'; +import { requireCluster, kubectl, waitDeploymentReady, getPodLogs } from '../../helpers/kubectl.js'; +import { + tableExists, + configurationRowCount, + managementControllerCount, +} from '../../helpers/postgres.js'; +import { startMcPortForward, mcFetch } from '../../helpers/api-client.js'; + +describe('management stack health', () => { + beforeAll(() => { + requireCluster(); + waitDeploymentReady(MC_DEPLOYMENT); + }, 600_000); + + it('Postgres schema tables exist', () => { + for (const table of EXPECTED_TABLES) { + expect(tableExists(table), `missing table ${table}`).toBe(true); + } + }); + + it('Configuration seed row is present', () => { + expect(configurationRowCount()).toBeGreaterThan(0); + }); + + it('management-server deployment is available', () => { + const { stdout } = kubectl([ + 'get', + 'deployment', + MC_DEPLOYMENT, + '-o', + 'jsonpath={.status.availableReplicas}', + ]); + expect(Number(stdout)).toBeGreaterThanOrEqual(1); + }); + + it('management-server pod is running and ready', () => { + const selector = `app.kubernetes.io/instance=${MC_DEPLOYMENT}`; + + const { stdout: phase } = kubectl([ + 'get', + 'pods', + '-l', + selector, + '-o', + 'jsonpath={.items[0].status.phase}', + ]); + expect(phase).toBe('Running'); + + const { stdout: ready } = kubectl([ + 'get', + 'pods', + '-l', + selector, + '-o', + 'jsonpath={.items[0].status.conditions[?(@.type=="Ready")].status}', + ]); + expect(ready).toBe('True'); + + const { stdout: waitingReason, status } = kubectl( + [ + 'get', + 'pods', + '-l', + selector, + '-o', + 'jsonpath={.items[0].status.containerStatuses[0].state.waiting.reason}', + ], + { allowFailure: true }, + ); + if (status === 0 && waitingReason) { + expect(['CrashLoopBackOff', 'Error', 'ImagePullBackOff']).not.toContain(waitingReason); + } + }); + + it('management-controller startup log markers are present', () => { + const logs = getPodLogs(`app.kubernetes.io/instance=${MC_DEPLOYMENT}`); + for (const marker of MC_STARTUP_LOG_MARKERS) { + expect(logs).toContain(marker); + } + expect(logs).not.toMatch(/Management controller initialization failed/i); + }); + + it('ManagementControllers row created on startup', () => { + const { stdout: podName } = kubectl([ + 'get', + 'pods', + '-l', + `app.kubernetes.io/instance=${MC_DEPLOYMENT}`, + '-o', + 'jsonpath={.items[0].metadata.name}', + ]); + expect(managementControllerCount(podName)).toBeGreaterThanOrEqual(1); + }); + + describe('HTTP via port-forward', () => { + /** @type {{ localPort: number, stop: () => void } | undefined} */ + let portForward; + + beforeAll(async () => { + portForward = await startMcPortForward(); + }, 120_000); + + afterAll(() => { + portForward?.stop(); + }); + + it('endpoint responds with 401 when unauthenticated', async () => { + const res = await mcFetch(portForward.localPort, '/api/v1alpha1/', { + headers: { Accept: 'application/json' }, + }); + expect(res.status).toBe(401); + expect(res.headers.get('location')).toBeNull(); + }); + }); +}); From 9d700c3ca2b1306e123c79e90f289cff101ef3c2 Mon Sep 17 00:00:00 2001 From: JPadovano1483 Date: Mon, 6 Jul 2026 14:06:20 -0400 Subject: [PATCH 2/4] fixed skupper deployment timing --- tests/integration/kind/config.js | 2 +- .../kind/scripts/install-skupper.sh | 23 +++++++++++++++---- .../kind/scripts/seed-integration.sh | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/integration/kind/config.js b/tests/integration/kind/config.js index 7e6c8243..f029a34b 100644 --- a/tests/integration/kind/config.js +++ b/tests/integration/kind/config.js @@ -60,7 +60,7 @@ export const TEST_SITE_CERT_SECRET = "vms-site-cert-integration" export const TEST_MANAGE_AP_TLS_SECRET = `skx-access-${TEST_MANAGE_AP_ID}` /** Expected manage access point endpoint after seed-integration.sh (matches integration-seed.sql). */ -export const TEST_MANAGE_AP_HOSTNAME = `skupper-router.${SITE_NAMESPACE}.svc.cluster.local` +export const TEST_MANAGE_AP_HOSTNAME = `skupper-router-local.${SITE_NAMESPACE}.svc.cluster.local` export const TEST_MANAGE_AP_PORT = "5671" /** Tables created by charts/helmfile/resources/db-setup.sql (smoke subset). */ diff --git a/tests/integration/kind/scripts/install-skupper.sh b/tests/integration/kind/scripts/install-skupper.sh index 3228d7f9..80303939 100755 --- a/tests/integration/kind/scripts/install-skupper.sh +++ b/tests/integration/kind/scripts/install-skupper.sh @@ -10,19 +10,32 @@ echo "Ensuring namespace ${SITE_NAMESPACE}..." kubectl --context "${KUBECTL_CONTEXT}" create namespace "${SITE_NAMESPACE}" --dry-run=client -o yaml \ | kubectl --context "${KUBECTL_CONTEXT}" apply -f - +echo "Installing Skupper controller (${SKUPPER_INSTALL_URL})..." +kubectl --context "${KUBECTL_CONTEXT}" apply -f "${SKUPPER_INSTALL_URL}" + echo "Applying multi-van Skupper CRDs..." for crd in \ - skupper_network_crd.yaml \ - skupper_network_link_crd.yaml \ + skupper_access_grant_crd.yaml \ + skupper_access_token_crd.yaml \ + skupper_attached_connector_binding_crd.yaml \ + skupper_attached_connector_crd.yaml \ + skupper_certificate_crd.yaml \ + skupper_certificate_request_crd.yaml \ + skupper_connector_crd.yaml \ skupper_inter_network_ingress_crd.yaml \ + skupper_link_crd.yaml \ + skupper_listener_crd.yaml \ + skupper_multikeylistener_crd.yaml \ skupper_network_access_crd.yaml \ - skupper_certificate_request_crd.yaml + skupper_network_crd.yaml \ + skupper_network_link_crd.yaml \ + skupper_router_access_crd.yaml \ + skupper_secured_access_crd.yaml \ + skupper_site_crd.yaml do kubectl --context "${KUBECTL_CONTEXT}" apply -f "${SKUPPER_CRD_BASE}/${crd}" done -echo "Installing Skupper controller (${SKUPPER_INSTALL_URL})..." -kubectl --context "${KUBECTL_CONTEXT}" apply -f "${SKUPPER_INSTALL_URL}" echo "Patching Skupper controller to multi-van images..." # Skupper 2.2 controller deployment has a single "controller" container; router/adaptor diff --git a/tests/integration/kind/scripts/seed-integration.sh b/tests/integration/kind/scripts/seed-integration.sh index 352c4d74..9a68303b 100755 --- a/tests/integration/kind/scripts/seed-integration.sh +++ b/tests/integration/kind/scripts/seed-integration.sh @@ -37,7 +37,7 @@ echo "Waiting for site certificate ${TEST_SITE_CERT_SECRET}..." kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" wait "certificate/${TEST_SITE_CERT_SECRET}" \ --for=condition=Ready --timeout=300s -MANAGE_AP_DNS="skupper-router.${SITE_NAMESPACE}.svc.cluster.local" +MANAGE_AP_DNS="skupper-router-local.${SITE_NAMESPACE}.svc.cluster.local" MANAGE_AP_TLS_SECRET="skx-access-${TEST_MANAGE_AP_ID}" echo "Issuing manage access-point TLS certificate (${MANAGE_AP_TLS_SECRET})..." kubectl --context "${KUBECTL_CONTEXT}" -n "${NAMESPACE}" apply -f - < Date: Tue, 7 Jul 2026 11:38:05 -0400 Subject: [PATCH 3/4] pin test keycloak image to more recent version --- tests/integration/kind/config.sh | 2 +- tests/integration/kind/fixtures/keycloak/deployment.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/kind/config.sh b/tests/integration/kind/config.sh index 94f96ca1..087be412 100644 --- a/tests/integration/kind/config.sh +++ b/tests/integration/kind/config.sh @@ -19,7 +19,7 @@ export POSTGRES_STATEFULSET="${VMS_POSTGRES_STATEFULSET:-postgresql}" export ROOT_CA_CERT="${VMS_ROOT_CA_CERT:-skupperx-root-ca}" export ROOT_ISSUER="${VMS_ROOT_ISSUER:-skupperx-root}" export POSTGRES_IMAGE="${VMS_POSTGRES_IMAGE:-bitnami/postgresql:latest}" -export KEYCLOAK_IMAGE="${VMS_KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.0.7}" +export KEYCLOAK_IMAGE="${VMS_KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.6.4}" export KEYCLOAK_ADMIN_USER="${VMS_KEYCLOAK_ADMIN_USER:-integration-admin}" export KEYCLOAK_ADMIN_PASSWORD="${VMS_KEYCLOAK_ADMIN_PASSWORD:-integration-admin}" export KEYCLOAK_VIEWER_USER="${VMS_KEYCLOAK_VIEWER_USER:-integration-viewer}" diff --git a/tests/integration/kind/fixtures/keycloak/deployment.yaml b/tests/integration/kind/fixtures/keycloak/deployment.yaml index 196e0d2a..71126489 100644 --- a/tests/integration/kind/fixtures/keycloak/deployment.yaml +++ b/tests/integration/kind/fixtures/keycloak/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: keycloak - image: quay.io/keycloak/keycloak:26.0.7 + image: quay.io/keycloak/keycloak:26.6.4 imagePullPolicy: IfNotPresent args: - start-dev From 6cafe1d1a35a85b780c8890945ba86fa8c910b5e Mon Sep 17 00:00:00 2001 From: JPadovano1483 Date: Tue, 7 Jul 2026 12:28:27 -0400 Subject: [PATCH 4/4] clean up test README --- tests/integration/kind/README.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/integration/kind/README.md b/tests/integration/kind/README.md index 00746f3f..9c24563e 100644 --- a/tests/integration/kind/README.md +++ b/tests/integration/kind/README.md @@ -17,7 +17,7 @@ From the repo root: ```shell pnpm install -pnpm run test:integration:local +pnpm test:integration:local ``` This creates cluster `vms-kind`, builds MC and site-controller images, deploys the stack via `helmfile -e kind`, installs Skupper, seeds backbone rows, runs all integration specs, then deletes the cluster (`pnpm run cluster:down`). Cluster teardown runs even when tests fail. @@ -25,7 +25,7 @@ This creates cluster `vms-kind`, builds MC and site-controller images, deploys t If the cluster is already up: ```shell -pnpm run test:integration +pnpm test:integration ``` Run a single spec file: @@ -37,9 +37,9 @@ pnpm exec vitest run --project integration tests/integration/kind/specs/backbone ## Manual cluster lifecycle ```shell -pnpm run cluster:up # create cluster and deploy the full stack -pnpm run test:integration -pnpm run cluster:down # helmfile destroy + delete cluster +pnpm cluster:up # create cluster and deploy the full stack +pnpm test:integration # run integration tests +pnpm cluster:down # helmfile destroy + delete cluster ``` Port-forward the management API (optional): @@ -81,7 +81,7 @@ Helmfile environment **`kind`** is defined in `charts/helmfile/helmfile.yaml.got `cluster-up.sh` installs cert-manager first, waits for the validating webhook, then PostgreSQL and management-server, then builds the site-controller image, installs Skupper, and runs the SQL seed. -If `skupper-router` init container `config-init` crash-loops, verify the controller has multi-van **both** `SKUPPER_KUBE_ADAPTOR_IMAGE` and `SKUPPER_ROUTER_IMAGE` set (stock `kube-adaptor:2.2.0` cannot initialize a multi-van router). Re-run `./tests/integration/kind/scripts/install-skupper.sh`. +If `skupper-router` init container `config-init` crash-loops, verify the controller has the multi-van images for `SKUPPER_KUBE_ADAPTOR_IMAGE`, `SKUPPER_ROUTER_IMAGE`, and `SKUPPER_CONTROLLER_IMAGE` set (the standard Skupper images cannot initialize a multi-van router). Re-run `./tests/integration/kind/scripts/install-skupper.sh` to reinstall and patch Skupper with the multi-van images. Bootstrap YAML includes the manage AP TLS secret as **`skx-access-{accessPointId}`** (same name RouterAccess expects) and a `RouterAccess` CR. cert-manager issues that secret in the MC namespace first; bootstrap copies it into the site namespace. The MC may also push the same secret via AMQP state-sync when the site connects. @@ -131,9 +131,3 @@ pnpm exec vitest run --project integration tests/integration/kind/specs/backbone - `POST /backbones` + `DELETE /backbones/:id` round-trip CRUD Test users (password grant via port-forward): `integration-admin` / `integration-admin`, `integration-viewer` / `integration-viewer`. - -## Out of scope - -- Browser OIDC login redirect (MC uses in-cluster `keycloak:8080`; host browser cannot resolve that DNS name) -- Member claim flow, console UI -- CI nightly Kind workflow