From 2fa7fea29b8b9562a2c9e42d2e53ca9fa72fb01d Mon Sep 17 00:00:00 2001 From: Yan Bondarenko <202671653+yankawai@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:29:49 +0300 Subject: [PATCH] fix(dashboard): show ConfigMaps listed in the application resource map FoundationDB keeps its connection string in a ConfigMap that its operator writes, and tenants may read it, but the console had no view for ConfigMaps. Tenant roles grant ConfigMaps by name only, so the tab takes the names from the application's resource map and reads each one with a metadata.name field selector, which RBAC checks against resourceNames. An application without a readable resource map shows no tab, while a listed ConfigMap that cannot be read is reported instead of looking empty. Assisted-by: LLM Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com> --- .../detail/ApplicationDetailPage.test.tsx | 21 +++- .../routes/detail/ApplicationDetailPage.tsx | 10 ++ .../src/routes/detail/ConfigMapsTab.test.tsx | 107 ++++++++++++++++++ .../src/routes/detail/ConfigMapsTab.tsx | 57 ++++++++++ .../src/routes/detail/use-app-configmaps.ts | 48 ++++++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx create mode 100644 packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx create mode 100644 packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx index da85e3402..4fa1fa66d 100644 --- a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.test.tsx @@ -8,13 +8,14 @@ const h = vi.hoisted(() => ({ appKind: "Postgres", appPlural: "postgreses", tabLabels: [] as string[], + configMaps: [] as unknown[], })) vi.mock("@cozystack/k8s-client", () => ({ useK8sGet: () => h.get, useK8sDelete: () => ({ mutateAsync: vi.fn() }), // Presence probes (use-resource-presence.ts) — report empty lists. - useK8sList: () => ({ data: undefined, isLoading: false }), + useK8sList: (ref: { plural: string }) => ({ data: { items: ref.plural === "configmaps" ? h.configMaps : [] }, isLoading: false }), })) vi.mock("../../lib/app-definitions.ts", () => ({ useApplicationDefinitions: () => ({ @@ -109,3 +110,21 @@ describe("ApplicationDetailPage tabs for a virtual machine", () => { ]) }) }) + +describe("ApplicationDetailPage configuration", () => { + it("offers ConfigMaps declared by the application's resource map", () => { + h.appKind = "FoundationDB" + h.appPlural = "foundationdbs" + h.get = { + data: { kind: "FoundationDB", metadata: { name: "demo", namespace: "tenant-test" } }, + isLoading: false, + error: undefined, + } + h.configMaps = [{ metadata: { name: "demo-resourcemap" }, data: { + resources: "- apiVersion: v1\n kind: ConfigMap\n name: foundationdb-demo-config", + } }] + h.tabLabels = [] + renderPage() + expect(h.tabLabels).toContain("ConfigMaps") + }) +}) diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx index e3bd39000..55fa684b8 100644 --- a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ApplicationDetailPage.tsx @@ -40,6 +40,8 @@ const SerialTab = lazy(() => import("./SerialTab.tsx").then((m) => ({ default: m import { VMPowerControls } from "./VMPowerControls.tsx" import { useResourceBasePath } from "../../lib/portal.ts" import { useResourcePresence } from "./use-resource-presence.ts" +import { useApplicationConfigMaps } from "./use-app-configmaps.ts" +import { ConfigMapsTab } from "./ConfigMapsTab.tsx" export function ApplicationDetailPage() { const { plural, name } = useParams<{ plural: string; name: string }>() @@ -76,6 +78,7 @@ export function ApplicationDetailPage() { }) const presence = useResourcePresence(ad, instance) + const configMaps = useApplicationConfigMaps(ad, instance, tenantNamespace ?? undefined) if (!plural || !name) return // Check the fetch error before the loading guard: on a failed GET, isLoading @@ -155,6 +158,9 @@ export function ApplicationDetailPage() { if (presence.ingresses) { tabs.push({ to: `${base}/ingresses`, label: "Ingresses", end: false }) } + if (configMaps.names.length > 0 || configMaps.error) { + tabs.push({ to: `${base}/configmaps`, label: "ConfigMaps", end: false }) + } tabs.push( { to: `${base}/secrets`, label: "Secrets", end: false }, { to: `${base}/events`, label: "Events", end: false }, @@ -229,6 +235,10 @@ export function ApplicationDetailPage() { path="ingresses" element={} /> + } + /> } diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx new file mode 100644 index 000000000..eabdbd613 --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { fireEvent, screen, waitFor } from "@testing-library/react" +import { K8sApiError, K8sClient } from "@cozystack/k8s-client" +import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" +import { renderWithK8sProvider } from "../../test-utils/render.tsx" +import { ConfigMapsTab } from "./ConfigMapsTab.tsx" +import { configMapNames, useApplicationConfigMaps } from "./use-app-configmaps.ts" + +const namespace = "tenant-test" +const clusterFile = "demo:example@192.0.2.1:4500" +const ad: ApplicationDefinition = { + apiVersion: "cozystack.io/v1alpha1", kind: "ApplicationDefinition", + metadata: { name: "foundationdb" }, + spec: { application: { kind: "FoundationDB", plural: "foundationdbs", singular: "foundationdb", openAPISchema: "{}" } }, +} +const instance: ApplicationInstance = { + apiVersion: "apps.cozystack.io/v1alpha1", kind: "FoundationDB", + metadata: { name: "demo", namespace }, +} +const resources = "- apiVersion: v1\n kind: ConfigMap\n name: foundationdb-demo-config" + +function Configuration() { + const maps = useApplicationConfigMaps(ad, instance, namespace) + return +} + +function makeClient(error?: Error) { + const client = new K8sClient() + vi.spyOn(client, "watch").mockReturnValue(() => {}) + vi.spyOn(client, "list").mockImplementation(async (_group, _version, _plural, ns, options) => { + expect(ns).toBe(namespace) + const name = options?.fieldSelector?.replace("metadata.name=", "") + if (name === "foundationdb-demo-resourcemap") { + return { apiVersion: "v1", kind: "ConfigMapList", metadata: { resourceVersion: "1" }, items: [ + { metadata: { name, namespace }, data: { resources } }, + ] } + } + expect(name).toBe("foundationdb-demo-config") + if (error) throw error + return { apiVersion: "v1", kind: "ConfigMapList", metadata: { resourceVersion: "1" }, items: [ + { metadata: { name: "foundationdb-demo-config", namespace }, data: { "cluster-file": clusterFile } }, + ] } + }) + return client +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe("application ConfigMaps", () => { + it("discovers and reads only the named ConfigMaps in the active namespace", async () => { + const client = makeClient() + renderWithK8sProvider(, { client }) + expect(await screen.findByText(clusterFile)).toBeInTheDocument() + expect(client.list).toHaveBeenCalledWith("", "v1", "configmaps", namespace, { + labelSelector: undefined, fieldSelector: "metadata.name=foundationdb-demo-resourcemap", + }) + expect(client.list).toHaveBeenCalledWith("", "v1", "configmaps", namespace, { + labelSelector: undefined, fieldSelector: "metadata.name=foundationdb-demo-config", + }) + expect(client.watch).toHaveBeenCalledWith("", "v1", "configmaps", namespace, "1", + expect.any(Function), expect.any(Function), { + labelSelector: undefined, fieldSelector: "metadata.name=foundationdb-demo-config", + }) + }) + + it("copies the cluster file without base64 decoding", async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal("navigator", { clipboard: { writeText } }) + renderWithK8sProvider(, { client: makeClient() }) + fireEvent.click(await screen.findByRole("button", { name: "Copy cluster-file" })) + await waitFor(() => expect(writeText).toHaveBeenCalledWith(clusterFile)) + expect(await screen.findByText("Copied.")).toBeInTheDocument() + }) + + it("reports a clipboard failure", async () => { + vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("Denied")) } }) + renderWithK8sProvider(, { client: makeClient() }) + fireEvent.click(await screen.findByRole("button", { name: "Copy cluster-file" })) + expect(await screen.findByRole("status")).toHaveTextContent("Could not copy") + }) + + it("shows a read denial instead of reporting an empty ConfigMap", async () => { + renderWithK8sProvider(, { client: makeClient(new K8sApiError(403, "ConfigMap access denied")) }) + expect(await screen.findByRole("alert")).toHaveTextContent("ConfigMap access denied") + expect(screen.queryByText("No configuration values.")).not.toBeInTheDocument() + }) + + it("ignores non-ConfigMap and cross-namespace entries and removes duplicate names", () => { + expect(configMapNames(JSON.stringify([ + { apiVersion: "v1", kind: "ConfigMap", name: "connection" }, + { apiVersion: "v1", kind: "ConfigMap", name: "connection", namespace }, + { apiVersion: "v1", kind: "ConfigMap", name: "foreign", namespace: "tenant-other" }, + { apiVersion: "v1", kind: "Secret", name: "password" }, + { apiVersion: "other/v1", kind: "ConfigMap", name: "unrelated" }, + null, + ]), namespace)).toEqual(["connection"]) + }) + + it("rejects malformed maps and unsafe resource names", () => { + expect(() => configMapNames("{broken", namespace)).toThrow() + expect(() => configMapNames("not a list", namespace)).toThrow("must contain a list") + expect(() => configMapNames(resources.replace("foundationdb-demo-config", "../secrets"), namespace)).toThrow("invalid ConfigMap name") + }) +}) diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx new file mode 100644 index 000000000..c883c827b --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/ConfigMapsTab.tsx @@ -0,0 +1,57 @@ +import { useState } from "react" +import { useK8sList } from "@cozystack/k8s-client" +import { Button, Section, Spinner } from "@cozystack/ui" +import type { ConfigMap } from "./use-app-configmaps.ts" + +function ConfigValue({ name, value }: { name: string, value: string }) { + const [copyResult, setCopyResult] = useState<{ value: string, copied: boolean } | null>(null) + const copy = async () => { + try { + await navigator.clipboard.writeText(value) + setCopyResult({ value, copied: true }) + } catch { + setCopyResult({ value, copied: false }) + } + } + return ( +
+
+ {name} + +
+
{value || "(empty)"}
+ {copyResult?.value === value &&

{copyResult.copied ? "Copied." : "Could not copy. Select and copy the value manually."}

} +
+ ) +} + +function ConfigMapItem({ namespace, name }: { namespace: string, name: string }) { + const { data, isLoading, error } = useK8sList( + { apiGroup: "", apiVersion: "v1", plural: "configmaps", namespace }, + { enabled: !!namespace, fieldSelector: `metadata.name=${name}`, retry: false }, + ) + const item = data?.items.find((entry) => entry.metadata.name === name) + return ( +
+ {error ?

{error.message}

+ : isLoading ?
Loading…
+ : !item ?

ConfigMap is not available yet.

+ : Object.keys(item.data ?? {}).length === 0 ?

No configuration values.

+ :
{Object.entries(item.data ?? {}).map(([key, value]) => ( + + ))}
} +
+ ) +} + +export function ConfigMapsTab({ namespace, names, error }: { namespace: string, names: string[], error: Error | null }) { + return ( +
+ {error ?

{error.message}

+ : names.length === 0 ?

No ConfigMaps.

+ : names.map((name) => )} +
+ ) +} diff --git a/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts b/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts new file mode 100644 index 000000000..7a5953882 --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/routes/detail/use-app-configmaps.ts @@ -0,0 +1,48 @@ +import { useMemo } from "react" +import { JSON_SCHEMA, load } from "js-yaml" +import { K8sApiError, useK8sList, type K8sResource } from "@cozystack/k8s-client" +import type { ApplicationDefinition, ApplicationInstance } from "@cozystack/types" +import { releasePrefix } from "../../lib/app-definitions.ts" + +export type ConfigMap = K8sResource & { data?: Record } + +export function configMapNames(resources: string | undefined, namespace: string): string[] { + if (!resources) return [] + const entries: unknown = load(resources, { schema: JSON_SCHEMA }) + if (!Array.isArray(entries)) throw new Error("The application's resource map must contain a list.") + const names = new Set() + for (const value of entries as unknown[]) { + if (!value || typeof value !== "object") continue + const entry = value as Record + if (entry.apiVersion !== "v1" || entry.kind !== "ConfigMap") continue + if (entry.namespace && entry.namespace !== namespace) continue + if (typeof entry.name !== "string" || entry.name.length > 253 || + !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(entry.name)) { + throw new Error("The application's resource map contains an invalid ConfigMap name.") + } + names.add(entry.name) + } + return [...names] +} + +export function useApplicationConfigMaps( + ad: ApplicationDefinition | undefined, + instance: ApplicationInstance | undefined, + namespace: string | undefined, +) { + const name = ad && instance ? `${releasePrefix(ad)}${instance.metadata.name}-resourcemap` : "" + const query = useK8sList( + { apiGroup: "", apiVersion: "v1", plural: "configmaps", namespace }, + { enabled: !!name && !!namespace, fieldSelector: `metadata.name=${name}`, retry: false }, + ) + const resources = query.data?.items.find((item) => item.metadata.name === name)?.data?.resources + const parsed = useMemo(() => { + try { + return { names: configMapNames(resources, namespace ?? ""), error: null } + } catch (error) { + return { names: [], error: error instanceof Error ? error : new Error("Invalid resource map.") } + } + }, [resources, namespace]) + const unavailable = query.error && query.error instanceof K8sApiError && [403, 404].includes(query.error.status) + return { ...parsed, isLoading: query.isLoading, error: parsed.error ?? (unavailable ? null : query.error) ?? null } +}