mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 22:13:18 +09:00
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.
On release-1.6 ApplicationDetailPage.test.tsx has none of the virtual
machine tab tests it sits next to on main, so only the ConfigMaps case
is added there.
Assisted-by: LLM
Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
(cherry picked from commit 2fa7fea29b)
This commit is contained in:
1 parent
5b0682a073
commit
2610fb8565
5 files changed
+250
-2
No files matched your search
+28
-2
@@ -5,13 +5,15 @@ import { MemoryRouter, Routes, Route } from "react-router"
|
||||
// Drive useK8sGet's result per test; the page's loading/error guards are the unit under test.
|
||||
const h = vi.hoisted(() => ({
|
||||
get: { data: undefined as unknown, isLoading: true, error: undefined as unknown },
|
||||
configMaps: [] as unknown[],
|
||||
tabLabels: [] as string[],
|
||||
}))
|
||||
|
||||
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: () => ({
|
||||
@@ -19,13 +21,20 @@ vi.mock("../../lib/app-definitions.ts", () => ({
|
||||
}),
|
||||
appDisplayName: () => "Postgres",
|
||||
iconDataUrl: () => null,
|
||||
releasePrefix: () => "",
|
||||
isTenantModule: () => false,
|
||||
}))
|
||||
vi.mock("../../lib/tenant-context.tsx", () => ({
|
||||
useTenantContext: () => ({ tenantNamespace: "tenant-test" }),
|
||||
}))
|
||||
// Stub the tab tree — the loading/error branches return before any tab renders,
|
||||
// and these modules pull heavy deps (noVNC, Monaco) we don't want in jsdom.
|
||||
vi.mock("./tabs.tsx", () => ({ TabBar: () => null }))
|
||||
vi.mock("./tabs.tsx", () => ({
|
||||
TabBar: ({ tabs }: { tabs: { label: string }[] }) => {
|
||||
h.tabLabels = tabs.map((t) => t.label)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
vi.mock("./OverviewTab.tsx", () => ({ OverviewTab: () => null }))
|
||||
vi.mock("./WorkloadsTab.tsx", () => ({ WorkloadsTab: () => null }))
|
||||
vi.mock("./ServicesTab.tsx", () => ({ ServicesTab: () => null }))
|
||||
@@ -64,3 +73,20 @@ describe("ApplicationDetailPage guards", () => {
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe("ApplicationDetailPage configuration", () => {
|
||||
it("offers ConfigMaps declared by the application's resource map", () => {
|
||||
h.get = {
|
||||
data: { kind: "Postgres", 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: postgres-demo-config",
|
||||
} }]
|
||||
h.tabLabels = []
|
||||
renderPage()
|
||||
expect(h.tabLabels).toContain("ConfigMaps")
|
||||
})
|
||||
})
|
||||
+10
@@ -36,6 +36,8 @@ import { VncTab } from "./VncTab.tsx"
|
||||
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 }>()
|
||||
@@ -72,6 +74,7 @@ export function ApplicationDetailPage() {
|
||||
})
|
||||
|
||||
const presence = useResourcePresence(ad, instance)
|
||||
const configMaps = useApplicationConfigMaps(ad, instance, tenantNamespace ?? undefined)
|
||||
|
||||
if (!plural || !name) return <Navigate to="/console" replace />
|
||||
// Check the fetch error before the loading guard: on a failed GET, isLoading
|
||||
@@ -147,6 +150,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 },
|
||||
@@ -221,6 +227,10 @@ export function ApplicationDetailPage() {
|
||||
path="ingresses"
|
||||
element={<IngressesTab ad={ad} instance={instance} />}
|
||||
/>
|
||||
<Route
|
||||
path="configmaps"
|
||||
element={<ConfigMapsTab namespace={tenantNamespace ?? ""} names={configMaps.names} error={configMaps.error} />}
|
||||
/>
|
||||
<Route
|
||||
path="secrets"
|
||||
element={<SecretsTab ad={ad} instance={instance} />}
|
||||
|
||||
+107
@@ -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 <ConfigMapsTab namespace={namespace} names={maps.names} error={maps.error} />
|
||||
}
|
||||
|
||||
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(<Configuration />, { 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(<Configuration />, { 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(<Configuration />, { 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(<Configuration />, { 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")
|
||||
})
|
||||
})
|
||||
+57
@@ -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 (
|
||||
<div className="space-y-2 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<code className="break-all text-xs">{name}</code>
|
||||
<Button size="sm" variant="outline" disabled={!navigator.clipboard} onClick={copy}>
|
||||
Copy {name}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded bg-slate-900 p-3 text-xs text-slate-100">{value || "(empty)"}</pre>
|
||||
{copyResult?.value === value && <p role="status" className="text-xs">{copyResult.copied ? "Copied." : "Could not copy. Select and copy the value manually."}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigMapItem({ namespace, name }: { namespace: string, name: string }) {
|
||||
const { data, isLoading, error } = useK8sList<ConfigMap>(
|
||||
{ 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 (
|
||||
<Section title={name} bodyClassName="p-0">
|
||||
{error ? <p role="alert" className="p-4 text-red-600">{error.message}</p>
|
||||
: isLoading ? <div className="flex items-center gap-2 p-4"><Spinner /> Loading…</div>
|
||||
: !item ? <p className="p-4">ConfigMap is not available yet.</p>
|
||||
: Object.keys(item.data ?? {}).length === 0 ? <p className="p-4">No configuration values.</p>
|
||||
: <div className="divide-y divide-slate-100">{Object.entries(item.data ?? {}).map(([key, value]) => (
|
||||
<ConfigValue key={key} name={key} value={value} />
|
||||
))}</div>}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfigMapsTab({ namespace, names, error }: { namespace: string, names: string[], error: Error | null }) {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{error ? <p role="alert" className="text-red-600">{error.message}</p>
|
||||
: names.length === 0 ? <p>No ConfigMaps.</p>
|
||||
: names.map((name) => <ConfigMapItem key={`${namespace}/${name}`} namespace={namespace} name={name} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+48
@@ -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<string, string> }
|
||||
|
||||
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<string>()
|
||||
for (const value of entries as unknown[]) {
|
||||
if (!value || typeof value !== "object") continue
|
||||
const entry = value as Record<string, unknown>
|
||||
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<ConfigMap>(
|
||||
{ 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 }
|
||||
}
|
||||
Reference in new issue
Block a user