feat:Fix the bootstrap to be deploy key application
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.env
|
||||
@@ -0,0 +1,18 @@
|
||||
# Playwright base ships WebKit + all OS deps. Pin must match @playwright/test in
|
||||
# package.json. Verify tag exists: https://mcr.microsoft.com/en-us/product/playwright/tags
|
||||
FROM mcr.microsoft.com/playwright:v1.50.0-jammy
|
||||
|
||||
WORKDIR /e2e
|
||||
|
||||
# minio client for artifact upload (video/trace → MinIO bucket)
|
||||
RUN curl -sSLo /usr/local/bin/mc https://dl.min.io/client/mc/release/linux-amd64/mc \
|
||||
&& chmod +x /usr/local/bin/mc
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
# run-and-upload.sh runs the suite, uploads artifacts regardless of result,
|
||||
# then exits with the suite's real exit code so the Job/gate reflects pass/fail.
|
||||
ENTRYPOINT ["/e2e/run-and-upload.sh"]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Homelab E2E smoke (Playwright · WebKit / Safari engine)
|
||||
|
||||
Proves apps are **viewable**, not just that a pod is `Running`. Verifies real
|
||||
ingress + TLS + rendered UI through WebKit — Safari's engine (hard constraint).
|
||||
|
||||
## Layout
|
||||
- `playwright.config.ts` — WebKit-only project, video + trace + screenshot always on.
|
||||
- `targets.ts` — the app list (`NAME-APP.<BASE_DOMAIN>`) + per-app "ready" selector. **Edit to match your ingress hosts.**
|
||||
- `tests/reachability.spec.ts` — every non-auth app: 2xx/3xx + own-UI rendered (not nginx default backend).
|
||||
- `tests/portainer.spec.ts` — login page renders.
|
||||
- `tests/authentik.spec.ts` — full admin sign-in (needs `AK_ADMIN_PASSWORD`).
|
||||
- `tests/oauth.spec.ts` — OIDC/OAuth **federation** flow per app: app → Authentik → back, authenticated. Driven by `OAUTH_TARGETS` in `targets.ts`; uses `helpers/authentik.ts`.
|
||||
- `Dockerfile` / `run-and-upload.sh` — container image; runs suite, uploads artifacts to MinIO, exits with the suite's real code.
|
||||
|
||||
## Run locally
|
||||
```bash
|
||||
cd tests/e2e
|
||||
npm install
|
||||
npx playwright install webkit
|
||||
BASE_DOMAIN=riotpiao.com npm test # add E2E_IGNORE_TLS=1 for staging certs
|
||||
AK_ADMIN_PASSWORD=… npm test # to exercise the authentik login
|
||||
npm run report # open HTML report (video/trace)
|
||||
```
|
||||
|
||||
## Env
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `BASE_DOMAIN` | `riotpiao.com` | domain suffix for `NAME.<domain>` |
|
||||
| `E2E_IGNORE_TLS` | `0` | `1` = accept staging/self-signed certs (bootstrap only) |
|
||||
| `AK_ADMIN_USER` / `AK_ADMIN_PASSWORD` | `akadmin` / — | authentik login (password from secret store) |
|
||||
| `MINIO_ENDPOINT` / `MINIO_BUCKET` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — | artifact upload target |
|
||||
|
||||
## In-cluster
|
||||
`k8s/platform/e2e/` has a **Job** (deploy gate — attach as ArgoCD PostSync hook or
|
||||
Rollouts AnalysisTemplate) and a **CronJob** (continuous smoke, alert on failure).
|
||||
Both need a `e2e-credentials` Secret (authentik password + MinIO keys) — create it
|
||||
SOPS-encrypted. **Not yet wired into any kustomization / app-of-apps** — that is
|
||||
ADR 0001 migration phase 7.
|
||||
|
||||
## Build image
|
||||
```bash
|
||||
docker build -t <registry>/riotpiao.com/homelab-e2e:$(git rev-parse --short HEAD) tests/e2e
|
||||
docker push <registry>/riotpiao.com/homelab-e2e:…
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
// Complete Authentik's identification → password flow on whatever page is
|
||||
// currently showing it (used both for direct login and mid-OAuth redirect).
|
||||
// Idempotent: if Authentik already has a session and skipped straight through,
|
||||
// the fields won't appear and this returns without error.
|
||||
export async function completeAuthentikLogin(page: Page, user: string, pass: string): Promise<void> {
|
||||
const uid = page.locator('input[name="uidField"], input[type="email"], input[type="text"]').first();
|
||||
if (await uid.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await uid.fill(user);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
const pw = page.locator('input[type="password"]').first();
|
||||
if (await pw.isVisible({ timeout: 8_000 }).catch(() => false)) {
|
||||
await pw.fill(pass);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
// Authentik may show a consent/authorize step on first SSO to an app.
|
||||
const authorize = page.getByRole('button', { name: /authorize|continue|allow/i }).first();
|
||||
if (await authorize.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await authorize.click();
|
||||
}
|
||||
|
||||
await expect(page.getByText(/invalid|incorrect|failed to/i)).toHaveCount(0);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "homelab-e2e",
|
||||
"private": true,
|
||||
"description": "Playwright WebKit (Safari-engine) synthetic E2E smoke suite for homelab ingress apps",
|
||||
"scripts": {
|
||||
"test": "playwright test --project=webkit",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.50.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// WebKit only — hard constraint: verify apps in Safari's rendering engine.
|
||||
// Video + trace recorded for every test so a failed deploy has a replay.
|
||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'riotpiao.com';
|
||||
|
||||
// Set E2E_IGNORE_TLS=1 only during early bootstrap (staging certs). Default is
|
||||
// strict so a broken TLS chain FAILS the suite — that is a real deploy failure.
|
||||
const ignoreHTTPSErrors = process.env.E2E_IGNORE_TLS === '1';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
outputDir: './test-results',
|
||||
// Serial + retries: this is a smoke gate, not a load test.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { outputFolder: 'playwright-report', open: 'never' }],
|
||||
['json', { outputFile: 'test-results/results.json' }],
|
||||
],
|
||||
|
||||
use: {
|
||||
baseURL: `https://${BASE_DOMAIN}`,
|
||||
ignoreHTTPSErrors,
|
||||
video: 'on',
|
||||
trace: 'on',
|
||||
screenshot: 'on',
|
||||
navigationTimeout: 30_000,
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
],
|
||||
});
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the WebKit smoke suite, upload artifacts (video/trace/screenshots/report)
|
||||
# to MinIO, then exit with the suite's real result so a CD gate can act on it.
|
||||
set -uo pipefail
|
||||
|
||||
STAMP="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}"
|
||||
DEST="e2e/${STAMP}"
|
||||
|
||||
echo "▶ Playwright WebKit smoke — BASE_DOMAIN=${BASE_DOMAIN:-riotpiao.com}"
|
||||
npm test
|
||||
RESULT=$?
|
||||
echo "▶ suite exit=${RESULT}"
|
||||
|
||||
# Upload artifacts even on failure — a failed deploy is exactly when you want the video.
|
||||
if [[ -n "${MINIO_ENDPOINT:-}" && -n "${MINIO_ACCESS_KEY:-}" ]]; then
|
||||
echo "▶ uploading artifacts → ${MINIO_ENDPOINT}/${MINIO_BUCKET:-e2e-artifacts}/${DEST}"
|
||||
mc alias set store "${MINIO_ENDPOINT}" "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}" >/dev/null 2>&1
|
||||
mc mb --ignore-existing "store/${MINIO_BUCKET:-e2e-artifacts}" >/dev/null 2>&1
|
||||
mc cp --recursive test-results/ "store/${MINIO_BUCKET:-e2e-artifacts}/${DEST}/test-results/" >/dev/null 2>&1 || true
|
||||
mc cp --recursive playwright-report/ "store/${MINIO_BUCKET:-e2e-artifacts}/${DEST}/playwright-report/" >/dev/null 2>&1 || true
|
||||
echo "▶ artifacts uploaded under ${DEST}/"
|
||||
else
|
||||
echo "▶ MINIO_ENDPOINT unset — skipping upload (artifacts in ./test-results)"
|
||||
fi
|
||||
|
||||
exit ${RESULT}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Ingress apps to smoke-test. Each entry = NAME-APP.<BASE_DOMAIN>.
|
||||
// `ready` is a selector or text that only appears once the app actually rendered
|
||||
// (not just a 200 from nginx's default backend).
|
||||
//
|
||||
// EDIT THIS LIST to match what you actually expose. Confirmed hosts are marked;
|
||||
// others are best-guess — fix the subdomain if yours differs.
|
||||
|
||||
export type Target = {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
path?: string;
|
||||
// A locator that proves the app's own UI rendered.
|
||||
ready: { role?: string; name?: RegExp; text?: RegExp; selector?: string };
|
||||
// Skip in the default reachability run (has a dedicated spec, e.g. auth flow).
|
||||
dedicated?: boolean;
|
||||
};
|
||||
|
||||
export const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'riotpiao.com';
|
||||
|
||||
export function url(t: Pick<Target, 'subdomain' | 'path'>): string {
|
||||
return `https://${t.subdomain}.${BASE_DOMAIN}${t.path ?? '/'}`;
|
||||
}
|
||||
|
||||
// Apps that federate login through Authentik (OIDC/OAuth2). The OAuth spec drives
|
||||
// the full chain: open the app, click its "sign in via SSO" control, complete the
|
||||
// Authentik login on redirect, and assert we land back in the app authenticated.
|
||||
// EDIT selectors to match your apps — the SSO button text/label varies per app.
|
||||
export type OAuthTarget = {
|
||||
name: string;
|
||||
subdomain: string;
|
||||
path?: string;
|
||||
// The app's "log in with Authentik/SSO" control.
|
||||
ssoButton: { role?: string; name?: RegExp; selector?: string };
|
||||
// Proof we are authenticated back inside the app after the round-trip.
|
||||
success: { urlRe?: RegExp; text?: RegExp; selector?: string };
|
||||
};
|
||||
|
||||
export const OAUTH_TARGETS: OAuthTarget[] = [
|
||||
{
|
||||
name: 'argocd',
|
||||
subdomain: 'argocd',
|
||||
ssoButton: { role: 'link', name: /log ?in via|authentik|sso|oidc/i },
|
||||
success: { urlRe: /\/applications/i, text: /Applications/i },
|
||||
},
|
||||
{
|
||||
name: 'forgejo',
|
||||
subdomain: 'forgejo',
|
||||
path: '/user/login',
|
||||
ssoButton: { role: 'link', name: /authentik|oauth|sign in with|openid/i },
|
||||
success: { selector: 'a[href="/notifications"], .avatar, nav .user' },
|
||||
},
|
||||
{
|
||||
name: 'grafana',
|
||||
subdomain: 'grafana',
|
||||
path: '/login',
|
||||
ssoButton: { role: 'link', name: /sign in with|authentik|oauth/i },
|
||||
success: { urlRe: /\/(\?|$)|\/d\//, text: /Welcome to Grafana|Dashboards|Home/i },
|
||||
},
|
||||
];
|
||||
|
||||
export const TARGETS: Target[] = [
|
||||
// confirmed hosts (from bootstrap values)
|
||||
{ name: 'argocd', subdomain: 'argocd', ready: { text: /Argo\s*CD|Let's get started|Login/i } },
|
||||
{ name: 'forgejo', subdomain: 'forgejo', ready: { text: /Forgejo|Sign In|Explore/i } },
|
||||
|
||||
// dashboards / apps — adjust subdomain to your actual ingress host
|
||||
{ name: 'portainer', subdomain: 'portainer', dedicated: true, ready: { selector: 'input[type="password"]' } },
|
||||
{ name: 'authentik', subdomain: 'authentik', dedicated: true, ready: { selector: 'input[name="uidField"], input[type="password"]' } },
|
||||
{ name: 'homarr', subdomain: 'homarr', ready: { text: /Homarr|Dashboard/i } },
|
||||
{ name: 'grafana', subdomain: 'grafana', ready: { text: /Grafana|Welcome/i } },
|
||||
{ name: 'minio', subdomain: 'minio', ready: { selector: 'input, button' } },
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TARGETS, url } from '../targets';
|
||||
|
||||
const t = TARGETS.find((x) => x.name === 'authentik')!;
|
||||
|
||||
// Authentik: the real signal. A healthy pod does NOT prove OIDC/redirect/session
|
||||
// work — only a completed sign-in does. Credentials come from env (injected from
|
||||
// the secret store in-cluster), never hardcoded.
|
||||
const USER = process.env.AK_ADMIN_USER ?? 'akadmin';
|
||||
const PASS = process.env.AK_ADMIN_PASSWORD;
|
||||
|
||||
test('authentik admin can sign in', async ({ page }) => {
|
||||
test.skip(!PASS, 'AK_ADMIN_PASSWORD not set — provide via secret to run the login flow');
|
||||
|
||||
await page.goto(url(t), { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Authentik identification stage: username, Enter/continue, then password.
|
||||
const uid = page.locator('input[name="uidField"], input[type="text"], input[type="email"]').first();
|
||||
await expect(uid).toBeVisible();
|
||||
await uid.fill(USER);
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
const pw = page.locator('input[type="password"]').first();
|
||||
await expect(pw).toBeVisible();
|
||||
await pw.fill(PASS!);
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Landed on the user dashboard — no auth error banner.
|
||||
await expect(page).toHaveURL(/\/if\/user|\/if\/admin|\/library/i, { timeout: 20_000 });
|
||||
await expect(page.getByText(/invalid|incorrect|failed/i)).toHaveCount(0);
|
||||
await page.screenshot({ path: 'test-results/authentik-dashboard.png', fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { OAUTH_TARGETS, url, BASE_DOMAIN } from '../targets';
|
||||
import { completeAuthentikLogin } from '../helpers/authentik';
|
||||
|
||||
// OIDC/OAuth federation flow — the real SSO integration signal. A healthy app pod
|
||||
// does NOT prove SSO works: client mis-registration, redirect-URI mismatch, issuer
|
||||
// cert, or a broken Authentik provider all fail HERE, not at the pod. Each test:
|
||||
// 1. open the app,
|
||||
// 2. click its "sign in via Authentik" control,
|
||||
// 3. complete the Authentik login on the redirect,
|
||||
// 4. assert we return to the app authenticated.
|
||||
const USER = process.env.AK_ADMIN_USER ?? 'akadmin';
|
||||
const PASS = process.env.AK_ADMIN_PASSWORD;
|
||||
|
||||
for (const t of OAUTH_TARGETS) {
|
||||
test(`${t.name} SSO login via Authentik`, async ({ page }) => {
|
||||
test.skip(!PASS, 'AK_ADMIN_PASSWORD not set — provide via secret to run OAuth flows');
|
||||
|
||||
await page.goto(url(t), { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Click the SSO control.
|
||||
const b = t.ssoButton;
|
||||
const btn = b.selector
|
||||
? page.locator(b.selector).first()
|
||||
: page.getByRole((b.role as any) ?? 'link', { name: b.name! }).first();
|
||||
await expect(btn, `${t.name}: SSO login control not found`).toBeVisible();
|
||||
await btn.click();
|
||||
|
||||
// We should be redirected to the Authentik domain (or already have a session).
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
if (page.url().includes(`.${BASE_DOMAIN}`) && /authentik|\/if\/flow/i.test(page.url())) {
|
||||
await completeAuthentikLogin(page, USER, PASS!);
|
||||
} else {
|
||||
// Not obviously on Authentik — still attempt, in case creds render inline.
|
||||
await completeAuthentikLogin(page, USER, PASS!).catch(() => {});
|
||||
}
|
||||
|
||||
// Back in the app, authenticated.
|
||||
const s = t.success;
|
||||
if (s.urlRe) await expect(page).toHaveURL(s.urlRe, { timeout: 25_000 });
|
||||
if (s.selector) await expect(page.locator(s.selector).first()).toBeVisible({ timeout: 25_000 });
|
||||
if (s.text) await expect(page.getByText(s.text).first()).toBeVisible({ timeout: 25_000 });
|
||||
|
||||
await expect(page.getByText(/invalid|unauthorized|access denied|redirect_uri/i)).toHaveCount(0);
|
||||
await page.screenshot({ path: `test-results/oauth-${t.name}.png`, fullPage: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TARGETS, url } from '../targets';
|
||||
|
||||
const t = TARGETS.find((x) => x.name === 'portainer')!;
|
||||
|
||||
// Portainer: prove the login UI renders over real ingress+TLS in Safari's engine.
|
||||
// (Full authenticated flow needs an initial-admin password; add once seeded.)
|
||||
test('portainer login page renders', async ({ page }) => {
|
||||
const resp = await page.goto(url(t), { waitUntil: 'domcontentloaded' });
|
||||
expect(resp?.status(), 'ingress did not serve portainer').toBeLessThan(400);
|
||||
|
||||
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||
await page.screenshot({ path: 'test-results/portainer-login.png', fullPage: true });
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TARGETS, url } from '../targets';
|
||||
|
||||
// Core assertion the user asked for: once ingress-nginx is up and the app is
|
||||
// ready, NAME-APP.<domain> must be VIEWABLE in Safari's engine — not just return
|
||||
// a status code. We navigate, assert a real 2xx/3xx (not nginx 404/503 default
|
||||
// backend), and assert the app's own UI actually rendered.
|
||||
|
||||
// Only apps without a dedicated flow spec (auth logins live in their own file).
|
||||
const smokeTargets = TARGETS.filter((t) => !t.dedicated);
|
||||
|
||||
for (const t of smokeTargets) {
|
||||
test(`${t.name} is viewable at ${url(t)}`, async ({ page }) => {
|
||||
const resp = await page.goto(url(t), { waitUntil: 'domcontentloaded' });
|
||||
|
||||
expect(resp, 'no response from ingress').toBeTruthy();
|
||||
const status = resp!.status();
|
||||
expect(status, `unexpected HTTP status ${status}`).toBeLessThan(400);
|
||||
|
||||
// Not the nginx default backend / error page.
|
||||
await expect(page.locator('body')).not.toContainText(/default backend - 404|503 Service Temporarily/i);
|
||||
|
||||
// App's own UI rendered.
|
||||
const r = t.ready;
|
||||
if (r.selector) {
|
||||
await expect(page.locator(r.selector).first()).toBeVisible();
|
||||
} else if (r.role && r.name) {
|
||||
await expect(page.getByRole(r.role as any, { name: r.name }).first()).toBeVisible();
|
||||
} else if (r.text) {
|
||||
await expect(page.getByText(r.text).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user