mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-08 21:14:56 +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. Assisted-by: LLM Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
This commit is contained in:
1 parent
e3ae8033e6
commit
2fa7fea29b
5 files changed
+242
-1
No files matched your search
+20
-1
@@ -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")
|
||||
})
|
||||
})
|
||||
+10
@@ -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 <Navigate to="/console" replace />
|
||||
// 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={<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