Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c695c0ae0 | ||
|
|
11a0b56be5 | ||
|
|
09c9531ad9 | ||
|
|
c3e8ccf7c7 | ||
|
|
7cec86400b | ||
|
|
eb48c83c7e | ||
|
|
60645d5773 |
No files matched your search
@@ -16,8 +16,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- id: setup
|
||||
uses: ./.gitea/actions/setup-pnpm
|
||||
- name: Build image
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
@@ -30,20 +28,33 @@ jobs:
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker volume create load-data
|
||||
docker run --rm -v load-data:/data --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
|
||||
docker run --rm -v load-data:/data --user root --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
|
||||
docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \
|
||||
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data -p 18080:8080 \
|
||||
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data \
|
||||
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
|
||||
-e [email protected] -e ISSUER=http://127.0.0.1:18080 -e RATE_LIMIT_MAX=1000000 authportal:load
|
||||
for i in $(seq 1 40); do curl -fsS http://127.0.0.1:18080/ready && break; sleep 0.5; done
|
||||
- name: Load test
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
|
||||
LOAD_PASSWORD: load-admin-pass-1
|
||||
-e [email protected] -e ISSUER=http://127.0.0.1:8080 \
|
||||
-e RATE_LIMIT_MAX=1000000 authportal:load
|
||||
for i in $(seq 1 40); do
|
||||
docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break
|
||||
sleep 0.5
|
||||
done
|
||||
# Раннер сам работает в контейнере: нагрузку гоним из соседнего контейнера
|
||||
# в сетевом namespace приложения (127.0.0.1:8080 — это приложение).
|
||||
- name: Load test (autocannon в namespace приложения)
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
node scripts/perf/load.mjs http://127.0.0.1:18080 authportal-load
|
||||
docker run --rm -i --network container:authportal-load \
|
||||
-e LOAD_PASSWORD=load-admin-pass-1 -e LOAD_DURATION_S=15 \
|
||||
node:22-alpine sh -c 'mkdir /w && cd /w && npm i --silent --no-audit --no-fund [email protected] && cat > load.mjs && node load.mjs http://127.0.0.1:8080' \
|
||||
< scripts/perf/load.mjs
|
||||
- name: RSS после нагрузки
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
rss=$(docker stats --no-stream --format '{{.MemUsage}}' authportal-load \
|
||||
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
|
||||
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
|
||||
echo "RSS: ${rss} MiB (budget 200)"
|
||||
[ "$rss" -le 200 ]
|
||||
- name: Container logs on failure
|
||||
if: failure()
|
||||
run: docker logs --tail 100 authportal-load || true
|
||||
@@ -48,6 +48,11 @@ describe('Dockerfile', () => {
|
||||
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
|
||||
})
|
||||
|
||||
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
|
||||
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
|
||||
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
|
||||
})
|
||||
|
||||
it('нативные модули проверяются при сборке', () => {
|
||||
expect(dockerfile).toContain('native modules ok')
|
||||
})
|
||||
|
||||
@@ -21,5 +21,7 @@ export default defineConfig({
|
||||
'const __dirname = __dn(__filename);',
|
||||
].join('\n'),
|
||||
},
|
||||
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
|
||||
splitting: false,
|
||||
clean: true,
|
||||
})
|
||||
@@ -200,7 +200,7 @@ export function PortalShell({ children }: { children: ReactNode }) {
|
||||
<PortalSidebar />
|
||||
<SidebarInset id="main-content" className="min-w-0">
|
||||
<PortalHeader />
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1480px] flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||
<div className="flex w-full min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">{children}</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EyeIcon, EyeOffIcon, FingerprintIcon } from 'lucide-react'
|
||||
import { EyeIcon, EyeOffIcon, FingerprintIcon, HistoryIcon, ShieldIcon } from 'lucide-react'
|
||||
import {
|
||||
browserSupportsWebAuthn,
|
||||
browserSupportsWebAuthnAutofill,
|
||||
@@ -35,6 +35,15 @@ import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { webauthnLogin, webauthnLoginOptions } from '@/queries/webauthn'
|
||||
import { AuthLogo } from '@/components/auth-logo'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
|
||||
const PERKS = [
|
||||
{ icon: FingerprintIcon, title: 'Passkey вместо пароля', text: 'Отпечаток, Face ID или ключ безопасности' },
|
||||
{ icon: ShieldIcon, title: 'Права по приложениям и разделам', text: 'Чтение, запись и администрирование для каждого сервиса' },
|
||||
{ icon: HistoryIcon, title: 'Журнал входов и изменений', text: 'Кто, когда и откуда заходил в сервисы' },
|
||||
] as const
|
||||
|
||||
export function PortalLoginForm() {
|
||||
const navigate = useNavigate()
|
||||
@@ -153,92 +162,137 @@ export function PortalLoginForm() {
|
||||
}
|
||||
}
|
||||
|
||||
const returnHost = (() => {
|
||||
try {
|
||||
return search.return_to ? new URL(search.return_to).host : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<section className="flex min-h-svh min-w-0 flex-col justify-center py-8">
|
||||
<div className="mx-auto flex w-full max-w-90 flex-col gap-6 px-4">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<AuthLogo />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Auth Portal
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Единый вход в приложения shnt.top
|
||||
</p>
|
||||
<section className="bg-background flex min-h-svh min-w-0 items-center justify-center px-6 py-8">
|
||||
<div className="grid w-full max-w-[1040px] items-center gap-12 md:grid-cols-2">
|
||||
<div className="hidden flex-col gap-6 md:flex">
|
||||
<div className="flex items-center gap-3">
|
||||
<AuthLogo />
|
||||
<div className="flex flex-col">
|
||||
<b className="text-base font-semibold">Auth Portal</b>
|
||||
<span className="text-muted-foreground text-[13px]">auth.shnt.top</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl leading-tight font-semibold tracking-[-0.02em] text-balance">
|
||||
Один вход для всех сервисов shnt.top
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-[15px] leading-relaxed text-pretty">
|
||||
Войдите один раз — и открывайте CFDM, VPS Tracker, EvoBGP, EvoFirewall, CDN Manager, MikrotikManager и
|
||||
Technitium DNS без повторного ввода пароля.
|
||||
</p>
|
||||
<Frame spacing="sm" className="max-w-[420px]">
|
||||
<FramePanel className="p-1">
|
||||
{PERKS.map((perk) => (
|
||||
<div key={perk.title} className="flex items-start gap-3 px-4 py-2.5">
|
||||
<IconTile variant="outline" aria-hidden className="shrink-0">
|
||||
<perk.icon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col">
|
||||
<b className="text-sm font-medium">{perk.title}</b>
|
||||
<span className="text-muted-foreground text-[13px]">{perk.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Ошибка входа</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Frame spacing="sm" className="w-full max-w-[420px] justify-self-center">
|
||||
<FrameHeader className="gap-1 px-5 pt-5 pb-1">
|
||||
<FrameTitle className="text-xl">Вход в портал</FrameTitle>
|
||||
<FrameDescription className="text-sm">Введите email и пароль или используйте passkey</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-4 px-5 pt-4 pb-5">
|
||||
{returnHost ? (
|
||||
<div className="bg-muted/40 flex items-start gap-2.5 rounded-lg border px-3 py-2.5 text-[13px]" role="status">
|
||||
<Badge variant="info-light" size="sm">
|
||||
SSO
|
||||
</Badge>
|
||||
<span>
|
||||
После входа вы вернётесь в <b className="font-semibold">{returnHost}</b>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<FieldGroup className="gap-3.5">
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username webauthn"
|
||||
placeholder="[email protected]"
|
||||
className="bg-background"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
{error ? (
|
||||
<Alert variant="destructive" role="alert">
|
||||
<AlertTitle>Ошибка входа</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||
<InputGroup className="bg-background w-full">
|
||||
<InputGroupInput
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
placeholder="Пароль"
|
||||
required
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||
<FieldGroup className="gap-3.5">
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username webauthn"
|
||||
placeholder="[email protected]"
|
||||
aria-invalid={error ? true : undefined}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
<Field className="gap-2">
|
||||
<FieldLabel htmlFor="password">Пароль</FieldLabel>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupInput
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
placeholder="Пароль"
|
||||
aria-invalid={error ? true : undefined}
|
||||
required
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{passkeySupported ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">или</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={pending}
|
||||
onClick={() => void handlePasskeyClick()}
|
||||
>
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Войти с passkey
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? 'Вход…' : 'Войти'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passkeySupported ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">или</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="w-full" disabled={pending} onClick={() => void handlePasskeyClick()}>
|
||||
<FingerprintIcon aria-hidden="true" />
|
||||
Войти с passkey
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-center text-xs">
|
||||
Пароль остаётся запасным входом. Passkey добавляется в разделе «Безопасность».
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -31,8 +31,8 @@ RUN rm -f apps/web/src/routes/design-preview*.tsx
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm turbo build --filter=web --filter=@authportal/api
|
||||
# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile.
|
||||
RUN mkdir -p /out/dist \
|
||||
&& cp apps/api/dist/server.js /out/dist/server.js \
|
||||
RUN mkdir -p /out \
|
||||
&& cp -r apps/api/dist /out/dist \
|
||||
&& cp -r apps/web/dist /out/static \
|
||||
&& find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
|
||||
-exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
|
||||
|
||||
@@ -8,37 +8,37 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: oklch(0.973 0.004 247.858);
|
||||
--foreground: oklch(0.141 0.006 264.0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.141 0.006 264.0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.141 0.006 264.0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary: oklch(0.958 0.005 247.858);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--muted: oklch(0.958 0.005 247.858);
|
||||
--muted-foreground: oklch(0.520 0.010 264.0);
|
||||
--accent: oklch(0.942 0.016 264.376);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--border: oklch(0.904 0.006 264.0);
|
||||
--input: oklch(0.904 0.006 264.0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.546 0.215 264.376);
|
||||
--chart-2: oklch(0.527 0.154 150.069);
|
||||
--chart-3: oklch(0.625 0.162 41.500);
|
||||
--chart-4: oklch(0.591 0.224 296.400);
|
||||
--chart-5: oklch(0.577 0.245 27.325);
|
||||
--sidebar: oklch(0.152 0.010 264.376);
|
||||
--sidebar-foreground: oklch(0.920 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-accent: oklch(0.240 0.012 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.950 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 8%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-800);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-900);
|
||||
@@ -50,40 +50,55 @@
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--focus-foreground: var(--color-blue-900);
|
||||
--chart-line-1: oklch(0.527 0.154 150.069);
|
||||
--chart-line-2: oklch(0.546 0.215 264.376);
|
||||
--chart-line-3: oklch(0.625 0.162 41.500);
|
||||
--chart-line-4: oklch(0.591 0.224 296.400);
|
||||
--chart-rx: oklch(0.527 0.154 150.069);
|
||||
--chart-tx: oklch(0.546 0.215 264.376);
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
--status-online-fg: oklch(0.437 0.149 150.069);
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
|
||||
--status-offline-fg: oklch(0.505 0.213 27.325);
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
|
||||
--status-degraded-fg: oklch(0.555 0.163 58.318);
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.133 0.011 264.376);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card: oklch(0.191 0.011 264.376);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.191 0.011 264.376);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary: oklch(0.252 0.010 264.376);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--muted: oklch(0.252 0.010 264.376);
|
||||
--muted-foreground: oklch(0.648 0.008 264.376);
|
||||
--accent: oklch(0.285 0.022 264.376);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.637 0.222 264.376);
|
||||
--chart-2: oklch(0.696 0.170 151.328);
|
||||
--chart-3: oklch(0.713 0.176 47.600);
|
||||
--chart-4: oklch(0.714 0.173 303.900);
|
||||
--chart-5: oklch(0.704 0.191 22.216);
|
||||
--sidebar: oklch(0.191 0.011 264.376);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.262 0.014 264.376);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
--destructive-foreground: var(--color-red-600);
|
||||
--success: var(--color-emerald-500);
|
||||
--success-foreground: var(--color-emerald-600);
|
||||
@@ -95,6 +110,21 @@
|
||||
--invert-foreground: var(--color-zinc-50);
|
||||
--focus: var(--color-blue-500);
|
||||
--focus-foreground: var(--color-blue-600);
|
||||
--chart-line-1: oklch(0.696 0.170 151.328);
|
||||
--chart-line-2: oklch(0.637 0.222 264.376);
|
||||
--chart-line-3: oklch(0.713 0.176 47.600);
|
||||
--chart-line-4: oklch(0.714 0.173 303.900);
|
||||
--chart-rx: oklch(0.696 0.170 151.328);
|
||||
--chart-tx: oklch(0.637 0.222 264.376);
|
||||
--status-online: oklch(0.527 0.154 150.069);
|
||||
--status-offline: oklch(0.577 0.245 27.325);
|
||||
--status-degraded: oklch(0.666 0.179 58.318);
|
||||
--status-online-fg: oklch(0.696 0.170 151.328);
|
||||
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
|
||||
--status-offline-fg: oklch(0.704 0.191 22.216);
|
||||
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
|
||||
--status-degraded-fg: oklch(0.769 0.149 70.080);
|
||||
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -129,10 +159,13 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--color-invert-foreground: var(--invert-foreground);
|
||||
--color-invert: var(--invert);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
|
||||
+35
-16
@@ -2,13 +2,15 @@
|
||||
# Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose),
|
||||
# /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных.
|
||||
#
|
||||
# scripts/ci/smoke-image.sh <image> [port]
|
||||
# scripts/ci/smoke-image.sh <image>
|
||||
#
|
||||
# Запросы идут ВНУТРИ контейнера (docker exec + busybox wget): раннер CI сам работает
|
||||
# в контейнере, и опубликованный порт хоста с 127.0.0.1 задания недостижим.
|
||||
#
|
||||
# Бюджеты (env): MAX_IMAGE_MB (250), MAX_RSS_MB (200), MAX_START_S (15), MAX_STOP_S (5)
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: smoke-image.sh <image> [port]}"
|
||||
PORT="${2:-18080}"
|
||||
IMAGE="${1:?usage: smoke-image.sh <image>}"
|
||||
MAX_IMAGE_MB="${MAX_IMAGE_MB:-250}"
|
||||
MAX_RSS_MB="${MAX_RSS_MB:-200}"
|
||||
MAX_START_S="${MAX_START_S:-15}"
|
||||
@@ -18,7 +20,7 @@ NAME="authportal-smoke-$$"
|
||||
VOLUME="authportal-smoke-data-$$"
|
||||
SECRET="smoke-jwt-secret-0123456789"
|
||||
ADMIN_PW="smoke-admin-pass-1"
|
||||
BASE="http://127.0.0.1:${PORT}"
|
||||
BASE="http://127.0.0.1:8080"
|
||||
|
||||
fail() { echo "SMOKE FAIL: $*" >&2; docker logs --tail 50 "$NAME" >&2 || true; exit 1; }
|
||||
cleanup() {
|
||||
@@ -27,11 +29,22 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# GET (или POST с JSON-телом) изнутри контейнера; тело ответа в stdout.
|
||||
req() {
|
||||
local url="$1" body="${2:-}"
|
||||
if [ -n "$body" ]; then
|
||||
docker exec "$NAME" wget -q -O - -T 5 \
|
||||
--header='content-type: application/json' --post-data="$body" "$url"
|
||||
else
|
||||
docker exec "$NAME" wget -q -O - -T 5 "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
run_container() {
|
||||
docker run -d --name "$NAME" --init \
|
||||
--memory 256m --memory-swap 256m --cpus 1.0 --pids-limit 128 \
|
||||
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \
|
||||
-v "$VOLUME:/data" -p "${PORT}:8080" \
|
||||
-v "$VOLUME:/data" \
|
||||
-e JWT_SECRET="$SECRET" -e ADMIN_PASSWORD="$ADMIN_PW" \
|
||||
-e ADMIN_EMAIL=[email protected] -e ISSUER="$BASE" \
|
||||
"$IMAGE" >/dev/null
|
||||
@@ -41,7 +54,7 @@ wait_ready() {
|
||||
local start deadline
|
||||
start=$(date +%s)
|
||||
deadline=$((start + MAX_START_S))
|
||||
until curl -fsS -m 2 "$BASE/ready" >/dev/null 2>&1; do
|
||||
until req "$BASE/ready" >/dev/null 2>&1; do
|
||||
[ "$(date +%s)" -lt "$deadline" ] || fail "/ready не ответил за ${MAX_START_S}s"
|
||||
sleep 0.3
|
||||
done
|
||||
@@ -49,9 +62,12 @@ wait_ready() {
|
||||
}
|
||||
|
||||
login() {
|
||||
curl -fsS -m 10 -X POST "$BASE/api/v1/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"email\":\"[email protected]\",\"password\":\"${ADMIN_PW}\"}"
|
||||
req "$BASE/api/v1/auth/login" \
|
||||
"{\"email\":\"[email protected]\",\"password\":\"${ADMIN_PW}\"}"
|
||||
}
|
||||
|
||||
jwks_kid() {
|
||||
req "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/'
|
||||
}
|
||||
|
||||
# 1. размер образа
|
||||
@@ -61,19 +77,22 @@ echo "image size: ${size_mb} MiB (budget ${MAX_IMAGE_MB})"
|
||||
|
||||
# 2. старт, ready, логин, JWKS
|
||||
docker volume create "$VOLUME" >/dev/null
|
||||
docker run --rm -v "$VOLUME:/data" --entrypoint sh "$IMAGE" -c 'chown 1000:1000 /data' >/dev/null 2>&1 || true
|
||||
docker run --rm -v "$VOLUME:/data" --user root --entrypoint sh "$IMAGE" -c 'chown 1000:1000 /data' >/dev/null 2>&1 || true
|
||||
run_container
|
||||
wait_ready
|
||||
[ "$(curl -fsS -m 3 "$BASE/health")" = '{"ok":true}' ] || fail "/health"
|
||||
[ "$(req "$BASE/health")" = '{"ok":true}' ] || fail "/health"
|
||||
login | grep -q '"access_token"' || fail "логин администратора"
|
||||
kid1=$(curl -fsS -m 3 "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/')
|
||||
kid1=$(jwks_kid)
|
||||
[ -n "$kid1" ] || fail "JWKS пуст"
|
||||
code=$(curl -s -m 3 -o /dev/null -w '%{http_code}' "$BASE/oauth/nope")
|
||||
[ "$code" = 404 ] || fail "/oauth/nope вернул $code, ожидался 404"
|
||||
# wget на 404 завершается с ошибкой (pipefail!) — собираем вывод отдельно.
|
||||
not_found=$(docker exec "$NAME" wget -S -O /dev/null -T 5 "$BASE/oauth/nope" 2>&1 || true)
|
||||
echo "$not_found" | grep -q "HTTP/1.[01] 404" || fail "/oauth/nope: ожидался 404, получено: $not_found"
|
||||
|
||||
# 3. память под нагрузкой логинов
|
||||
for _ in $(seq 1 10); do login >/dev/null; done
|
||||
rss_mb=$(docker stats --no-stream --format '{{.MemUsage}}' "$NAME" | sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' | awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
|
||||
rss_mb=$(docker stats --no-stream --format '{{.MemUsage}}' "$NAME" \
|
||||
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
|
||||
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
|
||||
echo "RSS: ${rss_mb} MiB (budget ${MAX_RSS_MB})"
|
||||
[ "$rss_mb" -le "$MAX_RSS_MB" ] || fail "RSS ${rss_mb} MiB > ${MAX_RSS_MB} MiB"
|
||||
|
||||
@@ -90,7 +109,7 @@ docker rm "$NAME" >/dev/null
|
||||
run_container
|
||||
wait_ready
|
||||
login | grep -q '"access_token"' || fail "логин после рестарта"
|
||||
kid2=$(curl -fsS -m 3 "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/')
|
||||
kid2=$(jwks_kid)
|
||||
[ "$kid1" = "$kid2" ] || fail "kid изменился после рестарта ($kid1 → $kid2)"
|
||||
|
||||
echo "SMOKE OK"
|
||||
Reference in new issue
Block a user