Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a051ffafed | |||
| 7f03ae6265 | |||
| 53fce54df8 | |||
| 6c6e8a55ce | |||
| 483348aef0 | |||
| 9502ca804d | |||
| 76d0e106b2 | |||
| 63050174e9 | |||
| cd1fa2613d | |||
| bfeb1068bb | |||
| 2aff05b632 | |||
| d37431ce8c | |||
| b2a97cdcad | |||
| 73b2baec9d | |||
| 36e220660d | |||
| 51e68b1b88 | |||
| 48d704a6b6 | |||
| b0cefdbe24 | |||
| 92f8c958d8 | |||
| 22fea9a99d | |||
| 73fb1359ed | |||
| cf9e0513b9 | |||
| 733cfad8d3 | |||
| 5aa54a526b | |||
| 83aa0329b3 | |||
| 8f343be06d | |||
| 9dc5fd673d | |||
| 125b06734a | |||
| def89f8d71 | |||
| 90721641cc | |||
| af42d9c52a | |||
| 61582d7534 | |||
| f6a296df1b | |||
| d593a11fd9 | |||
| 8fb9215933 | |||
| 35c09186df | |||
| 5744d9083f | |||
| 34ea111776 | |||
| 398e3f3b95 | |||
| 1343ba3e65 | |||
| 96145c21cb | |||
| a781027d3b | |||
| e2ae92648c | |||
| 7a0c068a93 | |||
| 2d629809a2 | |||
| 3fe787a550 | |||
| 1f02811731 | |||
| 7b58f684cf | |||
| aa1db9215a | |||
| 202ce66c61 | |||
| e2f220c418 | |||
| 58c9597388 | |||
| dff1265435 | |||
| 7c58826668 | |||
| 4edc829b3f | |||
| 8f10be39bd | |||
| 27212a91e1 | |||
| 7b72306133 | |||
| e16e6255d0 | |||
| 4beb0c4d0e | |||
| 175d3ec6a2 | |||
| e63cd03267 | |||
| 4d878c8737 | |||
| 5f817ec4f6 | |||
| 490807cef6 | |||
| 06d7dfb212 | |||
| ba508b8fc4 | |||
| b928fff4a5 | |||
| df6a5967ea | |||
| 415e32cdc9 | |||
| aa32e7a353 | |||
| 67bfe5ff5c | |||
| c08f3fbdbe | |||
| 02dc79b739 | |||
| d1097c2dbf | |||
| 5fa14ab353 | |||
| acd53c297b | |||
| fd66b119b3 | |||
| 21026cc992 | |||
| 95096562e4 | |||
| 62baf2bd5e | |||
| d2da09406a | |||
| a975192dfb | |||
| 2c80d0451e | |||
| d4a4e9a355 | |||
| a08c0fc368 | |||
| d0a6794576 | |||
| 00c270b0d4 | |||
| 7f115e0d6e | |||
| 9d02f504fd | |||
| 65c25067ec | |||
| 4c6324c4c2 | |||
| ca4832bcc3 | |||
| d6c8a8bbfc | |||
| 3d91572b59 | |||
| f0f3bd51a4 | |||
| 6e9c97593c | |||
| a5398e8409 | |||
| bb1df5f3f6 | |||
| 1bf5c2431c | |||
| 08a3009ba8 | |||
| b3f1f65b2f | |||
| 74a5bb0a01 | |||
| 9249f151a8 | |||
| dd782fbea0 | |||
| 0a52a8effa | |||
| 902f206e32 | |||
| 4344d33349 | |||
| 8ac890a1c6 | |||
| 6189f2b983 | |||
| 4296eb97fb | |||
| 87bf1a321f | |||
| 37af076456 | |||
| 0476fd1076 | |||
| 6a47358771 | |||
| f7d415e013 | |||
| 2a60029104 | |||
| 76c7a5bc1f | |||
| d64db24240 | |||
| 9bd07e1928 | |||
| 40b0a2d220 | |||
| fb3d262eb7 | |||
| 0f88a9b19f | |||
| d3860ff5a2 | |||
| 7165bdf79b | |||
| eb218dc7f4 | |||
| c02efe5430 | |||
| daf0ebbff5 | |||
| fc8a9eebac | |||
| 07bcfa084a | |||
| 1755cedd88 | |||
| 07a99a76ce | |||
| c3d3989cdc | |||
| 2012a34938 | |||
| 7603dfeb29 | |||
| 9ad0b24580 | |||
| acc9d8fac1 | |||
| 7413f699de | |||
| 497c040dbe | |||
| 29bc953522 | |||
| 0bd5223587 | |||
| 2c441bf867 | |||
| 222346759e | |||
| d543e3bf9d | |||
| 4e66a4b7cc | |||
| e800adfc19 | |||
| b3349b71d5 | |||
| ceb7f31257 | |||
| 8f69329764 | |||
| 0882d663fd | |||
| 6c7064faf0 | |||
| fb445954e0 | |||
| 8bc6575ac3 | |||
| 514de78ba7 | |||
| 6dd64e87ce | |||
| 9b9052243f |
@@ -0,0 +1,241 @@
|
||||
---
|
||||
name: artifacthub-headlamp
|
||||
description: Use when working with ArtifactHub metadata, releases, or publishing for Headlamp plugins. Covers artifacthub-repo.yml, artifacthub-pkg.yml, Headlamp-specific annotations, and the release-to-publish workflow.
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are an expert in publishing Headlamp Kubernetes dashboard plugins to ArtifactHub. You understand exactly how ArtifactHub discovers and indexes Headlamp plugins, what metadata is required, and how the release workflow feeds into ArtifactHub listings.
|
||||
|
||||
Before editing any metadata files, read the existing `artifacthub-repo.yml`, `artifacthub-pkg.yml`, and `package.json` to understand the current state.
|
||||
|
||||
---
|
||||
|
||||
## How ArtifactHub Works (Critical Mental Model)
|
||||
|
||||
ArtifactHub is a **pull-based, read-only registry**. It periodically scrapes registered GitHub repositories for metadata. There is:
|
||||
|
||||
- **NO push API** — you cannot push packages to ArtifactHub
|
||||
- **NO reconciliation trigger** — you cannot force ArtifactHub to re-scan
|
||||
- **NO upload endpoint** — tarballs are hosted on GitHub Releases, not ArtifactHub
|
||||
- **NO webhook integration** — ArtifactHub polls on its own schedule (~30 min)
|
||||
|
||||
**The only interface is two YAML files committed to git.** ArtifactHub reads them, and that's it.
|
||||
|
||||
---
|
||||
|
||||
## Repository Registration
|
||||
|
||||
### artifacthub-repo.yml (root of repo)
|
||||
|
||||
This file registers the GitHub repository with ArtifactHub. Created once, rarely changed.
|
||||
|
||||
```yaml
|
||||
# Artifact Hub repository metadata file
|
||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
|
||||
repositoryID: <uuid> # Assigned by ArtifactHub when you add the repo via the web UI
|
||||
owners:
|
||||
- name: <github-username-or-org>
|
||||
email: <email>
|
||||
```
|
||||
|
||||
**How to get the repositoryID:**
|
||||
1. Log into artifacthub.io
|
||||
2. Go to Control Panel → Repositories → Add
|
||||
3. Select repository kind: "Headlamp plugins"
|
||||
4. Provide the GitHub repo URL
|
||||
5. ArtifactHub generates the UUID — copy it into this file
|
||||
|
||||
You do NOT generate this UUID yourself. It comes from ArtifactHub's web UI.
|
||||
|
||||
---
|
||||
|
||||
## Package Metadata
|
||||
|
||||
### artifacthub-pkg.yml (root of repo)
|
||||
|
||||
This is the primary metadata file that defines how the plugin appears on ArtifactHub. Updated with each release.
|
||||
|
||||
```yaml
|
||||
version: "X.Y.Z" # MUST match package.json version
|
||||
name: <package-name> # npm package name from package.json
|
||||
displayName: <Human Readable Name> # Shown on ArtifactHub listing
|
||||
createdAt: "YYYY-MM-DDTHH:MM:SSZ" # ISO 8601 — update each release
|
||||
description: >-
|
||||
Multi-line description of what the plugin does.
|
||||
Be specific about features and requirements.
|
||||
license: Apache-2.0
|
||||
homeURL: https://github.com/<owner>/<repo>
|
||||
appVersion: "X.Y.Z" # Version of upstream project (optional)
|
||||
category: <category> # See categories below
|
||||
keywords:
|
||||
- headlamp
|
||||
- kubernetes
|
||||
- <plugin-specific>
|
||||
maintainers:
|
||||
- name: <name>
|
||||
email: <email>
|
||||
provider:
|
||||
name: <name>
|
||||
links:
|
||||
- name: GitHub
|
||||
url: https://github.com/<owner>/<repo>
|
||||
- name: Issues
|
||||
url: https://github.com/<owner>/<repo>/issues
|
||||
changes: # Changelog for this version
|
||||
- kind: added|changed|fixed|removed
|
||||
description: "What changed"
|
||||
annotations: # CRITICAL — Headlamp-specific
|
||||
headlamp/plugin/archive-url: "https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz"
|
||||
headlamp/plugin/archive-checksum: "sha256:<checksum>"
|
||||
headlamp/plugin/version-compat: ">=X.Y.Z"
|
||||
headlamp/plugin/distro-compat: "<targets>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Headlamp-Specific Annotations (Required)
|
||||
|
||||
These annotations in `artifacthub-pkg.yml` are what make ArtifactHub treat the package as a Headlamp plugin:
|
||||
|
||||
### headlamp/plugin/archive-url
|
||||
**Required.** Direct download URL to the plugin tarball on GitHub Releases.
|
||||
|
||||
Format: `https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz`
|
||||
|
||||
- The tarball is built by `npx @kinvolk/headlamp-plugin build` and then `npx @kinvolk/headlamp-plugin package`
|
||||
- The `<pkgname>` comes from `package.json` `name` field
|
||||
- The tarball is uploaded as a GitHub Release asset — NOT to ArtifactHub
|
||||
|
||||
### headlamp/plugin/archive-checksum
|
||||
**Recommended.** SHA256 checksum of the tarball.
|
||||
|
||||
Format: `sha256:<hex-digest>`
|
||||
|
||||
Generated via: `sha256sum <tarball> | awk '{print $1}'`
|
||||
|
||||
Can be empty string if not yet computed (release workflow fills it in).
|
||||
|
||||
### headlamp/plugin/version-compat
|
||||
**Required.** Minimum Headlamp version the plugin works with.
|
||||
|
||||
Format: `>=X.Y.Z` (e.g., `>=0.20.0`, `>=0.26`)
|
||||
|
||||
### headlamp/plugin/distro-compat
|
||||
**Required.** Comma-separated list of supported Headlamp deployment targets.
|
||||
|
||||
Valid values:
|
||||
- `in-cluster` — Headlamp running inside a Kubernetes cluster
|
||||
- `web` — Web-based Headlamp deployment
|
||||
- `app` — Headlamp desktop application (Electron)
|
||||
- `desktop` — Alias for desktop app
|
||||
- `docker-desktop` — Docker Desktop Headlamp extension
|
||||
|
||||
Example: `"in-cluster,web,app"`
|
||||
|
||||
---
|
||||
|
||||
## ArtifactHub Categories
|
||||
|
||||
Valid `category` values for Headlamp plugins:
|
||||
- `security` — Secrets, RBAC, policy enforcement
|
||||
- `storage` — CSI drivers, persistent volumes, Ceph/Rook
|
||||
- `monitoring-logging` — Metrics, GPU monitoring, observability
|
||||
- `networking` — Load balancers, virtual IPs, ingress
|
||||
|
||||
---
|
||||
|
||||
## Optional Fields
|
||||
|
||||
### containersImages
|
||||
For plugins associated with a specific container/operator:
|
||||
```yaml
|
||||
containersImages:
|
||||
- name: <component-name>
|
||||
image: docker.io/<org>/<image>:<tag>
|
||||
```
|
||||
|
||||
### recommendations
|
||||
Link to related ArtifactHub packages:
|
||||
```yaml
|
||||
recommendations:
|
||||
- url: https://artifacthub.io/packages/helm/<repo>/<chart>
|
||||
```
|
||||
|
||||
### install
|
||||
Custom installation instructions (markdown):
|
||||
```yaml
|
||||
install: |
|
||||
## Install via Headlamp Plugin Manager
|
||||
...
|
||||
```
|
||||
|
||||
### logoPath
|
||||
Path to a logo image file in the repo (relative to root).
|
||||
|
||||
---
|
||||
|
||||
## The Release → ArtifactHub Pipeline
|
||||
|
||||
This is the actual flow. There is NO other way to publish:
|
||||
|
||||
```
|
||||
1. Developer triggers release workflow (workflow_dispatch with version)
|
||||
2. CI runs tests
|
||||
3. Workflow updates:
|
||||
- package.json (npm version)
|
||||
- artifacthub-pkg.yml (version, archive-url, checksum, createdAt, changes)
|
||||
4. Plugin is built: npx @kinvolk/headlamp-plugin build
|
||||
5. Plugin is packaged: creates <pkgname>-<version>.tar.gz
|
||||
6. SHA256 checksum is computed and written to artifacthub-pkg.yml
|
||||
7. Changes committed to main
|
||||
8. Git tag created: v<version>
|
||||
9. GitHub Release created with tarball attached
|
||||
10. ArtifactHub polls the repo (~30 min) and picks up the new metadata
|
||||
11. Plugin appears/updates on artifacthub.io
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Steps 1-9 happen in your GitHub Actions workflow
|
||||
- Step 10 is entirely controlled by ArtifactHub — you cannot trigger it
|
||||
- The tarball lives on GitHub Releases, not ArtifactHub
|
||||
- ArtifactHub only reads `artifacthub-pkg.yml` to discover the download URL
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Trying to push/trigger ArtifactHub** — There is no API for this. Just commit metadata and wait.
|
||||
2. **Version mismatch** — `version` in `artifacthub-pkg.yml` MUST match `package.json`. The release workflow should update both.
|
||||
3. **Wrong archive-url** — Must point to the actual GitHub Release asset URL. Verify the tarball filename matches what the build produces.
|
||||
4. **Missing checksum** — While optional, missing checksums may cause warnings. The release workflow should compute and write it.
|
||||
5. **Forgetting createdAt** — Must be updated each release. ArtifactHub uses this for sorting.
|
||||
6. **Stale changes section** — The `changes` list should reflect the current version's changelog only, not cumulative history.
|
||||
7. **Assuming ArtifactHub hosts anything** — It's an index/catalog. All artifacts are hosted elsewhere (GitHub Releases).
|
||||
8. **Trying to generate repositoryID** — This UUID comes from ArtifactHub's web UI when you register the repo. Don't make one up.
|
||||
|
||||
---
|
||||
|
||||
## Tarball Structure
|
||||
|
||||
The plugin tarball built by `@kinvolk/headlamp-plugin` contains:
|
||||
|
||||
```
|
||||
<pkgname>/
|
||||
main.js # Bundled plugin code
|
||||
package.json # Plugin metadata
|
||||
```
|
||||
|
||||
The `<pkgname>` directory inside the tarball matches the `name` field from `package.json`.
|
||||
|
||||
---
|
||||
|
||||
## Validating Metadata
|
||||
|
||||
Before committing, check:
|
||||
1. `version` matches across `package.json` and `artifacthub-pkg.yml`
|
||||
2. `archive-url` version tag matches the `version` field
|
||||
3. `name` in `artifacthub-pkg.yml` matches `package.json` `name`
|
||||
4. `createdAt` is a valid ISO 8601 timestamp
|
||||
5. All required annotations are present
|
||||
6. `changes` entries use valid `kind` values: `added`, `changed`, `fixed`, `removed`
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
name: headlamp-plugin-developer
|
||||
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Registration Functions
|
||||
|
||||
All from `@kinvolk/headlamp-plugin/lib`:
|
||||
|
||||
```typescript
|
||||
registerRoute({
|
||||
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
|
||||
sidebar?: string; // Sidebar entry name to highlight
|
||||
component: () => JSX.Element; // Arrow function wrapper required
|
||||
exact?: boolean;
|
||||
name?: string; // Used by Link's routeName prop
|
||||
}): void
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: string | null; // null = top-level
|
||||
name: string;
|
||||
label: string;
|
||||
url: string;
|
||||
icon?: string; // Iconify ID (e.g., 'mdi:lock')
|
||||
}): void
|
||||
|
||||
registerDetailsViewSection(
|
||||
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||
): void
|
||||
// Runs for ALL resource detail views — MUST check resource?.kind
|
||||
|
||||
registerDetailsViewHeaderAction(
|
||||
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||
): void
|
||||
|
||||
registerResourceTableColumnsProcessor(
|
||||
(args: { id: string; columns: Column[] }) => Column[]
|
||||
): void
|
||||
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
|
||||
|
||||
registerPluginSettings(
|
||||
pluginName: string,
|
||||
component: React.ComponentType<{
|
||||
data?: Record<string, string | number | boolean>;
|
||||
onDataChange?: (data: Record<string, string | number | boolean>) => void;
|
||||
}>,
|
||||
showSaveButton?: boolean
|
||||
): void
|
||||
|
||||
// Also available but less commonly used:
|
||||
registerAppBarAction(component): void
|
||||
registerAppLogo(component): void
|
||||
registerClusterChooser(component): void
|
||||
registerSidebarEntryFilter(filter): void
|
||||
registerRouteFilter(filter): void
|
||||
registerDetailsViewSectionsProcessor(fn): void
|
||||
registerHeadlampEventCallback(callback): void
|
||||
registerAppTheme(theme): void
|
||||
registerUIPanel(panel): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## K8s Module
|
||||
|
||||
```typescript
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
```
|
||||
|
||||
### KubeObject Base Class
|
||||
|
||||
```typescript
|
||||
class KubeObject<T extends KubeObjectInterface> {
|
||||
jsonData: T; // Raw K8s JSON — use this for spec/status access
|
||||
metadata: KubeMetadata;
|
||||
kind: string;
|
||||
|
||||
getAge(): string;
|
||||
getName(): string;
|
||||
getNamespace(): string | undefined;
|
||||
delete(force?: boolean): Promise<void>;
|
||||
patch(body: RecursivePartial<T>): Promise<void>;
|
||||
|
||||
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
|
||||
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
|
||||
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
|
||||
static className: string;
|
||||
}
|
||||
```
|
||||
|
||||
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
|
||||
|
||||
### ResourceClasses
|
||||
|
||||
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
|
||||
```typescript
|
||||
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
|
||||
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ApiProxy
|
||||
|
||||
```typescript
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
|
||||
ApiProxy.request(
|
||||
path: string,
|
||||
options?: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: string; // JSON.stringify'd
|
||||
isJSON?: boolean; // false for non-JSON (logs, metrics)
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
): Promise<unknown>
|
||||
|
||||
// CRD endpoint factories
|
||||
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
|
||||
ApiProxy.apiFactory(group, version, resource): ApiClient
|
||||
```
|
||||
|
||||
**Service proxy URL** (accessing in-cluster services):
|
||||
```
|
||||
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CommonComponents
|
||||
|
||||
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
|
||||
|
||||
`SectionBox` — container with title and optional `headerProps.actions`
|
||||
`SectionHeader` — standalone header with title and actions array
|
||||
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
|
||||
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
|
||||
`Link` — internal nav; `routeName` + `params` object
|
||||
`Loader` — spinner with `title` prop
|
||||
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
|
||||
|
||||
### SimpleTable (non-obvious props)
|
||||
```typescript
|
||||
<SimpleTable
|
||||
data={items}
|
||||
columns={[
|
||||
{ label: 'Name', getter: (item) => item.metadata.name },
|
||||
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
|
||||
]}
|
||||
emptyMessage="No items found."
|
||||
/>
|
||||
```
|
||||
|
||||
### NameValueTable (non-obvious props)
|
||||
```typescript
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Key', value: 'display value' },
|
||||
{ name: 'Hidden', value: 'x', hide: true },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### ConfigStore
|
||||
```typescript
|
||||
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
|
||||
const store = new ConfigStore<MyConfig>('plugin-name');
|
||||
store.get(): MyConfig;
|
||||
store.update(partial: Partial<MyConfig>): void;
|
||||
store.useConfig(): () => MyConfig;
|
||||
```
|
||||
|
||||
### Pre-bundled (no package.json entry needed)
|
||||
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
|
||||
|
||||
---
|
||||
|
||||
## CRD Class Pattern
|
||||
|
||||
```typescript
|
||||
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
const { apiFactoryWithNamespace } = ApiProxy;
|
||||
const { KubeObject } = K8s.cluster;
|
||||
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
|
||||
|
||||
interface MyResourceInterface extends KubeObjectInterface {
|
||||
spec: MySpec;
|
||||
status?: MyStatus;
|
||||
}
|
||||
|
||||
export class MyResource extends KubeObject<MyResourceInterface> {
|
||||
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
|
||||
static get className(): string { return 'MyResource'; }
|
||||
get spec(): MySpec { return this.jsonData.spec; }
|
||||
get status(): MyStatus | undefined { return this.jsonData.status; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Entry Point Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Sidebar (parent → children)
|
||||
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
|
||||
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
|
||||
|
||||
// 2. Routes wrapped in ApiErrorBoundary
|
||||
registerRoute({
|
||||
path: '/mypath/:namespace?/:name?',
|
||||
sidebar: 'my-list',
|
||||
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
|
||||
exact: true, name: 'my-resource',
|
||||
});
|
||||
|
||||
// 3. Detail injection wrapped in GenericErrorBoundary
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind !== 'Secret') return null;
|
||||
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
|
||||
});
|
||||
|
||||
// 4. Settings
|
||||
registerPluginSettings('my-plugin', SettingsPage, true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Headlamp Test Mocks
|
||||
|
||||
```typescript
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
|
||||
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
|
||||
}));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
|
||||
SimpleTable: ({ data, columns }: any) => (
|
||||
<table><tbody>{data.map((d: any, i: number) =>
|
||||
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
|
||||
)}</tbody></table>
|
||||
),
|
||||
NameValueTable: ({ rows }: any) => (
|
||||
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
|
||||
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
|
||||
)}</dl>
|
||||
),
|
||||
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
|
||||
Link: ({ children }: any) => <a>{children}</a>,
|
||||
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Theming & Dark Mode
|
||||
|
||||
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
|
||||
|
||||
### Required CSS variables for inline styles
|
||||
```typescript
|
||||
// Text
|
||||
color: 'var(--mui-palette-text-primary)'
|
||||
color: 'var(--mui-palette-text-secondary, #666)'
|
||||
|
||||
// Backgrounds
|
||||
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
|
||||
|
||||
// Borders
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
|
||||
|
||||
// Interactive
|
||||
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
|
||||
color: 'var(--mui-palette-primary-contrastText, #fff)'
|
||||
|
||||
// Disabled states
|
||||
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
|
||||
// Links
|
||||
color: 'var(--link-color, #1976d2)'
|
||||
```
|
||||
|
||||
### Common mistakes to avoid
|
||||
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
|
||||
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
|
||||
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
|
||||
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
|
||||
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
|
||||
|
||||
### Form inputs in custom components
|
||||
```typescript
|
||||
const inputStyle = {
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Rules
|
||||
|
||||
1. **Functional components only** — no class components (except ErrorBoundary)
|
||||
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
|
||||
3. **Headlamp CommonComponents + MUI** — `@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
|
||||
4. **Inline CSS only** — `style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
|
||||
5. **Accessibility** — `aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
|
||||
6. **Cancellation safety** — async effects must check a `cancelled` flag
|
||||
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
|
||||
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
|
||||
9. Run `npm run tsc` and `npm test` after implementation changes
|
||||
@@ -0,0 +1 @@
|
||||
github: [privilegedescalation]
|
||||
+183
-13
@@ -2,40 +2,210 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
branches: [main, dev, uat]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: local-ubuntu-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
container: node:22-slim
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Install Python
|
||||
run: apt-get update && apt-get install -y --no-install-recommends python3 python3-yaml
|
||||
|
||||
- name: Validate artifacthub-pkg.yml
|
||||
run: |
|
||||
python3 - <<'EOF'
|
||||
import sys, re
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("::warning::PyYAML not available, skipping artifacthub-pkg.yml validation")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
with open("artifacthub-pkg.yml") as f:
|
||||
pkg = yaml.safe_load(f)
|
||||
except FileNotFoundError:
|
||||
print("::error::artifacthub-pkg.yml not found")
|
||||
sys.exit(1)
|
||||
except yaml.YAMLError as e:
|
||||
print(f"::error::artifacthub-pkg.yml is invalid YAML: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
errors = []
|
||||
|
||||
for field in ["version", "name", "description", "homeURL"]:
|
||||
if not pkg.get(field):
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
version = pkg.get("version", "")
|
||||
if version and not re.match(r'^\d+\.\d+\.\d+$', str(version)):
|
||||
errors.append(f"version '{version}' is not SemVer (expected X.Y.Z)")
|
||||
|
||||
annotations = pkg.get("annotations", {}) or {}
|
||||
archive_url = annotations.get("headlamp/plugin/archive-url", "")
|
||||
archive_checksum = annotations.get("headlamp/plugin/archive-checksum", "")
|
||||
|
||||
if not archive_url:
|
||||
errors.append("Missing annotation: headlamp/plugin/archive-url")
|
||||
if not archive_checksum:
|
||||
errors.append("Missing annotation: headlamp/plugin/archive-checksum")
|
||||
elif not re.match(r'^sha256:[0-9a-f]{64}$', str(archive_checksum)):
|
||||
errors.append(f"archive-checksum has unexpected format: '{archive_checksum}' (expected sha256:<64 hex chars>)")
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f"::error::{e}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"artifacthub-pkg.yml valid: name={pkg['name']} version={pkg['version']}")
|
||||
EOF
|
||||
|
||||
- name: Detect package manager
|
||||
id: pkg-manager
|
||||
run: |
|
||||
if [ -f "pnpm-lock.yaml" ]; then
|
||||
echo "manager=pnpm" >> $GITHUB_OUTPUT
|
||||
PM=$(python3 -c "import json,sys; d=json.load(open('package.json')); print('true' if d.get('packageManager','').startswith('pnpm@') else 'false')" 2>/dev/null || echo "false")
|
||||
echo "has_package_manager=$PM" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "manager=npm" >> $GITHUB_OUTPUT
|
||||
echo "has_package_manager=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache: ${{ steps.pkg-manager.outputs.manager == 'npm' && 'npm' || '' }}
|
||||
|
||||
- name: Setup pnpm (via Corepack, reads version from packageManager field)
|
||||
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'true'
|
||||
run: |
|
||||
npm install -g corepack
|
||||
corepack enable pnpm
|
||||
corepack install
|
||||
|
||||
- name: Setup pnpm (version latest)
|
||||
if: steps.pkg-manager.outputs.manager == 'pnpm' && steps.pkg-manager.outputs.has_package_manager == 'false'
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
run_install: false
|
||||
version: latest
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
||||
run: echo "dir=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm store
|
||||
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.dir }}
|
||||
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-
|
||||
|
||||
- name: Validate pnpm lockfile freshness
|
||||
if: steps.pkg-manager.outputs.manager == 'pnpm'
|
||||
run: |
|
||||
if [ ! -f "pnpm-lock.yaml" ]; then
|
||||
echo "No pnpm-lock.yaml found, skipping lockfile freshness check"
|
||||
exit 0
|
||||
fi
|
||||
if ! grep -q 'overrides:' pnpm-lock.yaml 2>/dev/null; then
|
||||
echo "No overrides section in pnpm-lock.yaml, skipping lockfile freshness check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Detected pnpm-lock.yaml with overrides section. Checking lockfile freshness..."
|
||||
ERR_FILE=$(mktemp)
|
||||
if pnpm install --frozen-lockfile 2>&1 | tee "$ERR_FILE"; then
|
||||
echo "Lockfile is fresh."
|
||||
else
|
||||
if grep -q "CONFIG_MISMATCH\|EBADLOCKFILE\|ERR_PNPM_LOCKFILE" "$ERR_FILE"; then
|
||||
echo ""
|
||||
echo "::error::pnpm-lock.yaml is out of sync with package.json overrides."
|
||||
echo "::error::Run 'pnpm install' to regenerate the lockfile and commit the updated pnpm-lock.yaml."
|
||||
rm -f "$ERR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$ERR_FILE"
|
||||
echo "::warning::Install failed with a different error. Will retry in the Install dependencies step."
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
max_attempts=3
|
||||
attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Attempt $attempt of $max_attempts"
|
||||
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||
pnpm install --frozen-lockfile && break
|
||||
else
|
||||
npm ci && break
|
||||
fi
|
||||
if [ $attempt -lt $max_attempts ]; then
|
||||
echo "::warning::Install step failed on attempt $attempt. Retrying in 5 seconds..."
|
||||
sleep 5
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "::error::Install step failed after $max_attempts attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: |
|
||||
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||
pnpm run lint
|
||||
else
|
||||
npm run lint
|
||||
fi
|
||||
|
||||
- name: Type-check
|
||||
run: npm run tsc
|
||||
run: |
|
||||
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||
pnpm run tsc
|
||||
else
|
||||
npm run tsc
|
||||
fi
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
run: |
|
||||
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||
pnpm run format:check
|
||||
else
|
||||
npm run format:check
|
||||
fi
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
run: |
|
||||
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||
pnpm test
|
||||
else
|
||||
npm test
|
||||
fi
|
||||
|
||||
- name: Security audit
|
||||
run: |
|
||||
if [ "${{ steps.pkg-manager.outputs.manager }}" = "pnpm" ]; then
|
||||
npx audit-ci --pnpm --audit-level=high --config ./audit-ci.jsonc
|
||||
else
|
||||
npx audit-ci --npm --audit-level=high --config ./audit-ci.jsonc
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
name: Promotion Gate
|
||||
|
||||
# dev PRs: no gate (engineer self-merges).
|
||||
# uat PRs: QA approval required.
|
||||
# main PRs: UAT approval required (uat→main promotions).
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
pull_request:
|
||||
branches: [uat, main]
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
promotion-gate:
|
||||
name: Promotion Gate
|
||||
runs-on: ubuntu-latest
|
||||
container: ubuntu:latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl jq
|
||||
|
||||
- name: Check promotion approval
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then
|
||||
echo "::notice::No PR number in context. Skipping promotion gate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Checking promotion gate for PR #${PR_NUMBER} targeting ${BASE_REF} in ${REPO}"
|
||||
|
||||
if [ -z "${BASE_REF}" ] && [ -n "${PR_NUMBER}" ] && [ "${PR_NUMBER}" != "null" ]; then
|
||||
BASE_REF=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.base.ref')
|
||||
echo "BASE_REF was empty; resolved from PR #${PR_NUMBER} API: ${BASE_REF}"
|
||||
fi
|
||||
|
||||
# Determine required reviewer based on target branch
|
||||
case "${BASE_REF}" in
|
||||
dev)
|
||||
echo "Target is dev — no review required. Engineers self-merge."
|
||||
exit 0
|
||||
;;
|
||||
uat)
|
||||
REQUIRED_REVIEWER="pe_regina"
|
||||
GATE_NAME="QA"
|
||||
;;
|
||||
main)
|
||||
REQUIRED_REVIEWER="pe_regina"
|
||||
GATE_NAME="QA"
|
||||
# For plugin repos (Pipeline A), UAT approval is needed for uat→main
|
||||
# Check if the source branch is uat
|
||||
SOURCE_REF="${HEAD_REF}"
|
||||
|
||||
if [ "${SOURCE_REF}" = "uat" ]; then
|
||||
REQUIRED_REVIEWER="pe_patty"
|
||||
GATE_NAME="UAT"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::notice::Target branch '${BASE_REF}' has no promotion gate configured."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Required reviewer: ${REQUIRED_REVIEWER} (${GATE_NAME})"
|
||||
|
||||
# For uat→main promotions, pe_patty may not be able to review (bot account).
|
||||
# Accept pe_nancy (CTO) as a valid alternative reviewer.
|
||||
ALT_REVIEWER=""
|
||||
if [ "${REQUIRED_REVIEWER}" = "pe_patty" ]; then
|
||||
ALT_REVIEWER="pe_nancy"
|
||||
fi
|
||||
|
||||
REVIEWS=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"https://git.farh.net/api/v1/repos/${REPO}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
if [ -z "${REVIEWS}" ] || [ "${REVIEWS}" = "null" ]; then
|
||||
echo "::warning::Could not fetch reviews for PR #${PR_NUMBER}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${REQUIRED_REVIEWER}" \
|
||||
'[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end')
|
||||
|
||||
echo "${GATE_NAME} (${REQUIRED_REVIEWER}) approved: ${REVIEWER_APPROVED}"
|
||||
|
||||
# Fallback: check if CTO approved as alternative for uat→main
|
||||
if [ "${REVIEWER_APPROVED}" != "true" ] && [ -n "${ALT_REVIEWER}" ]; then
|
||||
REVIEWER_APPROVED=$(echo "${REVIEWS}" | jq -r --arg user "${ALT_REVIEWER}" \
|
||||
'[.[] | select(.user.login == $user)] | last | if .state then .state == "APPROVED" else false end')
|
||||
if [ "${REVIEWER_APPROVED}" = "true" ]; then
|
||||
echo "CTO (${ALT_REVIEWER}) approved as fallback for UAT gate."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${REVIEWER_APPROVED}" = "true" ]; then
|
||||
echo "Promotion gate passed: ${GATE_NAME} has approved."
|
||||
else
|
||||
echo "Promotion gate failed: waiting for ${GATE_NAME} approval from ${REQUIRED_REVIEWER}."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,53 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: local-ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npm run e2e
|
||||
env:
|
||||
HEADLAMP_URL: ${{ secrets.HEADLAMP_URL || 'http://headlamp.kube-system.svc.cluster.local' }}
|
||||
HEADLAMP_TOKEN: ${{ secrets.HEADLAMP_TOKEN }}
|
||||
AUTHENTIK_USERNAME: ${{ secrets.AUTHENTIK_USERNAME }}
|
||||
AUTHENTIK_PASSWORD: ${{ secrets.AUTHENTIK_PASSWORD }}
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
@@ -4,103 +4,80 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. 1.0.0)'
|
||||
description: 'Release version (e.g. 1.0.1)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yaml
|
||||
|
||||
release:
|
||||
needs: ci
|
||||
runs-on: local-ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in X.Y.Z format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Update version in package.json
|
||||
run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Update artifacthub-pkg.yml
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
PKG_NAME=$(jq -r .name package.json)
|
||||
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${VERSION}.tar.gz"
|
||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
|
||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Package plugin
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Prepare release tarball
|
||||
- name: Get tarball path
|
||||
id: tarball
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
PKG_NAME=$(jq -r .name package.json)
|
||||
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
|
||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
||||
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
|
||||
# headlamp-plugin package outputs the tarball path, e.g.:
|
||||
# "Packaged: /path/to/headlamp-polaris-1.0.0.tar.gz"
|
||||
output=$(pnpm run package 2>&1)
|
||||
echo "output=$output"
|
||||
# Extract tarball name, e.g. headlamp-polaris-1.0.0.tar.gz
|
||||
tarball_name=$(echo "$output" | grep -oP 'headlamp-polaris-\d+\.\d+\.\d+\.tar\.gz' | tail -1)
|
||||
echo "tarball_name=$tarball_name" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate tarball
|
||||
run: |
|
||||
echo "Tarball: ${{ env.TARBALL }}"
|
||||
ls -lh "${{ env.TARBALL }}"
|
||||
tar -tzf "${{ env.TARBALL }}" | head -20
|
||||
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
|
||||
|
||||
- name: Compute checksum
|
||||
run: |
|
||||
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
||||
|
||||
- name: Commit and tag
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
git add package.json package-lock.json artifacthub-pkg.yml
|
||||
git commit -m "release: v${VERSION}"
|
||||
git tag "v${VERSION}"
|
||||
git push origin main --tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: "v${{ inputs.version }}"
|
||||
files: ${{ env.TARBALL }}
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_URL: https://git.farh.net
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
REPO: privilegedescalation/headlamp-polaris-plugin
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
ASSET_NAME="headlamp-polaris-${VERSION}.tar.gz"
|
||||
|
||||
# Create the release via Gitea API
|
||||
RELEASE_RESPONSE=$(
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
||||
-d "{
|
||||
\"tag_name\": \"v${VERSION}\",
|
||||
\"name\": \"v${VERSION}\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}"
|
||||
)
|
||||
echo "Release response: ${RELEASE_RESPONSE}"
|
||||
|
||||
RELEASE_ID=$(echo "${RELEASE_RESPONSE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Failed to create release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Upload the tarball asset
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "${{ steps.tarball.outputs.tarball_name }}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${ASSET_NAME}"
|
||||
+1
-3
@@ -2,9 +2,7 @@ node_modules/
|
||||
dist/
|
||||
.headlamp-plugin/
|
||||
*.tar.gz
|
||||
e2e/.auth/
|
||||
test-results/
|
||||
.playwright-mcp/
|
||||
.env
|
||||
.env.local
|
||||
.eslintcache
|
||||
package-lock.json
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"config": {
|
||||
// Line length — not enforced for docs with code examples
|
||||
"MD013": false,
|
||||
// First line heading — files use YAML frontmatter, not headings
|
||||
"MD041": false,
|
||||
// Emphasis as heading — common pattern for Option 1/2/3 sections
|
||||
"MD036": false,
|
||||
// No duplicate heading — changelog files repeat section names intentionally
|
||||
"MD024": false,
|
||||
// Fenced code language — not always applicable for diagram blocks
|
||||
"MD040": false,
|
||||
// Table column style — table alignment is visual, not semantic
|
||||
"MD060": false,
|
||||
// Ordered list item prefix — number resets are intentional in documents
|
||||
"MD029": false,
|
||||
// No inline HTML — each elements are valid in valid Markdown
|
||||
"MD033": false,
|
||||
// List marker space — spacing after list markers varies by editor
|
||||
"MD030": false,
|
||||
// Blanks around headings — not always needed in compact docs
|
||||
"MD022": false,
|
||||
// Blanks around lists — not always needed in compact docs
|
||||
"MD032": false,
|
||||
// Blanks around fences — not always needed between adjacent blocks
|
||||
"MD031": false,
|
||||
// Multiple blanks — editor artifacts, not semantic
|
||||
"MD012": false,
|
||||
// Single title — files may have multiple H1 sections
|
||||
"MD025": false,
|
||||
// Trailing spaces — editor artifacts
|
||||
"MD009": false,
|
||||
// Bare URLs — URL shortening not always needed
|
||||
"MD034": false,
|
||||
// Single trailing newline — editor artifacts
|
||||
"MD047": false,
|
||||
// Trailing punctuation — heading punctuation is intentional
|
||||
"MD026": false,
|
||||
// Space in emphasis — double-asterisk bold spacing varies by renderer
|
||||
"MD037": false,
|
||||
// No hard tabs — some generated docs use tabs for indentation
|
||||
"MD010": false,
|
||||
// Code block style — generated docs may use inconsistent styles
|
||||
"MD046": false,
|
||||
// Comment style — generated docs have no comments
|
||||
"MD048": false,
|
||||
// Commands show output — shell examples intentionally show only commands
|
||||
"MD014": false
|
||||
},
|
||||
"ignores": [
|
||||
"docs/api-reference/generated/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
docs/api-reference/generated/**
|
||||
+56
-1
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-03-22
|
||||
|
||||
First stable release. The plugin API (routes, sidebar entries, settings schema, and app bar action) is
|
||||
now frozen — no breaking changes without a new major version.
|
||||
|
||||
### Security
|
||||
- Patched 8 of 9 npm audit vulnerabilities via `pnpm.overrides` (#92)
|
||||
|
||||
### Added
|
||||
- **Dual-approval CI check**: PRs now require approval from both CTO and QA before merging (#98, #76)
|
||||
- **ExemptionManager test suite**: Full coverage of annotation-based exemption flows, exemption creation, and inline feedback (#82)
|
||||
- **RBAC preflight check**: `deploy-e2e-headlamp.sh` now verifies runner RBAC before attempting E2E deploy (#80)
|
||||
|
||||
### Fixed
|
||||
- **E2E infrastructure overhaul**: Replaced Dockerfile.e2e with ConfigMap volume mount for plugin loading; tests now run in the `privilegedescalation-dev` namespace (#73, #89, #94)
|
||||
- **E2E token auth**: Workflow uses GitHub App token auth and handles the `/token` redirect correctly (#97)
|
||||
- **E2E HTTP readiness**: `deploy-e2e-headlamp.sh` waits for HTTP reachability after rollout before running tests (#104)
|
||||
- **E2E runner label**: Updated to `runners-privilegedescalation` for self-hosted ARC runners (#71)
|
||||
- **Direct devDependencies**: Added `typescript`, `eslint`, `prettier`, and `@headlamp-k8s/eslint-config` as explicit direct devDependencies to prevent phantom-dep failures in clean installs (#95, #102)
|
||||
|
||||
### Changed
|
||||
- **pnpm version pinned**: `packageManager` field in `package.json` pins the pnpm version used in CI (#103)
|
||||
- **GitHub Actions SHA pinning**: Renovate `pinDigests` enabled to SHA-pin all GitHub Actions (#105)
|
||||
- **ArtifactHub metadata polish**: Improved `install` instructions and `changes` section formatting (#82)
|
||||
|
||||
## [0.6.0] - 2026-03-04
|
||||
|
||||
### Fixed
|
||||
- **ExemptionManager apiVersion bug**: `apps` and `batch` resources now correctly use `/apis/{group}/v1/` instead of the broken `/api/v1/` path
|
||||
- **Strict TypeScript**: Replaced `resource: any` in InlineAuditSection with proper `KubeResource` interface
|
||||
- **PolarisDataContext test mock**: Added missing `triggerRefresh` to mock, preventing silent `undefined` for `refresh` in context
|
||||
- **DashboardView test**: Fixed `SimpleTable` mock that used `Array<any>` and didn't exercise column getters
|
||||
|
||||
### Changed
|
||||
- **Dark mode / theming**: Replaced all `var(--mui-palette-*)` CSS variables with `useTheme()` + `theme.palette.*` across all components (DashboardView, NamespacesListView, InlineAuditSection, ExemptionManager, PolarisSettings, AppBarScoreBadge)
|
||||
- **Namespace drawer**: Replaced custom `<style>` block + positioned `<div>` with MUI `Drawer` component for proper accessibility (`role="dialog"`, `aria-modal`, Escape key handling via MUI)
|
||||
- **AppBarScoreBadge**: Uses `theme.palette.success/warning/error` with proper `contrastText` instead of hardcoded hex colors
|
||||
- **ExemptionManager feedback**: Replaced `alert()` calls with `StatusLabel`-based inline feedback; removed dead `getExemptions()` stub and unreachable remove-exemption UI
|
||||
- **URL construction**: Exported `getPolarisApiPath` and `isFullUrl` from `polaris.ts`; PolarisSettings now reuses them instead of duplicating logic
|
||||
|
||||
### Added
|
||||
- **Error boundaries**: All registered components (routes, detail sections, app bar action) wrapped in `PolarisErrorBoundary` for graceful error rendering
|
||||
- **Tests for InlineAuditSection** (7 tests): loading, unsupported kind, not found, score/summary, failing checks, link, exemption manager
|
||||
- **Tests for AppBarScoreBadge** (6 tests): loading, no data, score colors, navigation, aria-label
|
||||
- **Tests for topIssues.ts** (8 tests): empty, all pass, controller/pod/container results, counting, ignore filter, sorting, max 10
|
||||
- **Tests for checkMapping.ts** (11 tests): name/description/category/severity lookups, unknown checks, CHECK_MAPPING structure validation
|
||||
|
||||
### Removed
|
||||
- **NamespaceDetailView.tsx**: Dead code with no registered route (replaced by drawer in NamespacesListView)
|
||||
- **NamespaceDetailView.test.tsx**: Tests for removed component
|
||||
- **MockPolarisProvider in test-utils.tsx**: Unused mock provider (tests use `vi.mock` instead)
|
||||
- **`getSeverityColor` export in checkMapping.ts**: Dead export not imported anywhere
|
||||
|
||||
## [0.3.5] - 2026-02-12
|
||||
|
||||
### Fixed
|
||||
@@ -242,7 +295,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Automated release workflow
|
||||
- Basic CI/CD pipeline
|
||||
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v0.3.5...HEAD
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v0.7.2...v1.0.0
|
||||
[0.6.0]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.6.0
|
||||
[0.3.5]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.5
|
||||
[0.3.4]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.4
|
||||
[0.3.3]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.3
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project
|
||||
|
||||
Headlamp plugin surfacing Fairwinds Polaris audit results. Queries the Polaris dashboard API via Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). Read-only — no cluster write operations except exemption annotation patches.
|
||||
Headlamp plugin surfacing Fairwinds Polaris audit results. Queries the Polaris dashboard API via Kubernetes service proxy (`/api/v1/namespaces/polaris/services/http:polaris-dashboard:80/proxy/results.json`). Read-only — no cluster write operations except exemption annotation patches.
|
||||
|
||||
- **Plugin name**: `polaris`
|
||||
- **Target**: Headlamp >= v0.26
|
||||
@@ -25,8 +25,6 @@ npm run format:check # Prettier check
|
||||
npm test # vitest run
|
||||
npm run test:watch # vitest watch mode
|
||||
npx vitest run src/api/polaris.test.ts # run a single test file
|
||||
npm run e2e # Playwright E2E tests
|
||||
npm run e2e:headed # Playwright headed mode
|
||||
```
|
||||
|
||||
All tests and `tsc` must pass before committing.
|
||||
@@ -35,17 +33,16 @@ All tests and `tsc` must pass before committing.
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerAppBarAction, registerPluginSettings
|
||||
├── test-utils.tsx # Shared test utilities
|
||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerAppBarAction, registerPluginSettings; PolarisErrorBoundary
|
||||
├── test-utils.tsx # Shared test fixtures (makeResult, makeAuditData)
|
||||
├── api/
|
||||
│ ├── polaris.ts # Types (AuditData schema), countResults utilities, refresh settings
|
||||
│ ├── polaris.ts # Types (AuditData schema), countResults utilities, refresh settings, getPolarisApiPath, isFullUrl
|
||||
│ ├── checkMapping.ts # Polaris check ID → human-readable name mapping
|
||||
│ ├── topIssues.ts # Top failing checks aggregation logic
|
||||
│ └── PolarisDataContext.tsx # Shared React context provider (ApiProxy.request + configurable refresh)
|
||||
└── components/
|
||||
├── DashboardView.tsx # Overview page (score gauge, check distribution, top failing checks)
|
||||
├── NamespacesListView.tsx # Namespace list with per-namespace scores
|
||||
├── NamespaceDetailView.tsx # Per-namespace drill-down with resource table
|
||||
├── NamespacesListView.tsx # Namespace list with per-namespace scores + MUI Drawer detail panel
|
||||
├── InlineAuditSection.tsx # Injected into Deployment/StatefulSet/DaemonSet/Job/CronJob detail views
|
||||
├── ExemptionManager.tsx # Polaris exemption annotation management
|
||||
├── AppBarScoreBadge.tsx # App bar cluster score chip
|
||||
@@ -60,12 +57,15 @@ Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy an
|
||||
|
||||
## Code conventions
|
||||
|
||||
- Functional React components only — no class components
|
||||
- Functional React components only — class components only for error boundaries (PolarisErrorBoundary in index.tsx)
|
||||
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
|
||||
- No additional UI libraries (no MUI direct imports, no Ant Design, etc.)
|
||||
- `@mui/material` is available as a shared external via Headlamp — use `useTheme` from `@mui/material/styles` for theming, MUI `Drawer`/`IconButton` etc. as needed. Do NOT add `@mui/material` to package.json dependencies.
|
||||
- Use `useTheme()` + `theme.palette.*` for all theme-aware colors — never use `var(--mui-palette-*)` CSS variables
|
||||
- No other UI libraries (no Ant Design, etc.)
|
||||
- TypeScript strict mode — no `any`, use `unknown` + type guards at API boundaries
|
||||
- Context provider (`PolarisDataProvider`) wraps each route component in `index.tsx`
|
||||
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
|
||||
- All registered components wrapped in `PolarisErrorBoundary` for graceful error handling
|
||||
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)` and `vi.mock('@mui/material/styles', ...)`
|
||||
- `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Installation Policy
|
||||
|
||||
## Approved Installation Method
|
||||
|
||||
**The ONLY approved method for installing this plugin is via [Artifact Hub](https://artifacthub.io/) using the Headlamp plugin installer.**
|
||||
|
||||
No other installation method is acceptable. This includes but is not limited to:
|
||||
|
||||
- Direct installation from GitHub release assets
|
||||
- Manual npm pack / tarball extraction
|
||||
- initContainer workarounds that bypass Artifact Hub
|
||||
- Direct file copy or sidecar injection
|
||||
|
||||
## Enforcement
|
||||
|
||||
All deployment configurations, CI/CD pipelines, and documentation MUST reference Artifact Hub as the sole plugin distribution channel. Any pull request that introduces an alternative installation method will be rejected.
|
||||
|
||||
## Rationale
|
||||
|
||||
Artifact Hub provides verified checksums, consistent versioning, and a standard discovery mechanism for the CNCF ecosystem. Bypassing it introduces security and integrity risks.
|
||||
|
||||
---
|
||||
|
||||
*This policy is set by the CTO and approved by the CEO of Privileged Escalation.*
|
||||
@@ -0,0 +1,73 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -229,7 +229,7 @@ Headlamp v0.39.0 with default `watchPlugins: true` treats catalog-managed plugin
|
||||
**Action Items:**
|
||||
- [ ] Parallelize test execution
|
||||
- [ ] Add npm cache to GitHub Actions
|
||||
- [ ] Integrate Dependabot
|
||||
- [x] Renovate is configured org-wide via `github>privilegedescalation/.github:renovate-config`
|
||||
- [ ] Add semantic-release
|
||||
|
||||
---
|
||||
|
||||
@@ -26,7 +26,7 @@ Adds a **Polaris** top-level sidebar section to Headlamp with comprehensive secu
|
||||
- **Exemption Management** -- add or remove Polaris exemptions via annotation patches directly from the UI; supports per-check exemptions or exempt-all
|
||||
- **Configurable Dashboard URL** -- supports both Kubernetes service proxy URLs and full HTTP/HTTPS URLs for external Polaris deployments
|
||||
- **Connection Testing** -- test button in settings to verify Polaris dashboard connectivity and show version info
|
||||
- **Dark Mode Support** -- full theme adaptation using MUI CSS variables; drawer, settings, and all UI elements respect system/Headlamp theme
|
||||
- **Dark Mode Support** -- full theme adaptation using MUI `useTheme()` API; drawer, settings, and all UI elements respect system/Headlamp theme
|
||||
|
||||
### Data & Refresh
|
||||
|
||||
@@ -48,9 +48,14 @@ Polaris must be deployed in the `polaris` namespace with the dashboard component
|
||||
|
||||
## Installing
|
||||
|
||||
### Option 1: Headlamp Plugin Manager (Recommended)
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin). Install via the Headlamp UI:
|
||||
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin). Configure Headlamp via Helm:
|
||||
1. Go to **Settings → Plugins**
|
||||
2. Click **Catalog** tab
|
||||
3. Search for "Polaris"
|
||||
4. Click **Install**
|
||||
|
||||
Or configure Headlamp via Helm:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
@@ -62,56 +67,6 @@ pluginsManager:
|
||||
url: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v0.3.10/polaris-0.3.10.tar.gz
|
||||
```
|
||||
|
||||
Or install via the Headlamp UI:
|
||||
|
||||
1. Go to **Settings → Plugins**
|
||||
2. Click **Catalog** tab
|
||||
3. Search for "Polaris"
|
||||
4. Click **Install**
|
||||
|
||||
### Option 2: Sidecar Container (Alternative)
|
||||
|
||||
For detailed sidecar installation instructions, see [docs/DEPLOYMENT.md#installation-method-2-sidecar-container](docs/DEPLOYMENT.md#installation-method-2-sidecar-container).
|
||||
|
||||
```yaml
|
||||
sidecars:
|
||||
- name: headlamp-plugin
|
||||
image: node:lts-alpine
|
||||
command: ['/bin/sh']
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
npm install -g @kinvolk/headlamp-plugin
|
||||
headlamp-plugin install --config /config/plugin.yml
|
||||
tail -f /dev/null
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
mountPath: /headlamp/plugins
|
||||
- name: plugin-config
|
||||
mountPath: /config
|
||||
```
|
||||
|
||||
### Option 3: Manual Tarball Install
|
||||
|
||||
Download the `.tar.gz` from the [GitHub releases page](https://github.com/privilegedescalation/headlamp-polaris-plugin/releases), then extract into Headlamp's plugin directory:
|
||||
|
||||
```bash
|
||||
wget https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v0.3.10/polaris-0.3.10.tar.gz
|
||||
tar xzf polaris-0.3.10.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
### Option 4: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/privilegedescalation/headlamp-polaris-plugin.git
|
||||
cd headlamp-polaris-plugin
|
||||
npm install
|
||||
npm run build
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
```
|
||||
|
||||
For complete installation instructions including Helm integration, FluxCD examples, and production deployment checklist, see **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)**.
|
||||
|
||||
## RBAC / Security Setup
|
||||
|
||||
The plugin fetches audit data through the Kubernetes API server's **service proxy** sub-resource. The identity making the request (Headlamp's service account, or the user's own token in token-auth mode) must be granted:
|
||||
@@ -142,7 +97,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp # adjust to match your Headlamp service account
|
||||
namespace: kube-system # adjust to match the namespace Headlamp runs in
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -199,7 +154,7 @@ Quick reference:
|
||||
| **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh browser (Cmd+Shift+R / Ctrl+Shift+F5) |
|
||||
| **403 Access Denied** | Missing RBAC binding for `services/proxy` | Apply Role + RoleBinding from RBAC section |
|
||||
| **404 or 503** | Polaris not installed, or dashboard disabled | Install Polaris with `dashboard.enabled: true` in `polaris` namespace |
|
||||
| **Dark mode white backgrounds** | Old plugin version | Upgrade to v0.3.5+ and hard refresh browser |
|
||||
| **Dark mode white backgrounds** | Old plugin version | Upgrade to v0.6.0+ and hard refresh browser |
|
||||
| **Settings page empty** | Old plugin version | Upgrade to v0.3.3+ |
|
||||
| **No data / infinite spinner** | Network policy or Polaris pod down | Check network policies and `kubectl get pods -n polaris` |
|
||||
|
||||
@@ -242,7 +197,7 @@ npm test
|
||||
npm run test:watch
|
||||
|
||||
# E2E tests (Playwright)
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system --duration=24h)
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n <your-namespace> --duration=24h)
|
||||
npm run e2e
|
||||
npm run e2e:headed # see browser
|
||||
```
|
||||
@@ -253,17 +208,21 @@ For complete testing guide including CI/CD integration, see **[docs/TESTING.md](
|
||||
|
||||
```
|
||||
src/
|
||||
index.tsx -- Entry point. Registers sidebar entries and routes.
|
||||
index.tsx -- Entry point. Registers sidebar entries, routes, and error boundaries.
|
||||
test-utils.tsx -- Shared test fixtures (makeResult, makeAuditData).
|
||||
api/
|
||||
polaris.ts -- TypeScript types (AuditData schema), usePolarisData hook,
|
||||
countResults utilities, refresh interval settings.
|
||||
polaris.test.ts -- Unit tests for utility functions (vitest).
|
||||
checkMapping.ts -- Polaris check ID → human-readable name mapping.
|
||||
topIssues.ts -- Top failing checks aggregation logic.
|
||||
PolarisDataContext.tsx -- React context provider; shared data fetch across views.
|
||||
components/
|
||||
DashboardView.tsx -- Overview page (score, check summary with skipped, cluster info).
|
||||
NamespacesListView.tsx -- Namespace list with scores and links to detail views.
|
||||
NamespaceDetailView.tsx -- Per-namespace drill-down with resource table.
|
||||
PolarisSettings.tsx -- Plugin settings page (refresh interval selector).
|
||||
NamespacesListView.tsx -- Namespace list with scores; MUI Drawer detail panel.
|
||||
InlineAuditSection.tsx -- Inline audit for Deployment/StatefulSet/DaemonSet/Job/CronJob detail views.
|
||||
ExemptionManager.tsx -- Polaris exemption annotation management.
|
||||
AppBarScoreBadge.tsx -- App bar cluster score chip.
|
||||
PolarisSettings.tsx -- Plugin settings page (refresh interval, dashboard URL).
|
||||
vitest.config.mts -- Vitest configuration (jsdom environment).
|
||||
```
|
||||
|
||||
|
||||
+4
-4
@@ -71,7 +71,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -149,7 +149,7 @@ spec:
|
||||
|
||||
### Service Account (Default)
|
||||
|
||||
Headlamp runs with a dedicated service account (`headlamp` in `kube-system`). All users share the same permissions defined by this service account's RBAC bindings.
|
||||
Headlamp runs with a dedicated service account (`headlamp` in the namespace where Headlamp is installed). All users share the same permissions defined by this service account's RBAC bindings.
|
||||
|
||||
**Security Considerations:**
|
||||
- All users have identical access to the plugin
|
||||
@@ -212,7 +212,7 @@ If you discover a security vulnerability in this plugin, please report it via:
|
||||
|
||||
The project uses:
|
||||
- **npm audit**: Runs automatically during `npm install`
|
||||
- **Dependabot**: GitHub Dependabot monitors dependencies and creates PRs for updates
|
||||
- **Renovate**: Automated dependency updates via Mend Renovate (org-wide configured)
|
||||
- **GitHub Actions**: CI workflow runs `npm audit` on every commit
|
||||
|
||||
### Updating Dependencies
|
||||
@@ -317,7 +317,7 @@ All service proxy requests are logged in Kubernetes API audit logs (if enabled):
|
||||
"verb": "get",
|
||||
"requestURI": "/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json",
|
||||
"user": {
|
||||
"username": "system:serviceaccount:kube-system:headlamp",
|
||||
"username": "system:serviceaccount:<your-namespace>:headlamp",
|
||||
"groups": ["system:serviceaccounts", "system:authenticated"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# PRI-324 Spec: Make E2E Workflow Self-Sufficient with RBAC
|
||||
|
||||
## Context
|
||||
|
||||
PR #123 introduced an RBAC pre-flight check to the E2E workflow. QA (Nancy, acting as QA) verified the "fails fast without RBAC" path works, but found that the "with RBAC passes" path had no green CI evidence — the workflow did not apply RBAC before the pre-flight check.
|
||||
|
||||
PR #131 attempted to fix this by adding `kubectl apply` steps and extending the CI runner RBAC, but its merge commit (739db6fe) was reverted by the next commit on main (aa1db921) due to a vulnerability fix PR (#128).
|
||||
|
||||
The current E2E workflow on `main` lacks the RBAC apply steps and CI runner permissions needed to make the pre-flight check meaningful.
|
||||
|
||||
## Required Changes
|
||||
|
||||
### 1. `.github/workflows/e2e.yaml`
|
||||
|
||||
Add between the "Setup kubectl" and "Install dependencies" steps:
|
||||
|
||||
```yaml
|
||||
- name: Apply RBAC for E2E pipeline
|
||||
run: |
|
||||
set -x
|
||||
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml --dry-run=server 2>&1 || true
|
||||
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml 2>&1
|
||||
echo "exit code: $?"
|
||||
echo "Waiting for RBAC propagation..."
|
||||
sleep 5
|
||||
echo "Verifying CI runner permissions..."
|
||||
kubectl auth can-i create roles -n headlamp-dev --as="system:serviceaccount:arc-runners:runners-privilegedescalation-gha-rs-no-permission" 2>&1 || { echo "::error::CI runner still lacks roles permission after propagation wait"; exit 1; }
|
||||
set +x
|
||||
|
||||
- name: Apply Polaris dashboard RBAC
|
||||
run: kubectl apply -f deployment/polaris-rbac.yaml
|
||||
|
||||
- name: RBAC pre-flight check
|
||||
run: |
|
||||
echo "Checking RBAC resources..."
|
||||
MISSING=0
|
||||
kubectl get role polaris-dashboard-proxy-reader -n polaris -o name >/dev/null 2>&1 || MISSING=1
|
||||
kubectl get rolebinding polaris-dashboard-proxy-reader -n polaris -o name >/dev/null 2>&1 || MISSING=1
|
||||
kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null || MISSING=1
|
||||
if [ "$MISSING" -eq 0 ]; then
|
||||
echo "RBAC pre-flight check passed."
|
||||
else
|
||||
echo "::error::RBAC pre-flight check failed. Missing required permissions."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### 2. `deployment/e2e-ci-runner-rbac.yaml`
|
||||
|
||||
Add a new Role + RoleBinding for the `polaris` namespace (from PR #131):
|
||||
|
||||
```yaml
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: e2e-ci-runner-polaris
|
||||
namespace: polaris
|
||||
rules:
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources: ["roles", "rolebindings"]
|
||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: e2e-ci-runner-polaris
|
||||
namespace: polaris
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: runners-privilegedescalation-gha-rs-no-permission
|
||||
namespace: arc-runners
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: e2e-ci-runner-polaris
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
And add to the existing `e2e-ci-runner` Role in the `headlamp-dev` namespace:
|
||||
```yaml
|
||||
# Apply Polaris dashboard RBAC in the polaris namespace
|
||||
- apiGroups: ["rbac.authorization.k8s.io"]
|
||||
resources: ["roles", "rolebindings"]
|
||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Workflow applies `deployment/e2e-ci-runner-rbac.yaml` before the pre-flight check
|
||||
- [ ] Workflow applies `deployment/polaris-rbac.yaml` before the pre-flight check
|
||||
- [ ] CI runner has RBAC to apply the manifests (added via new Role+RoleBinding in polaris namespace)
|
||||
- [ ] E2E pipeline passes on the PR branch (proof of green path)
|
||||
- [ ] `kubectl get … --quiet` flag removed (QA nit)
|
||||
- [ ] `MISSING_ROLE`/`MISSING_ROLEBINDING` collapsed to single `MISSING` flag (QA nit)
|
||||
|
||||
## Definition of Done
|
||||
|
||||
PR #123 QA changes-requested are addressed: the workflow is self-sufficient (applies its own RBAC), the green path is demonstrated, and QA review is re-requested.
|
||||
+47
-5
@@ -1,5 +1,5 @@
|
||||
version: "0.5.1"
|
||||
name: headlamp-polaris-plugin
|
||||
version: "1.0.0"
|
||||
name: headlamp-polaris
|
||||
displayName: Polaris
|
||||
createdAt: "2026-02-05T19:00:00Z"
|
||||
description: >-
|
||||
@@ -11,6 +11,7 @@ description: >-
|
||||
`polaris-dashboard` service in the `polaris` namespace.
|
||||
license: Apache-2.0
|
||||
homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
|
||||
appVersion: "10.1.6"
|
||||
category: security
|
||||
keywords:
|
||||
- polaris
|
||||
@@ -24,11 +25,52 @@ links:
|
||||
url: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
|
||||
- name: Polaris
|
||||
url: "https://polaris.docs.fairwinds.com/"
|
||||
install: |
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. [Headlamp](https://headlamp.dev) v0.26.0 or later
|
||||
2. [Fairwinds Polaris](https://polaris.docs.fairwinds.com/) installed and the dashboard running in your cluster
|
||||
|
||||
### Install via Headlamp Plugin Catalog
|
||||
|
||||
1. Open Headlamp and navigate to **Settings → Plugin Catalog**
|
||||
2. Search for **"Polaris"**
|
||||
3. Click **Install** and restart Headlamp when prompted
|
||||
|
||||
The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-polaris).
|
||||
|
||||
## Usage
|
||||
|
||||
After installation, the Polaris plugin adds:
|
||||
- A **cluster score badge** in the Headlamp app bar
|
||||
- A **Polaris** section in the sidebar with the full dashboard and namespace drill-downs
|
||||
- An **inline audit panel** on Deployment, StatefulSet, DaemonSet, Job, and CronJob detail pages
|
||||
|
||||
For more information, see the [README](https://github.com/privilegedescalation/headlamp-polaris-plugin/blob/main/README.md).
|
||||
changes:
|
||||
- kind: security
|
||||
description: Patched 8 npm audit vulnerabilities via pnpm.overrides
|
||||
- kind: added
|
||||
description: Dual-approval required CI check — PRs must be approved by both CTO and QA before merge
|
||||
- kind: added
|
||||
description: ExemptionManager test suite — full coverage of annotation-based exemption flows
|
||||
- kind: fixed
|
||||
description: E2E infrastructure overhauled — ConfigMap volume mount replaces Dockerfile-based approach, tests run in privilegedescalation-dev namespace
|
||||
- kind: fixed
|
||||
description: E2E workflow uses token auth and waits for HTTP reachability before running tests
|
||||
- kind: fixed
|
||||
description: Added explicit direct devDependencies (typescript, eslint, prettier, @headlamp-k8s/eslint-config) to prevent phantom dep failures
|
||||
- kind: changed
|
||||
description: pnpm version pinned via packageManager field; GitHub Actions SHA-pinned via Renovate pinDigests
|
||||
- kind: changed
|
||||
description: v1.0.0 stable release — plugin API (routes, sidebar, settings schema, app bar action) is stable and will not change without a major version bump
|
||||
maintainers:
|
||||
- name: privilegedescalation
|
||||
email: "chris@farhood.org"
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v0.5.1/polaris-0.5.1.tar.gz"
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v1.0.0/headlamp-polaris-1.0.0.tar.gz"
|
||||
headlamp/plugin/version-compat: ">=0.26"
|
||||
headlamp/plugin/archive-checksum: sha256:6203461dccc978ae3f33f4feae102c4eb3169ea87c23dc407ef10ea76dd952db
|
||||
headlamp/plugin/distro-compat: in-cluster
|
||||
headlamp/plugin/archive-checksum: sha256:a165e871b40f11a44950aa9f10eb7f7883276f749026ae7a4f886278ecd9bd7d
|
||||
headlamp/plugin/distro-compat: "in-cluster,web,desktop"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
repositoryID: fc3397f6-a75a-4950-ab50-da75c08a8089
|
||||
repositoryID: 0243bdaf-c926-44dc-b411-a7c291bf1fcd
|
||||
owners:
|
||||
- name: privilegedescalation
|
||||
email: "chris@farhood.org"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// Allowlist for inherited dev-dependency CVEs from @kinvolk/headlamp-plugin
|
||||
// CTO decision (PRI-854): these high-severity vulns are dev/build-time only,
|
||||
// trace to @kinvolk/headlamp-plugin transitive deps (Picomatch, Vite, lodash),
|
||||
// and do NOT ship in production plugin artifacts.
|
||||
"allowlist": [
|
||||
{
|
||||
"id": "GHSA-hhpm-516h-p3p6",
|
||||
"reason": "Picomatch ReDoS: devDependency only, does not ship in production plugin bundle"
|
||||
},
|
||||
{
|
||||
"id": "GHSA-36xf-7xpp-53w5",
|
||||
"reason": "Vite arbitrary file read: devDependency only, does not ship in production plugin bundle"
|
||||
},
|
||||
{
|
||||
"id": "GHSA-jf8v-p3pp-93qh",
|
||||
"reason": "lodash code injection via _.template: devDependency only, does not ship in production plugin bundle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# Headlamp Plugin Loading Issue - Root Cause and Fix
|
||||
|
||||
## Problem
|
||||
Headlamp v0.39.0 was not loading plugins installed via the plugin manager. Plugins appeared in Settings → Plugins but:
|
||||
- No sidebar entries appeared
|
||||
- No plugin settings were available
|
||||
- Plugin JavaScript was not being executed in the browser
|
||||
|
||||
## Root Cause
|
||||
When `config.watchPlugins: true` (the default), Headlamp treats catalog-managed plugins in `/headlamp/plugins/` as "development directory" plugins. This causes:
|
||||
- Backend serves plugin metadata correctly
|
||||
- Backend logs show "Treating catalog-installed plugin in development directory as user plugin"
|
||||
- **Frontend does NOT execute the plugin JavaScript**
|
||||
- Plugin registrations (`registerSidebarEntry`, `registerRoute`, etc.) never happen
|
||||
|
||||
## Solution
|
||||
Set `config.watchPlugins: false` in the Headlamp HelmRelease values:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
values:
|
||||
config:
|
||||
watchPlugins: false
|
||||
pluginsManager:
|
||||
enabled: true
|
||||
configContent: |
|
||||
plugins:
|
||||
- name: polaris
|
||||
source: https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin
|
||||
# ... other plugins
|
||||
```
|
||||
|
||||
## Why This Works
|
||||
With `watchPlugins: false`:
|
||||
- Headlamp no longer treats catalog-managed plugins as "development" plugins
|
||||
- Frontend properly loads and executes plugin JavaScript on startup
|
||||
- Plugin registrations happen correctly
|
||||
- All plugin features (sidebar, routes, settings, etc.) work as expected
|
||||
|
||||
## Testing
|
||||
After applying this fix:
|
||||
1. Verify plugins are installed: `kubectl logs -n kube-system <headlamp-pod> -c headlamp-plugin`
|
||||
2. Verify watchPlugins is false: `kubectl logs -n kube-system <headlamp-pod> -c headlamp | grep "Watch Plugins"`
|
||||
3. Hard refresh browser (Cmd+Shift+R / Ctrl+Shift+F5) to clear cached JavaScript
|
||||
4. Verify plugin sidebar entries appear
|
||||
5. Verify plugin functionality works
|
||||
|
||||
## Additional Notes
|
||||
- This appears to be a bug/limitation in Headlamp v0.39.0
|
||||
- The `watchPlugins` feature is intended for development scenarios where plugins are being actively modified
|
||||
- For production deployments with catalog-managed plugins, `watchPlugins: false` is the correct configuration
|
||||
- Once plugins are loaded, subsequent restarts or updates work correctly as long as `watchPlugins` remains false
|
||||
|
||||
## References
|
||||
- Headlamp Helm Chart: https://github.com/headlamp-k8s/headlamp/tree/main/charts/headlamp
|
||||
- Plugin Manager: https://github.com/headlamp-k8s/headlamp/tree/main/plugins/headlamp-plugin
|
||||
- Issue discovered: 2026-02-11
|
||||
- Fix applied: 2026-02-12
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
# Custom Headlamp values for static plugin installation
|
||||
# This disables the plugin manager and uses an init container instead
|
||||
|
||||
# Disable the plugin manager sidecar
|
||||
pluginsManager:
|
||||
enabled: false
|
||||
|
||||
# Use an init container to install plugins to /headlamp/static-plugins
|
||||
initContainers:
|
||||
- name: install-plugins
|
||||
image: node:lts-alpine
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
echo "Installing plugins to /headlamp/static-plugins..."
|
||||
|
||||
# Create plugins directory
|
||||
mkdir -p /headlamp/static-plugins
|
||||
|
||||
# Set up npm cache
|
||||
export NPM_CONFIG_CACHE=/tmp/npm-cache
|
||||
export NPM_CONFIG_USERCONFIG=/tmp/npm-userconfig
|
||||
mkdir -p /tmp/npm-cache /tmp/npm-userconfig
|
||||
|
||||
# Install polaris plugin
|
||||
echo "Installing polaris plugin..."
|
||||
cd /headlamp/static-plugins
|
||||
npm pack headlamp-polaris-plugin@0.3.0
|
||||
tar -xzf headlamp-polaris-plugin-0.3.0.tgz
|
||||
mv package headlamp-polaris-plugin
|
||||
rm headlamp-polaris-plugin-0.3.0.tgz
|
||||
|
||||
# Install other plugins
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_flux \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-trivy/headlamp_trivy \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_cert-manager \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
npx --yes @headlamp-k8s/plugin@latest install \
|
||||
--source https://artifacthub.io/packages/headlamp/headlamp-plugins/headlamp_ai_assistant \
|
||||
--folderName /headlamp/static-plugins
|
||||
|
||||
echo "All plugins installed successfully"
|
||||
ls -la /headlamp/static-plugins
|
||||
securityContext:
|
||||
runAsUser: 100
|
||||
runAsGroup: 101
|
||||
runAsNonRoot: true
|
||||
privileged: false
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: static-plugins
|
||||
mountPath: /headlamp/static-plugins
|
||||
|
||||
# Configure headlamp to use static plugins
|
||||
config:
|
||||
pluginsDir: /headlamp/static-plugins
|
||||
|
||||
# Add volume for static plugins
|
||||
volumes:
|
||||
- name: static-plugins
|
||||
emptyDir: {}
|
||||
|
||||
# Add volume mount to main container
|
||||
volumeMounts:
|
||||
- name: static-plugins
|
||||
mountPath: /headlamp/static-plugins
|
||||
readOnly: true
|
||||
+2
-2
@@ -33,7 +33,7 @@ kubectl -n polaris get svc polaris-dashboard
|
||||
kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq .PolarisOutputVersion
|
||||
|
||||
# Verify Headlamp is deployed
|
||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
|
||||
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
|
||||
```
|
||||
|
||||
## Installation Methods
|
||||
@@ -59,7 +59,7 @@ kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
|
||||
|
||||
```bash
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
```
|
||||
|
||||
|
||||
+2
-3
@@ -268,10 +268,9 @@ npm run e2e
|
||||
|
||||
```bash
|
||||
# Create token
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system --duration=24h)
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n <your-namespace> --duration=24h)
|
||||
|
||||
# Port-forward for local testing
|
||||
kubectl port-forward -n kube-system svc/headlamp 4466:80
|
||||
kubectl port-forward -n <your-namespace> svc/headlamp 4466:80
|
||||
|
||||
# Run tests
|
||||
HEADLAMP_URL=http://localhost:4466 npm run e2e
|
||||
|
||||
+16
-16
@@ -33,7 +33,7 @@ This guide covers common issues encountered when using the Headlamp Polaris Plug
|
||||
|
||||
```bash
|
||||
# View Headlamp pod logs (plugin sidecar)
|
||||
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
|
||||
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
|
||||
|
||||
# Expected output:
|
||||
# Installing plugin from https://github.com/.../headlamp-polaris-plugin-X.Y.Z.tar.gz
|
||||
@@ -43,7 +43,7 @@ kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
|
||||
**Verify plugin files exist**:
|
||||
|
||||
```bash
|
||||
kubectl exec -n kube-system deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
|
||||
kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
|
||||
# Should show: headlamp-polaris-plugin/
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ Expected subjects:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
```
|
||||
|
||||
For OIDC mode:
|
||||
@@ -154,7 +154,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -169,7 +169,7 @@ Service account mode:
|
||||
```bash
|
||||
# Impersonate Headlamp service account
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
--resource-name=polaris-dashboard \
|
||||
-n polaris
|
||||
# Expected: yes
|
||||
@@ -189,7 +189,7 @@ kubectl auth can-i get services/proxy \
|
||||
After applying RBAC changes:
|
||||
|
||||
```bash
|
||||
kubectl rollout restart deployment headlamp -n kube-system
|
||||
kubectl rollout restart deployment headlamp -n <your-namespace>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -490,7 +490,7 @@ Run this script to test all RBAC components:
|
||||
#!/bin/bash
|
||||
NS="polaris"
|
||||
SA="headlamp"
|
||||
SA_NS="kube-system"
|
||||
SA_NS="<your-namespace>"
|
||||
|
||||
echo "=== Testing RBAC for Polaris Plugin ==="
|
||||
|
||||
@@ -529,8 +529,8 @@ echo "=== Test complete ==="
|
||||
Test connectivity from Headlamp to Polaris:
|
||||
|
||||
```bash
|
||||
# Create debug pod in kube-system namespace
|
||||
kubectl run netdebug -n kube-system --rm -it --image=nicolaka/netshoot -- bash
|
||||
# Create debug pod in headlamp namespace
|
||||
kubectl run netdebug -n <your-namespace> --rm -it --image=nicolaka/netshoot -- bash
|
||||
|
||||
# Inside pod, test DNS and HTTP
|
||||
nslookup polaris-dashboard.polaris.svc.cluster.local
|
||||
@@ -545,11 +545,11 @@ If you have audit logging enabled, check for denied requests:
|
||||
|
||||
```bash
|
||||
# View recent audit logs (location varies by cluster)
|
||||
kubectl logs -n kube-system kube-apiserver-* | grep polaris-dashboard
|
||||
kubectl logs -n <your-namespace> kube-apiserver-* | grep polaris-dashboard
|
||||
|
||||
# Look for lines with:
|
||||
# "reason": "Forbidden"
|
||||
# "user": "system:serviceaccount:kube-system:headlamp"
|
||||
# "user": "system:serviceaccount:<your-namespace>:headlamp"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -567,7 +567,7 @@ kubectl logs -n kube-system kube-apiserver-* | grep polaris-dashboard
|
||||
**Check sidecar logs**:
|
||||
|
||||
```bash
|
||||
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
|
||||
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
|
||||
```
|
||||
|
||||
**Common errors**:
|
||||
@@ -591,7 +591,7 @@ Error: 404 Not Found
|
||||
**Solution**: Verify `archive-url` in plugin config matches GitHub release:
|
||||
|
||||
```bash
|
||||
kubectl get configmap headlamp-plugin-config -n kube-system -o yaml
|
||||
kubectl get configmap headlamp-plugin-config -n <your-namespace> -o yaml
|
||||
```
|
||||
|
||||
Expected format:
|
||||
@@ -677,13 +677,13 @@ If none of these solutions work, gather debugging information and open an issue:
|
||||
1. **Version Information**:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n kube-system -l app.kubernetes.io/name=headlamp -o yaml | grep image:
|
||||
kubectl get pods -n <your-namespace> -l app.kubernetes.io/name=headlamp -o yaml | grep image:
|
||||
```
|
||||
|
||||
2. **Plugin Version**:
|
||||
|
||||
- Check Settings → Plugins in Headlamp UI
|
||||
- Or: `kubectl exec -n kube-system deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
|
||||
- Or: `kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
|
||||
|
||||
3. **Browser Console Output**:
|
||||
|
||||
@@ -698,7 +698,7 @@ If none of these solutions work, gather debugging information and open an issue:
|
||||
5. **Pod Logs**:
|
||||
|
||||
```bash
|
||||
kubectl logs -n kube-system deployment/headlamp -c headlamp --tail=100
|
||||
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp --tail=100
|
||||
kubectl logs -n polaris deployment/polaris-dashboard --tail=100
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# ADR-002: Service Proxy as Single Data Source
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-05
|
||||
**Deciders:** Plugin maintainers
|
||||
|
||||
## Context
|
||||
|
||||
The Polaris plugin needs audit data from the Polaris dashboard. Polaris dashboard exposes a `/results.json` endpoint containing pre-computed audit results for all workloads in the cluster.
|
||||
|
||||
Several approaches were considered for obtaining this data:
|
||||
|
||||
1. Query Kubernetes resources directly and re-implement Polaris audit logic
|
||||
2. Use the Polaris CLI as a sidecar container
|
||||
3. Use the Polaris dashboard's REST API via Kubernetes service proxy
|
||||
4. Embed Polaris as a Go/JS library
|
||||
|
||||
The service proxy approach uses the Kubernetes API server's built-in service proxy capability to reach the Polaris dashboard at `/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`. This means the plugin receives pre-computed audit results without needing to understand Polaris internals.
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Headlamp plugins can make API calls via `ApiProxy.request()` (proxied through the Headlamp backend) or direct `fetch()` for external URLs
|
||||
- The Polaris dashboard service name, namespace, and port may vary across cluster setups
|
||||
- Some users may run Polaris externally (not in-cluster)
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Retrieve all audit data in a single API call
|
||||
- Support configurable endpoint URL for different cluster configurations
|
||||
- Support external Polaris instances via full HTTP/HTTPS URLs
|
||||
- Work through existing Kubernetes RBAC without additional configuration
|
||||
|
||||
## Decision
|
||||
|
||||
Use **`ApiProxy.request()`** to fetch from the Polaris dashboard service proxy as the single data source for all audit data.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Default endpoint: `/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`
|
||||
- URL is configurable via plugin settings stored in `localStorage` (key: `polaris-plugin-dashboard-url`)
|
||||
- For full URLs starting with `http://` or `https://`, use browser `fetch()` directly to support external Polaris instances
|
||||
- `getPolarisApiPath()` in `polaris.ts` resolves the configured URL, with `isFullUrl()` determining the fetch strategy
|
||||
- Single fetch shared across all views via `PolarisDataProvider` (see ADR-001)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- ✅ **Single API call gets all audit data** - One request to `/results.json` returns scores for every workload
|
||||
- ✅ **No need to understand Polaris internals** - Plugin receives pre-computed results, no audit logic duplication
|
||||
- ✅ **Works through existing K8s RBAC** - Service proxy uses standard Kubernetes RBAC (`get` on `services/proxy`)
|
||||
- ✅ **Configurable endpoint** - Users can customize namespace, service name, or point to an external instance
|
||||
- ✅ **Minimal plugin complexity** - No CRD watches, no custom controllers, no library dependencies
|
||||
|
||||
### Negative
|
||||
|
||||
- ❌ **Requires Polaris dashboard to be deployed and accessible** - Plugin has no data without the dashboard
|
||||
- **Mitigated by:** Clear error messages guiding users to install Polaris (404/503 → install guidance)
|
||||
- ❌ **Single point of failure** - If the dashboard service is down, the plugin shows no data
|
||||
- **Mitigated by:** Status-code-specific error messages (403 → RBAC guidance, 404/503 → deployment guidance)
|
||||
- ❌ **Dashboard must be running continuously** - Unlike CRD-based approaches where data persists
|
||||
- **Mitigated by:** Polaris dashboard is typically deployed as a long-running service
|
||||
|
||||
### Neutral
|
||||
|
||||
- The Polaris dashboard is a lightweight Go service with minimal resource requirements
|
||||
- Service proxy is a standard Kubernetes pattern used by many tools (kubectl port-forward, dashboard proxying)
|
||||
- The configurable URL approach supports both in-cluster and external Polaris deployments
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Option 1: Query Polaris CRDs Directly
|
||||
|
||||
**Pros:**
|
||||
|
||||
- No dependency on Polaris dashboard being running
|
||||
- Data persists in CRDs even if dashboard restarts
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Polaris audit logic is complex and would need to be duplicated in the plugin
|
||||
- Would require watching multiple CRD types
|
||||
- Plugin would need to be updated whenever Polaris changes its audit rules
|
||||
|
||||
**Decision:** Rejected (would duplicate Polaris internals, maintenance burden)
|
||||
|
||||
### Option 2: Use Polaris CLI as a Sidecar
|
||||
|
||||
**Pros:**
|
||||
|
||||
- CLI has full audit capability
|
||||
- Could run audits on-demand
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Adds operational complexity (sidecar container management)
|
||||
- Not suitable for a browser-based plugin (CLI runs server-side)
|
||||
- Would require a separate backend service to bridge CLI output to the plugin
|
||||
|
||||
**Decision:** Rejected (operational complexity, not suitable for plugin architecture)
|
||||
|
||||
### Option 3: Embed Polaris as a Library
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Full control over audit execution
|
||||
- No external service dependency
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Polaris is a Go library, not available in JavaScript/TypeScript plugin runtime
|
||||
- Would massively increase bundle size
|
||||
- Would duplicate the entire Polaris engine
|
||||
|
||||
**Decision:** Rejected (not available in plugin runtime, massive dependency)
|
||||
|
||||
## References
|
||||
|
||||
- [Kubernetes Service Proxy](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster-services/)
|
||||
- [Polaris Dashboard](https://polaris.docs.fairwinds.com/dashboard/)
|
||||
- [Plugin Implementation](../../api/polaris.ts)
|
||||
- [Data Context](../../api/PolarisDataContext.tsx)
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ----------- | ---------------- |
|
||||
| 2026-03-05 | Plugin Team | Initial decision |
|
||||
@@ -0,0 +1,124 @@
|
||||
# ADR-003: Error Boundary as Class Component Exception
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-05
|
||||
**Deciders:** Plugin maintainers
|
||||
|
||||
## Context
|
||||
|
||||
The plugin follows a strict "functional components only" convention (see CLAUDE.md). However, React error boundaries require the `getDerivedStateFromError` and `componentDidCatch` lifecycle methods, which are only available on class components. As of React 18, there is no hooks-based error boundary API, and the React team has not announced a timeline for one.
|
||||
|
||||
The plugin registers components at multiple Headlamp integration points:
|
||||
|
||||
- Routes (dashboard, namespaces list)
|
||||
- Detail view sections (Deployment, StatefulSet, DaemonSet, Job, CronJob)
|
||||
- App bar action (score badge)
|
||||
- Plugin settings page
|
||||
|
||||
An unhandled error in any one of these registered components would crash the entire Headlamp UI, not just the plugin. This is because Headlamp renders plugin components inline within its own React tree.
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- React does not support error boundaries via hooks or functional components
|
||||
- The `react-error-boundary` library is not available as a peer dependency in Headlamp plugins
|
||||
- Plugin errors must not crash the host Headlamp application
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Catch and contain errors in all plugin-registered components
|
||||
- Provide user-friendly error display with recovery option
|
||||
- Isolate failures per registration point (an error in the app bar badge should not affect the dashboard view)
|
||||
|
||||
## Decision
|
||||
|
||||
Define **`PolarisErrorBoundary`** as a class component directly in `index.tsx`. This is the sole exception to the functional-component-only convention.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `PolarisErrorBoundary` is a React class component with `getDerivedStateFromError` and `componentDidCatch`
|
||||
- Every registered component (routes, detail sections, app bar action) is wrapped in this boundary
|
||||
- On error, displays a user-friendly fallback with an option to retry
|
||||
- Error details are logged to the console for debugging
|
||||
- The boundary is minimal (~30 lines) and co-located in `index.tsx` to minimize the convention violation
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- ✅ **Prevents plugin errors from crashing Headlamp** - Errors are caught and contained within the boundary
|
||||
- ✅ **User-friendly error display** - Shows a clear message with recovery option instead of a blank screen
|
||||
- ✅ **Isolated per registration point** - Each registered component has its own boundary instance
|
||||
- ✅ **No external dependencies** - Uses built-in React class component API
|
||||
- ✅ **Minimal implementation** - Small class component, easy to understand and maintain
|
||||
|
||||
### Negative
|
||||
|
||||
- ❌ **Breaks functional-only convention** - One class component in an otherwise functional codebase
|
||||
- **Mitigated by:** Kept minimal and co-located in `index.tsx` with clear documentation of why
|
||||
- ❌ **Class component syntax less familiar to contributors** - Modern React developers may not be fluent in class components
|
||||
- **Mitigated by:** The boundary is simple (no complex state, no lifecycle methods beyond error handling)
|
||||
|
||||
### Neutral
|
||||
|
||||
- This is a well-known React limitation acknowledged by the React team
|
||||
- Many React projects that otherwise use functional components make this same exception for error boundaries
|
||||
- The pattern is explicitly documented in the React documentation
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Option 1: No Error Boundary
|
||||
|
||||
**Pros:**
|
||||
|
||||
- No class component needed
|
||||
- Simpler code
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Plugin errors would crash the entire Headlamp UI
|
||||
- Users would see a blank screen with no recovery option
|
||||
- Poor user experience and potential data loss in other Headlamp features
|
||||
|
||||
**Decision:** Rejected (unacceptable risk of crashing host application)
|
||||
|
||||
### Option 2: react-error-boundary Library
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Provides a functional component API for error boundaries
|
||||
- Well-maintained, widely used library
|
||||
- Supports error recovery and reset
|
||||
|
||||
**Cons:**
|
||||
|
||||
- External dependency not available in Headlamp plugin runtime
|
||||
- Cannot add peer dependencies that Headlamp does not provide
|
||||
|
||||
**Decision:** Rejected (dependency not available in plugin environment)
|
||||
|
||||
### Option 3: Wait for React Hooks-Based Error Boundary API
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Would maintain functional-only convention
|
||||
- Official React solution
|
||||
|
||||
**Cons:**
|
||||
|
||||
- No timeline from the React team for this feature
|
||||
- Plugin needs error boundaries now, not at some future date
|
||||
- May never be implemented (React team has not committed to this)
|
||||
|
||||
**Decision:** Rejected (no timeline, cannot ship without error boundaries)
|
||||
|
||||
## References
|
||||
|
||||
- [React Error Boundaries](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
|
||||
- [React getDerivedStateFromError](https://react.dev/reference/react/Component#static-getderivedstatefromerror)
|
||||
- [Plugin Implementation](../../../src/index.tsx)
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ----------- | ---------------- |
|
||||
| 2026-03-05 | Plugin Team | Initial decision |
|
||||
@@ -0,0 +1,132 @@
|
||||
# ADR-004: Browser localStorage for User Settings
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-05
|
||||
**Deciders:** Plugin maintainers
|
||||
|
||||
## Context
|
||||
|
||||
The plugin has two user-configurable settings:
|
||||
|
||||
1. **Auto-refresh interval** (1-30 minutes, default 5 minutes) - how often to re-fetch Polaris audit data
|
||||
2. **Polaris dashboard URL** - endpoint for the Polaris dashboard service (supports custom namespaces/service names and external instances)
|
||||
|
||||
These are per-user preferences, not cluster configuration. They should persist across browser sessions and page reloads.
|
||||
|
||||
Several storage mechanisms are available:
|
||||
|
||||
- Browser `localStorage` - simple key-value store, persistent, synchronous API
|
||||
- Headlamp `ConfigStore` API - backed by Redux, reactive, integrated with Headlamp's state management
|
||||
- React state only - in-memory, lost on page reload
|
||||
- URL query parameters - visible in URL, lost on navigation
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Settings need to be reactive: the `PolarisDataProvider` must detect changes made on the settings page
|
||||
- Headlamp provides `registerPluginSettings` which renders a settings component - the settings page and the data provider are separate component trees
|
||||
- Only two scalar values need to be stored
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Persist settings across browser sessions and page reloads
|
||||
- React to setting changes without requiring a full page reload
|
||||
- Simple implementation for two scalar values
|
||||
- Work with Headlamp's `registerPluginSettings` API
|
||||
|
||||
## Decision
|
||||
|
||||
Use **browser `localStorage`** directly for persisting plugin settings.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Refresh interval stored at key `polaris-plugin-refresh-interval` (value in minutes as string)
|
||||
- Dashboard URL stored at key `polaris-plugin-dashboard-url` (URL string or empty for default)
|
||||
- `PolarisSettings` component (registered via `registerPluginSettings`) reads/writes these keys
|
||||
- `PolarisDataProvider` polls `localStorage` via `setInterval` every 1 second to detect setting changes
|
||||
- Helper functions in `polaris.ts` (`getRefreshInterval()`, `getPolarisApiPath()`) encapsulate localStorage access
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- ✅ **Simple and well-understood API** - `localStorage.getItem`/`setItem` is straightforward
|
||||
- ✅ **Persists across browser sessions** - Data survives page reloads, tab closes, browser restarts
|
||||
- ✅ **No dependency on Headlamp store internals** - Decoupled from Headlamp's Redux implementation
|
||||
- ✅ **Works with `registerPluginSettings`** - Settings page and data provider communicate via shared localStorage keys
|
||||
- ✅ **Minimal code** - No state management boilerplate for two simple values
|
||||
|
||||
### Negative
|
||||
|
||||
- ❌ **Not reactive by default** - localStorage has no built-in change notification within the same tab
|
||||
- **Mitigated by:** 1-second polling interval in `PolarisDataProvider` to detect changes
|
||||
- ❌ **Settings are browser-local** - Not synced across devices or browsers
|
||||
- **Mitigated by:** These are user preferences, browser-local storage is appropriate
|
||||
- ❌ **No type safety on stored values** - All values stored as strings
|
||||
- **Mitigated by:** Helper functions with `parseInt` and default values handle type conversion
|
||||
|
||||
### Neutral
|
||||
|
||||
- localStorage has a 5-10 MB limit per origin, more than sufficient for two string values
|
||||
- The 1-second polling interval has negligible performance impact (reading two string keys)
|
||||
- The `storage` event could detect cross-tab changes but does not fire for same-tab writes
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Option 1: Headlamp ConfigStore API
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Integrated with Headlamp's Redux store
|
||||
- Reactive (Redux state changes trigger re-renders)
|
||||
- Type-safe with TypeScript
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Couples plugin to Headlamp's internal Redux store implementation
|
||||
- More complex API for two scalar values
|
||||
- ConfigStore API may change across Headlamp versions
|
||||
|
||||
**Decision:** Not chosen (localStorage is simpler for two scalar values, avoids coupling to Headlamp's Redux internals)
|
||||
|
||||
### Option 2: React State Only (No Persistence)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Simplest implementation
|
||||
- Fully reactive
|
||||
- No side effects
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Settings lost on page reload - users must reconfigure every session
|
||||
- Poor user experience for frequently changed settings
|
||||
|
||||
**Decision:** Rejected (settings must persist across page reloads)
|
||||
|
||||
### Option 3: URL Query Parameters
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Shareable via URL
|
||||
- No storage API needed
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Lost on navigation to different routes
|
||||
- Clutters the URL
|
||||
- Not suitable for persistent settings
|
||||
|
||||
**Decision:** Rejected (does not persist across navigation)
|
||||
|
||||
## References
|
||||
|
||||
- [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API)
|
||||
- [Headlamp Plugin Settings](https://headlamp.dev/docs/latest/development/plugins/)
|
||||
- [Settings Component](../../../src/components/PolarisSettings.tsx)
|
||||
- [Data Context](../../../src/api/PolarisDataContext.tsx)
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ----------- | ---------------- |
|
||||
| 2026-03-05 | Plugin Team | Initial decision |
|
||||
@@ -0,0 +1,131 @@
|
||||
# ADR-005: Annotation-Based Exemption Management
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-05
|
||||
**Deciders:** Plugin maintainers
|
||||
|
||||
## Context
|
||||
|
||||
Polaris allows exempting specific workloads from audit checks. When a workload is exempt, Polaris skips all audit checks for that resource. The exemption mechanism uses the annotation `polaris.fairwinds.com/exempt=true` on the workload resource.
|
||||
|
||||
The plugin needs to let users manage these exemptions directly from the Headlamp UI. Several approaches were considered:
|
||||
|
||||
1. Use Polaris's native annotation-based exemption mechanism
|
||||
2. Create a separate exemption ConfigMap
|
||||
3. Define a custom ExemptionPolicy CRD
|
||||
4. Read-only display with kubectl instructions
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Polaris only recognizes `polaris.fairwinds.com/exempt` annotations on workload resources
|
||||
- The plugin is otherwise read-only (this would be the only write operation)
|
||||
- Users need appropriate RBAC permissions to patch workload resources
|
||||
- Supported workload types: Deployments, StatefulSets, DaemonSets, Jobs, CronJobs
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Allow users to toggle exemptions for workloads from the Headlamp UI
|
||||
- Use a mechanism that Polaris actually respects (exemptions must take effect on next scan)
|
||||
- Support all workload types that Polaris audits
|
||||
- Respect Kubernetes RBAC (only authorized users can manage exemptions)
|
||||
|
||||
## Decision
|
||||
|
||||
Use **Polaris's native annotation-based exemption mechanism**. The `ExemptionManager` component patches `polaris.fairwinds.com/exempt` annotations onto workload resources via `ApiProxy.request`.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `ExemptionManager` component in `ExemptionManager.tsx` provides a toggle UI for each workload
|
||||
- Exemptions are applied via `ApiProxy.request` with `method: 'PATCH'` and `Content-Type: application/strategic-merge-patch+json`
|
||||
- Patch payload sets `metadata.annotations["polaris.fairwinds.com/exempt"]` to `"true"` or removes the annotation
|
||||
- This is the only write operation in the entire plugin
|
||||
- RBAC is enforced by Kubernetes - users without `patch` permission on the workload resource will receive a 403 error
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- ✅ **Uses Polaris's own exemption mechanism** - No custom storage or translation layer needed
|
||||
- ✅ **Exemptions visible in standard kubectl output** - `kubectl get deployment -o yaml` shows the annotation
|
||||
- ✅ **No additional CRDs or ConfigMaps** - No custom resources to manage or clean up
|
||||
- ✅ **Polaris automatically respects annotations** - Exemptions take effect on the next audit scan
|
||||
- ✅ **Standard Kubernetes pattern** - Annotations are the idiomatic way to attach metadata to resources
|
||||
|
||||
### Negative
|
||||
|
||||
- ❌ **Requires write RBAC on workload resources** - Users need `patch` permission on deployments, statefulsets, etc.
|
||||
- **Mitigated by:** RBAC scoping - only users with patch permission can manage exemptions; UI shows clear error for 403
|
||||
- ❌ **Annotation changes not versioned or auditable** - Beyond standard Kubernetes resource history
|
||||
- **Mitigated by:** Kubernetes audit logging captures annotation patches; resource `metadata.managedFields` tracks changes
|
||||
- ❌ **Only supports full-resource exemption** - Cannot exempt individual checks (Polaris limitation)
|
||||
- **Mitigated by:** This matches Polaris's own annotation-level granularity
|
||||
|
||||
### Neutral
|
||||
|
||||
- Strategic merge patch is the standard Kubernetes patching strategy for adding/removing annotations
|
||||
- The annotation key (`polaris.fairwinds.com/exempt`) is defined by Polaris and unlikely to change
|
||||
- Exemption state is stored on the workload resource itself, so it moves with the resource if migrated
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Option 1: Separate Exemption ConfigMap
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Centralizes all exemptions in one place
|
||||
- Does not require write access to workload resources
|
||||
- Easy to audit all exemptions at once
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Polaris does not read exemptions from ConfigMaps - it only checks annotations
|
||||
- Would require a custom reconciliation controller to sync ConfigMap entries to annotations
|
||||
- Adds operational complexity
|
||||
|
||||
**Decision:** Rejected (Polaris does not support ConfigMap-based exemptions)
|
||||
|
||||
### Option 2: Custom ExemptionPolicy CRD
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Dedicated resource type for exemption management
|
||||
- Could support per-check exemptions, time-based exemptions, etc.
|
||||
- Clean separation of concerns
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Over-engineering for what is essentially an annotation toggle
|
||||
- Would require a custom controller to reconcile CRDs to annotations
|
||||
- Adds CRD installation as a prerequisite
|
||||
- Polaris still needs the annotation, so the CRD would be an indirection layer
|
||||
|
||||
**Decision:** Rejected (over-engineering for annotation toggle, would require a controller)
|
||||
|
||||
### Option 3: Read-Only Display with kubectl Instructions
|
||||
|
||||
**Pros:**
|
||||
|
||||
- No write operations in the plugin
|
||||
- No RBAC requirements beyond read access
|
||||
- Simpler implementation
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Poor user experience - users must switch to terminal to manage exemptions
|
||||
- Defeats the purpose of a UI plugin
|
||||
- Error-prone (users may mistype annotation keys)
|
||||
|
||||
**Decision:** Rejected (poor UX compared to in-UI toggle)
|
||||
|
||||
## References
|
||||
|
||||
- [Polaris Exemptions Documentation](https://polaris.docs.fairwinds.com/customization/exemptions/)
|
||||
- [Kubernetes Annotations](https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/)
|
||||
- [Strategic Merge Patch](https://kubernetes.io/docs/tasks/manage-kubernetes-resources/update-api-object-kubectl-patch/#use-a-strategic-merge-patch-to-update-a-deployment)
|
||||
- [Plugin Implementation](../../../src/components/ExemptionManager.tsx)
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Author | Change |
|
||||
| ---------- | ----------- | ---------------- |
|
||||
| 2026-03-05 | Plugin Team | Initial decision |
|
||||
@@ -70,8 +70,10 @@ What becomes easier or more difficult to do because of this change?
|
||||
| ADR | Title | Status | Date |
|
||||
| ------------------------------------- | -------------------------------------- | -------- | ---------- |
|
||||
| [001](001-react-context-for-state.md) | Use React Context for State Management | Accepted | 2026-02-12 |
|
||||
|
||||
**Note:** Additional ADRs documenting other significant decisions (service proxy approach, drawer navigation, MUI import restrictions) can be created following the template above.
|
||||
| [002](002-service-proxy-data-source.md) | Service Proxy as Single Data Source | Accepted | 2026-03-05 |
|
||||
| [003](003-error-boundary-class-component.md) | Error Boundary as Class Component Exception | Accepted | 2026-03-05 |
|
||||
| [004](004-localstorage-settings.md) | Browser localStorage for User Settings | Accepted | 2026-03-05 |
|
||||
| [005](005-annotation-exemption-management.md) | Annotation-Based Exemption Management | Accepted | 2026-03-05 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
+22
-22
@@ -19,7 +19,7 @@ Helm provides the easiest way to deploy and manage the plugin in production. Thi
|
||||
|
||||
```bash
|
||||
# Add Headlamp Helm repository
|
||||
helm repo add headlamp https://headlamp-k8s.github.io/headlamp/
|
||||
helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/
|
||||
helm repo update
|
||||
```
|
||||
|
||||
@@ -41,11 +41,11 @@ pluginsManager:
|
||||
```bash
|
||||
# Install Headlamp
|
||||
helm install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
|
||||
# Wait for deployment
|
||||
kubectl -n kube-system wait --for=condition=available deployment/headlamp --timeout=300s
|
||||
kubectl -n <your-namespace> wait --for=condition=available deployment/headlamp --timeout=300s
|
||||
```
|
||||
|
||||
After installation, install the plugin via Headlamp UI (**Settings → Plugins → Catalog**).
|
||||
@@ -131,7 +131,7 @@ Deploy:
|
||||
|
||||
```bash
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml \
|
||||
--wait \
|
||||
--timeout 5m
|
||||
@@ -177,7 +177,7 @@ apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: headlamp-plugin-config
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
data:
|
||||
plugin.yml: |
|
||||
- name: headlamp-polaris-plugin
|
||||
@@ -191,7 +191,7 @@ Apply ConfigMap then deploy Headlamp:
|
||||
kubectl apply -f headlamp-plugin-config.yaml
|
||||
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
```
|
||||
|
||||
@@ -210,7 +210,7 @@ metadata:
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 1h
|
||||
url: https://headlamp-k8s.github.io/headlamp/
|
||||
url: https://kubernetes-sigs.github.io/headlamp/
|
||||
```
|
||||
|
||||
### HelmRelease
|
||||
@@ -221,7 +221,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
interval: 30m
|
||||
chart:
|
||||
@@ -300,7 +300,7 @@ kubectl apply -f helmrepository.yaml
|
||||
kubectl apply -f helmrelease.yaml
|
||||
|
||||
# Watch deployment
|
||||
flux get helmreleases -n kube-system --watch
|
||||
flux get helmreleases -n <your-namespace> --watch
|
||||
```
|
||||
|
||||
## RBAC Configuration
|
||||
@@ -329,7 +329,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -349,7 +349,7 @@ helm repo update
|
||||
|
||||
# Upgrade Headlamp (preserves plugin configuration)
|
||||
helm upgrade headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml \
|
||||
--wait
|
||||
```
|
||||
@@ -365,15 +365,15 @@ helm upgrade headlamp headlamp/headlamp \
|
||||
|
||||
```bash
|
||||
# Update ConfigMap with new version
|
||||
kubectl -n kube-system edit configmap headlamp-plugin-config
|
||||
kubectl -n <your-namespace> edit configmap headlamp-plugin-config
|
||||
|
||||
# Update version and URL:
|
||||
# version: 0.3.6
|
||||
# url: https://github.com/.../v0.3.6/polaris-0.3.10.tar.gz
|
||||
|
||||
# Restart deployment to trigger init container
|
||||
kubectl -n kube-system rollout restart deployment/headlamp
|
||||
kubectl -n kube-system rollout status deployment/headlamp
|
||||
kubectl -n <your-namespace> rollout restart deployment/headlamp
|
||||
kubectl -n <your-namespace> rollout status deployment/headlamp
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -382,25 +382,25 @@ kubectl -n kube-system rollout status deployment/headlamp
|
||||
|
||||
```bash
|
||||
# Check Headlamp values
|
||||
helm get values headlamp -n kube-system
|
||||
helm get values headlamp -n <your-namespace>
|
||||
|
||||
# Verify plugin files exist
|
||||
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
|
||||
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
|
||||
ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# If missing, reinstall plugin via UI or check init container logs
|
||||
kubectl -n kube-system logs deployment/headlamp -c install-polaris-plugin
|
||||
kubectl -n <your-namespace> logs deployment/headlamp -c install-polaris-plugin
|
||||
```
|
||||
|
||||
### Helm Release Stuck
|
||||
|
||||
```bash
|
||||
# Check Helm release status
|
||||
helm list -n kube-system
|
||||
helm list -n <your-namespace>
|
||||
|
||||
# If stuck, force upgrade
|
||||
helm upgrade headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml \
|
||||
--force \
|
||||
--wait
|
||||
@@ -410,13 +410,13 @@ helm upgrade headlamp headlamp/headlamp \
|
||||
|
||||
```bash
|
||||
# Check HelmRelease status
|
||||
flux get helmreleases -n kube-system
|
||||
flux get helmreleases -n <your-namespace>
|
||||
|
||||
# Check events
|
||||
kubectl -n kube-system describe helmrelease headlamp
|
||||
kubectl -n <your-namespace> describe helmrelease headlamp
|
||||
|
||||
# Force reconciliation
|
||||
flux reconcile helmrelease headlamp -n kube-system
|
||||
flux reconcile helmrelease headlamp -n <your-namespace>
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -47,7 +47,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -71,7 +71,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
|
||||
|
||||
# Test permission
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
@@ -90,7 +90,7 @@ apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: headlamp-plugin-config
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
app.kubernetes.io/component: plugin-config
|
||||
@@ -109,7 +109,7 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
spec:
|
||||
@@ -194,7 +194,7 @@ apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
|
||||
@@ -204,7 +204,7 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
spec:
|
||||
@@ -235,27 +235,27 @@ kubectl apply -f headlamp-service.yaml
|
||||
kubectl apply -f headlamp-serviceaccount.yaml
|
||||
|
||||
# Wait for deployment to be ready
|
||||
kubectl -n kube-system wait --for=condition=available deployment/headlamp --timeout=300s
|
||||
kubectl -n <your-namespace> wait --for=condition=available deployment/headlamp --timeout=300s
|
||||
```
|
||||
|
||||
### 2. Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check pods are running
|
||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
|
||||
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
|
||||
|
||||
# Expected output:
|
||||
# NAME READY STATUS RESTARTS AGE
|
||||
# headlamp-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
|
||||
|
||||
# Check init container logs
|
||||
kubectl -n kube-system logs deployment/headlamp -c install-plugins
|
||||
kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
|
||||
|
||||
# Expected output:
|
||||
# Plugin installation complete
|
||||
|
||||
# Verify plugin files exist
|
||||
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
|
||||
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
|
||||
ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# Expected output:
|
||||
@@ -273,7 +273,7 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
|
||||
|
||||
```bash
|
||||
# Port-forward to access locally
|
||||
kubectl -n kube-system port-forward service/headlamp 8080:80
|
||||
kubectl -n <your-namespace> port-forward service/headlamp 8080:80
|
||||
|
||||
# Open browser to http://localhost:8080
|
||||
```
|
||||
@@ -309,7 +309,7 @@ k8s/
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
|
||||
commonLabels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
@@ -401,7 +401,7 @@ spec:
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
```
|
||||
|
||||
## Upgrading the Plugin
|
||||
@@ -410,24 +410,24 @@ spec:
|
||||
|
||||
```bash
|
||||
# Edit ConfigMap with new version
|
||||
kubectl -n kube-system edit configmap headlamp-plugin-config
|
||||
kubectl -n <your-namespace> edit configmap headlamp-plugin-config
|
||||
|
||||
# Update version and URL:
|
||||
# version: 0.3.6
|
||||
# url: https://github.com/.../v0.3.6/polaris-0.3.10.tar.gz
|
||||
|
||||
# Restart deployment to trigger init container
|
||||
kubectl -n kube-system rollout restart deployment/headlamp
|
||||
kubectl -n <your-namespace> rollout restart deployment/headlamp
|
||||
|
||||
# Wait for rollout to complete
|
||||
kubectl -n kube-system rollout status deployment/headlamp
|
||||
kubectl -n <your-namespace> rollout status deployment/headlamp
|
||||
```
|
||||
|
||||
### Verify Upgrade
|
||||
|
||||
```bash
|
||||
# Check init container logs
|
||||
kubectl -n kube-system logs deployment/headlamp -c install-plugins
|
||||
kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
|
||||
|
||||
# Verify new version in UI
|
||||
# Navigate to Settings → Plugins in Headlamp
|
||||
@@ -439,7 +439,7 @@ kubectl -n kube-system logs deployment/headlamp -c install-plugins
|
||||
|
||||
```bash
|
||||
# Check init container logs
|
||||
kubectl -n kube-system logs deployment/headlamp -c install-plugins
|
||||
kubectl -n <your-namespace> logs deployment/headlamp -c install-plugins
|
||||
|
||||
# Common issues:
|
||||
# 1. Network connectivity to GitHub
|
||||
@@ -451,14 +451,14 @@ kubectl -n kube-system logs deployment/headlamp -c install-plugins
|
||||
|
||||
```bash
|
||||
# Verify HEADLAMP_CONFIG_WATCH_PLUGINS is false
|
||||
kubectl -n kube-system get deployment headlamp -o yaml | grep WATCH_PLUGINS
|
||||
kubectl -n <your-namespace> get deployment headlamp -o yaml | grep WATCH_PLUGINS
|
||||
|
||||
# Expected output:
|
||||
# - name: HEADLAMP_CONFIG_WATCH_PLUGINS
|
||||
# value: "false"
|
||||
|
||||
# If not set or "true", update deployment
|
||||
kubectl -n kube-system edit deployment headlamp
|
||||
kubectl -n <your-namespace> edit deployment headlamp
|
||||
```
|
||||
|
||||
### RBAC Permissions Denied
|
||||
@@ -466,7 +466,7 @@ kubectl -n kube-system edit deployment headlamp
|
||||
```bash
|
||||
# Test RBAC
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ kubectl -n polaris get svc polaris-dashboard
|
||||
kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq .PolarisOutputVersion
|
||||
|
||||
# Verify Headlamp
|
||||
kubectl -n kube-system get deployment headlamp
|
||||
kubectl -n kube-system get svc headlamp
|
||||
kubectl -n <your-namespace> get deployment headlamp
|
||||
kubectl -n <your-namespace> get svc headlamp
|
||||
```
|
||||
|
||||
## Production Checklist
|
||||
@@ -60,17 +60,17 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
|
||||
|
||||
# 2. Verify RBAC permissions
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
# Expected: yes
|
||||
|
||||
# 3. Check Headlamp logs for plugin loading
|
||||
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
|
||||
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
|
||||
# Expected: No errors related to plugin loading
|
||||
|
||||
# 4. Verify plugin files exist
|
||||
kubectl -n kube-system exec deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
# Expected: dist/, package.json present
|
||||
```
|
||||
|
||||
@@ -241,7 +241,7 @@ apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: headlamp-pdb
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
@@ -295,7 +295,7 @@ apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
@@ -312,10 +312,10 @@ spec:
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
kubectl -n kube-system logs deployment/headlamp -f
|
||||
kubectl -n <your-namespace> logs deployment/headlamp -f
|
||||
|
||||
# Filter for plugin-related logs
|
||||
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
|
||||
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
|
||||
```
|
||||
|
||||
**Polaris Dashboard Logs:**
|
||||
@@ -341,14 +341,14 @@ apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: headlamp-alerts
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
groups:
|
||||
- name: headlamp
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: HeadlampPodNotReady
|
||||
expr: kube_pod_status_ready{namespace="kube-system", pod=~"headlamp-.*"} == 0
|
||||
expr: kube_pod_status_ready{namespace="<your-namespace>", pod=~"headlamp-.*"} == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
@@ -422,9 +422,9 @@ If Headlamp or plugin becomes unavailable:
|
||||
2. **Redeploy Headlamp:**
|
||||
|
||||
```bash
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--values headlamp-values.yaml
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
```
|
||||
|
||||
3. **Reapply RBAC:**
|
||||
@@ -436,7 +436,7 @@ If Headlamp or plugin becomes unavailable:
|
||||
4. **Verify plugin files:**
|
||||
|
||||
```bash
|
||||
kubectl -n kube-system exec deployment/headlamp -- \
|
||||
kubectl -n <your-namespace> exec deployment/headlamp -- \
|
||||
ls /headlamp/plugins/headlamp-polaris-plugin/
|
||||
```
|
||||
|
||||
|
||||
@@ -268,10 +268,9 @@ npm run e2e
|
||||
|
||||
```bash
|
||||
# Create token
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system --duration=24h)
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n <your-namespace> --duration=24h)
|
||||
|
||||
# Port-forward for local testing
|
||||
kubectl port-forward -n kube-system svc/headlamp 4466:80
|
||||
kubectl port-forward -n <your-namespace> svc/headlamp 4466:80
|
||||
|
||||
# Run tests
|
||||
HEADLAMP_URL=http://localhost:4466 npm run e2e
|
||||
|
||||
@@ -72,7 +72,7 @@ Deploy or update Headlamp:
|
||||
|
||||
```bash
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
```
|
||||
|
||||
@@ -122,7 +122,7 @@ apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: headlamp-plugin-config
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
data:
|
||||
plugin.yml: |
|
||||
- name: headlamp-polaris-plugin
|
||||
@@ -138,14 +138,14 @@ kubectl apply -f headlamp-plugin-config.yaml
|
||||
|
||||
# Deploy/update Headlamp with sidecar
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
|
||||
# Wait for pod to be ready
|
||||
kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
|
||||
kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
|
||||
|
||||
# Verify plugin files
|
||||
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# Expected output:
|
||||
# drwxr-xr-x dist/
|
||||
@@ -270,7 +270,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -284,10 +284,10 @@ See [RBAC Permissions](../user-guide/rbac-permissions.md) for detailed RBAC conf
|
||||
|
||||
```bash
|
||||
# If you updated Helm values or ConfigMaps
|
||||
kubectl -n kube-system rollout restart deployment/headlamp
|
||||
kubectl -n <your-namespace> rollout restart deployment/headlamp
|
||||
|
||||
# Wait for pod to be ready
|
||||
kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
|
||||
kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
|
||||
```
|
||||
|
||||
### 3. Clear Browser Cache
|
||||
@@ -312,14 +312,14 @@ kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=
|
||||
|
||||
```bash
|
||||
# Verify plugin files exist
|
||||
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# Expected output:
|
||||
# drwxr-xr-x dist/
|
||||
# -rw-r--r-- package.json
|
||||
|
||||
# Check Headlamp logs for errors
|
||||
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
|
||||
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
|
||||
|
||||
# Expected: No errors related to plugin loading
|
||||
|
||||
@@ -345,13 +345,13 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
|
||||
|
||||
```bash
|
||||
# 1. Verify plugin files exist
|
||||
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
|
||||
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
|
||||
ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# Expected: dist/, package.json present
|
||||
|
||||
# 2. Check Headlamp logs for plugin errors
|
||||
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
|
||||
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
|
||||
|
||||
# 3. Hard refresh browser (Cmd+Shift+R or Ctrl+Shift+R)
|
||||
|
||||
@@ -404,7 +404,7 @@ helm install polaris fairwinds-stable/polaris \
|
||||
```bash
|
||||
# Wait 30 minutes for ArtifactHub sync
|
||||
# Or manually force Headlamp restart:
|
||||
kubectl -n kube-system rollout restart deployment/headlamp
|
||||
kubectl -n <your-namespace> rollout restart deployment/headlamp
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -67,14 +67,14 @@ kubectl -n polaris wait --for=condition=ready pod -l app.kubernetes.io/name=pola
|
||||
|
||||
```bash
|
||||
# Check Headlamp is deployed
|
||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
|
||||
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
|
||||
|
||||
# Expected output:
|
||||
# NAME READY STATUS RESTARTS AGE
|
||||
# headlamp-xxxxxxxxxx-xxxxx 1/1 Running 0 1h
|
||||
|
||||
# Check Headlamp version (must be v0.26+)
|
||||
kubectl -n kube-system get deployment headlamp -o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||
kubectl -n <your-namespace> get deployment headlamp -o jsonpath='{.spec.template.spec.containers[0].image}'
|
||||
|
||||
# Expected output:
|
||||
# ghcr.io/headlamp-k8s/headlamp:v0.39.0 (or similar)
|
||||
@@ -84,17 +84,17 @@ kubectl -n kube-system get deployment headlamp -o jsonpath='{.spec.template.spec
|
||||
|
||||
```bash
|
||||
# Add Headlamp Helm repository
|
||||
helm repo add headlamp https://headlamp-k8s.github.io/headlamp/
|
||||
helm repo add headlamp https://kubernetes-sigs.github.io/headlamp/
|
||||
helm repo update
|
||||
|
||||
# Install Headlamp
|
||||
helm install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--set config.pluginsDir="/headlamp/plugins" \
|
||||
--set pluginsManager.enabled=true
|
||||
|
||||
# Wait for pod to be ready
|
||||
kubectl -n kube-system wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
|
||||
kubectl -n <your-namespace> wait --for=condition=ready pod -l app.kubernetes.io/name=headlamp --timeout=300s
|
||||
```
|
||||
|
||||
## RBAC Requirements
|
||||
@@ -112,7 +112,7 @@ The plugin requires permissions to access the Polaris dashboard via Kubernetes s
|
||||
```bash
|
||||
# Test if Headlamp service account has permission
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ EOF
|
||||
|
||||
# Update Headlamp
|
||||
helm upgrade --install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--values headlamp-values.yaml
|
||||
```
|
||||
|
||||
@@ -70,7 +70,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -111,7 +111,7 @@ EOF
|
||||
|
||||
```bash
|
||||
# Verify plugin files exist
|
||||
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- \
|
||||
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- \
|
||||
ls /headlamp/plugins/headlamp-polaris-plugin/dist/
|
||||
|
||||
# Expected output:
|
||||
@@ -119,7 +119,7 @@ kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- \
|
||||
|
||||
# Verify RBAC is correct
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
@@ -185,7 +185,7 @@ Cluster score badge in top navigation:
|
||||
|
||||
```bash
|
||||
# Verify plugin files exist
|
||||
kubectl -n kube-system exec -it deployment/headlamp -c headlamp -- \
|
||||
kubectl -n <your-namespace> exec -it deployment/headlamp -c headlamp -- \
|
||||
ls /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# If missing, reinstall via Headlamp UI or sidecar method
|
||||
|
||||
@@ -38,17 +38,17 @@ kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy
|
||||
|
||||
# 3. Verify RBAC permissions
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
# Expected output: yes
|
||||
|
||||
# 4. Check Headlamp pod is running
|
||||
kubectl -n kube-system get pods -l app.kubernetes.io/name=headlamp
|
||||
kubectl -n <your-namespace> get pods -l app.kubernetes.io/name=headlamp
|
||||
|
||||
# 5. Check Headlamp logs for plugin errors
|
||||
kubectl -n kube-system logs deployment/headlamp | grep -i polaris
|
||||
kubectl -n <your-namespace> logs deployment/headlamp | grep -i polaris
|
||||
|
||||
# Expected: No errors
|
||||
```
|
||||
@@ -57,7 +57,7 @@ kubectl -n kube-system logs deployment/headlamp | grep -i polaris
|
||||
|
||||
```bash
|
||||
# Verify plugin files exist
|
||||
kubectl -n kube-system exec deployment/headlamp -c headlamp -- \
|
||||
kubectl -n <your-namespace> exec deployment/headlamp -c headlamp -- \
|
||||
ls -la /headlamp/plugins/headlamp-polaris-plugin/
|
||||
|
||||
# Expected output:
|
||||
@@ -76,7 +76,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
|
||||
|
||||
# Test permission (service account mode)
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ This guide covers common issues encountered when using the Headlamp Polaris Plug
|
||||
|
||||
```bash
|
||||
# View Headlamp pod logs (plugin sidecar)
|
||||
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
|
||||
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
|
||||
|
||||
# Expected output:
|
||||
# Installing plugin from https://github.com/.../headlamp-polaris-plugin-X.Y.Z.tar.gz
|
||||
@@ -43,7 +43,7 @@ kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
|
||||
**Verify plugin files exist**:
|
||||
|
||||
```bash
|
||||
kubectl exec -n kube-system deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
|
||||
kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- ls -la /headlamp/plugins/
|
||||
# Should show: headlamp-polaris-plugin/
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ Expected subjects:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
```
|
||||
|
||||
For OIDC mode:
|
||||
@@ -154,7 +154,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -169,7 +169,7 @@ Service account mode:
|
||||
```bash
|
||||
# Impersonate Headlamp service account
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
--resource-name=polaris-dashboard \
|
||||
-n polaris
|
||||
# Expected: yes
|
||||
@@ -189,7 +189,7 @@ kubectl auth can-i get services/proxy \
|
||||
After applying RBAC changes:
|
||||
|
||||
```bash
|
||||
kubectl rollout restart deployment headlamp -n kube-system
|
||||
kubectl rollout restart deployment headlamp -n <your-namespace>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -490,7 +490,7 @@ Run this script to test all RBAC components:
|
||||
#!/bin/bash
|
||||
NS="polaris"
|
||||
SA="headlamp"
|
||||
SA_NS="kube-system"
|
||||
SA_NS="<your-namespace>"
|
||||
|
||||
echo "=== Testing RBAC for Polaris Plugin ==="
|
||||
|
||||
@@ -529,8 +529,8 @@ echo "=== Test complete ==="
|
||||
Test connectivity from Headlamp to Polaris:
|
||||
|
||||
```bash
|
||||
# Create debug pod in kube-system namespace
|
||||
kubectl run netdebug -n kube-system --rm -it --image=nicolaka/netshoot -- bash
|
||||
# Create debug pod in the namespace where Headlamp is installed
|
||||
kubectl run netdebug -n <your-namespace> --rm -it --image=nicolaka/netshoot -- bash
|
||||
|
||||
# Inside pod, test DNS and HTTP
|
||||
nslookup polaris-dashboard.polaris.svc.cluster.local
|
||||
@@ -545,11 +545,11 @@ If you have audit logging enabled, check for denied requests:
|
||||
|
||||
```bash
|
||||
# View recent audit logs (location varies by cluster)
|
||||
kubectl logs -n kube-system kube-apiserver-* | grep polaris-dashboard
|
||||
kubectl logs -n <your-namespace> kube-apiserver-* | grep polaris-dashboard
|
||||
|
||||
# Look for lines with:
|
||||
# "reason": "Forbidden"
|
||||
# "user": "system:serviceaccount:kube-system:headlamp"
|
||||
# "user": "system:serviceaccount:<your-namespace>:headlamp"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -567,7 +567,7 @@ kubectl logs -n kube-system kube-apiserver-* | grep polaris-dashboard
|
||||
**Check sidecar logs**:
|
||||
|
||||
```bash
|
||||
kubectl logs -n kube-system deployment/headlamp -c headlamp-plugin
|
||||
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp-plugin
|
||||
```
|
||||
|
||||
**Common errors**:
|
||||
@@ -591,7 +591,7 @@ Error: 404 Not Found
|
||||
**Solution**: Verify `archive-url` in plugin config matches GitHub release:
|
||||
|
||||
```bash
|
||||
kubectl get configmap headlamp-plugin-config -n kube-system -o yaml
|
||||
kubectl get configmap headlamp-plugin-config -n <your-namespace> -o yaml
|
||||
```
|
||||
|
||||
Expected format:
|
||||
@@ -677,13 +677,13 @@ If none of these solutions work, gather debugging information and open an issue:
|
||||
1. **Version Information**:
|
||||
|
||||
```bash
|
||||
kubectl get pods -n kube-system -l app.kubernetes.io/name=headlamp -o yaml | grep image:
|
||||
kubectl get pods -n <your-namespace> -l app.kubernetes.io/name=headlamp -o yaml | grep image:
|
||||
```
|
||||
|
||||
2. **Plugin Version**:
|
||||
|
||||
- Check Settings → Plugins in Headlamp UI
|
||||
- Or: `kubectl exec -n kube-system deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
|
||||
- Or: `kubectl exec -n <your-namespace> deployment/headlamp -c headlamp -- cat /headlamp/plugins/headlamp-polaris-plugin/package.json`
|
||||
|
||||
3. **Browser Console Output**:
|
||||
|
||||
@@ -698,7 +698,7 @@ If none of these solutions work, gather debugging information and open an issue:
|
||||
5. **Pod Logs**:
|
||||
|
||||
```bash
|
||||
kubectl logs -n kube-system deployment/headlamp -c headlamp --tail=100
|
||||
kubectl logs -n <your-namespace> deployment/headlamp -c headlamp --tail=100
|
||||
kubectl logs -n polaris deployment/polaris-dashboard --tail=100
|
||||
```
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -83,7 +83,7 @@ roleRef:
|
||||
```bash
|
||||
# Test service account (in-cluster mode)
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
|
||||
|
||||
# Test permission
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
```
|
||||
|
||||
@@ -65,7 +65,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp # Adjust to your Headlamp SA name
|
||||
namespace: kube-system # Adjust to Headlamp's namespace
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -75,7 +75,7 @@ roleRef:
|
||||
**Adjust for your environment:**
|
||||
|
||||
- `subjects[0].name` - Your Headlamp service account name (often `headlamp`)
|
||||
- `subjects[0].namespace` - Namespace where Headlamp runs (often `kube-system`)
|
||||
- `subjects[0].namespace` - Namespace where Headlamp is installed
|
||||
|
||||
### Step 3: Apply and Verify
|
||||
|
||||
@@ -91,7 +91,7 @@ kubectl -n polaris get rolebinding headlamp-polaris-proxy
|
||||
|
||||
# Test permission
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
@@ -109,7 +109,7 @@ In token-auth mode, **each user's own identity** is used for Kubernetes API requ
|
||||
With service account mode:
|
||||
|
||||
- Single RoleBinding grants access to all Headlamp users
|
||||
- Kubernetes sees all requests as `system:serviceaccount:kube-system:headlamp`
|
||||
- Kubernetes sees all requests as `system:serviceaccount:<your-namespace>:headlamp`
|
||||
|
||||
With token-auth mode:
|
||||
|
||||
@@ -267,7 +267,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -281,7 +281,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: polaris-proxy-reader
|
||||
@@ -411,7 +411,7 @@ Every plugin data fetch creates a Kubernetes API audit log entry.
|
||||
"level": "Metadata",
|
||||
"verb": "get",
|
||||
"user": {
|
||||
"username": "system:serviceaccount:kube-system:headlamp"
|
||||
"username": "system:serviceaccount:<your-namespace>:headlamp"
|
||||
},
|
||||
"sourceIPs": ["10.96.0.1"],
|
||||
"objectRef": {
|
||||
@@ -494,7 +494,7 @@ If using a log aggregator (e.g., Elasticsearch), create filters to exclude or do
|
||||
```bash
|
||||
# Service account mode
|
||||
kubectl auth can-i get services/proxy \
|
||||
--as=system:serviceaccount:kube-system:headlamp \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp \
|
||||
-n polaris \
|
||||
--resource-name=polaris-dashboard
|
||||
|
||||
|
||||
-294
@@ -1,294 +0,0 @@
|
||||
# E2E Smoke Tests
|
||||
|
||||
Playwright-based smoke tests that validate the Polaris plugin against a live Headlamp deployment.
|
||||
|
||||
## CI
|
||||
|
||||
E2E tests run automatically in GitHub Actions on pushes to `main` and pull requests. The workflow (`.github/workflows/e2e.yaml`) uses either Authentik OIDC or token-based authentication via repository secrets.
|
||||
|
||||
### Required GitHub Secrets
|
||||
|
||||
Configure these in GitHub repository settings (Settings → Secrets and variables → Actions):
|
||||
|
||||
| Secret | Required | Description |
|
||||
| -------------------- | -------- | -------------------------------------------------------------- |
|
||||
| `HEADLAMP_URL` | Optional | Headlamp instance URL (defaults to `https://headlamp.animaniacs.farh.net`) |
|
||||
| `AUTHENTIK_USERNAME` | OIDC | Authentik email or username for a CI user with Headlamp access |
|
||||
| `AUTHENTIK_PASSWORD` | OIDC | Password for that user |
|
||||
| `HEADLAMP_TOKEN` | Token | Kubernetes service account token (alternative to OIDC) |
|
||||
|
||||
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` **or** `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Option 1: OIDC via Authentik (same as CI)
|
||||
|
||||
```bash
|
||||
AUTHENTIK_USERNAME=you@example.com AUTHENTIK_PASSWORD=... npm run e2e
|
||||
```
|
||||
|
||||
The default base URL is `https://headlamp.animaniacs.farh.net`. Override with `HEADLAMP_URL` if needed.
|
||||
|
||||
### Option 2: K8s bearer token (port-forward)
|
||||
|
||||
```bash
|
||||
kubectl port-forward -n kube-system svc/headlamp 4466:80
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system)
|
||||
HEADLAMP_URL=http://localhost:4466 npm run e2e
|
||||
```
|
||||
|
||||
Or in headed mode (opens a browser window):
|
||||
|
||||
```bash
|
||||
HEADLAMP_URL=http://localhost:4466 npm run e2e:headed
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| -------------------- | -------- | -------------------------------------- | --------------------------------------- |
|
||||
| `HEADLAMP_URL` | No | `https://headlamp.animaniacs.farh.net` | Base URL of the Headlamp instance |
|
||||
| `AUTHENTIK_USERNAME` | OIDC | — | Authentik email/username |
|
||||
| `AUTHENTIK_PASSWORD` | OIDC | — | Authentik password |
|
||||
| `HEADLAMP_TOKEN` | Token | — | Kubernetes bearer token (fallback auth) |
|
||||
|
||||
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
|
||||
|
||||
## What the Tests Validate
|
||||
|
||||
- **Sidebar entry** — The Polaris sidebar item appears after login
|
||||
- **Overview page** — Cluster score and check distribution render correctly
|
||||
- **Namespaces page** — Table of namespaces loads with clickable links
|
||||
- **Namespace detail** — Clicking a namespace shows its score and resource table
|
||||
|
||||
These are smoke tests against real cluster data. They verify the plugin loads and renders without errors, not specific data values.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Current Tests (`polaris.spec.ts`)
|
||||
|
||||
1. **`sidebar contains Polaris entry`**
|
||||
- Verifies Polaris appears in the navigation sidebar
|
||||
- Ensures plugin successfully registered sidebar entry
|
||||
|
||||
2. **`overview page renders cluster score`**
|
||||
- Navigates to `/c/main/polaris`
|
||||
- Checks for "Polaris — Overview" heading
|
||||
- Verifies cluster score percentage is displayed
|
||||
- Validates data fetching and rendering
|
||||
|
||||
3. **`namespaces page renders table with namespace buttons`**
|
||||
- Navigates to `/c/main/polaris/namespaces`
|
||||
- Checks for "Polaris — Namespaces" heading
|
||||
- Verifies table is visible with at least one row
|
||||
- Ensures namespace buttons are clickable
|
||||
|
||||
4. **`namespace detail drawer opens from table button`**
|
||||
- Clicks first namespace button in table
|
||||
- Verifies drawer opens with namespace name in heading
|
||||
- Checks "Namespace Score" section is visible
|
||||
- Confirms "Resources" table is displayed
|
||||
- Validates URL hash is updated with namespace name
|
||||
|
||||
5. **`namespace detail drawer closes with Escape key`**
|
||||
- Opens namespace drawer
|
||||
- Presses Escape key
|
||||
- Verifies drawer closes
|
||||
- Checks URL hash is cleared
|
||||
|
||||
6. **`namespace detail drawer opens from URL hash`**
|
||||
- Navigates directly to `/c/main/polaris/namespaces#<namespace>`
|
||||
- Verifies drawer automatically opens
|
||||
- Checks namespace details are displayed
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Cluster Requirements
|
||||
|
||||
1. **Polaris Deployment**
|
||||
```bash
|
||||
# Verify Polaris is running
|
||||
kubectl -n polaris get pods
|
||||
kubectl -n polaris get svc polaris-dashboard
|
||||
```
|
||||
|
||||
2. **Polaris Audit Data**
|
||||
```bash
|
||||
# Check if Polaris has generated audit results
|
||||
kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json | jq '.AuditTime'
|
||||
```
|
||||
|
||||
3. **RBAC Permissions**
|
||||
- Headlamp service account (or test user) needs `get` on `services/proxy` for `polaris-dashboard`
|
||||
- See main README for RBAC setup
|
||||
|
||||
### Local Setup
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
|
||||
# 2. Create .env file (optional, for persistent config)
|
||||
cp .env.example .env
|
||||
|
||||
# 3. Set environment variables
|
||||
export HEADLAMP_URL=https://your-headlamp-instance.com
|
||||
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system)
|
||||
|
||||
# 4. Run tests
|
||||
npm run e2e
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Run in Headed Mode
|
||||
|
||||
See the browser UI while tests run:
|
||||
|
||||
```bash
|
||||
npm run e2e:headed
|
||||
```
|
||||
|
||||
### Enable Debug Mode
|
||||
|
||||
Step through tests with Playwright Inspector:
|
||||
|
||||
```bash
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
### Generate Trace
|
||||
|
||||
Record full trace for failed tests:
|
||||
|
||||
```bash
|
||||
npx playwright test --trace on
|
||||
npx playwright show-trace test-results/<test-name>/trace.zip
|
||||
```
|
||||
|
||||
### Screenshot on Failure
|
||||
|
||||
Tests automatically capture screenshots on failure in `test-results/`
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Auth fails with "Sign In button not found":**
|
||||
- Check HEADLAMP_URL is correct
|
||||
- Verify Headlamp is accessible
|
||||
- Ensure OIDC is configured if using Authentik
|
||||
|
||||
**Polaris sidebar entry not found:**
|
||||
- Plugin may not be installed: Check Settings → Plugins in Headlamp
|
||||
- Plugin may have failed to load: Check browser console
|
||||
- Clear browser cache and hard refresh
|
||||
|
||||
**Cluster score not displayed:**
|
||||
- Polaris may not have audit data yet
|
||||
- Check Polaris is running: `kubectl -n polaris get pods`
|
||||
- Verify service proxy: `kubectl get --raw /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`
|
||||
|
||||
**Namespace table empty:**
|
||||
- Polaris hasn't run audit yet (wait a few minutes)
|
||||
- Check Polaris logs: `kubectl -n polaris logs -l app.kubernetes.io/name=polaris`
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Example: Testing Plugin Settings
|
||||
|
||||
```typescript
|
||||
test('plugin settings page shows Polaris configuration', async ({ page }) => {
|
||||
await page.goto('/c/main/settings/plugins');
|
||||
|
||||
// Find and click Polaris plugin
|
||||
await page.getByText('headlamp-polaris-plugin').click();
|
||||
|
||||
// Check settings are visible
|
||||
await expect(page.getByText('Polaris Settings')).toBeVisible();
|
||||
await expect(page.getByText('Refresh Interval')).toBeVisible();
|
||||
await expect(page.getByText('Dashboard URL')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Example: Testing App Bar Badge
|
||||
|
||||
```typescript
|
||||
test('app bar displays Polaris score badge', async ({ page }) => {
|
||||
await page.goto('/c/main');
|
||||
|
||||
// Badge should be visible in app bar
|
||||
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
|
||||
await expect(badge).toBeVisible();
|
||||
|
||||
// Clicking should navigate to overview
|
||||
await badge.click();
|
||||
await expect(page).toHaveURL(/\/c\/main\/polaris$/);
|
||||
});
|
||||
```
|
||||
|
||||
### Example: Testing Dark Mode
|
||||
|
||||
```typescript
|
||||
test('plugin UI adapts to dark mode', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris');
|
||||
|
||||
// Toggle dark mode
|
||||
await page.getByRole('button', { name: /theme/i }).click();
|
||||
|
||||
// Check background color changes
|
||||
const body = page.locator('body');
|
||||
await expect(body).toHaveCSS('background-color', 'rgb(18, 18, 18)');
|
||||
|
||||
// Plugin components should adapt
|
||||
const sectionBox = page.locator('[class*="MuiPaper"]').first();
|
||||
await expect(sectionBox).not.toHaveCSS('background-color', 'rgb(255, 255, 255)');
|
||||
});
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests run automatically in GitHub Actions on pushes to `main` and pull requests. See `.github/workflows/e2e.yaml` for workflow configuration.
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Configure these in GitHub repository settings (Settings → Secrets and variables → Actions):
|
||||
|
||||
- `HEADLAMP_URL` (optional): Headlamp instance URL
|
||||
- `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` (for OIDC auth)
|
||||
- OR `HEADLAMP_TOKEN` (for token-based auth)
|
||||
|
||||
### Workflow Overview
|
||||
|
||||
1. Checkout code
|
||||
2. Setup Node.js 20 with npm cache
|
||||
3. Install dependencies (`npm ci`)
|
||||
4. Install Playwright browsers (`chromium` only)
|
||||
5. Run auth setup (creates session in `e2e/.auth/state.json`)
|
||||
6. Run all E2E tests
|
||||
7. Upload artifacts on failure:
|
||||
- `playwright-report/` - HTML test report
|
||||
- `test-results/` - Screenshots, traces, videos
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
You can manually trigger E2E tests from GitHub Actions:
|
||||
1. Go to Actions → E2E Tests
|
||||
2. Click "Run workflow"
|
||||
3. Select branch and run
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use semantic selectors**: `getByRole`, `getByText` over CSS selectors
|
||||
2. **Wait for visibility**: Use `await expect(...).toBeVisible()` instead of `waitForTimeout`
|
||||
3. **Keep tests independent**: Each test should work in isolation
|
||||
4. **Test user flows**: Complete journeys, not just page loads
|
||||
5. **Clean up state**: Close drawers/modals after tests
|
||||
6. **Use storage state**: Reuse auth across tests (already configured)
|
||||
7. **Parallelize carefully**: Currently disabled due to shared state
|
||||
|
||||
## Resources
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
- [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||
- [Headlamp Plugin Development](https://headlamp.dev/docs/latest/development/plugins/)
|
||||
- [Project Main README](../README.md)
|
||||
@@ -1,94 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Polaris app bar badge', () => {
|
||||
test('badge displays cluster score in app bar', async ({ page }) => {
|
||||
await page.goto('/c/main');
|
||||
|
||||
// Wait for page to load
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible();
|
||||
|
||||
// Badge should be visible in app bar with score percentage
|
||||
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Badge should show shield emoji
|
||||
await expect(badge).toContainText('🛡️');
|
||||
});
|
||||
|
||||
test('clicking badge navigates to overview page', async ({ page }) => {
|
||||
await page.goto('/c/main');
|
||||
|
||||
// Find and click the badge
|
||||
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
await badge.click();
|
||||
|
||||
// Should navigate to Polaris overview
|
||||
await expect(page).toHaveURL(/\/c\/main\/polaris$/);
|
||||
await expect(page.getByRole('heading', { name: 'Polaris — Overview' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('badge color reflects score level', async ({ page }) => {
|
||||
await page.goto('/c/main');
|
||||
|
||||
// Get the badge
|
||||
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Extract score from button text
|
||||
const badgeText = await badge.textContent();
|
||||
const scoreMatch = badgeText?.match(/(\d+)%/);
|
||||
expect(scoreMatch).toBeTruthy();
|
||||
|
||||
const score = parseInt(scoreMatch![1]);
|
||||
|
||||
// Check background color matches score level
|
||||
const bgColor = await badge.evaluate(el =>
|
||||
window.getComputedStyle(el).backgroundColor
|
||||
);
|
||||
|
||||
if (score >= 80) {
|
||||
// Green: rgb(76, 175, 80) or #4caf50
|
||||
expect(bgColor).toMatch(/rgb\(76,\s*175,\s*80\)/);
|
||||
} else if (score >= 50) {
|
||||
// Orange: rgb(255, 152, 0) or #ff9800
|
||||
expect(bgColor).toMatch(/rgb\(255,\s*152,\s*0\)/);
|
||||
} else {
|
||||
// Red: rgb(244, 67, 54) or #f44336
|
||||
expect(bgColor).toMatch(/rgb\(244,\s*67,\s*54\)/);
|
||||
}
|
||||
});
|
||||
|
||||
test('badge updates when navigating between clusters', async ({ page }) => {
|
||||
// This test assumes multi-cluster setup; skip if only one cluster
|
||||
await page.goto('/c/main');
|
||||
|
||||
// Get initial badge score
|
||||
const badge = page.getByRole('button', { name: /Polaris: \d+%/ });
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
const initialScore = await badge.textContent();
|
||||
|
||||
// Try to switch clusters (if available)
|
||||
const clusterSelector = page.getByRole('button', { name: /cluster/i });
|
||||
if (await clusterSelector.isVisible()) {
|
||||
// Note: This part will only work in multi-cluster setups
|
||||
// For single-cluster, this test will just verify badge persists
|
||||
await clusterSelector.click();
|
||||
|
||||
// Select different cluster if available
|
||||
const clusterOptions = page.getByRole('menuitem');
|
||||
const count = await clusterOptions.count();
|
||||
|
||||
if (count > 1) {
|
||||
await clusterOptions.nth(1).click();
|
||||
|
||||
// Badge should update or disappear (if new cluster doesn't have Polaris)
|
||||
// This is just verifying no crash occurs
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
}
|
||||
|
||||
// Badge should still be functional
|
||||
await expect(badge).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { test as setup, expect, Page } from '@playwright/test';
|
||||
|
||||
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
|
||||
|
||||
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
|
||||
// Navigate to login — Headlamp redirects / to /c/main/login
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/login');
|
||||
|
||||
// Click "Sign In" and capture the Authentik popup
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Authentik step 1: fill username
|
||||
await popup.getByRole('textbox', { name: /email or username/i }).fill(username);
|
||||
await popup.getByRole('button', { name: /log in/i }).click();
|
||||
|
||||
// Authentik step 2: fill password
|
||||
await popup.getByRole('textbox', { name: /password/i }).fill(password);
|
||||
await popup.getByRole('button', { name: /continue|log in/i }).click();
|
||||
|
||||
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
|
||||
await popup.waitForEvent('close', { timeout: 15_000 });
|
||||
|
||||
// Original page should now be authenticated — wait for sidebar
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticateWithToken(page: Page, token: string): Promise<void> {
|
||||
// Navigate to login — Headlamp redirects / to /c/main/login
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/login');
|
||||
|
||||
// Click the token auth option
|
||||
await page.getByRole('button', { name: /use a token/i }).click();
|
||||
await page.waitForURL('**/token');
|
||||
|
||||
// Fill the "ID token" field and submit
|
||||
await page.getByRole('textbox', { name: /id token/i }).fill(token);
|
||||
await page.getByRole('button', { name: /authenticate/i }).click();
|
||||
|
||||
// Wait for the main UI to load
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
setup('authenticate with Headlamp', async ({ page }) => {
|
||||
const username = process.env.AUTHENTIK_USERNAME;
|
||||
const password = process.env.AUTHENTIK_PASSWORD;
|
||||
const token = process.env.HEADLAMP_TOKEN;
|
||||
|
||||
if (username && password) {
|
||||
await authenticateWithOIDC(page, username, password);
|
||||
} else if (token) {
|
||||
await authenticateWithToken(page, token);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
|
||||
);
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: AUTH_STATE_PATH });
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Polaris plugin smoke tests', () => {
|
||||
test('sidebar contains Polaris entry', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The sidebar is the "Navigation" nav element (not "Appbar Tools")
|
||||
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('overview page renders cluster score', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris');
|
||||
|
||||
// SectionHeader renders a heading
|
||||
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Overview' })).toBeVisible();
|
||||
|
||||
// "Cluster Score" section exists with a percentage
|
||||
await expect(page.getByText('Cluster Score')).toBeVisible();
|
||||
await expect(page.getByText(/%/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('namespaces page renders table with namespace buttons', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible();
|
||||
|
||||
// Table should have at least one row with a namespace button
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const rows = table.locator('tbody tr');
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
// Each namespace row should contain a button (now buttons instead of links for drawer)
|
||||
const firstButton = rows.first().locator('button');
|
||||
await expect(firstButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('namespace detail drawer opens from table button', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
// Click the first namespace button in the table
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
await firstButton.click();
|
||||
|
||||
// Drawer should open and show the namespace name in the heading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// "Namespace Score" section should be present in drawer
|
||||
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||
|
||||
// Resources table should exist in drawer
|
||||
await expect(page.getByText('Resources')).toBeVisible();
|
||||
|
||||
// URL hash should be updated with namespace name
|
||||
await expect(page).toHaveURL(/\/polaris\/namespaces#/);
|
||||
});
|
||||
|
||||
test('namespace detail drawer closes with Escape key', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
// Open the drawer by clicking a namespace button
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
await firstButton.click();
|
||||
|
||||
// Verify drawer is open
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// Press Escape key
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Drawer should close (heading should not be visible anymore)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).not.toBeVisible();
|
||||
|
||||
// URL hash should be cleared
|
||||
await expect(page).toHaveURL(/\/polaris\/namespaces$/);
|
||||
});
|
||||
|
||||
test('namespace detail drawer opens from URL hash', async ({ page }) => {
|
||||
// Get a namespace name first
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
|
||||
// Navigate directly to URL with hash
|
||||
await page.goto(`/c/main/polaris/namespaces#${namespaceName}`);
|
||||
|
||||
// Drawer should automatically open with the namespace details
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// "Namespace Score" section should be present
|
||||
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Polaris plugin settings', () => {
|
||||
test('settings page shows configuration options', async ({ page }) => {
|
||||
await page.goto('/c/main/settings/plugins');
|
||||
|
||||
// Find Polaris plugin in the list
|
||||
const pluginCard = page.locator('text=headlamp-polaris-plugin').first();
|
||||
await expect(pluginCard).toBeVisible();
|
||||
|
||||
// Click to view settings (if settings are displayed inline, they should already be visible)
|
||||
// Note: Headlamp v0.39.0+ shows settings inline on the plugins page
|
||||
await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('refresh interval setting is configurable', async ({ page }) => {
|
||||
await page.goto('/c/main/settings/plugins');
|
||||
|
||||
// Navigate to Polaris settings
|
||||
await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Find the refresh interval dropdown
|
||||
const intervalSelect = page.locator('select').filter({ hasText: /minute|second/ });
|
||||
await expect(intervalSelect).toBeVisible();
|
||||
|
||||
// Get current value
|
||||
const currentValue = await intervalSelect.inputValue();
|
||||
|
||||
// Change to a different value
|
||||
const newValue = currentValue === '300' ? '600' : '300';
|
||||
await intervalSelect.selectOption(newValue);
|
||||
|
||||
// Value should be updated
|
||||
await expect(intervalSelect).toHaveValue(newValue);
|
||||
});
|
||||
|
||||
test('dashboard URL setting is configurable', async ({ page }) => {
|
||||
await page.goto('/c/main/settings/plugins');
|
||||
|
||||
// Navigate to Polaris settings
|
||||
await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Find the dashboard URL input
|
||||
const urlInput = page.getByPlaceholder(/polaris-dashboard/);
|
||||
await expect(urlInput).toBeVisible();
|
||||
|
||||
// Input should have the default proxy URL or custom URL
|
||||
const currentUrl = await urlInput.inputValue();
|
||||
expect(currentUrl).toBeTruthy();
|
||||
|
||||
// Examples text should be visible
|
||||
await expect(page.getByText('Examples:')).toBeVisible();
|
||||
await expect(page.getByText(/K8s proxy:/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('connection test button is available', async ({ page }) => {
|
||||
await page.goto('/c/main/settings/plugins');
|
||||
|
||||
// Navigate to Polaris settings
|
||||
await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Find and verify test connection button
|
||||
const testButton = page.getByRole('button', { name: /test connection/i });
|
||||
await expect(testButton).toBeVisible();
|
||||
await expect(testButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('connection test works with valid URL', async ({ page }) => {
|
||||
await page.goto('/c/main/settings/plugins');
|
||||
|
||||
// Navigate to Polaris settings
|
||||
await expect(page.getByText('Polaris Settings')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Click test connection
|
||||
const testButton = page.getByRole('button', { name: /test connection/i });
|
||||
await testButton.click();
|
||||
|
||||
// Wait for either success or error message
|
||||
// Note: This will succeed if Polaris is accessible, fail otherwise
|
||||
await page.waitForSelector('text=/Connected successfully|Connection failed/', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Either success or failure is acceptable (depends on environment)
|
||||
const result = await page.textContent('body');
|
||||
expect(result).toMatch(/(Connected successfully|Connection failed)/);
|
||||
});
|
||||
});
|
||||
Generated
-18217
File diff suppressed because it is too large
Load Diff
+39
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "polaris",
|
||||
"version": "0.5.1",
|
||||
"name": "headlamp-polaris",
|
||||
"version": "1.0.0",
|
||||
"description": "Headlamp plugin for Fairwinds Polaris audit results",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,6 +12,7 @@
|
||||
"homepage": "https://github.com/privilegedescalation/headlamp-polaris-plugin#readme",
|
||||
"author": "privilegedescalation",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"scripts": {
|
||||
"start": "headlamp-plugin start",
|
||||
"build": "headlamp-plugin build",
|
||||
@@ -22,12 +23,43 @@
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed"
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"tar": "^7.5.11",
|
||||
"undici": "^7.24.3",
|
||||
"flatted": "^3.4.2",
|
||||
"lodash": ">=4.18.0",
|
||||
"picomatch": ">=4.0.4",
|
||||
"vite": ">=6.4.2",
|
||||
"elliptic": ">=6.6.1",
|
||||
"fast-uri": ">=3.1.2"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||
"@playwright/test": "^1.58.2"
|
||||
"@kinvolk/headlamp-plugin": "^0.14.0",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@headlamp-k8s/eslint-config": "^0.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"tar": "^7.5.11",
|
||||
"typescript": "~5.6.2",
|
||||
"undici": "^7.24.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.HEADLAMP_URL || 'https://headlamp.animaniacs.farh.net',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.auth/state.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
+12082
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"]
|
||||
"extends": ["github>privilegedescalation/.github:renovate-config"]
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock('./polaris', async importOriginal => {
|
||||
data: makeAuditData([makeResult()]),
|
||||
loading: false,
|
||||
error: null,
|
||||
triggerRefresh: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -44,5 +45,6 @@ describe('usePolarisDataContext', () => {
|
||||
expect(result.current.data).not.toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.refresh).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHECK_MAPPING,
|
||||
getCheckCategory,
|
||||
getCheckDescription,
|
||||
getCheckName,
|
||||
getSeverityStatus,
|
||||
} from './checkMapping';
|
||||
|
||||
describe('checkMapping', () => {
|
||||
describe('getCheckName', () => {
|
||||
it('returns human-readable name for known check IDs', () => {
|
||||
expect(getCheckName('hostIPCSet')).toBe('Host IPC');
|
||||
expect(getCheckName('cpuRequestsMissing')).toBe('CPU Requests');
|
||||
expect(getCheckName('readinessProbeMissing')).toBe('Readiness Probe');
|
||||
});
|
||||
|
||||
it('returns the raw check ID for unknown checks', () => {
|
||||
expect(getCheckName('unknownCheck')).toBe('unknownCheck');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckDescription', () => {
|
||||
it('returns description for known checks', () => {
|
||||
expect(getCheckDescription('hostIPCSet')).toBe('Host IPC should not be configured');
|
||||
});
|
||||
|
||||
it('returns "Unknown check" for unknown checks', () => {
|
||||
expect(getCheckDescription('unknownCheck')).toBe('Unknown check');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckCategory', () => {
|
||||
it('returns correct category for each type', () => {
|
||||
expect(getCheckCategory('hostIPCSet')).toBe('Security');
|
||||
expect(getCheckCategory('cpuRequestsMissing')).toBe('Efficiency');
|
||||
expect(getCheckCategory('readinessProbeMissing')).toBe('Reliability');
|
||||
});
|
||||
|
||||
it('defaults to Security for unknown checks', () => {
|
||||
expect(getCheckCategory('unknownCheck')).toBe('Security');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSeverityStatus', () => {
|
||||
it('maps danger to error', () => {
|
||||
expect(getSeverityStatus('danger')).toBe('error');
|
||||
});
|
||||
|
||||
it('maps warning to warning', () => {
|
||||
expect(getSeverityStatus('warning')).toBe('warning');
|
||||
});
|
||||
|
||||
it('defaults to success for other values', () => {
|
||||
expect(getSeverityStatus('ignore')).toBe('success');
|
||||
expect(getSeverityStatus('unknown')).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHECK_MAPPING', () => {
|
||||
it('has entries for all expected categories', () => {
|
||||
const categories = new Set(Object.values(CHECK_MAPPING).map(c => c.category));
|
||||
expect(categories).toContain('Security');
|
||||
expect(categories).toContain('Efficiency');
|
||||
expect(categories).toContain('Reliability');
|
||||
});
|
||||
|
||||
it('all entries have required fields', () => {
|
||||
for (const [id, info] of Object.entries(CHECK_MAPPING)) {
|
||||
expect(info.name, `${id} missing name`).toBeTruthy();
|
||||
expect(info.description, `${id} missing description`).toBeTruthy();
|
||||
expect(['Security', 'Efficiency', 'Reliability']).toContain(info.category);
|
||||
expect(['danger', 'warning', 'ignore']).toContain(info.defaultSeverity);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -207,22 +207,6 @@ export function getCheckCategory(checkId: string): 'Security' | 'Efficiency' | '
|
||||
return CHECK_MAPPING[checkId]?.category || 'Security';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for severity
|
||||
*/
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'danger':
|
||||
return '#f44336';
|
||||
case 'warning':
|
||||
return '#ff9800';
|
||||
case 'ignore':
|
||||
return '#9e9e9e';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for StatusLabel component
|
||||
*/
|
||||
|
||||
+4
-3
@@ -218,7 +218,8 @@ const REFRESH_STORAGE_KEY = 'polaris-plugin-refresh-interval';
|
||||
const DEFAULT_INTERVAL_SECONDS = 300; // 5 minutes
|
||||
|
||||
const URL_STORAGE_KEY = 'polaris-plugin-dashboard-url';
|
||||
const DEFAULT_DASHBOARD_URL = '/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
|
||||
const DEFAULT_DASHBOARD_URL =
|
||||
'/api/v1/namespaces/polaris/services/http:polaris-dashboard:80/proxy/';
|
||||
|
||||
/**
|
||||
* Retrieves the configured refresh interval from localStorage.
|
||||
@@ -300,7 +301,7 @@ export function computeScore(counts: ResultCounts): number {
|
||||
*
|
||||
* @returns Full path to results.json endpoint
|
||||
*/
|
||||
function getPolarisApiPath(): string {
|
||||
export function getPolarisApiPath(): string {
|
||||
const baseUrl = getDashboardUrl();
|
||||
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
}
|
||||
@@ -311,7 +312,7 @@ function getPolarisApiPath(): string {
|
||||
* @param url - URL to check
|
||||
* @returns true if full URL, false if relative path
|
||||
*/
|
||||
function isFullUrl(url: string): boolean {
|
||||
export function isFullUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
import { getTopIssues } from './topIssues';
|
||||
|
||||
describe('getTopIssues', () => {
|
||||
it('returns empty array when no results', () => {
|
||||
const data = makeAuditData([]);
|
||||
expect(getTopIssues(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when all checks pass', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(getTopIssues(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates failing checks from controller-level results', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: 'missing',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const issues = getTopIssues(data);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].checkId).toBe('cpuRequestsMissing');
|
||||
expect(issues[0].checkName).toBe('CPU Requests');
|
||||
expect(issues[0].severity).toBe('warning');
|
||||
expect(issues[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it('aggregates failing checks from pod and container results', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {},
|
||||
PodResult: {
|
||||
Name: 'pod-1',
|
||||
Results: {
|
||||
hostIPCSet: {
|
||||
ID: 'hostIPCSet',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
ContainerResults: [
|
||||
{
|
||||
Name: 'container-1',
|
||||
Results: {
|
||||
cpuLimitsMissing: {
|
||||
ID: 'cpuLimitsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const issues = getTopIssues(data);
|
||||
expect(issues).toHaveLength(2);
|
||||
// Danger first
|
||||
expect(issues[0].checkId).toBe('hostIPCSet');
|
||||
expect(issues[0].severity).toBe('danger');
|
||||
expect(issues[1].checkId).toBe('cpuLimitsMissing');
|
||||
});
|
||||
|
||||
it('counts same check across multiple workloads', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-1',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResult({
|
||||
Name: 'deploy-2',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const issues = getTopIssues(data);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores checks with severity "ignore"', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'ignore',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(getTopIssues(data)).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts danger before warning, then by count descending', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-1',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResult({
|
||||
Name: 'deploy-2',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
hostIPCSet: {
|
||||
ID: 'hostIPCSet',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const issues = getTopIssues(data);
|
||||
// Danger first regardless of count
|
||||
expect(issues[0].severity).toBe('danger');
|
||||
expect(issues[1].severity).toBe('warning');
|
||||
expect(issues[1].count).toBe(2);
|
||||
});
|
||||
|
||||
it('returns at most 10 issues', () => {
|
||||
const results: Record<
|
||||
string,
|
||||
{
|
||||
ID: string;
|
||||
Message: string;
|
||||
Details: string[];
|
||||
Success: boolean;
|
||||
Severity: 'warning';
|
||||
Category: string;
|
||||
}
|
||||
> = {};
|
||||
for (let i = 0; i < 15; i++) {
|
||||
results[`check${i}`] = {
|
||||
ID: `check${i}`,
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
};
|
||||
}
|
||||
const data = makeAuditData([makeResult({ Results: results })]);
|
||||
expect(getTopIssues(data)).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
K8s: {},
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/styles', () => ({
|
||||
useTheme: () => ({
|
||||
palette: {
|
||||
success: { main: '#4caf50', contrastText: '#fff' },
|
||||
warning: { main: '#ff9800', contrastText: '#000' },
|
||||
error: { main: '#f44336', contrastText: '#fff' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useHistory: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
// Set window.location.pathname for cluster extraction
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/c/test-cluster/some-page' },
|
||||
writable: true,
|
||||
});
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
const mockUsePolarisDataContext = vi.fn();
|
||||
vi.mock('../api/PolarisDataContext', () => ({
|
||||
usePolarisDataContext: () => mockUsePolarisDataContext(),
|
||||
}));
|
||||
|
||||
import AppBarScoreBadge from './AppBarScoreBadge';
|
||||
|
||||
describe('AppBarScoreBadge', () => {
|
||||
it('returns null when loading', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({ data: null, loading: true });
|
||||
const { container } = render(<AppBarScoreBadge />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('returns null when no data', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({ data: null, loading: false });
|
||||
const { container } = render(<AppBarScoreBadge />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders score with success color for high score', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
|
||||
|
||||
render(<AppBarScoreBadge />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveTextContent('Polaris: 100%');
|
||||
expect(button.style.backgroundColor).toBe('rgb(76, 175, 80)');
|
||||
});
|
||||
|
||||
it('renders score with error color for low score', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
|
||||
|
||||
render(<AppBarScoreBadge />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveTextContent('Polaris: 0%');
|
||||
expect(button.style.backgroundColor).toBe('rgb(244, 67, 54)');
|
||||
});
|
||||
|
||||
it('navigates to /c/<cluster>/polaris on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
|
||||
|
||||
render(<AppBarScoreBadge />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/c/test-cluster/polaris');
|
||||
});
|
||||
|
||||
it('navigates to /polaris when no cluster in URL', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/settings' },
|
||||
writable: true,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
|
||||
|
||||
render(<AppBarScoreBadge />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/polaris');
|
||||
});
|
||||
|
||||
it('has correct aria-label', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
|
||||
|
||||
render(<AppBarScoreBadge />);
|
||||
expect(screen.getByLabelText('Polaris: 100%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,27 @@
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { computeScore, countResults } from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
|
||||
/**
|
||||
* Extract the cluster name from the current browser URL.
|
||||
* Headlamp cluster routes follow the pattern /c/<cluster>/...
|
||||
* We read window.location.pathname directly because the AppBar renders
|
||||
* outside the cluster route context, so useCluster() returns null and
|
||||
* React Router's useLocation() may not reflect the cluster prefix.
|
||||
*/
|
||||
function getClusterFromUrl(): string | null {
|
||||
const match = window.location.pathname.match(/\/c\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* App bar badge showing cluster Polaris score
|
||||
* Clicking navigates to the overview dashboard
|
||||
*/
|
||||
export default function AppBarScoreBadge() {
|
||||
const theme = useTheme();
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -18,15 +32,23 @@ export default function AppBarScoreBadge() {
|
||||
const counts = countResults(data);
|
||||
const score = computeScore(counts);
|
||||
|
||||
// Color based on score
|
||||
const getColor = (score: number): string => {
|
||||
if (score >= 80) return '#4caf50'; // green
|
||||
if (score >= 50) return '#ff9800'; // orange
|
||||
return '#f44336'; // red
|
||||
// Color based on score using theme palette
|
||||
const getColor = (s: number): string => {
|
||||
if (s >= 80) return theme.palette.success.main;
|
||||
if (s >= 50) return theme.palette.warning.main;
|
||||
return theme.palette.error.main;
|
||||
};
|
||||
|
||||
const getContrastColor = (s: number): string => {
|
||||
if (s >= 80) return theme.palette.success.contrastText;
|
||||
if (s >= 50) return theme.palette.warning.contrastText;
|
||||
return theme.palette.error.contrastText;
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
history.push('/polaris');
|
||||
const cluster = getClusterFromUrl();
|
||||
const prefix = cluster ? `/c/${cluster}` : '';
|
||||
history.push(`${prefix}/polaris`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -39,16 +61,16 @@ export default function AppBarScoreBadge() {
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
backgroundColor: getColor(score),
|
||||
color: 'white',
|
||||
color: getContrastColor(score),
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
aria-label={`Polaris cluster score: ${score}%`}
|
||||
aria-label={`Polaris: ${score}%`}
|
||||
>
|
||||
<span>🛡️</span>
|
||||
<span>{'\u{1F6E1}\uFE0F'}</span>
|
||||
<span>Polaris: {score}%</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,15 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/styles', () => ({
|
||||
useTheme: () => ({
|
||||
palette: {
|
||||
primary: { main: '#1976d2' },
|
||||
text: { primary: '#000', secondary: '#666' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents as thin pass-throughs
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
@@ -34,12 +43,27 @@ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
SimpleTable: ({ data }: { data: Array<any> }) => (
|
||||
SimpleTable: ({
|
||||
columns,
|
||||
data,
|
||||
}: {
|
||||
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
|
||||
data: unknown[];
|
||||
}) => (
|
||||
<table data-testid="simple-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col.label}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{JSON.stringify(item)}</td>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map(col => (
|
||||
<td key={col.label}>{col.getter(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { getSeverityStatus } from '../api/checkMapping';
|
||||
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
|
||||
@@ -87,6 +88,7 @@ function formatAuditTime(auditTime: string): string {
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const { data, loading, error, refresh } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
@@ -109,7 +111,7 @@ export default function DashboardView() {
|
||||
<SectionHeader title="Polaris — Overview" />
|
||||
{data && (
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
|
||||
<span style={{ fontSize: '14px', color: theme.palette.text.secondary }}>
|
||||
Last updated: {formatAuditTime(data.AuditTime)}
|
||||
</span>
|
||||
<button
|
||||
@@ -117,8 +119,8 @@ export default function DashboardView() {
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
|
||||
color: theme.palette.primary.main,
|
||||
border: `1px solid ${theme.palette.primary.main}`,
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeResult } from '../test-utils';
|
||||
|
||||
const { mockApiRequest } = vi.hoisted(() => ({ mockApiRequest: vi.fn() }));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: mockApiRequest },
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/styles', () => ({
|
||||
useTheme: () => ({
|
||||
palette: {
|
||||
primary: { main: '#1976d2', contrastText: '#fff' },
|
||||
action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
|
||||
divider: '#e0e0e0',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<div data-testid="section-box" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||
<span data-testid="status-label" data-status={status}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
Dialog: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import ExemptionManager from './ExemptionManager';
|
||||
|
||||
const defaultProps = {
|
||||
workloadResult: makeResult(),
|
||||
namespace: 'default',
|
||||
kind: 'Deployment',
|
||||
name: 'my-deploy',
|
||||
};
|
||||
|
||||
const resultWithPodFailures = makeResult({
|
||||
PodResult: {
|
||||
Name: 'pod',
|
||||
Results: {
|
||||
hostIPCSet: {
|
||||
ID: 'hostIPCSet',
|
||||
Message: 'Host IPC is set',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
hostPIDSet: {
|
||||
ID: 'hostPIDSet',
|
||||
Message: 'Host PID is set',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
ContainerResults: [],
|
||||
},
|
||||
});
|
||||
|
||||
const resultWithContainerFailures = makeResult({
|
||||
PodResult: {
|
||||
Name: 'pod',
|
||||
Results: {},
|
||||
ContainerResults: [
|
||||
{
|
||||
Name: 'container-1',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: 'CPU requests missing',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const resultWithIgnoredFailures = makeResult({
|
||||
PodResult: {
|
||||
Name: 'pod',
|
||||
Results: {
|
||||
hostIPCSet: {
|
||||
ID: 'hostIPCSet',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'ignore',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
ContainerResults: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe('ExemptionManager', () => {
|
||||
describe('rendering failing checks', () => {
|
||||
it('shows disabled Add Exemption button when no failing checks', () => {
|
||||
render(<ExemptionManager {...defaultProps} />);
|
||||
const btn = screen.getByRole('button', { name: /add exemption/i });
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows enabled Add Exemption button when there are failing checks', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
const btn = screen.getByRole('button', { name: /add exemption/i });
|
||||
expect(btn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not include ignored-severity checks as failing', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithIgnoredFailures} />);
|
||||
const btn = screen.getByRole('button', { name: /add exemption/i });
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('collects failing checks from pod-level results', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
expect(screen.getByText('Host IPC')).toBeInTheDocument();
|
||||
expect(screen.getByText('Host PID')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collects failing checks from container-level results', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithContainerFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
expect(screen.getByText('CPU Requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deduplicates checks that appear in multiple containers', () => {
|
||||
const resultWithDuplicate = makeResult({
|
||||
PodResult: {
|
||||
Name: 'pod',
|
||||
Results: {},
|
||||
ContainerResults: [
|
||||
{
|
||||
Name: 'container-1',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'container-2',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithDuplicate} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
const items = screen.getAllByText('CPU Requests');
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialog interactions', () => {
|
||||
it('opens dialog when Add Exemption button is clicked', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
expect(screen.getByTestId('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes dialog when Cancel button is clicked', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
expect(screen.getByTestId('dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles individual check selection', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
|
||||
// Find the checkbox next to "Host IPC"
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
// First checkbox is "Exempt from all checks", rest are individual checks
|
||||
const hostIPCCheckbox = checkboxes[1];
|
||||
expect(hostIPCCheckbox).not.toBeChecked();
|
||||
fireEvent.click(hostIPCCheckbox);
|
||||
expect(hostIPCCheckbox).toBeChecked();
|
||||
fireEvent.click(hostIPCCheckbox);
|
||||
expect(hostIPCCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('hides individual checks list when exempt-all is toggled', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
expect(screen.getByText('Host IPC')).toBeInTheDocument();
|
||||
|
||||
const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i });
|
||||
fireEvent.click(exemptAllCheckbox);
|
||||
expect(screen.queryByText('Host IPC')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Apply button is disabled when no checks selected and exemptAll is false', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Apply button is enabled when exemptAll is checked', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i });
|
||||
fireEvent.click(exemptAllCheckbox);
|
||||
expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('Apply button is enabled when at least one individual check is selected', () => {
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[1]); // select first individual check
|
||||
expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiProxy.request calls', () => {
|
||||
it('patches with exempt-all annotation when exemptAll is selected', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
'/apis/apps/v1/namespaces/default/deployments/my-deploy',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/strategic-merge-patch+json' },
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
annotations: { 'polaris.fairwinds.com/exempt': 'true' },
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('patches with per-check annotations when individual checks selected', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
// Select first check (hostIPCSet)
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[1]);
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
'/apis/apps/v1/namespaces/default/deployments/my-deploy',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
annotations: { 'polaris.fairwinds.com/hostIPCSet-exempt': 'true' },
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses core API path for Pod kind (no api group)', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(
|
||||
<ExemptionManager {...defaultProps} kind="Pod" workloadResult={resultWithPodFailures} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
'/api/v1/namespaces/default/pods/my-deploy',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses batch API group for Job kind', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(
|
||||
<ExemptionManager {...defaultProps} kind="Job" workloadResult={resultWithPodFailures} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
'/apis/batch/v1/namespaces/default/jobs/my-deploy',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses batch API group for CronJob kind', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(
|
||||
<ExemptionManager {...defaultProps} kind="CronJob" workloadResult={resultWithPodFailures} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
'/apis/batch/v1/namespaces/default/cronjobs/my-deploy',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses apps API group for StatefulSet kind', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(
|
||||
<ExemptionManager
|
||||
{...defaultProps}
|
||||
kind="StatefulSet"
|
||||
workloadResult={resultWithPodFailures}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiRequest).toHaveBeenCalledWith(
|
||||
'/apis/apps/v1/namespaces/default/statefulsets/my-deploy',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback states', () => {
|
||||
it('shows success feedback and closes dialog after successful apply', async () => {
|
||||
mockApiRequest.mockResolvedValue({});
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
|
||||
const label = screen.getByTestId('status-label');
|
||||
expect(label).toHaveAttribute('data-status', 'success');
|
||||
expect(label).toHaveTextContent('Exemptions applied successfully');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error feedback and keeps dialog closed after failed apply', async () => {
|
||||
mockApiRequest.mockRejectedValue(new Error('403 Forbidden'));
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const label = screen.getByTestId('status-label');
|
||||
expect(label).toHaveAttribute('data-status', 'error');
|
||||
expect(label).toHaveTextContent(/failed to apply exemptions/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Applying..." text on Apply button while in-flight', async () => {
|
||||
let resolveRequest!: () => void;
|
||||
mockApiRequest.mockReturnValue(
|
||||
new Promise<void>(res => {
|
||||
resolveRequest = res;
|
||||
})
|
||||
);
|
||||
|
||||
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /applying/i })).toBeInTheDocument();
|
||||
resolveRequest();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Dialog, NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Dialog, SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { getCheckName } from '../api/checkMapping';
|
||||
import { Result } from '../api/polaris';
|
||||
@@ -26,17 +27,14 @@ export default function ExemptionManager({
|
||||
kind,
|
||||
name,
|
||||
}: ExemptionManagerProps) {
|
||||
const theme = useTheme();
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [selectedChecks, setSelectedChecks] = React.useState<Set<string>>(new Set());
|
||||
const [exemptAll, setExemptAll] = React.useState(false);
|
||||
const [applying, setApplying] = React.useState(false);
|
||||
|
||||
// Extract current exemptions from workload metadata
|
||||
const getExemptions = (): string[] => {
|
||||
// This would need to fetch the actual workload from K8s API
|
||||
// For now, return empty array as placeholder
|
||||
return [];
|
||||
};
|
||||
const [feedback, setFeedback] = React.useState<{ success: boolean; message: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Extract failing checks for this workload
|
||||
const getFailingChecks = (): CheckFailure[] => {
|
||||
@@ -75,7 +73,6 @@ export default function ExemptionManager({
|
||||
};
|
||||
|
||||
const failingChecks = getFailingChecks();
|
||||
const currentExemptions = getExemptions();
|
||||
|
||||
const handleCheckToggle = (checkId: string) => {
|
||||
const newSelected = new Set(selectedChecks);
|
||||
@@ -89,15 +86,15 @@ export default function ExemptionManager({
|
||||
|
||||
const applyExemptions = async () => {
|
||||
setApplying(true);
|
||||
setFeedback(null);
|
||||
|
||||
try {
|
||||
// Construct the API path based on kind
|
||||
const apiGroup = getApiGroup(kind);
|
||||
const apiVersion = 'v1'; // This would need to be dynamic based on kind
|
||||
const plural = getPlural(kind);
|
||||
|
||||
const patchPath = apiGroup
|
||||
? `/apis/${apiGroup}/${apiVersion}/namespaces/${namespace}/${plural}/${name}`
|
||||
? `/apis/${apiGroup}/v1/namespaces/${namespace}/${plural}/${name}`
|
||||
: `/api/v1/namespaces/${namespace}/${plural}/${name}`;
|
||||
|
||||
// Build annotations patch
|
||||
@@ -128,46 +125,27 @@ export default function ExemptionManager({
|
||||
setDialogOpen(false);
|
||||
setSelectedChecks(new Set());
|
||||
setExemptAll(false);
|
||||
|
||||
// Show success message (would need notistack integration)
|
||||
alert('Exemptions applied successfully');
|
||||
setFeedback({ success: true, message: 'Exemptions applied successfully' });
|
||||
} catch (err) {
|
||||
alert(`Failed to apply exemptions: ${String(err)}`);
|
||||
setFeedback({ success: false, message: `Failed to apply exemptions: ${String(err)}` });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = applying || (!exemptAll && selectedChecks.size === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Exemptions">
|
||||
{currentExemptions.length > 0 ? (
|
||||
<NameValueTable
|
||||
rows={currentExemptions.map(exemption => ({
|
||||
name: exemption,
|
||||
value: (
|
||||
<button
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: 'var(--mui-palette-error-main, #f44336)',
|
||||
color: 'var(--mui-palette-error-contrastText, #fff)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onClick={() => {
|
||||
// Remove exemption logic
|
||||
alert('Remove exemption: ' + exemption);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p>No exemptions configured</p>
|
||||
<p>No exemptions configured</p>
|
||||
|
||||
{feedback && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<StatusLabel status={feedback.success ? 'success' : 'error'}>
|
||||
{feedback.message}
|
||||
</StatusLabel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -177,18 +155,14 @@ export default function ExemptionManager({
|
||||
marginTop: '8px',
|
||||
padding: '6px 16px',
|
||||
backgroundColor:
|
||||
failingChecks.length === 0
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'transparent',
|
||||
failingChecks.length === 0 ? theme.palette.action.disabledBackground : 'transparent',
|
||||
color:
|
||||
failingChecks.length === 0
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette.primary.main,
|
||||
border: '1px solid',
|
||||
borderColor:
|
||||
failingChecks.length === 0
|
||||
? 'var(--mui-palette-divider, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
failingChecks.length === 0 ? theme.palette.divider : theme.palette.primary.main,
|
||||
borderRadius: '4px',
|
||||
cursor: failingChecks.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
@@ -246,7 +220,7 @@ export default function ExemptionManager({
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: theme.palette.primary.main,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
@@ -257,21 +231,18 @@ export default function ExemptionManager({
|
||||
</button>
|
||||
<button
|
||||
onClick={applyExemptions}
|
||||
disabled={applying || (!exemptAll && selectedChecks.size === 0)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor:
|
||||
applying || (!exemptAll && selectedChecks.size === 0)
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color:
|
||||
applying || (!exemptAll && selectedChecks.size === 0)
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-contrastText, #fff)',
|
||||
backgroundColor: isDisabled
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.main,
|
||||
color: isDisabled
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette.primary.contrastText,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor:
|
||||
applying || (!exemptAll && selectedChecks.size === 0) ? 'not-allowed' : 'pointer',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/styles', () => ({
|
||||
useTheme: () => ({
|
||||
palette: {
|
||||
primary: { main: '#1976d2' },
|
||||
text: { primary: '#000', secondary: '#666' },
|
||||
action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
|
||||
divider: '#e0e0e0',
|
||||
error: { main: '#f44336', contrastText: '#fff' },
|
||||
success: { main: '#4caf50' },
|
||||
warning: { main: '#ff9800' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-router-dom
|
||||
vi.mock('react-router-dom', () => ({
|
||||
Link: ({ to, children, style }: { to: string; children: React.ReactNode; style?: object }) => (
|
||||
<a href={to} style={style}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<div data-testid="section-box" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||
<span data-testid="status-label" data-status={status}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
|
||||
<table data-testid="name-value-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.name}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
SimpleTable: ({
|
||||
columns,
|
||||
data,
|
||||
}: {
|
||||
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
|
||||
data: unknown[];
|
||||
}) => (
|
||||
<table data-testid="simple-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col.label}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map(col => (
|
||||
<td key={col.label}>{col.getter(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
Dialog: () => null,
|
||||
}));
|
||||
|
||||
// Mock ExemptionManager
|
||||
vi.mock('./ExemptionManager', () => ({
|
||||
default: () => <div data-testid="exemption-manager" />,
|
||||
}));
|
||||
|
||||
const mockUsePolarisDataContext = vi.fn();
|
||||
vi.mock('../api/PolarisDataContext', () => ({
|
||||
usePolarisDataContext: () => mockUsePolarisDataContext(),
|
||||
}));
|
||||
|
||||
import InlineAuditSection from './InlineAuditSection';
|
||||
|
||||
describe('InlineAuditSection', () => {
|
||||
it('returns null when loading', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({ data: null, loading: true, error: null });
|
||||
const { container } = render(
|
||||
<InlineAuditSection
|
||||
resource={{ kind: 'Deployment', metadata: { name: 'x', namespace: 'y' } }}
|
||||
/>
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('returns null for unsupported kind', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: makeAuditData([]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const { container } = render(
|
||||
<InlineAuditSection resource={{ kind: 'Service', metadata: { name: 'x', namespace: 'y' } }} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows "not detected" when workload not found in audit data', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: makeAuditData([]),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
render(
|
||||
<InlineAuditSection
|
||||
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Polaris dashboard not detected/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders score and summary for a matching workload', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'my-app',
|
||||
Namespace: 'default',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
|
||||
|
||||
render(
|
||||
<InlineAuditSection
|
||||
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 passing, 1 warnings, 0 dangers/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders failing checks table with pod and container results', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'my-app',
|
||||
Namespace: 'default',
|
||||
Kind: 'Deployment',
|
||||
Results: {},
|
||||
PodResult: {
|
||||
Name: 'pod',
|
||||
Results: {
|
||||
hostIPCSet: {
|
||||
ID: 'hostIPCSet',
|
||||
Message: 'Host IPC is set',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'danger',
|
||||
Category: 'Security',
|
||||
},
|
||||
},
|
||||
ContainerResults: [
|
||||
{
|
||||
Name: 'container-1',
|
||||
Results: {
|
||||
cpuRequestsMissing: {
|
||||
ID: 'cpuRequestsMissing',
|
||||
Message: 'CPU requests missing',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'Efficiency',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
|
||||
|
||||
render(
|
||||
<InlineAuditSection
|
||||
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Host IPC')).toBeInTheDocument();
|
||||
expect(screen.getByText('CPU Requests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Host IPC is set')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders link to full report', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'my-app',
|
||||
Namespace: 'default',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
|
||||
|
||||
render(
|
||||
<InlineAuditSection
|
||||
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
|
||||
/>
|
||||
);
|
||||
const link = screen.getByText('View Full Report →');
|
||||
expect(link).toHaveAttribute('href', '/polaris/namespaces#default');
|
||||
});
|
||||
|
||||
it('renders ExemptionManager', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'my-app',
|
||||
Namespace: 'default',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
|
||||
|
||||
render(
|
||||
<InlineAuditSection
|
||||
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('exemption-manager')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getCheckName, getSeverityStatus } from '../api/checkMapping';
|
||||
@@ -18,8 +19,17 @@ interface CheckFailure {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface KubeResource {
|
||||
kind: string;
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
interface InlineAuditSectionProps {
|
||||
resource: any; // KubeObject from Headlamp
|
||||
resource: KubeResource;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +37,7 @@ interface InlineAuditSectionProps {
|
||||
* Shows a compact summary of Polaris findings for Deployments, StatefulSets, etc.
|
||||
*/
|
||||
export default function InlineAuditSection({ resource }: InlineAuditSectionProps) {
|
||||
const theme = useTheme();
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
|
||||
if (loading || !data) {
|
||||
@@ -156,10 +167,7 @@ export default function InlineAuditSection({ resource }: InlineAuditSectionProps
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Link
|
||||
to={`/polaris/namespaces#${namespace}`}
|
||||
style={{ color: 'var(--link-color, #1976d2)' }}
|
||||
>
|
||||
<Link to={`/polaris/namespaces#${namespace}`} style={{ color: theme.palette.primary.main }}>
|
||||
View Full Report →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { makeAuditData, makeResult } from '../test-utils';
|
||||
|
||||
// Mock Headlamp lib
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock react-router-dom useParams
|
||||
const mockNamespace = vi.fn(() => 'test-ns');
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useParams: () => ({ namespace: mockNamespace() }),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
<div data-testid="section-box" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SectionHeader: ({ title }: { title: string }) => <div data-testid="section-header">{title}</div>,
|
||||
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||
<span data-testid="status-label" data-status={status}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
|
||||
<table data-testid="name-value-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.name}>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
SimpleTable: ({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage,
|
||||
}: {
|
||||
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
|
||||
data: unknown[];
|
||||
emptyMessage?: string;
|
||||
}) =>
|
||||
data.length === 0 ? (
|
||||
<div data-testid="simple-table-empty">{emptyMessage}</div>
|
||||
) : (
|
||||
<table data-testid="simple-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col.label}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map(col => (
|
||||
<td key={col.label}>{col.getter(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUsePolarisDataContext = vi.fn();
|
||||
vi.mock('../api/PolarisDataContext', () => ({
|
||||
usePolarisDataContext: () => mockUsePolarisDataContext(),
|
||||
}));
|
||||
|
||||
import NamespaceDetailView from './NamespaceDetailView';
|
||||
|
||||
describe('NamespaceDetailView', () => {
|
||||
it('renders loader when loading', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris data for test-ns');
|
||||
});
|
||||
|
||||
it('renders error message when error is set', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: 'Access denied (403)',
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByText('Access denied (403)')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns');
|
||||
});
|
||||
|
||||
it('renders "No Data" when no data and no error', () => {
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders namespace score and resource table with data', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'test-ns',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
c2: {
|
||||
ID: 'c2',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: false,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeResult({
|
||||
Name: 'other',
|
||||
Namespace: 'other-ns',
|
||||
Kind: 'Deployment',
|
||||
Results: {
|
||||
c3: {
|
||||
ID: 'c3',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
|
||||
// Header
|
||||
expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns');
|
||||
|
||||
// Score section: 50% (1 pass / 2 total)
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
expect(screen.getByText('Total Checks')).toBeInTheDocument();
|
||||
|
||||
// Resource table shows only test-ns resources
|
||||
expect(screen.getByText('deploy-a')).toBeInTheDocument();
|
||||
expect(screen.queryByText('other')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty table message for namespace with no results', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'other-ns',
|
||||
Results: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(<NamespaceDetailView />);
|
||||
expect(screen.getByTestId('simple-table-empty')).toHaveTextContent(
|
||||
'No resources found in namespace "test-ns"'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
computeScore,
|
||||
countResultsForItems,
|
||||
filterResultsByNamespace,
|
||||
getPolarisProxyUrl,
|
||||
Result,
|
||||
ResultCounts,
|
||||
} from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
|
||||
function scoreStatus(score: number): 'success' | 'warning' | 'error' {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 50) return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
function resourceCounts(result: Result): ResultCounts {
|
||||
return countResultsForItems([result]);
|
||||
}
|
||||
|
||||
export default function NamespaceDetailView() {
|
||||
const { namespace } = useParams<{ namespace: string }>();
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
return <Loader title={`Loading Polaris data for ${namespace}...`} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={`Polaris — ${namespace}`} />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">{error}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={`Polaris — ${namespace}`} />
|
||||
<SectionBox title="No Data">
|
||||
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const results = filterResultsByNamespace(data, namespace);
|
||||
const counts = countResultsForItems(results);
|
||||
const score = computeScore(counts);
|
||||
const status = scoreStatus(score);
|
||||
|
||||
const countsPerResource = new Map<string, ResultCounts>();
|
||||
for (const r of results) {
|
||||
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
|
||||
}
|
||||
|
||||
function getResourceCounts(row: Result): ResultCounts {
|
||||
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={`Polaris — ${namespace}`} />
|
||||
|
||||
<SectionBox title="External">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Namespace Score">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: <StatusLabel status={status}>{score}%</StatusLabel>,
|
||||
},
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Resources">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (row: Result) => row.Name },
|
||||
{ label: 'Kind', getter: (row: Result) => row.Kind },
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
emptyMessage={`No resources found in namespace "${namespace}".`}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,48 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock MUI components
|
||||
vi.mock('@mui/material/Drawer', () => ({
|
||||
default: ({
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
anchor?: string;
|
||||
}) => (open ? <div data-testid="mui-drawer">{children}</div> : null),
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/IconButton', () => ({
|
||||
default: ({
|
||||
children,
|
||||
onClick,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
'aria-label': string;
|
||||
title?: string;
|
||||
size?: string;
|
||||
}) => (
|
||||
<button onClick={onClick} aria-label={ariaLabel}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/styles', () => ({
|
||||
useTheme: () => ({
|
||||
palette: {
|
||||
primary: { main: '#1976d2' },
|
||||
text: { primary: '#000', secondary: '#666' },
|
||||
action: { hover: 'rgba(0,0,0,0.04)' },
|
||||
background: { default: '#fafafa' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
@@ -44,6 +47,7 @@ interface NamespaceDetailPanelProps {
|
||||
}
|
||||
|
||||
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
|
||||
const theme = useTheme();
|
||||
const [isMaximized, setIsMaximized] = React.useState(false);
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
@@ -96,172 +100,119 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
|
||||
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
|
||||
}
|
||||
|
||||
// Generate a unique class name for this drawer to avoid conflicts
|
||||
const drawerClass = `polaris-namespace-drawer-${namespace}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.${drawerClass} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${isMaximized ? 'calc(100vw - 240px)' : '1000px'};
|
||||
background-color: var(--mui-palette-background-default, #fafafa);
|
||||
color: var(--mui-palette-text-primary);
|
||||
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
|
||||
overflow-y: auto;
|
||||
z-index: 1200;
|
||||
padding: 20px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={drawerClass}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
|
||||
Polaris — {namespace}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'var(--mui-palette-action-hover, rgba(0, 0, 0, 0.04))';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
|
||||
title={isMaximized ? 'Minimize' : 'Maximize'}
|
||||
>
|
||||
{isMaximized ? '⊟' : '⊡'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'var(--mui-palette-action-hover, rgba(0, 0, 0, 0.04))';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
aria-label="Close panel"
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: isMaximized ? 'calc(100vw - 240px)' : '1000px',
|
||||
padding: '20px',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: theme.palette.text.primary }}>Polaris — {namespace}</h2>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
|
||||
title={isMaximized ? 'Minimize' : 'Maximize'}
|
||||
size="small"
|
||||
>
|
||||
{isMaximized ? '\u229F' : '\u22A1'}
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose} aria-label="Close panel" title="Close" size="small">
|
||||
\u00D7
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<SectionBox title="External">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Namespace Score">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: <StatusLabel status={status}>{score}%</StatusLabel>,
|
||||
},
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Resources">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (row: Result) => row.Name },
|
||||
{ label: 'Kind', getter: (row: Result) => row.Kind },
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
emptyMessage={`No resources found in namespace "${namespace}".`}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<SectionBox title="External">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Namespace Score">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: <StatusLabel status={status}>{score}%</StatusLabel>,
|
||||
},
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Resources">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (row: Result) => row.Name },
|
||||
{ label: 'Kind', getter: (row: Result) => row.Kind },
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
emptyMessage={`No resources found in namespace "${namespace}".`}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NamespacesListView() {
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
@@ -287,21 +238,6 @@ export default function NamespacesListView() {
|
||||
history.push(location.pathname);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation (Escape key closes drawer)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && selectedNamespace) {
|
||||
closeNamespace();
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedNamespace) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedNamespace]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
@@ -364,7 +300,7 @@ export default function NamespacesListView() {
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
color: theme.palette.primary.main,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
@@ -405,24 +341,11 @@ export default function NamespacesListView() {
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{selectedNamespace && (
|
||||
<>
|
||||
<div
|
||||
onClick={closeNamespace}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1100,
|
||||
}}
|
||||
aria-label="Close panel backdrop"
|
||||
/>
|
||||
<Drawer anchor="right" open={Boolean(selectedNamespace)} onClose={closeNamespace}>
|
||||
{selectedNamespace && (
|
||||
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@mui/material/styles', () => ({
|
||||
useTheme: () => ({
|
||||
palette: {
|
||||
primary: { main: '#1976d2', contrastText: '#fff' },
|
||||
text: { primary: '#000', secondary: '#666' },
|
||||
action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
|
||||
divider: '#e0e0e0',
|
||||
background: { paper: '#fff' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import {
|
||||
AuditData,
|
||||
getDashboardUrl,
|
||||
getPolarisApiPath,
|
||||
getRefreshInterval,
|
||||
INTERVAL_OPTIONS,
|
||||
isFullUrl,
|
||||
setDashboardUrl,
|
||||
setRefreshInterval,
|
||||
} from '../api/polaris';
|
||||
@@ -20,6 +23,7 @@ interface PluginSettingsProps {
|
||||
}
|
||||
|
||||
export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
const theme = useTheme();
|
||||
const { data, onDataChange } = props;
|
||||
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
|
||||
const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl();
|
||||
@@ -45,13 +49,11 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const baseUrl = currentUrl;
|
||||
const apiPath = baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
const isFullUrl = apiPath.startsWith('http://') || apiPath.startsWith('https://');
|
||||
const apiPath = getPolarisApiPath();
|
||||
|
||||
let result: AuditData;
|
||||
|
||||
if (isFullUrl) {
|
||||
if (isFullUrl(apiPath)) {
|
||||
const response = await fetch(apiPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -103,27 +105,27 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
type="text"
|
||||
value={currentUrl}
|
||||
onChange={handleUrlChange}
|
||||
placeholder="/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/"
|
||||
placeholder="/api/v1/namespaces/polaris/services/http:polaris-dashboard:80/proxy/"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
color: 'var(--mui-palette-text-primary, #000)',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Examples:
|
||||
<br />• K8s proxy:{' '}
|
||||
<code>/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/</code>
|
||||
<code>/api/v1/namespaces/polaris/services/http:polaris-dashboard:80/proxy/</code>
|
||||
<br />• Full URL: <code>https://my-polaris.example.com</code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,11 +141,11 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: testing
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.main,
|
||||
color: testing
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-contrastText, #fff)',
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette.primary.contrastText,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: testing ? 'not-allowed' : 'pointer',
|
||||
|
||||
+50
-13
@@ -5,6 +5,7 @@ import {
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import { SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { PolarisDataProvider } from './api/PolarisDataContext';
|
||||
import AppBarScoreBadge from './components/AppBarScoreBadge';
|
||||
@@ -13,6 +14,34 @@ import InlineAuditSection from './components/InlineAuditSection';
|
||||
import NamespacesListView from './components/NamespacesListView';
|
||||
import PolarisSettings from './components/PolarisSettings';
|
||||
|
||||
// --- Error boundary for plugin components ---
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
class PolarisErrorBoundary extends React.Component<
|
||||
{ children: React.ReactNode },
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error: error.message };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<SectionBox title="Polaris Plugin Error">
|
||||
<StatusLabel status="error">{this.state.error}</StatusLabel>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sidebar entries ---
|
||||
|
||||
registerSidebarEntry({
|
||||
@@ -47,9 +76,11 @@ registerRoute({
|
||||
name: 'polaris',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<PolarisDataProvider>
|
||||
<DashboardView />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<DashboardView />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -59,14 +90,16 @@ registerRoute({
|
||||
name: 'polaris-namespaces',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<PolarisDataProvider>
|
||||
<NamespacesListView />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<NamespacesListView />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
),
|
||||
});
|
||||
|
||||
// Register plugin settings
|
||||
registerPluginSettings('polaris', PolarisSettings, true);
|
||||
registerPluginSettings('headlamp-polaris', PolarisSettings, true);
|
||||
|
||||
// Register details view section for supported controller types
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
@@ -77,15 +110,19 @@ registerDetailsViewSection(({ resource }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PolarisDataProvider>
|
||||
<InlineAuditSection resource={resource} />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<InlineAuditSection resource={resource} />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
// Register app bar score badge
|
||||
registerAppBarAction(() => (
|
||||
<PolarisDataProvider>
|
||||
<AppBarScoreBadge />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<AppBarScoreBadge />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { AuditData, Result } from './api/polaris';
|
||||
|
||||
// --- Fixtures ---
|
||||
@@ -25,37 +24,3 @@ export function makeAuditData(results: Result[]): AuditData {
|
||||
Results: results,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Mock Polaris Context Provider ---
|
||||
|
||||
interface MockPolarisProviderProps {
|
||||
data?: AuditData | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// We dynamically import PolarisDataContext to inject mock values.
|
||||
// This avoids mocking the hook module — we supply real context with controlled values.
|
||||
const PolarisDataContext = React.createContext<{
|
||||
data: AuditData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
|
||||
export function MockPolarisProvider({
|
||||
data = null,
|
||||
loading = false,
|
||||
error = null,
|
||||
children,
|
||||
}: MockPolarisProviderProps) {
|
||||
return (
|
||||
<PolarisDataContext.Provider value={{ data, loading, error }}>
|
||||
{children}
|
||||
</PolarisDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// The context reference used in test-utils must be the SAME object the components import.
|
||||
// We achieve this by having component tests mock `usePolarisDataContext` to read from our context.
|
||||
export { PolarisDataContext };
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: ['src/**/*.test.{ts,tsx}', 'src/test-utils.tsx', 'src/index.tsx'],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user