rename(phase 3): compose/DB/infra names + stray code refs capakraken → nexus
CI / Architecture Guardrails (pull_request) Successful in 2m59s
CI / Typecheck (pull_request) Successful in 6m41s
CI / Lint (pull_request) Successful in 4m18s
CI / Assistant Split Regression (pull_request) Successful in 5m6s
CI / Unit Tests (pull_request) Successful in 7m21s
CI / Build (pull_request) Successful in 5m21s
CI / Fresh-Linux Docker Deploy (pull_request) Failing after 38s
CI / E2E Tests (pull_request) Successful in 3m28s
CI / Release Images (pull_request) Has been skipped
CI / Architecture Guardrails (pull_request) Successful in 2m59s
CI / Typecheck (pull_request) Successful in 6m41s
CI / Lint (pull_request) Successful in 4m18s
CI / Assistant Split Regression (pull_request) Successful in 5m6s
CI / Unit Tests (pull_request) Successful in 7m21s
CI / Build (pull_request) Successful in 5m21s
CI / Fresh-Linux Docker Deploy (pull_request) Failing after 38s
CI / E2E Tests (pull_request) Successful in 3m28s
CI / Release Images (pull_request) Has been skipped
- docker-compose.yml / .prod.yml / .ci.yml: project names, POSTGRES_DB/USER, pg_isready, DATABASE_URL, volume names (nexus_pgdata, nexus_prod_*) - .github/workflows/ci.yml: POSTGRES_PASSWORD, pg_isready, psql credentials, GRANT statements, POSTGRES_PASSWORD=nexus_dev for Docker Deploy job - scripts/db-target-guard.mjs: expectedDatabase default, NEXUS_EXPECTED_DB_NAME - scripts/prisma-with-env.mjs, e2e/test-server.mjs: env-var rename - packages/db/src/safe-destructive-env.ts + reset-dispo-import.ts: DB name set - packages/db/src/destructive-db-guard.ts: PROTECTED_DATABASE_NAMES → "nexus" - packages/db/src/destructive-db-guard.test.ts: all fixture DB names + comments - .env.example, tooling/deploy/deploy.env.example: DATABASE_URL, image refs - packages/api: Redis channel/key prefixes (rbac-invalidate, sse, ratelimit), logger service name, app-base-url log prefix - E2E: DB container names, localStorage/sessionStorage keys, email domains - scripts: architecture-guardrails filter, export/import-dev-seed defaults, harden-postgres defaults, start.sh pg_isready, worktree-hygiene fixture - tooling/migrate/rename-to-nexus.sh: new maintenance-window cutover script Only intentional capakraken survivor: anonymization.ts DEFAULT_ANONYMIZATION_SEED (functional cryptographic constant — changing it would invalidate stored aliases). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"aed37e34-4be8-4788-b03a-7145d9b4b2ce","pid":3544538,"procStart":"34480817","acquiredAt":1779373227101}
|
||||||
+2
-2
@@ -32,7 +32,7 @@ POSTGRES_PASSWORD=
|
|||||||
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
|
# host (outside Docker). Must match POSTGRES_PASSWORD above. Inside the app
|
||||||
# container this variable is overridden by docker-compose.yml (which routes
|
# container this variable is overridden by docker-compose.yml (which routes
|
||||||
# to the postgres service name on the internal network).
|
# to the postgres service name on the internal network).
|
||||||
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
|
DATABASE_URL=postgresql://nexus:nexus_dev@localhost:5433/nexus
|
||||||
|
|
||||||
# ─── Redis ───────────────────────────────────────────────────────────────────
|
# ─── Redis ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ PGADMIN_PASSWORD=
|
|||||||
# that any resolved path remains inside this directory; this prevents an
|
# that any resolved path remains inside this directory; this prevents an
|
||||||
# admin (or compromised admin token) from pointing the parser at arbitrary
|
# admin (or compromised admin token) from pointing the parser at arbitrary
|
||||||
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
|
# files on disk and reaching ExcelJS CVEs. Defaults to ./imports if unset.
|
||||||
# DISPO_IMPORT_DIR=/var/lib/capakraken/imports
|
# DISPO_IMPORT_DIR=/var/lib/nexus/imports
|
||||||
|
|
||||||
# ─── Testing (never enable in production) ────────────────────────────────────
|
# ─── Testing (never enable in production) ────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+24
-24
@@ -159,11 +159,11 @@ jobs:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: capakraken_test
|
POSTGRES_DB: nexus_test
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: capakraken_test
|
POSTGRES_PASSWORD: nexus_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd="pg_isready -U capakraken -d capakraken_test"
|
--health-cmd="pg_isready -U nexus -d nexus_test"
|
||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
@@ -175,7 +175,7 @@ jobs:
|
|||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://capakraken:capakraken_test@postgres:5432/capakraken_test
|
DATABASE_URL: postgresql://nexus:nexus_test@postgres:5432/nexus_test
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
|
# Force in-memory rate limiter to avoid cross-test state when Redis drops.
|
||||||
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
|
# Redis fallback downgrades to max/10 limits which rate-limits unit tests.
|
||||||
@@ -291,11 +291,11 @@ jobs:
|
|||||||
e2epg:
|
e2epg:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: capakraken_test
|
POSTGRES_DB: nexus_test
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: capakraken_test
|
POSTGRES_PASSWORD: nexus_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd="pg_isready -U capakraken -d capakraken_test"
|
--health-cmd="pg_isready -U nexus -d nexus_test"
|
||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
@@ -307,14 +307,14 @@ jobs:
|
|||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
|
DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
|
||||||
# Playwright test-server.mjs requires an explicit test DB URL.
|
# Playwright test-server.mjs requires an explicit test DB URL.
|
||||||
PLAYWRIGHT_DATABASE_URL: postgresql://capakraken:capakraken_test@e2epg:5432/capakraken_test
|
PLAYWRIGHT_DATABASE_URL: postgresql://nexus:nexus_test@e2epg:5432/nexus_test
|
||||||
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
|
# prisma-with-env.mjs refuses to run unless DATABASE_URL's db name matches
|
||||||
# the expected target; default is "capakraken", CI uses capakraken_test.
|
# the expected target; default is "nexus", CI uses nexus_test.
|
||||||
CAPAKRAKEN_EXPECTED_DB_NAME: capakraken_test
|
NEXUS_EXPECTED_DB_NAME: nexus_test
|
||||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
||||||
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
|
CONFIRM_DESTRUCTIVE_DB_NAME: nexus_test
|
||||||
REDIS_URL: redis://e2eredis:6379
|
REDIS_URL: redis://e2eredis:6379
|
||||||
PORT: 3100
|
PORT: 3100
|
||||||
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
|
# test-server.mjs spawns `docker compose --profile test up postgres-test`;
|
||||||
@@ -375,7 +375,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Push DB schema & seed
|
- name: Push DB schema & seed
|
||||||
env:
|
env:
|
||||||
PGPASSWORD: capakraken_test
|
PGPASSWORD: nexus_test
|
||||||
run: |
|
run: |
|
||||||
# Nuke any leftover schema state from a previous job that shared the
|
# Nuke any leftover schema state from a previous job that shared the
|
||||||
# postgres service container (act_runner reuses service volumes).
|
# postgres service container (act_runner reuses service volumes).
|
||||||
@@ -397,7 +397,7 @@ jobs:
|
|||||||
IPS=$(getent hosts e2epg | awk '{print $1}')
|
IPS=$(getent hosts e2epg | awk '{print $1}')
|
||||||
PG_IP=""
|
PG_IP=""
|
||||||
for ip in $IPS; do
|
for ip in $IPS; do
|
||||||
if PGPASSWORD=capakraken_test psql -h "$ip" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
|
if PGPASSWORD=nexus_test psql -h "$ip" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -Atc "SELECT 1" >/dev/null 2>&1; then
|
||||||
PG_IP="$ip"
|
PG_IP="$ip"
|
||||||
echo "Locked onto postgres at $PG_IP"
|
echo "Locked onto postgres at $PG_IP"
|
||||||
break
|
break
|
||||||
@@ -406,19 +406,19 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$PG_IP" ]; then
|
if [ -z "$PG_IP" ]; then
|
||||||
echo "ERROR: no resolved e2epg IP accepted capakraken_test credentials"
|
echo "ERROR: no resolved e2epg IP accepted nexus_test credentials"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
PINNED_URL="postgresql://capakraken:capakraken_test@$PG_IP:5432/capakraken_test"
|
PINNED_URL="postgresql://nexus:nexus_test@$PG_IP:5432/nexus_test"
|
||||||
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
echo "DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
||||||
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
echo "PLAYWRIGHT_DATABASE_URL=$PINNED_URL" >> "$GITHUB_ENV"
|
||||||
echo "--- DROP SCHEMA ---"
|
echo "--- DROP SCHEMA ---"
|
||||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 \
|
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 \
|
||||||
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO capakraken; GRANT ALL ON SCHEMA public TO public;"
|
-c "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO nexus; GRANT ALL ON SCHEMA public TO public;"
|
||||||
echo "--- prisma db push ---"
|
echo "--- prisma db push ---"
|
||||||
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
DATABASE_URL="$PINNED_URL" pnpm --filter @nexus/db exec prisma db push --schema ./prisma/schema.prisma --accept-data-loss --skip-generate
|
||||||
echo "--- tables in public after push ---"
|
echo "--- tables in public after push ---"
|
||||||
psql -h "$PG_IP" -U capakraken -d capakraken_test -v ON_ERROR_STOP=1 -At \
|
psql -h "$PG_IP" -U nexus -d nexus_test -v ON_ERROR_STOP=1 -At \
|
||||||
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
-c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename" \
|
||||||
| tee /tmp/tables.txt
|
| tee /tmp/tables.txt
|
||||||
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
|
if ! grep -qx 'audit_logs' /tmp/tables.txt; then
|
||||||
@@ -468,8 +468,8 @@ jobs:
|
|||||||
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
NEXTAUTH_SECRET=ci-test-secret-minimum-32-chars-xx
|
||||||
PGADMIN_PASSWORD=ci-pgadmin
|
PGADMIN_PASSWORD=ci-pgadmin
|
||||||
# Must match the password baked into docker-compose.ci.yml's
|
# Must match the password baked into docker-compose.ci.yml's
|
||||||
# DATABASE_URL override (capakraken_dev).
|
# DATABASE_URL override (nexus_dev).
|
||||||
POSTGRES_PASSWORD=capakraken_dev
|
POSTGRES_PASSWORD=nexus_dev
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Tear down any stale stack & volumes
|
- name: Tear down any stale stack & volumes
|
||||||
@@ -485,7 +485,7 @@ jobs:
|
|||||||
- name: Wait for postgres
|
- name: Wait for postgres
|
||||||
run: |
|
run: |
|
||||||
for i in $(seq 1 20); do
|
for i in $(seq 1 20); do
|
||||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U capakraken -d capakraken && break
|
docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T postgres pg_isready -U nexus -d nexus && break
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ test.describe("Assistant approvals", () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.addInitScript((conversationId) => {
|
await page.addInitScript((conversationId) => {
|
||||||
window.sessionStorage.setItem("capakraken-chat-conversation-id", conversationId);
|
window.sessionStorage.setItem("nexus-chat-conversation-id", conversationId);
|
||||||
}, CURRENT_CONVERSATION_ID);
|
}, CURRENT_CONVERSATION_ID);
|
||||||
|
|
||||||
runDb(`
|
runDb(`
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ test.describe("Auth — login / logout", () => {
|
|||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
await expect(page).toHaveURL(/\/auth\/signin/, { timeout: 5000 });
|
||||||
// Error message visible
|
// Error message visible
|
||||||
await expect(
|
await expect(page.locator("text=/invalid|incorrect|wrong|credentials/i")).toBeVisible({
|
||||||
page.locator("text=/invalid|incorrect|wrong|credentials/i"),
|
timeout: 5000,
|
||||||
).toBeVisible({ timeout: 5000 });
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
test("after logout, protected routes redirect to sign-in", async ({ page }) => {
|
||||||
@@ -75,7 +75,7 @@ test.describe("Session registry — no tRPC 401s after login", () => {
|
|||||||
|
|
||||||
// At least one user row should be visible
|
// At least one user row should be visible
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
await expect(page.locator("text=No users found")).toHaveCount(0);
|
await expect(page.locator("text=No users found")).toHaveCount(0);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* - Creates a temporary test user via tRPC (admin session) for isolation.
|
* - Creates a temporary test user via tRPC (admin session) for isolation.
|
||||||
* - Cleans up the test user in afterAll.
|
* - Cleans up the test user in afterAll.
|
||||||
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
|
* - Uses an empty storageState to ensure no cross-user localStorage bleed.
|
||||||
* - localStorage key is user-scoped: "capakraken_dashboard_v1_{userId}".
|
* - localStorage key is user-scoped: "nexus_dashboard_v1_{userId}".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||||
@@ -20,9 +20,16 @@ import { STORAGE_STATE } from "../../playwright.dev.config.js";
|
|||||||
|
|
||||||
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
// ─── tRPC helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type TrpcResult = { result?: { data?: unknown }; error?: { data?: { code?: string }; message?: string } };
|
type TrpcResult = {
|
||||||
|
result?: { data?: unknown };
|
||||||
|
error?: { data?: { code?: string }; message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
async function trpcMutation(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
async function trpcMutation(
|
||||||
|
page: Page,
|
||||||
|
procedure: string,
|
||||||
|
input: unknown = null,
|
||||||
|
): Promise<TrpcResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
const res = await fetch(`/api/trpc/${procedure}?batch=1`, {
|
||||||
@@ -38,7 +45,11 @@ async function trpcMutation(page: Page, procedure: string, input: unknown = null
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function trpcQuery(page: Page, procedure: string, input: unknown = null): Promise<TrpcResult> {
|
async function trpcQuery(
|
||||||
|
page: Page,
|
||||||
|
procedure: string,
|
||||||
|
input: unknown = null,
|
||||||
|
): Promise<TrpcResult> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
async ({ procedure, input }) => {
|
async ({ procedure, input }) => {
|
||||||
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
const encodedInput = encodeURIComponent(JSON.stringify({ "0": { json: input } }));
|
||||||
@@ -128,7 +139,9 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
|
|
||||||
// Default layout should show at least the stat-cards widget
|
// Default layout should show at least the stat-cards widget
|
||||||
// (from createDefaultDashboardLayout in useDashboardLayout)
|
// (from createDefaultDashboardLayout in useDashboardLayout)
|
||||||
await expect(page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first()).toBeVisible({
|
await expect(
|
||||||
|
page.locator('[data-testid="widget-stat-cards"], .react-grid-item').first(),
|
||||||
|
).toBeVisible({
|
||||||
timeout: 8000,
|
timeout: 8000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -138,16 +151,21 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
await navigateToDashboard(page);
|
await navigateToDashboard(page);
|
||||||
|
|
||||||
// Open modal
|
// Open modal
|
||||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Verify modal is open
|
// Verify modal is open
|
||||||
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({ timeout: 5000 });
|
await expect(page.getByRole("heading", { name: /add widget/i })).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// Verify widget entries are visible in the modal
|
// Verify widget entries are visible in the modal
|
||||||
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
|
// The catalog has 11 widgets; check for at least 5 visible buttons inside the modal
|
||||||
const widgetButtons = page.locator(
|
const widgetButtons = page
|
||||||
'[role="dialog"] button, .fixed button[type="button"]',
|
.locator('[role="dialog"] button, .fixed button[type="button"]')
|
||||||
).filter({ hasText: /./ });
|
.filter({ hasText: /./ });
|
||||||
|
|
||||||
// Count items in the grid (the ×-close button is excluded by checking for icon content)
|
// Count items in the grid (the ×-close button is excluded by checking for icon content)
|
||||||
const modalContent = page.locator(".fixed.inset-0 .grid");
|
const modalContent = page.locator(".fixed.inset-0 .grid");
|
||||||
@@ -166,10 +184,16 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
const initialCount = await page.locator(".react-grid-item").count();
|
const initialCount = await page.locator(".react-grid-item").count();
|
||||||
|
|
||||||
// Open modal and add "Resource Table" widget
|
// Open modal and add "Resource Table" widget
|
||||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /resource table/i }).click();
|
await page
|
||||||
|
.locator(".fixed.inset-0 button")
|
||||||
|
.filter({ hasText: /resource table/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
// Modal should close after adding
|
// Modal should close after adding
|
||||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||||
@@ -184,9 +208,15 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
await navigateToDashboard(page);
|
await navigateToDashboard(page);
|
||||||
|
|
||||||
// Add a recognizable widget
|
// Add a recognizable widget
|
||||||
await page.getByRole("button", { name: /add widget/i }).first().click();
|
await page
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).toBeVisible({ timeout: 5000 });
|
||||||
await page.locator(".fixed.inset-0 button").filter({ hasText: /project overview/i }).click();
|
await page
|
||||||
|
.locator(".fixed.inset-0 button")
|
||||||
|
.filter({ hasText: /project overview/i })
|
||||||
|
.click();
|
||||||
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
await expect(page.locator(".fixed.inset-0")).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
const countAfterAdd = await page.locator(".react-grid-item").count();
|
const countAfterAdd = await page.locator(".react-grid-item").count();
|
||||||
@@ -214,19 +244,23 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
|
|
||||||
// Read the admin's localStorage key to verify it is user-scoped
|
// Read the admin's localStorage key to verify it is user-scoped
|
||||||
const adminUserId = await adminPage.evaluate(async () => {
|
const adminUserId = await adminPage.evaluate(async () => {
|
||||||
const res = await fetch("/api/trpc/user.me?batch=1&input=" + encodeURIComponent(JSON.stringify({ "0": { json: null } })), {
|
const res = await fetch(
|
||||||
|
"/api/trpc/user.me?batch=1&input=" +
|
||||||
|
encodeURIComponent(JSON.stringify({ "0": { json: null } })),
|
||||||
|
{
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
},
|
||||||
const body = await res.json() as [{ result?: { data?: { json?: { id?: string } } } }];
|
);
|
||||||
|
const body = (await res.json()) as [{ result?: { data?: { json?: { id?: string } } } }];
|
||||||
return body[0]?.result?.data?.json?.id ?? null;
|
return body[0]?.result?.data?.json?.id ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify admin has a user-scoped storage key (not shared "capakraken_dashboard_v1")
|
// Verify admin has a user-scoped storage key (not shared "nexus_dashboard_v1")
|
||||||
if (adminUserId) {
|
if (adminUserId) {
|
||||||
const storageKey = await adminPage.evaluate((userId) => {
|
const storageKey = await adminPage.evaluate((userId) => {
|
||||||
// Check both old (unscoped) and new (user-scoped) key formats
|
// Check both old (unscoped) and new (user-scoped) key formats
|
||||||
const oldKey = "capakraken_dashboard_v1";
|
const oldKey = "nexus_dashboard_v1";
|
||||||
const newKey = `capakraken_dashboard_v1_${userId}`;
|
const newKey = `nexus_dashboard_v1_${userId}`;
|
||||||
const oldValue = localStorage.getItem(oldKey);
|
const oldValue = localStorage.getItem(oldKey);
|
||||||
const newValue = localStorage.getItem(newKey);
|
const newValue = localStorage.getItem(newKey);
|
||||||
return { oldKey: oldValue !== null, newKey: newValue !== null };
|
return { oldKey: oldValue !== null, newKey: newValue !== null };
|
||||||
@@ -244,8 +278,13 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
|
|
||||||
// Inject the admin's storage key to simulate same browser
|
// Inject the admin's storage key to simulate same browser
|
||||||
await newUserPage.evaluate(
|
await newUserPage.evaluate(
|
||||||
({ key, value }) => { localStorage.setItem(key, value ?? ""); },
|
({ key, value }) => {
|
||||||
{ key: `capakraken_dashboard_v1_${adminUserId}`, value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }) },
|
localStorage.setItem(key, value ?? "");
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `nexus_dashboard_v1_${adminUserId}`,
|
||||||
|
value: JSON.stringify({ version: 2, gridCols: 12, widgets: [] }),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log in as test user
|
// Log in as test user
|
||||||
@@ -262,7 +301,10 @@ test.describe("Dashboard — widget management", () => {
|
|||||||
const gridItems = await newUserPage.locator(".react-grid-item").count();
|
const gridItems = await newUserPage.locator(".react-grid-item").count();
|
||||||
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
|
// Either show default layout (≥1 widget) OR the properly-scoped empty state with Add Widget CTA
|
||||||
// The key check: the test user's Add Widget button should still work
|
// The key check: the test user's Add Widget button should still work
|
||||||
await newUserPage.getByRole("button", { name: /add widget/i }).first().click();
|
await newUserPage
|
||||||
|
.getByRole("button", { name: /add widget/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Modal must show widgets to choose from
|
// Modal must show widgets to choose from
|
||||||
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
|
const modalContent = newUserPage.locator(".fixed.inset-0 .grid");
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ const RESET_TEST_USER = {
|
|||||||
password: "Dev123456!",
|
password: "Dev123456!",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DB_CONTAINER = "capakraken-postgres-1";
|
const DB_CONTAINER = "nexus-postgres-1";
|
||||||
const DB_USER = "capakraken";
|
const DB_USER = "nexus";
|
||||||
const DB_NAME = "capakraken";
|
const DB_NAME = "nexus";
|
||||||
|
|
||||||
function psqlExec(sql: string): string {
|
function psqlExec(sql: string): string {
|
||||||
return execSync(
|
return execSync(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function signOut(page: Page) {
|
|||||||
await page.goto("/dashboard"); // land on any authenticated page for cookie context
|
await page.goto("/dashboard"); // land on any authenticated page for cookie context
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const csrfRes = await fetch("/api/auth/csrf");
|
const csrfRes = await fetch("/api/auth/csrf");
|
||||||
const { csrfToken } = await csrfRes.json() as { csrfToken: string };
|
const { csrfToken } = (await csrfRes.json()) as { csrfToken: string };
|
||||||
await fetch("/api/auth/signout", {
|
await fetch("/api/auth/signout", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@@ -64,9 +64,7 @@ function decodeMimeBody(body: string, encoding: string | undefined): string {
|
|||||||
return body
|
return body
|
||||||
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
.replace(/=\r\n/g, "") // soft line break (CRLF)
|
||||||
.replace(/=\n/g, "") // soft line break (LF)
|
.replace(/=\n/g, "") // soft line break (LF)
|
||||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) =>
|
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
String.fromCharCode(parseInt(hex, 16)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (enc === "base64") {
|
if (enc === "base64") {
|
||||||
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
||||||
@@ -90,7 +88,10 @@ export async function clearMailhog(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function getLatestEmailTo(
|
export async function getLatestEmailTo(
|
||||||
address: string,
|
address: string,
|
||||||
{ timeoutMs = 10_000, pollIntervalMs = 500 }: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
{
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
pollIntervalMs = 500,
|
||||||
|
}: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
||||||
): Promise<{ subject: string; body: string; html: string }> {
|
): Promise<{ subject: string; body: string; html: string }> {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
@@ -144,7 +145,9 @@ export function extractUrlFromEmail(
|
|||||||
pathPrefix: string,
|
pathPrefix: string,
|
||||||
): string {
|
): string {
|
||||||
const text = email.html || email.body;
|
const text = email.html || email.body;
|
||||||
const match = text.match(new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`));
|
const match = text.match(
|
||||||
|
new RegExp(`https?://[^\\s"'<>]*${pathPrefix.replace("/", "\\/")}[^\\s"'<>]*`),
|
||||||
|
);
|
||||||
if (!match?.[0]) {
|
if (!match?.[0]) {
|
||||||
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
throw new Error(`No URL with prefix "${pathPrefix}" found in email`);
|
||||||
}
|
}
|
||||||
@@ -166,10 +169,10 @@ export async function resetPasswordViaApi(
|
|||||||
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
|
// argon2id hashes use base64 chars only — safe inside a SQL single-quoted string
|
||||||
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
|
// Column name is camelCase (Prisma default) — must be double-quoted in SQL
|
||||||
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
|
const sql = `UPDATE users SET "passwordHash" = '${passwordHash}' WHERE email = '${email}';`;
|
||||||
execSync(
|
execSync(`docker exec -i nexus-postgres-1 psql -U nexus -d nexus`, {
|
||||||
`docker exec -i capakraken-postgres-1 psql -U capakraken -d capakraken`,
|
input: sql,
|
||||||
{ input: sql, encoding: "utf8" },
|
encoding: "utf8",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
// ── tRPC helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test.describe("invite flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
|
test("admin invites a new user and invited user can sign in", async ({ page, browser }) => {
|
||||||
const testEmail = `invite-e2e-${Date.now()}@capakraken.test`;
|
const testEmail = `invite-e2e-${Date.now()}@nexus.test`;
|
||||||
|
|
||||||
// Step 1: Navigate to admin users page
|
// Step 1: Navigate to admin users page
|
||||||
await page.goto("/admin/users");
|
await page.goto("/admin/users");
|
||||||
@@ -36,7 +36,7 @@ test.describe("invite flow", () => {
|
|||||||
// Step 2: Open invite modal
|
// Step 2: Open invite modal
|
||||||
await page.click('button:has-text("Invite User")');
|
await page.click('button:has-text("Invite User")');
|
||||||
// Wait for the modal heading — AnimatedModal does not use role="dialog"
|
// Wait for the modal heading — AnimatedModal does not use role="dialog"
|
||||||
await page.waitForSelector('text=Invite User', { state: "visible" });
|
await page.waitForSelector("text=Invite User", { state: "visible" });
|
||||||
|
|
||||||
// Step 3: Fill in invite form
|
// Step 3: Fill in invite form
|
||||||
await page.fill('input[type="email"]', testEmail);
|
await page.fill('input[type="email"]', testEmail);
|
||||||
@@ -45,7 +45,9 @@ test.describe("invite flow", () => {
|
|||||||
await page.click('button:has-text("Send Invite")');
|
await page.click('button:has-text("Send Invite")');
|
||||||
|
|
||||||
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
|
// Step 5: Wait for success message (exact text from InviteUserModal.tsx)
|
||||||
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator("text=Invitation sent successfully.")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Step 6: Read invite email from Mailhog
|
// Step 6: Read invite email from Mailhog
|
||||||
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
const email = await getLatestEmailTo(testEmail, { timeoutMs: 15_000 });
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ test.describe("RBAC — admin routes (admin session)", () => {
|
|||||||
|
|
||||||
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
await expect(page.locator("table")).toBeVisible({ timeout: 10000 });
|
||||||
// Seed users have planarchy.dev or nexus.dev email domains
|
// Seed users have planarchy.dev or nexus.dev email domains
|
||||||
await expect(page.locator("text=/planarchy\\.dev|capakraken\\.dev/").first()).toBeVisible({
|
await expect(page.locator("text=/planarchy\\.dev|nexus\\.dev/").first()).toBeVisible({
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const webDistDirPath = resolve(webRoot, webDistDir);
|
|||||||
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
||||||
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
||||||
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
||||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
|
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `nexus-e2e-${randomBytes(24).toString("hex")}`;
|
||||||
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
||||||
const composeProjectName = `capakraken-e2e-${process.pid}`;
|
const composeProjectName = `nexus-e2e-${process.pid}`;
|
||||||
const managedEnvKeys = [
|
const managedEnvKeys = [
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
@@ -29,7 +29,7 @@ const managedEnvKeys = [
|
|||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"PORT",
|
"PORT",
|
||||||
];
|
];
|
||||||
const e2eComposePrefix = "capakraken-e2e-";
|
const e2eComposePrefix = "nexus-e2e-";
|
||||||
|
|
||||||
function dockerComposeArgs(...args) {
|
function dockerComposeArgs(...args) {
|
||||||
return ["compose", "-p", composeProjectName, ...args];
|
return ["compose", "-p", composeProjectName, ...args];
|
||||||
@@ -256,7 +256,7 @@ async function ensureE2eDatabaseContainer() {
|
|||||||
try {
|
try {
|
||||||
await runQuiet(
|
await runQuiet(
|
||||||
"docker",
|
"docker",
|
||||||
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
|
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "nexus", "-d", "nexus_test", "-q"),
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -360,7 +360,7 @@ process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
|
|||||||
if (selectedTestDbPort !== undefined) {
|
if (selectedTestDbPort !== undefined) {
|
||||||
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
||||||
}
|
}
|
||||||
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
|
process.env.NEXUS_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||||
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
|
||||||
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
|
||||||
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||||
|
|||||||
@@ -856,10 +856,10 @@ async function switchToResourceView(page: Page, readySelector?: string) {
|
|||||||
|
|
||||||
async function ensureOpenDemandVisibilityEnabled(page: Page) {
|
async function ensureOpenDemandVisibilityEnabled(page: Page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const raw = window.localStorage.getItem("capakraken_prefs");
|
const raw = window.localStorage.getItem("nexus_prefs");
|
||||||
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
"capakraken_prefs",
|
"nexus_prefs",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...parsed,
|
...parsed,
|
||||||
showDemandProjects: true,
|
showDemandProjects: true,
|
||||||
@@ -874,9 +874,9 @@ test.describe("Timeline", () => {
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
localStorage.setItem("nexus_theme", JSON.stringify({ mode: "dark" }));
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"capakraken_prefs",
|
"nexus_prefs",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
hideCompletedProjects: true,
|
hideCompletedProjects: true,
|
||||||
timelineDisplayMode: "strip",
|
timelineDisplayMode: "strip",
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ services:
|
|||||||
# REDIS_URL to the unique compose container names so resolution is
|
# REDIS_URL to the unique compose container names so resolution is
|
||||||
# unambiguous regardless of attached networks.
|
# unambiguous regardless of attached networks.
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://capakraken:capakraken_dev@capakraken-postgres-1:5432/capakraken
|
DATABASE_URL: postgresql://nexus:nexus_dev@nexus-postgres-1:5432/nexus
|
||||||
REDIS_URL: redis://capakraken-redis-1:6379
|
REDIS_URL: redis://nexus-redis-1:6379
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gitea_gitea:
|
gitea_gitea:
|
||||||
|
|||||||
+12
-12
@@ -1,4 +1,4 @@
|
|||||||
name: capakraken-prod
|
name: nexus-prod
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -7,8 +7,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: capakraken
|
POSTGRES_DB: nexus
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||||
command: >
|
command: >
|
||||||
postgres
|
postgres
|
||||||
@@ -18,9 +18,9 @@ services:
|
|||||||
-c log_line_prefix='%t [%p] %u@%d '
|
-c log_line_prefix='%t [%p] %u@%d '
|
||||||
-c log_min_duration_statement=1000
|
-c log_min_duration_statement=1000
|
||||||
volumes:
|
volumes:
|
||||||
- capakraken_prod_pgdata:/var/lib/postgresql/data
|
- nexus_prod_pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
|
test: ["CMD-SHELL", "pg_isready -U nexus -d nexus"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD}
|
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- capakraken_prod_redis:/data
|
- nexus_prod_redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"]
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
RATE_LIMIT_BACKEND: ${RATE_LIMIT_BACKEND:-redis}
|
||||||
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
capakraken_prod_pgdata:
|
nexus_prod_pgdata:
|
||||||
name: capakraken_prod_pgdata
|
name: nexus_prod_pgdata
|
||||||
capakraken_prod_redis:
|
nexus_prod_redis:
|
||||||
name: capakraken_prod_redis
|
name: nexus_prod_redis
|
||||||
|
|||||||
+18
-18
@@ -1,4 +1,4 @@
|
|||||||
name: capakraken
|
name: nexus
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -6,8 +6,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: capakraken
|
POSTGRES_DB: nexus
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (any non-empty value for local dev)}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (any non-empty value for local dev)}
|
||||||
command: >
|
command: >
|
||||||
postgres
|
postgres
|
||||||
@@ -17,9 +17,9 @@ services:
|
|||||||
-c log_line_prefix='%t [%p] %u@%d '
|
-c log_line_prefix='%t [%p] %u@%d '
|
||||||
-c log_min_duration_statement=1000
|
-c log_min_duration_statement=1000
|
||||||
volumes:
|
volumes:
|
||||||
- capakraken_pgdata:/var/lib/postgresql/data
|
- nexus_pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U capakraken -d capakraken"]
|
test: ["CMD-SHELL", "pg_isready -U nexus -d nexus"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
# Always use the Docker-internal service name. The host-level DATABASE_URL
|
# Always use the Docker-internal service name. The host-level DATABASE_URL
|
||||||
# (localhost:5433) must not bleed into the container where "localhost" is
|
# (localhost:5433) must not bleed into the container where "localhost" is
|
||||||
# the container itself, not the host.
|
# the container itself, not the host.
|
||||||
DATABASE_URL: postgresql://capakraken:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/capakraken
|
DATABASE_URL: postgresql://nexus:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/nexus
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)}
|
NEXTAUTH_URL: ${NEXTAUTH_URL:?NEXTAUTH_URL must be set (e.g. https://your-domain.com)}
|
||||||
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}
|
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?set NEXTAUTH_SECRET}
|
||||||
@@ -90,9 +90,9 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
# Named volumes mask the bind-mount for generated/installed artefacts.
|
# Named volumes mask the bind-mount for generated/installed artefacts.
|
||||||
# Named (not anonymous) so they can be selectively removed: docker volume rm capakraken_node_modules
|
# Named (not anonymous) so they can be selectively removed: docker volume rm nexus_node_modules
|
||||||
- capakraken_node_modules:/app/node_modules
|
- nexus_node_modules:/app/node_modules
|
||||||
- capakraken_next:/app/apps/web/.next
|
- nexus_next:/app/apps/web/.next
|
||||||
profiles:
|
profiles:
|
||||||
- full
|
- full
|
||||||
|
|
||||||
@@ -101,18 +101,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_TEST_PORT:-5434}:5432"
|
- "${POSTGRES_TEST_PORT:-5434}:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: capakraken_test
|
POSTGRES_DB: nexus_test
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: nexus
|
||||||
POSTGRES_PASSWORD: capakraken_test
|
POSTGRES_PASSWORD: nexus_test
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /var/lib/postgresql/data
|
- /var/lib/postgresql/data
|
||||||
profiles:
|
profiles:
|
||||||
- test
|
- test
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
capakraken_pgdata:
|
nexus_pgdata:
|
||||||
name: capakraken_pgdata
|
name: nexus_pgdata
|
||||||
capakraken_node_modules:
|
nexus_node_modules:
|
||||||
name: capakraken_node_modules
|
name: nexus_node_modules
|
||||||
capakraken_next:
|
nexus_next:
|
||||||
name: capakraken_next
|
name: nexus_next
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
"check:imports": "node ./scripts/check-workspace-imports.mjs",
|
"check:imports": "node ./scripts/check-workspace-imports.mjs",
|
||||||
"worktree:hygiene": "node ./scripts/worktree-hygiene.mjs",
|
"worktree:hygiene": "node ./scripts/worktree-hygiene.mjs",
|
||||||
"clean:next": "node ./scripts/clean-next-artifacts.mjs",
|
"clean:next": "node ./scripts/clean-next-artifacts.mjs",
|
||||||
"db:doctor": "node ./scripts/db-doctor.mjs capakraken",
|
"db:doctor": "node ./scripts/db-doctor.mjs nexus",
|
||||||
"db:prisma": "node ./scripts/prisma-with-env.mjs",
|
"db:prisma": "node ./scripts/prisma-with-env.mjs",
|
||||||
"db:push": "node ./scripts/prisma-with-env.mjs db push",
|
"db:push": "node ./scripts/prisma-with-env.mjs db push",
|
||||||
"db:migrate": "node ./scripts/prisma-with-env.mjs migrate dev",
|
"db:migrate": "node ./scripts/prisma-with-env.mjs migrate dev",
|
||||||
|
|||||||
@@ -105,10 +105,10 @@ describe("RBAC cache Redis pub/sub (#57)", () => {
|
|||||||
|
|
||||||
// Simulate a peer instance publishing an invalidation: grab any
|
// Simulate a peer instance publishing an invalidation: grab any
|
||||||
// subscriber on the channel and fire the event as if Redis delivered it.
|
// subscriber on the channel and fire the event as if Redis delivered it.
|
||||||
const subs = channelSubscribers.get("capakraken:rbac-invalidate");
|
const subs = channelSubscribers.get("nexus:rbac-invalidate");
|
||||||
expect(subs).toBeDefined();
|
expect(subs).toBeDefined();
|
||||||
expect(subs!.size).toBeGreaterThanOrEqual(1);
|
expect(subs!.size).toBeGreaterThanOrEqual(1);
|
||||||
for (const sub of subs!) sub.emit("message", "capakraken:rbac-invalidate", "1");
|
for (const sub of subs!) sub.emit("message", "nexus:rbac-invalidate", "1");
|
||||||
|
|
||||||
// Next load must hit the DB again.
|
// Next load must hit the DB again.
|
||||||
await loadRoleDefaults();
|
await loadRoleDefaults();
|
||||||
@@ -126,6 +126,6 @@ describe("RBAC cache Redis pub/sub (#57)", () => {
|
|||||||
|
|
||||||
const newPublishes = publishCalls.slice(countBefore);
|
const newPublishes = publishCalls.slice(countBefore);
|
||||||
expect(newPublishes.length).toBe(1);
|
expect(newPublishes.length).toBe(1);
|
||||||
expect(newPublishes[0]!.channel).toBe("capakraken:rbac-invalidate");
|
expect(newPublishes[0]!.channel).toBe("nexus:rbac-invalidate");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("assertWebhookUrlAllowed — SSRF guard", () => {
|
|||||||
|
|
||||||
it("allows an HTTPS URL with a path and query string", async () => {
|
it("allows an HTTPS URL with a path and query string", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
assertWebhookUrlAllowed("https://hooks.external.io/events?source=capakraken"),
|
assertWebhookUrlAllowed("https://hooks.external.io/events?source=nexus"),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export function getAppBaseUrl(): string {
|
|||||||
if (process.env["NODE_ENV"] === "production") {
|
if (process.env["NODE_ENV"] === "production") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"NEXTAUTH_URL must be set in production — email links will contain localhost otherwise. " +
|
"NEXTAUTH_URL must be set in production — email links will contain localhost otherwise. " +
|
||||||
"Set it to the public URL of this app (e.g. https://capakraken.example.com).",
|
"Set it to the public URL of this app (e.g. https://nexus.example.com).",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!warned) {
|
if (!warned) {
|
||||||
warned = true;
|
warned = true;
|
||||||
console.warn(
|
console.warn(
|
||||||
"[capakraken] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " +
|
"[nexus] NEXTAUTH_URL is not set — falling back to http://localhost:3100 for email links. " +
|
||||||
"Set NEXTAUTH_URL in your .env to suppress this warning.",
|
"Set NEXTAUTH_URL in your .env to suppress this warning.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,13 @@ const redactConfig = { paths: REDACT_PATHS, censor: "[REDACTED]" };
|
|||||||
export const logger = isProduction
|
export const logger = isProduction
|
||||||
? pino({
|
? pino({
|
||||||
level: LOG_LEVEL,
|
level: LOG_LEVEL,
|
||||||
base: { service: "capakraken-api" },
|
base: { service: "nexus-api" },
|
||||||
redact: redactConfig,
|
redact: redactConfig,
|
||||||
})
|
})
|
||||||
: pino(
|
: pino(
|
||||||
{
|
{
|
||||||
level: LOG_LEVEL,
|
level: LOG_LEVEL,
|
||||||
base: { service: "capakraken-api" },
|
base: { service: "nexus-api" },
|
||||||
redact: redactConfig,
|
redact: redactConfig,
|
||||||
formatters: {
|
formatters: {
|
||||||
level(label: string) {
|
level(label: string) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type RateLimiterBackend = {
|
|||||||
reset: () => Promise<void>;
|
reset: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_REDIS_KEY_PREFIX = "capakraken:ratelimit";
|
const DEFAULT_REDIS_KEY_PREFIX = "nexus:ratelimit";
|
||||||
const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined;
|
const DEFAULT_REDIS_BACKEND = process.env["RATE_LIMIT_BACKEND"] as RateLimitBackendMode | undefined;
|
||||||
const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
|
const DEFAULT_REDIS_URL = process.env["REDIS_URL"]?.trim();
|
||||||
const warnedRedisFailures = new Set<string>();
|
const warnedRedisFailures = new Set<string>();
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ const REDIS_URL =
|
|||||||
: (() => {
|
: (() => {
|
||||||
throw new Error("REDIS_URL required in production");
|
throw new Error("REDIS_URL required in production");
|
||||||
})());
|
})());
|
||||||
const CHANNEL = "capakraken:sse";
|
const CHANNEL = "nexus:sse";
|
||||||
|
|
||||||
let publisher: Redis | null = null;
|
let publisher: Redis | null = null;
|
||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const ROLE_DEFAULTS_TTL = 10_000;
|
|||||||
// We publish a single invalidate message per change; every node subscribes and
|
// We publish a single invalidate message per change; every node subscribes and
|
||||||
// clears its local cache on receipt. Failure to publish/subscribe is logged
|
// clears its local cache on receipt. Failure to publish/subscribe is logged
|
||||||
// but never thrown — the TTL above is the fall-back.
|
// but never thrown — the TTL above is the fall-back.
|
||||||
const RBAC_INVALIDATE_CHANNEL = "capakraken:rbac-invalidate";
|
const RBAC_INVALIDATE_CHANNEL = "nexus:rbac-invalidate";
|
||||||
|
|
||||||
let _rbacPublisher: Redis | null = null;
|
let _rbacPublisher: Redis | null = null;
|
||||||
let _rbacSubscriber: Redis | null = null;
|
let _rbacSubscriber: Redis | null = null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"./client": "./src/client.ts"
|
"./client": "./src/client.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"db:doctor": "node ../../scripts/db-doctor.mjs capakraken",
|
"db:doctor": "node ../../scripts/db-doctor.mjs nexus",
|
||||||
"db:push": "node ../../scripts/prisma-with-env.mjs db push --schema ./prisma/schema.prisma",
|
"db:push": "node ../../scripts/prisma-with-env.mjs db push --schema ./prisma/schema.prisma",
|
||||||
"db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma",
|
"db:migrate": "node ../../scripts/prisma-with-env.mjs migrate dev --schema ./prisma/schema.prisma",
|
||||||
"db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma",
|
"db:migrate:deploy": "node ../../scripts/prisma-with-env.mjs migrate deploy --schema ./prisma/schema.prisma",
|
||||||
|
|||||||
@@ -22,34 +22,34 @@ test.afterEach(() => {
|
|||||||
process.env = { ...ORIGINAL_ENV };
|
process.env = { ...ORIGINAL_ENV };
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable capakraken test database", () => {
|
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable nexus test database", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_test",
|
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_test",
|
||||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_test",
|
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus_test",
|
||||||
});
|
});
|
||||||
|
|
||||||
const target = assertDestructiveDbAllowed({
|
const target = assertDestructiveDbAllowed({
|
||||||
commandName: "db:test",
|
commandName: "db:test",
|
||||||
allowedDatabaseNames: ["capakraken_test"],
|
allowedDatabaseNames: ["nexus_test"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(target.databaseName, "capakraken_test");
|
assert.equal(target.databaseName, "nexus_test");
|
||||||
assert.equal(target.hostname, "localhost");
|
assert.equal(target.hostname, "localhost");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => {
|
test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken",
|
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus",
|
||||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken",
|
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
assertDestructiveDbAllowed({
|
assertDestructiveDbAllowed({
|
||||||
commandName: "db:test",
|
commandName: "db:test",
|
||||||
allowedDatabaseNames: ["capakraken"],
|
allowedDatabaseNames: ["nexus"],
|
||||||
}),
|
}),
|
||||||
/explicitly protected/u,
|
/explicitly protected/u,
|
||||||
);
|
);
|
||||||
@@ -57,7 +57,7 @@ test("assertDestructiveDbAllowed rejects protected live database names even if a
|
|||||||
|
|
||||||
test("assertDestructiveDbAllowed rejects missing confirmation", () => {
|
test("assertDestructiveDbAllowed rejects missing confirmation", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_e2e",
|
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_e2e",
|
||||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||||
CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db",
|
CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db",
|
||||||
});
|
});
|
||||||
@@ -66,24 +66,24 @@ test("assertDestructiveDbAllowed rejects missing confirmation", () => {
|
|||||||
() =>
|
() =>
|
||||||
assertDestructiveDbAllowed({
|
assertDestructiveDbAllowed({
|
||||||
commandName: "db:test",
|
commandName: "db:test",
|
||||||
allowedDatabaseNames: ["capakraken_e2e"],
|
allowedDatabaseNames: ["nexus_e2e"],
|
||||||
}),
|
}),
|
||||||
/CONFIRM_DESTRUCTIVE_DB_NAME=capakraken_e2e/u,
|
/CONFIRM_DESTRUCTIVE_DB_NAME=nexus_e2e/u,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => {
|
test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_ci",
|
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_ci",
|
||||||
ALLOW_DESTRUCTIVE_DB_TOOLS: undefined,
|
ALLOW_DESTRUCTIVE_DB_TOOLS: undefined,
|
||||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_ci",
|
CONFIRM_DESTRUCTIVE_DB_NAME: "nexus_ci",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
assertDestructiveDbAllowed({
|
assertDestructiveDbAllowed({
|
||||||
commandName: "db:test",
|
commandName: "db:test",
|
||||||
allowedDatabaseNames: ["capakraken_ci"],
|
allowedDatabaseNames: ["nexus_ci"],
|
||||||
}),
|
}),
|
||||||
/ALLOW_DESTRUCTIVE_DB_TOOLS=true/u,
|
/ALLOW_DESTRUCTIVE_DB_TOOLS=true/u,
|
||||||
);
|
);
|
||||||
@@ -99,19 +99,19 @@ test("assertSafeSeedTarget rejects unexpected legacy disposable databases", () =
|
|||||||
assert.throws(() => assertSafeSeedTarget("db:seed"), /not in the destructive-tool allowlist/u);
|
assert.throws(() => assertSafeSeedTarget("db:seed"), /not in the destructive-tool allowlist/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertNexusDbTarget accepts non-destructive capakraken targets", () => {
|
test("assertNexusDbTarget accepts non-destructive nexus targets", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_dev",
|
DATABASE_URL: "postgresql://tester:secret@localhost:5432/nexus_dev",
|
||||||
});
|
});
|
||||||
|
|
||||||
const target = assertNexusDbTarget("db:seed:holidays");
|
const target = assertNexusDbTarget("db:seed:holidays");
|
||||||
|
|
||||||
assert.equal(target.databaseName, "capakraken_dev");
|
assert.equal(target.databaseName, "nexus_dev");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertNexusDbTarget rejects legacy non-capakraken targets", () => {
|
test("assertNexusDbTarget rejects legacy non-nexus targets", () => {
|
||||||
setEnv({
|
setEnv({
|
||||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_capakraken",
|
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_non_nexus",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.throws(() => assertNexusDbTarget("db:seed:holidays"), /not a valid Nexus target/u);
|
assert.throws(() => assertNexusDbTarget("db:seed:holidays"), /not a valid Nexus target/u);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ interface DestructiveGuardOptions {
|
|||||||
requireConfirmation?: boolean;
|
requireConfirmation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]);
|
const PROTECTED_DATABASE_NAMES = new Set(["nexus"]);
|
||||||
|
|
||||||
export function parseDatabaseUrl(rawUrl: string) {
|
export function parseDatabaseUrl(rawUrl: string) {
|
||||||
const parsed = new URL(rawUrl);
|
const parsed = new URL(rawUrl);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ async function main() {
|
|||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
const target = assertDestructiveDbAllowed({
|
const target = assertDestructiveDbAllowed({
|
||||||
commandName: "db:reset:dispo",
|
commandName: "db:reset:dispo",
|
||||||
allowedDatabaseNames: ["capakraken_test", "capakraken_e2e", "capakraken_ci"],
|
allowedDatabaseNames: ["nexus_test", "nexus_e2e", "nexus_ci"],
|
||||||
});
|
});
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
parseDatabaseUrl,
|
parseDatabaseUrl,
|
||||||
} from "./destructive-db-guard.js";
|
} from "./destructive-db-guard.js";
|
||||||
|
|
||||||
const TEST_DATABASE_NAMES = ["capakraken_test", "capakraken_e2e", "capakraken_ci"];
|
const TEST_DATABASE_NAMES = ["nexus_test", "nexus_e2e", "nexus_ci"];
|
||||||
|
|
||||||
export function assertSafeSeedTarget(commandName: string) {
|
export function assertSafeSeedTarget(commandName: string) {
|
||||||
return assertDestructiveDbAllowed({
|
return assertDestructiveDbAllowed({
|
||||||
@@ -24,7 +24,7 @@ export function assertNexusDbTarget(commandName: string) {
|
|||||||
|
|
||||||
const target = parseDatabaseUrl(rawUrl);
|
const target = parseDatabaseUrl(rawUrl);
|
||||||
|
|
||||||
if (!target.databaseName.startsWith("capakraken")) {
|
if (!target.databaseName.startsWith("nexus")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${commandName} aborted: database '${target.databaseName}' is not a valid Nexus target. Target=${formatTarget(target)}`,
|
`${commandName} aborted: database '${target.databaseName}' is not a valid Nexus target. Target=${formatTarget(target)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2372,7 +2372,7 @@ async function main() {
|
|||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const email = `${eid}@capakraken.example`;
|
const email = `${eid}@nexus.example`;
|
||||||
const lcrCents = Math.round(lcr * 100);
|
const lcrCents = Math.round(lcr * 100);
|
||||||
const ucrCents = Math.round(ucr * 100);
|
const ucrCents = Math.round(ucr * 100);
|
||||||
const availability = computeAvailability(fraction, availDays);
|
const availability = computeAvailability(fraction, availDays);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function toDisplayName(eid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toEmail(eid) {
|
function toEmail(eid) {
|
||||||
return `${eid}@capakraken.example`;
|
return `${eid}@nexus.example`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSkillLabel(chapter, typeOfWork) {
|
function computeSkillLabel(chapter, typeOfWork) {
|
||||||
@@ -150,7 +150,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
col: 15, // O
|
col: 15, // O
|
||||||
header: "Email\n(generated)",
|
header: "Email\n(generated)",
|
||||||
doc: "Generated email: firstname.lastname@capakraken.example. Required unique field in Nexus. Replace with real email in production.",
|
doc: "Generated email: firstname.lastname@nexus.example. Required unique field in Nexus. Replace with real email in production.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
col: 16, // P
|
col: 16, // P
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ docker compose --profile "$PROFILE" stop app 2>/dev/null || true
|
|||||||
|
|
||||||
if $CLEAN; then
|
if $CLEAN; then
|
||||||
echo "==> Removing stale node_modules and .next volumes..."
|
echo "==> Removing stale node_modules and .next volumes..."
|
||||||
docker volume rm capakraken_node_modules capakraken_next 2>/dev/null || true
|
docker volume rm nexus_node_modules nexus_next 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Rebuilding and starting ($( [[ -z "$SERVICES" ]] && echo "all services" || echo "$SERVICES" ))..."
|
echo "==> Rebuilding and starting ($( [[ -z "$SERVICES" ]] && echo "all services" || echo "$SERVICES" ))..."
|
||||||
|
|||||||
Binary file not shown.
@@ -5,9 +5,9 @@
|
|||||||
import { createRequire } from "module";
|
import { createRequire } from "module";
|
||||||
import { writeFileSync, mkdirSync } from "fs";
|
import { writeFileSync, mkdirSync } from "fs";
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const XLSX = require("/home/hartmut/Documents/Copilot/capakraken/node_modules/.pnpm/xlsx@0.18.5/node_modules/xlsx/xlsx.js");
|
const XLSX = require("/home/hartmut/Documents/Copilot/nexus/node_modules/.pnpm/xlsx@0.18.5/node_modules/xlsx/xlsx.js");
|
||||||
|
|
||||||
const OUT_DIR = "/home/hartmut/Documents/Copilot/capakraken/samples/skillmatrix_dummydata";
|
const OUT_DIR = "/home/hartmut/Documents/Copilot/nexus/samples/skillmatrix_dummydata";
|
||||||
mkdirSync(OUT_DIR, { recursive: true });
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
// ─── Skill Definitions ─────────────────────────────────────────────────────
|
// ─── Skill Definitions ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -719,7 +719,7 @@ export const rules = [
|
|||||||
],
|
],
|
||||||
forbidden: [
|
forbidden: [
|
||||||
{
|
{
|
||||||
pattern: /pnpm --filter @capakraken\/db exec prisma generate/,
|
pattern: /pnpm --filter @nexus\/db exec prisma generate/,
|
||||||
message: "CI must not call prisma generate directly outside the workspace wrapper",
|
message: "CI must not call prisma generate directly outside the workspace wrapper",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function formatDatabaseTarget(parsedUrl, databaseName) {
|
|||||||
return `${parsedUrl.protocol}//${decodeURIComponent(parsedUrl.username)}@${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ""}/${databaseName}`;
|
return `${parsedUrl.protocol}//${decodeURIComponent(parsedUrl.username)}@${parsedUrl.hostname}${parsedUrl.port ? `:${parsedUrl.port}` : ""}/${databaseName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inspectDatabaseUrl(rawUrl, expectedDatabase = "capakraken") {
|
export function inspectDatabaseUrl(rawUrl, expectedDatabase = "nexus") {
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
throw new Error("DATABASE_URL is not configured.");
|
throw new Error("DATABASE_URL is not configured.");
|
||||||
}
|
}
|
||||||
@@ -82,5 +82,5 @@ export function shouldGuardPrismaCommand(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getExpectedDatabaseName() {
|
export function getExpectedDatabaseName() {
|
||||||
return process.env.CAPAKRAKEN_EXPECTED_DB_NAME?.trim() || "capakraken";
|
return process.env.NEXUS_EXPECTED_DB_NAME?.trim() || "nexus";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,21 @@ import {
|
|||||||
} from "./db-target-guard.mjs";
|
} from "./db-target-guard.mjs";
|
||||||
|
|
||||||
describe("db target guard", () => {
|
describe("db target guard", () => {
|
||||||
it("accepts the expected capakraken database target", () => {
|
it("accepts the expected nexus database target", () => {
|
||||||
const result = inspectDatabaseUrl(
|
const result = inspectDatabaseUrl(
|
||||||
"postgresql://capakraken:secret@localhost:5432/capakraken",
|
"postgresql://nexus:secret@localhost:5432/nexus",
|
||||||
"capakraken",
|
"nexus",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result.databaseName, "capakraken");
|
assert.equal(result.databaseName, "nexus");
|
||||||
assert.equal(result.expectedDatabase, "capakraken");
|
assert.equal(result.expectedDatabase, "nexus");
|
||||||
assert.equal(result.target, "postgresql://capakraken@localhost:5432/capakraken");
|
assert.equal(result.target, "postgresql://nexus@localhost:5432/nexus");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a mismatched database target", () => {
|
it("rejects a mismatched database target", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => inspectDatabaseUrl("postgresql://capakraken:secret@localhost:5432/planarchy", "capakraken"),
|
() => inspectDatabaseUrl("postgresql://nexus:secret@localhost:5432/planarchy", "nexus"),
|
||||||
/Unexpected database target 'planarchy'\. Expected 'capakraken'\./,
|
/Unexpected database target 'planarchy'\. Expected 'nexus'\./,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
* node scripts/export-dev-seed.mjs
|
* node scripts/export-dev-seed.mjs
|
||||||
*
|
*
|
||||||
* Requirements:
|
* Requirements:
|
||||||
* - The capakraken-postgres-1 Docker container must be running
|
* - The nexus-postgres-1 Docker container must be running
|
||||||
* - DATABASE_URL must point to a local capakraken database
|
* - DATABASE_URL must point to a local nexus database
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync, spawnSync } from "node:child_process";
|
import { execSync, spawnSync } from "node:child_process";
|
||||||
@@ -48,7 +48,7 @@ if (!["localhost", "127.0.0.1", "::1"].includes(host)) {
|
|||||||
|
|
||||||
// ── Docker container check ────────────────────────────────────────────────────
|
// ── Docker container check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CONTAINER = "capakraken-postgres-1";
|
const CONTAINER = "nexus-postgres-1";
|
||||||
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
|
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
@@ -83,8 +83,8 @@ const excludeFlags = EXCLUDE_TABLES.flatMap((t) => ["--exclude-table-data", `pub
|
|||||||
|
|
||||||
// ── Run pg_dump inside the Docker container ───────────────────────────────────
|
// ── Run pg_dump inside the Docker container ───────────────────────────────────
|
||||||
|
|
||||||
const DB_USER = decodeURIComponent(parsedUrl.username) || "capakraken";
|
const DB_USER = decodeURIComponent(parsedUrl.username) || "nexus";
|
||||||
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "capakraken";
|
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "nexus";
|
||||||
const DB_PORT = parsedUrl.port || "5432";
|
const DB_PORT = parsedUrl.port || "5432";
|
||||||
|
|
||||||
console.log(`🔍 Exporting ${DB_USER}@${host}:${DB_PORT}/${DB_NAME} …`);
|
console.log(`🔍 Exporting ${DB_USER}@${host}:${DB_PORT}/${DB_NAME} …`);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
# Remove SUPERUSER from the application database user
|
# Remove SUPERUSER from the application database user
|
||||||
# Run after initial setup: bash scripts/harden-postgres.sh
|
# Run after initial setup: bash scripts/harden-postgres.sh
|
||||||
|
|
||||||
DB_USER="${DB_USER:-capakraken}"
|
DB_USER="${DB_USER:-nexus}"
|
||||||
DB_NAME="${DB_NAME:-capakraken}"
|
DB_NAME="${DB_NAME:-nexus}"
|
||||||
|
|
||||||
echo "Hardening PostgreSQL for $DB_USER..."
|
echo "Hardening PostgreSQL for $DB_USER..."
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
* node scripts/import-dev-seed.mjs
|
* node scripts/import-dev-seed.mjs
|
||||||
*
|
*
|
||||||
* Requirements:
|
* Requirements:
|
||||||
* - The capakraken-postgres-1 Docker container must be running
|
* - The nexus-postgres-1 Docker container must be running
|
||||||
* - DATABASE_URL must point to a local capakraken database
|
* - DATABASE_URL must point to a local nexus database
|
||||||
* - dev-seed.sql must exist (run export-dev-seed.mjs first)
|
* - dev-seed.sql must exist (run export-dev-seed.mjs first)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -46,13 +46,13 @@ if (!["localhost", "127.0.0.1", "::1"].includes(host)) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_USER = decodeURIComponent(parsedUrl.username) || "capakraken";
|
const DB_USER = decodeURIComponent(parsedUrl.username) || "nexus";
|
||||||
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "capakraken";
|
const DB_NAME = parsedUrl.pathname.replace(/^\/+/, "") || "nexus";
|
||||||
const DB_PORT = parsedUrl.port || "5432";
|
const DB_PORT = parsedUrl.port || "5432";
|
||||||
|
|
||||||
// ── Docker container check ────────────────────────────────────────────────────
|
// ── Docker container check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const CONTAINER = "capakraken-postgres-1";
|
const CONTAINER = "nexus-postgres-1";
|
||||||
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
|
const containerCheck = spawnSync("docker", ["inspect", "--format={{.State.Running}}", CONTAINER], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ if (shouldGuardPrismaCommand(prismaArgs)) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
console.error("Refusing to run Prisma against an unexpected database target.");
|
console.error("Refusing to run Prisma against an unexpected database target.");
|
||||||
console.error("Use the repo env files for Nexus, or set CAPAKRAKEN_EXPECTED_DB_NAME explicitly if you intentionally target another database.");
|
console.error("Use the repo env files for Nexus, or set NEXUS_EXPECTED_DB_NAME explicitly if you intentionally target another database.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ sleep 2
|
|||||||
# 2. Wait for PostgreSQL to be healthy
|
# 2. Wait for PostgreSQL to be healthy
|
||||||
echo " Waiting for PostgreSQL..."
|
echo " Waiting for PostgreSQL..."
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
if docker compose exec -T postgres pg_isready -U capakraken -d capakraken -q 2>/dev/null; then
|
if docker compose exec -T postgres pg_isready -U nexus -d nexus -q 2>/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
function createGitStub(statusOutput) {
|
function createGitStub(statusOutput) {
|
||||||
return (args) => {
|
return (args) => {
|
||||||
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") {
|
if (args[0] === "rev-parse" && args[1] === "--show-toplevel") {
|
||||||
return "/tmp/capakraken\n";
|
return "/tmp/nexus\n";
|
||||||
}
|
}
|
||||||
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
|
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
|
||||||
return "main\n";
|
return "main\n";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
APP_IMAGE=ghcr.io/example/capakraken-app:sha-abc123
|
APP_IMAGE=ghcr.io/example/nexus-app:sha-abc123
|
||||||
MIGRATOR_IMAGE=ghcr.io/example/capakraken-migrator:sha-abc123
|
MIGRATOR_IMAGE=ghcr.io/example/nexus-migrator:sha-abc123
|
||||||
APP_HOST_PORT=3000
|
APP_HOST_PORT=3000
|
||||||
GHCR_USERNAME=
|
GHCR_USERNAME=
|
||||||
GHCR_TOKEN=
|
GHCR_TOKEN=
|
||||||
|
|||||||
Executable
+167
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Phase 3 cutover: migrate the running stack from `capakraken` (DB, role,
|
||||||
|
# volumes, compose project) to `nexus`. Intended to be run inside a brief
|
||||||
|
# maintenance window — the app is stopped for the duration.
|
||||||
|
#
|
||||||
|
# Idempotency: each step checks the current state. Re-running after a
|
||||||
|
# partial run only does the missing pieces. The script never DROPS the
|
||||||
|
# legacy `capakraken` DB or role — that's a manual decision after a
|
||||||
|
# stability window.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./tooling/migrate/rename-to-nexus.sh dev # against docker-compose.yml
|
||||||
|
# ./tooling/migrate/rename-to-nexus.sh prod # against docker-compose.prod.yml
|
||||||
|
#
|
||||||
|
# Requires:
|
||||||
|
# - POSTGRES_PASSWORD set in env (or .env file picked up by docker compose)
|
||||||
|
# - docker compose CLI v2+
|
||||||
|
# - the `nexus` branch of code already checked out (compose files reference nexus_*)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODE="${1:-dev}"
|
||||||
|
case "$MODE" in
|
||||||
|
dev)
|
||||||
|
COMPOSE_FILE=docker-compose.yml
|
||||||
|
OLD_PROJECT=capakraken
|
||||||
|
NEW_PROJECT=nexus
|
||||||
|
VOLUMES=(pgdata node_modules next)
|
||||||
|
;;
|
||||||
|
prod)
|
||||||
|
COMPOSE_FILE=docker-compose.prod.yml
|
||||||
|
OLD_PROJECT=capakraken-prod
|
||||||
|
NEW_PROJECT=nexus-prod
|
||||||
|
VOLUMES=(prod_pgdata prod_redis)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [dev|prod]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "POSTGRES_PASSWORD must be set in the environment." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
DUMP_FILE="/tmp/capakraken-pre-rename-$(date +%Y%m%d-%H%M%S).sql"
|
||||||
|
|
||||||
|
echo "=== Phase 3 cutover ($MODE) ==="
|
||||||
|
echo "compose: $COMPOSE_FILE project: $OLD_PROJECT → $NEW_PROJECT dump: $DUMP_FILE"
|
||||||
|
echo
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. Stop the app (DB stays up so we can dump it).
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[1/7] Stopping app container under old project name..."
|
||||||
|
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" stop app 2>/dev/null || true
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2. Capture row counts for verification.
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[2/7] Capturing pre-rename row counts..."
|
||||||
|
PRE_COUNTS=$(docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
|
||||||
|
psql -U capakraken -d capakraken -t -c \
|
||||||
|
"SELECT table_name, n_live_tup FROM pg_stat_user_tables ORDER BY table_name;")
|
||||||
|
echo "$PRE_COUNTS" | head -20
|
||||||
|
echo "..."
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 3. Dump existing DB.
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[3/7] pg_dump capakraken → $DUMP_FILE..."
|
||||||
|
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
|
||||||
|
pg_dump -U capakraken -d capakraken --clean --if-exists > "$DUMP_FILE"
|
||||||
|
echo "Dump size: $(du -h "$DUMP_FILE" | cut -f1)"
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 4. Create new role + DB inside the running postgres container.
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[4/7] Creating nexus role + database..."
|
||||||
|
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
|
||||||
|
psql -U capakraken -d postgres <<SQL
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='nexus') THEN
|
||||||
|
CREATE ROLE nexus LOGIN PASSWORD '${POSTGRES_PASSWORD}';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
|
||||||
|
SELECT 'CREATE DATABASE nexus OWNER nexus'
|
||||||
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname='nexus')
|
||||||
|
\gexec
|
||||||
|
SQL
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 5. Restore into new DB.
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[5/7] Restoring dump into nexus DB..."
|
||||||
|
# Replace OWNER directives so restored objects belong to `nexus`.
|
||||||
|
sed 's/OWNER TO capakraken/OWNER TO nexus/g' "$DUMP_FILE" | \
|
||||||
|
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
|
||||||
|
psql -U nexus -d nexus
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 6. Volume rename (offline copy).
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[6/7] Stopping old project and copying volumes..."
|
||||||
|
docker compose -p "$OLD_PROJECT" -f "$COMPOSE_FILE" down --remove-orphans
|
||||||
|
|
||||||
|
for v in "${VOLUMES[@]}"; do
|
||||||
|
src="${OLD_PROJECT//-/_}_${v}"
|
||||||
|
dst="${NEW_PROJECT//-/_}_${v}"
|
||||||
|
# Skip dev-mode app overlays (node_modules / next) — they regenerate on startup.
|
||||||
|
if [ "$MODE" = "dev" ] && { [ "$v" = "node_modules" ] || [ "$v" = "next" ]; }; then
|
||||||
|
echo " Skipping $v (regenerated on next boot)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! docker volume inspect "$src" >/dev/null 2>&1; then
|
||||||
|
echo " Source volume $src missing — skip"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if docker volume inspect "$dst" >/dev/null 2>&1; then
|
||||||
|
echo " Destination volume $dst already exists — skip"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo " $src → $dst"
|
||||||
|
docker volume create "$dst" >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v "${src}:/from:ro" \
|
||||||
|
-v "${dst}:/to" \
|
||||||
|
alpine sh -c "cd /from && cp -a . /to/"
|
||||||
|
done
|
||||||
|
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 7. Bring up under new compose project name.
|
||||||
|
#───────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo "[7/7] Starting stack under new project name '$NEW_PROJECT'..."
|
||||||
|
PROFILE=""
|
||||||
|
[ "$MODE" = "dev" ] && PROFILE="--profile full"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker compose -p "$NEW_PROJECT" -f "$COMPOSE_FILE" $PROFILE up -d
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Waiting 15s for postgres to be ready..."
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
echo "=== Verification ==="
|
||||||
|
POST_COUNTS=$(docker compose -p "$NEW_PROJECT" -f "$COMPOSE_FILE" exec -T postgres \
|
||||||
|
psql -U nexus -d nexus -t -c \
|
||||||
|
"SELECT table_name, n_live_tup FROM pg_stat_user_tables ORDER BY table_name;")
|
||||||
|
echo "Post-rename row counts (sample):"
|
||||||
|
echo "$POST_COUNTS" | head -20
|
||||||
|
|
||||||
|
if diff <(echo "$PRE_COUNTS") <(echo "$POST_COUNTS") >/dev/null; then
|
||||||
|
echo "✓ Row counts match — migration verified."
|
||||||
|
else
|
||||||
|
echo "⚠ Row counts differ — review diff:"
|
||||||
|
diff <(echo "$PRE_COUNTS") <(echo "$POST_COUNTS") | head
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Old DB+role retained for rollback. Dump kept at $DUMP_FILE."
|
||||||
|
echo "After a stability window, drop with:"
|
||||||
|
echo " docker compose -p $NEW_PROJECT -f $COMPOSE_FILE exec postgres psql -U nexus -d postgres \\"
|
||||||
|
echo " -c 'DROP DATABASE capakraken; DROP ROLE capakraken;'"
|
||||||
Reference in New Issue
Block a user