This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
foreach ($command in @('go', 'node', 'pnpm', 'python')) {
|
||||
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
|
||||
throw "Required command not found: $command"
|
||||
}
|
||||
}
|
||||
|
||||
pnpm install --frozen-lockfile
|
||||
go test ./...
|
||||
pnpm test
|
||||
Write-Host 'Bootstrap verification passed.' -ForegroundColor Green
|
||||
@@ -0,0 +1,16 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||
$cachedGo = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
|
||||
if (-not (Test-Path (Join-Path $cachedGo 'go.exe'))) { throw 'Go 1.26.6 is not available on PATH or in the recorded local toolchain cache.' }
|
||||
$env:PATH = $cachedGo + ';' + $env:PATH
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path bin -Force | Out-Null
|
||||
go build -o bin/pulse-api.exe ./cmd/api
|
||||
go build -o bin/pulse-migrate.exe ./cmd/migrate
|
||||
go build -o bin/pulse-worker.exe ./cmd/worker
|
||||
go build -o bin/pulse-agent.exe ./cmd/agent
|
||||
pnpm --filter @itworx/pulse-web build
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
const root = fs.realpathSync(argument("--repository") ?? process.cwd());
|
||||
const outputArgument = argument("--output");
|
||||
const reportArgument = argument("--report");
|
||||
const allowlistPath = path.resolve(root, argument("--allowlist") ?? "public-source.allowlist");
|
||||
if (!outputArgument) fail("Usage: node scripts/export-public-source.mjs --output <new-directory> [--report <file>]");
|
||||
|
||||
const output = path.resolve(outputArgument);
|
||||
const reportPath = reportArgument ? path.resolve(reportArgument) : undefined;
|
||||
if (output === root || output.startsWith(`${root}${path.sep}`)) fail("The public export must be outside the canonical repository.");
|
||||
if (fs.existsSync(output)) fail(`Output already exists: ${output}`);
|
||||
if (!fs.existsSync(path.join(root, "LICENSE"))) fail("Public export blocked: confirm the license proposal and add LICENSE first.");
|
||||
|
||||
const status = execFileSync("git", ["-C", root, "status", "--porcelain=v1", "--untracked-files=all"], { encoding: "utf8" });
|
||||
if (status.trim()) fail("Public export blocked: the canonical repository must be clean and committed.");
|
||||
|
||||
const allowlist = fs.readFileSync(allowlistPath, "utf8")
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"));
|
||||
|
||||
function selected(relativePath) {
|
||||
return allowlist.some((rule) => rule.endsWith("/**") ? relativePath.startsWith(rule.slice(0, -2)) : relativePath === rule);
|
||||
}
|
||||
|
||||
const deniedPrefixes = [".agents/", ".claude/", ".codex/", "artifacts/", "design/", "planning/", "prompts/"];
|
||||
const deniedRootFiles = new Set(["AGENTS.md", "AUDIT.md", "CURRENT_STATE.md", "DECISIONS.md", "MASTER_PROMPT.txt", "PACKAGE_MANIFEST.md", "PACKAGE_REPORT.md", "PLANS.md", "ROADMAP.md", "START_HERE.md"]);
|
||||
function denied(relativePath) {
|
||||
return deniedRootFiles.has(relativePath)
|
||||
|| path.posix.basename(relativePath) === "AGENTS.md"
|
||||
|| deniedPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
const replacements = [
|
||||
[/https?:\/\/pulse\.itworx\.tech/giu, "https://pulse.example.com"],
|
||||
[/\bgitea\.itworx\.tech\b/giu, "git.example.com"],
|
||||
[/\b192\.168\.10\.150\b/gu, "192.0.2.10"],
|
||||
[/\bTower\.local\b/gu, "unraid.example.test"],
|
||||
[/\/mnt\/user\/appdata\/itworx-pulse/gu, "/srv/pulse"],
|
||||
[/C:\\Users\\Jens\\/gu, "C:\\Users\\example\\"]
|
||||
];
|
||||
|
||||
function renderedBytes(sourceBytes) {
|
||||
if (sourceBytes.includes(0)) return sourceBytes;
|
||||
let value = sourceBytes.toString("utf8");
|
||||
for (const [expression, replacement] of replacements) value = value.replace(expression, replacement);
|
||||
return Buffer.from(value, "utf8");
|
||||
}
|
||||
|
||||
const privatePatterns = [
|
||||
{ id: "private-domain", expression: /\b(?:pulse|gitea)\.itworx\.tech\b/iu },
|
||||
{ id: "private-hostname", expression: /\bTower\.local\b/iu },
|
||||
{ id: "private-lan-address", expression: /\b192\.168\.10\.150\b/u },
|
||||
{ id: "operator-appdata-path", expression: /\/mnt\/user\/appdata\/itworx-pulse/iu },
|
||||
{ id: "personal-windows-path", expression: /[A-Z]:\\Users\\Jens\\/iu },
|
||||
{ id: "private-repository-owner", expression: /(?:git@[^\s:]+:|https?:\/\/[^\s/]+\/)Jens\/ITWorx-Pulse/iu }
|
||||
];
|
||||
|
||||
const tracked = execFileSync("git", ["-C", root, "ls-files", "-z"], { encoding: "utf8" })
|
||||
.split("\0").filter(Boolean).map((entry) => entry.replaceAll("\\", "/"));
|
||||
const files = tracked.filter((relativePath) => selected(relativePath) && !denied(relativePath))
|
||||
.sort((left, right) => left.localeCompare(right, "en"));
|
||||
if (files.length === 0) fail("The allowlist selected no tracked files.");
|
||||
|
||||
const findings = [];
|
||||
const rendered = new Map();
|
||||
for (const relativePath of files) {
|
||||
const source = path.join(root, ...relativePath.split("/"));
|
||||
const stat = fs.lstatSync(source);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
findings.push({ rule: "non-regular-file", path: relativePath });
|
||||
continue;
|
||||
}
|
||||
if (stat.size > 5 * 1024 * 1024) {
|
||||
findings.push({ rule: "oversized-file", path: relativePath, bytes: stat.size });
|
||||
continue;
|
||||
}
|
||||
const bytes = renderedBytes(fs.readFileSync(source));
|
||||
rendered.set(relativePath, bytes);
|
||||
if (!bytes.includes(0)) {
|
||||
const text = bytes.toString("utf8");
|
||||
for (const rule of privatePatterns) if (rule.expression.test(text)) findings.push({ rule: rule.id, path: relativePath });
|
||||
}
|
||||
}
|
||||
|
||||
for (const required of ["README.md", "SECURITY.md", "CONTRIBUTING.md", "LICENSE", "go.mod", "package.json", "deploy/compose.yaml", ".gitea/workflows/public-validation.yml"]) {
|
||||
if (!files.includes(required)) findings.push({ rule: "missing-required-file", path: required });
|
||||
}
|
||||
|
||||
const sourceRevision = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||
const report = { schemaVersion: 1, sourceRevision, selectedFiles: files.length, findings };
|
||||
if (reportPath) {
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
}
|
||||
if (findings.length > 0) fail(`Public export blocked by ${findings.length} finding(s). See ${reportPath ?? "the scan output"}.`);
|
||||
|
||||
fs.mkdirSync(output, { recursive: false });
|
||||
const manifest = [];
|
||||
for (const relativePath of files) {
|
||||
const bytes = rendered.get(relativePath);
|
||||
const destination = path.join(output, ...relativePath.split("/"));
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.writeFileSync(destination, bytes);
|
||||
manifest.push({ path: relativePath, bytes: bytes.length, sha256: crypto.createHash("sha256").update(bytes).digest("hex") });
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(output, "PUBLIC_SOURCE_EXPORT.md"), `# Curated public source export\n\nGenerated from private canonical revision \`${sourceRevision}\`.\n\nThis parentless candidate excludes private operational history, evidence, planning, prompts, and machine-local agent configuration.\n`, "utf8");
|
||||
fs.writeFileSync(path.join(output, "PUBLIC_SOURCE_MANIFEST.json"), `${JSON.stringify({ schemaVersion: 1, sourceRevision, files: manifest }, null, 2)}\n`, "utf8");
|
||||
console.log(`Exported ${files.length} reviewed files to ${output}`);
|
||||
@@ -0,0 +1,175 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
function Get-FreeLoopbackPort {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw 'Docker is required for the isolated integration smoke.' }
|
||||
$goBin = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $goBin 'go.exe'))) { $env:PATH = $goBin + ';' + $env:PATH }
|
||||
|
||||
$project = 'itworx-pulse-smoke-' + $PID
|
||||
$webPort = Get-FreeLoopbackPort
|
||||
$apiPort = Get-FreeLoopbackPort
|
||||
$dbPort = Get-FreeLoopbackPort
|
||||
$baseURL = "http://127.0.0.1:$webPort"
|
||||
$compose = @('-p', $project, '-f', 'deploy/compose.yaml', '-f', 'deploy/compose.dev.yaml', '-f', 'deploy/compose.smoke.yaml')
|
||||
$succeeded = $false
|
||||
|
||||
$env:PULSE_POSTGRES_PASSWORD = 'smoke-process-only'
|
||||
$env:PULSE_DATABASE_URL = 'postgres://pulse:smoke-process-only@pulse-postgres:5432/pulse?sslmode=disable'
|
||||
$env:PULSE_ENV = 'development'
|
||||
$env:PULSE_AUTH_MODE = 'mock'
|
||||
$env:PULSE_SESSION_IDLE_TTL = '30s'
|
||||
$env:PULSE_SESSION_ABSOLUTE_TTL = '3m'
|
||||
$env:PULSE_DEV_WEB_PORT = [string]$webPort
|
||||
$env:PULSE_DEV_API_PORT = [string]$apiPort
|
||||
$env:PULSE_DEV_DB_PORT = [string]$dbPort
|
||||
$env:PULSE_SMOKE_WEBHOOK_TOKEN = 'smoke-runtime-only'
|
||||
|
||||
try {
|
||||
Write-Host "== isolated stack ($project) ==" -ForegroundColor Cyan
|
||||
docker compose @compose config --quiet
|
||||
docker compose @compose build --quiet
|
||||
# Migrate and seed before the worker starts. Discovery claims one-minute
|
||||
# windows, so restarting a worker after an initial empty run is inherently
|
||||
# timing-dependent and can correctly retain the first claim until the next
|
||||
# window.
|
||||
docker compose @compose up -d --wait --wait-timeout 240 pulse-postgres
|
||||
docker compose @compose run --rm pulse-migrate
|
||||
|
||||
Write-Host '== agent snapshot -> discovery -> inventory ==' -ForegroundColor Cyan
|
||||
@'
|
||||
INSERT INTO data_sources (id,type,name,configuration_ref)
|
||||
VALUES ('b1011111-1111-4111-8111-111111111111','unraid','Smoke container agent','smoke/container-agent')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO agent_snapshots (agent_id,capability,observed_at,received_at,payload)
|
||||
VALUES (
|
||||
'pulse-smoke-agent',
|
||||
'containers',
|
||||
now(),
|
||||
now(),
|
||||
jsonb_build_object(
|
||||
'Source', jsonb_build_object('id','pulse-smoke-agent','type','agent'),
|
||||
'Containers', jsonb_build_array(jsonb_build_object(
|
||||
'ID','runtime-smoke-1',
|
||||
'Name','smoke-api',
|
||||
'Image','itworx/pulse-smoke',
|
||||
'ImageDigest','sha256:smoke',
|
||||
'State','running',
|
||||
'Health','healthy',
|
||||
'Project','smoke',
|
||||
'Labels',jsonb_build_object('com.docker.compose.project','smoke','com.docker.compose.service','api')
|
||||
)),
|
||||
'ObservedAt', to_jsonb(now()),
|
||||
'ReceivedAt', to_jsonb(now())
|
||||
)
|
||||
)
|
||||
ON CONFLICT (agent_id,capability) DO UPDATE
|
||||
SET observed_at=EXCLUDED.observed_at,received_at=EXCLUDED.received_at,payload=EXCLUDED.payload;
|
||||
INSERT INTO agent_snapshots (agent_id,capability,observed_at,received_at,payload)
|
||||
VALUES
|
||||
('pulse-smoke-agent','array',now(),now(),jsonb_build_object(
|
||||
'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'),
|
||||
'state','operational','parity',jsonb_build_object('present',true,'state','idle'),
|
||||
'members',jsonb_build_array(
|
||||
jsonb_build_object('id','disk1','name','Disk 1','role','data','state','online','capacityBytes',1000000),
|
||||
jsonb_build_object('id','parity','name','Parity','role','parity','state','online','capacityBytes',1000000)
|
||||
),'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now())
|
||||
)),
|
||||
('pulse-smoke-agent','disks',now(),now(),jsonb_build_object(
|
||||
'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'),
|
||||
'disks',jsonb_build_array(jsonb_build_object('id','disk1','name','Disk 1','role','data','state','online','sizeBytes',1000000,'usedBytes',400000)),
|
||||
'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now())
|
||||
)),
|
||||
('pulse-smoke-agent','pools',now(),now(),jsonb_build_object(
|
||||
'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'),
|
||||
'pools',jsonb_build_array(jsonb_build_object('id','cache','name','Cache','filesystem','btrfs','state','healthy','usableBytes',1000000,'usedBytes',250000)),
|
||||
'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now())
|
||||
)),
|
||||
('pulse-smoke-agent','shares',now(),now(),jsonb_build_object(
|
||||
'source',jsonb_build_object('id','pulse-smoke-agent','type','fixture'),
|
||||
'shares',jsonb_build_array(jsonb_build_object('id','share-media','name','Media','usedBytes',250000,'sizeObservedAt',to_jsonb(now()),'sizeState','cached')),
|
||||
'observedAt',to_jsonb(now()),'receivedAt',to_jsonb(now())
|
||||
))
|
||||
ON CONFLICT (agent_id,capability) DO UPDATE
|
||||
SET observed_at=EXCLUDED.observed_at,received_at=EXCLUDED.received_at,payload=EXCLUDED.payload;
|
||||
'@ | docker compose @compose exec -T pulse-postgres psql -v ON_ERROR_STOP=1 -U pulse -d pulse | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'failed to seed bounded container agent snapshot' }
|
||||
docker compose @compose up -d --wait --wait-timeout 240
|
||||
$inventoryReady = $false
|
||||
for ($attempt = 0; $attempt -lt 30; $attempt++) {
|
||||
Start-Sleep -Seconds 2
|
||||
$inventoryRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT count(*) FROM entities WHERE canonical_name='smoke/api';"
|
||||
if ($LASTEXITCODE -eq 0 -and [int]($inventoryRows | Select-Object -Last 1) -eq 1) { $inventoryReady = $true; break }
|
||||
}
|
||||
if (-not $inventoryReady) { throw 'worker did not reconcile the fresh agent container snapshot' }
|
||||
$identityRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT (SELECT count(*) FROM entity_aliases WHERE source_id='b1011111-1111-4111-8111-111111111111' AND external_type='application-project' AND external_id='smoke')::text || ',' || (SELECT count(*) FROM entity_aliases WHERE source_id='b1011111-1111-4111-8111-111111111111' AND external_type='container-service' AND external_id='smoke/api')::text || ',' || (SELECT count(*) FROM container_aliases WHERE source_id='b1011111-1111-4111-8111-111111111111')::text;"
|
||||
if (($identityRows | Select-Object -Last 1) -ne '1,1,1') { throw "discovery identity is not idempotent: $identityRows" }
|
||||
|
||||
$session = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
Invoke-RestMethod -Uri "$baseURL/auth/test-login" -WebSession $session | Out-Null
|
||||
$rule = @{
|
||||
schemaVersion = 1
|
||||
id = 'f1011111-1111-4111-8111-111111111111'
|
||||
name = 'Integratiesmoke CPU'
|
||||
enabled = $true
|
||||
severity = 'critical'
|
||||
scope = @{ serverId = 'smoke-host' }
|
||||
condition = @{ inputType = 'metric'; metric = 'host.cpu.utilization'; operator = '>='; threshold = 1; aggregation = 'avg'; windowSeconds = 60 }
|
||||
evaluationIntervalSeconds = 5
|
||||
pendingSeconds = 0
|
||||
resolveSeconds = 0
|
||||
cooldownSeconds = 60
|
||||
unknownBehavior = 'become-unknown'
|
||||
groupBy = @()
|
||||
suppressWhen = @()
|
||||
message = @{ titleKey = 'smoke.cpu.title'; bodyKey = 'smoke.cpu.body' }
|
||||
} | ConvertTo-Json -Depth 8 -Compress
|
||||
Invoke-RestMethod -Method Post -Uri "$baseURL/api/v1/alert-rules" -WebSession $session -ContentType 'application/json' -Body $rule | Out-Null
|
||||
|
||||
Write-Host '== source -> alert -> webhook ==' -ForegroundColor Cyan
|
||||
$fixtureStatus = $null
|
||||
for ($attempt = 0; $attempt -lt 45; $attempt++) {
|
||||
Start-Sleep -Seconds 2
|
||||
$fixtureJSON = docker compose @compose exec -T pulse-smoke-fixture /pulse-integration-fixture get http://127.0.0.1:9090/smoke/status
|
||||
if ($LASTEXITCODE -ne 0) { continue }
|
||||
$fixtureStatus = $fixtureJSON | ConvertFrom-Json
|
||||
if ($fixtureStatus.prometheusRequests -gt 0 -and $fixtureStatus.events.firing -gt 0) { break }
|
||||
}
|
||||
# One custom smoke rule plus the two implementation-owned aggregate metric
|
||||
# defaults must all reach the approved Prometheus fixture. This guards the
|
||||
# alert-scope/catalog contract, not merely a single happy-path query.
|
||||
if ($null -eq $fixtureStatus -or $fixtureStatus.prometheusRequests -lt 3 -or $fixtureStatus.events.firing -lt 1) {
|
||||
throw "full alert chain did not complete: $($fixtureStatus | ConvertTo-Json -Compress)"
|
||||
}
|
||||
$workerLogs = docker compose @compose logs --no-color pulse-worker 2>$null
|
||||
if ($workerLogs -match 'alert metric query failed') { throw 'implementation-owned alert rule failed its bounded metric query' }
|
||||
if ($fixtureStatus.uniqueDeliveryKeys -gt $fixtureStatus.webhookRequests) { throw 'receiver observed inconsistent idempotency keys' }
|
||||
|
||||
$snapshotRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT count(*) FROM agent_snapshots WHERE agent_id='pulse-smoke-agent' AND capability IN ('host','processes');"
|
||||
if ([int]($snapshotRows | Select-Object -Last 1) -lt 2) { throw "agent snapshots missing: $snapshotRows" }
|
||||
$deliveredRows = docker compose @compose exec -T pulse-postgres psql -U pulse -d pulse -Atc "SELECT count(*) FROM notification_outbox WHERE event_type='firing' AND status='delivered';"
|
||||
if ([int]($deliveredRows | Select-Object -Last 1) -lt 1) { throw "delivered firing audit missing: $deliveredRows" }
|
||||
|
||||
Write-Host '== real browser -> web proxy -> API -> PostgreSQL ==' -ForegroundColor Cyan
|
||||
$env:PULSE_E2E_REAL_BASE_URL = $baseURL
|
||||
pnpm --filter @itworx/pulse-web exec playwright test tests/e2e/real-stack.spec.ts --project=desktop-chromium
|
||||
if ($LASTEXITCODE -ne 0) { throw 'real-stack Playwright gate failed' }
|
||||
Write-Host ("INTEGRATION SMOKE: PASS sourceQueries={0} webhookAttempts={1} uniqueDeliveries={2}" -f $fixtureStatus.prometheusRequests, $fixtureStatus.webhookRequests, $fixtureStatus.uniqueDeliveryKeys) -ForegroundColor Green
|
||||
$succeeded = $true
|
||||
} finally {
|
||||
if (-not $succeeded) {
|
||||
docker compose @compose ps 2>$null
|
||||
docker compose @compose logs --no-color --tail 120 2>$null
|
||||
}
|
||||
# The project name is unique to this process. `-v` removes only the empty or
|
||||
# smoke-populated volume created above; no pre-existing Docker object can
|
||||
# match or be referenced by this compose project.
|
||||
docker compose @compose down --volumes --remove-orphans 2>$null
|
||||
Remove-Item Env:PULSE_POSTGRES_PASSWORD,Env:PULSE_DATABASE_URL,Env:PULSE_ENV,Env:PULSE_AUTH_MODE,Env:PULSE_SESSION_IDLE_TTL,Env:PULSE_SESSION_ABSOLUTE_TTL,Env:PULSE_DEV_WEB_PORT,Env:PULSE_DEV_API_PORT,Env:PULSE_DEV_DB_PORT,Env:PULSE_SMOKE_WEBHOOK_TOKEN,Env:PULSE_E2E_REAL_BASE_URL -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||
$cachedGo = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
|
||||
if (-not (Test-Path (Join-Path $cachedGo 'go.exe'))) { throw 'Go 1.26.6 is not available on PATH or in the recorded local toolchain cache.' }
|
||||
$env:PATH = $cachedGo + ';' + $env:PATH
|
||||
}
|
||||
|
||||
$unformatted = gofmt -l cmd internal
|
||||
if ($unformatted) { throw "Go files need formatting: $($unformatted -join ', ')" }
|
||||
go vet ./...
|
||||
pnpm lint
|
||||
@@ -0,0 +1,126 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidatePattern('^https://')]
|
||||
[string]$BaseUrl = 'https://pulse.example.com',
|
||||
|
||||
[Parameter()]
|
||||
[ValidatePattern('^http://')]
|
||||
[string]$HttpUrl = 'http://pulse.example.com',
|
||||
|
||||
[Parameter()]
|
||||
[ValidatePattern('^https://')]
|
||||
[string]$ExpectedOIDCIssuer = 'https://auth.nuklearrabbit.com/application/o/itworx-pulse',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateRange(1, 30)]
|
||||
[int]$TimeoutSeconds = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$base = [uri]$BaseUrl
|
||||
$plain = [uri]$HttpUrl
|
||||
$issuer = [uri]$ExpectedOIDCIssuer
|
||||
if (-not [string]::IsNullOrEmpty($base.UserInfo) -or -not [string]::IsNullOrEmpty($plain.UserInfo) -or -not [string]::IsNullOrEmpty($issuer.UserInfo)) {
|
||||
throw 'Production smoke URLs may not contain embedded credentials.'
|
||||
}
|
||||
|
||||
function Invoke-NoRedirect([uri]$Uri) {
|
||||
$handler = [System.Net.Http.HttpClientHandler]::new()
|
||||
$handler.AllowAutoRedirect = $false
|
||||
$client = [System.Net.Http.HttpClient]::new($handler)
|
||||
$client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds)
|
||||
try {
|
||||
$response = $client.GetAsync($Uri).GetAwaiter().GetResult()
|
||||
$headers = @{}
|
||||
foreach ($header in $response.Headers) {
|
||||
$headers[$header.Key] = $header.Value -join ', '
|
||||
}
|
||||
foreach ($header in $response.Content.Headers) {
|
||||
$headers[$header.Key] = $header.Value -join ', '
|
||||
}
|
||||
[pscustomobject]@{
|
||||
StatusCode = [int]$response.StatusCode
|
||||
Headers = $headers
|
||||
Content = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Probe([string]$Path) {
|
||||
Invoke-NoRedirect ([uri]($BaseUrl.TrimEnd('/') + $Path))
|
||||
}
|
||||
|
||||
function Assert-Status($Response, [int[]]$Expected, [string]$Label) {
|
||||
if ($Expected -notcontains [int]$Response.StatusCode) {
|
||||
throw "$Label returned HTTP $($Response.StatusCode); expected $($Expected -join '/')"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ResponseText($Response) {
|
||||
if ($Response.Content -is [byte[]]) {
|
||||
return [System.Text.Encoding]::UTF8.GetString([byte[]]$Response.Content)
|
||||
}
|
||||
return [string]$Response.Content
|
||||
}
|
||||
|
||||
Write-Host '== HTTPS health and security headers ==' -ForegroundColor Cyan
|
||||
$health = Invoke-Probe '/healthz'
|
||||
Assert-Status $health @(200) 'HTTPS /healthz'
|
||||
if ((Get-ResponseText $health).Trim() -ne 'ok' -or $health.Headers.'Content-Type' -notmatch '^text/plain') {
|
||||
throw 'HTTPS /healthz must return exact text/plain ok content.'
|
||||
}
|
||||
foreach ($header in @('Strict-Transport-Security', 'Content-Security-Policy', 'X-Content-Type-Options', 'X-Frame-Options')) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$health.Headers.$header)) { throw "HTTPS response is missing $header." }
|
||||
}
|
||||
if ([string]$health.Headers.'Strict-Transport-Security' -notmatch 'max-age=\d+') {
|
||||
throw 'HSTS does not contain a max-age directive.'
|
||||
}
|
||||
|
||||
$ready = Invoke-Probe '/readyz'
|
||||
Assert-Status $ready @(200) 'HTTPS /readyz'
|
||||
if ($ready.Headers.'Content-Type' -notmatch '^text/plain' -or (Get-ResponseText $ready) -match '<!doctype html') {
|
||||
throw 'HTTPS /readyz is not genuine text/plain API readiness.'
|
||||
}
|
||||
|
||||
Write-Host '== HTTP to HTTPS enforcement ==' -ForegroundColor Cyan
|
||||
$redirect = Invoke-NoRedirect ([uri]($HttpUrl.TrimEnd('/') + '/healthz'))
|
||||
Assert-Status $redirect @(301, 302, 307, 308) 'HTTP /healthz'
|
||||
$location = [uri]::new($plain, [string]$redirect.Headers.Location)
|
||||
if ($location.Scheme -ne 'https' -or $location.Host -ne $base.Host) {
|
||||
throw "HTTP redirect escapes the production HTTPS host: $location"
|
||||
}
|
||||
|
||||
Write-Host '== public exposure boundary ==' -ForegroundColor Cyan
|
||||
foreach ($path in @('/metrics', '/debug/pprof/')) {
|
||||
$response = Invoke-Probe $path
|
||||
Assert-Status $response @(404) $path
|
||||
if ((Get-ResponseText $response) -match '<!doctype html') { throw "$path fell through to the SPA." }
|
||||
}
|
||||
foreach ($path in @('/api/v1/system/status', '/api/v1/system/metrics', '/api/v1/system/diagnostics')) {
|
||||
$response = Invoke-Probe $path
|
||||
Assert-Status $response @(401) $path
|
||||
}
|
||||
|
||||
Write-Host '== OIDC entrypoint ==' -ForegroundColor Cyan
|
||||
$discovery = Invoke-NoRedirect ([uri]($ExpectedOIDCIssuer.TrimEnd('/') + '/.well-known/openid-configuration'))
|
||||
Assert-Status $discovery @(200) 'OIDC discovery'
|
||||
$provider = $discovery.Content | ConvertFrom-Json
|
||||
if (([string]$provider.issuer).TrimEnd('/') -ne $ExpectedOIDCIssuer.TrimEnd('/')) {
|
||||
throw "OIDC discovery returned an unexpected issuer: $($provider.issuer)"
|
||||
}
|
||||
$authorizationEndpoint = [uri][string]$provider.authorization_endpoint
|
||||
if ($authorizationEndpoint.Scheme -ne 'https' -or $authorizationEndpoint.Host -ne $issuer.Host) {
|
||||
throw "OIDC discovery returned an unsafe authorization endpoint: $authorizationEndpoint"
|
||||
}
|
||||
$login = Invoke-Probe '/auth/login'
|
||||
Assert-Status $login @(302, 303, 307) '/auth/login'
|
||||
$loginLocation = [uri]::new($base, [string]$login.Headers.Location)
|
||||
if ($loginLocation.Scheme -ne $authorizationEndpoint.Scheme -or $loginLocation.Host -ne $authorizationEndpoint.Host -or $loginLocation.AbsolutePath -ne $authorizationEndpoint.AbsolutePath) {
|
||||
throw "OIDC login redirect does not target the expected issuer: $loginLocation"
|
||||
}
|
||||
|
||||
Write-Host "PRODUCTION PUBLIC SMOKE: PASS ($BaseUrl)" -ForegroundColor Green
|
||||
@@ -0,0 +1,36 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||
$cachedGo = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
|
||||
if (-not (Test-Path (Join-Path $cachedGo 'go.exe'))) { throw 'Go 1.26.6 is not available on PATH or in the recorded local toolchain cache.' }
|
||||
$env:PATH = $cachedGo + ';' + $env:PATH
|
||||
}
|
||||
|
||||
function Invoke-Checked([string]$Label, [scriptblock]$Command) {
|
||||
Write-Host "== $Label ==" -ForegroundColor Cyan
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Label failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
Invoke-Checked 'Go tests' { go test ./... }
|
||||
Invoke-Checked 'Go vet' { go vet ./... }
|
||||
Invoke-Checked 'Frontend install' { pnpm install --frozen-lockfile }
|
||||
Invoke-Checked 'Frontend tests' { pnpm test }
|
||||
Invoke-Checked 'Frontend typecheck' { pnpm typecheck }
|
||||
Invoke-Checked 'Frontend lint' { pnpm lint }
|
||||
Invoke-Checked 'Frontend build' { pnpm build }
|
||||
Invoke-Checked 'API contract' { python tools/check_api_contract.py }
|
||||
Invoke-Checked 'Schema contracts' { python tools/validate_contracts.py }
|
||||
Invoke-Checked 'Production wiring' { python tools/check_wiring.py }
|
||||
Invoke-Checked 'Secret markers' { python tools/check_secrets.py }
|
||||
Invoke-Checked 'Image digest policy' { bash deploy/verify-image-digests.sh }
|
||||
|
||||
if (Test-Path -LiteralPath 'PUBLIC_SOURCE_MANIFEST.json') {
|
||||
Invoke-Checked 'Public source manifest' { node scripts/validate-public-source.mjs }
|
||||
} else {
|
||||
Write-Host 'Public source manifest check skipped in canonical private checkout.' -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host 'PUBLIC VERIFY: PASS' -ForegroundColor Green
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Keep the scanner independent of GitHub-specific composite-action setup.
|
||||
# The release is immutable and the embedded digest is from the official
|
||||
# v0.74.0 checksum manifest.
|
||||
version="0.74.0"
|
||||
archive="trivy_${version}_Linux-64bit.tar.gz"
|
||||
expected_sha256="2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a"
|
||||
work_root="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||
work_dir="$(mktemp -d "${work_root%/}/pulse-trivy-${version}.XXXXXX")"
|
||||
scan_root="${1:-.}"
|
||||
|
||||
case "${scan_root}" in
|
||||
-*) echo "scan root must be a directory path, not an option" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -d "${scan_root}" ] || { echo "scan root does not exist: ${scan_root}" >&2; exit 2; }
|
||||
|
||||
cleanup() {
|
||||
rm -rf -- "${work_dir}"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
mkdir -p "${work_dir}"
|
||||
curl --fail --silent --show-error --location \
|
||||
--output "${work_dir}/${archive}" \
|
||||
"https://github.com/aquasecurity/trivy/releases/download/v${version}/${archive}"
|
||||
printf '%s %s\n' "${expected_sha256}" "${work_dir}/${archive}" | sha256sum --check --status
|
||||
tar -xzf "${work_dir}/${archive}" -C "${work_dir}" trivy
|
||||
|
||||
"${work_dir}/trivy" fs \
|
||||
--scanners vuln \
|
||||
--include-dev-deps \
|
||||
--format table \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
--no-progress \
|
||||
"${scan_root}"
|
||||
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
|
||||
$cachedGo = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
|
||||
if (-not (Test-Path (Join-Path $cachedGo 'go.exe'))) { throw 'Go 1.26.6 is not available on PATH or in the recorded local toolchain cache.' }
|
||||
$env:PATH = $cachedGo + ';' + $env:PATH
|
||||
}
|
||||
|
||||
python -m unittest discover -s tools/tests -p 'test_*.py' -v
|
||||
go test ./...
|
||||
pnpm test
|
||||
python tools/validate_contracts.py
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const root = process.cwd();
|
||||
const required = ["README.md", "SECURITY.md", "CONTRIBUTING.md", "LICENSE", "go.mod", "package.json", "deploy/compose.yaml", ".gitea/workflows/public-validation.yml", "PUBLIC_SOURCE_EXPORT.md", "PUBLIC_SOURCE_MANIFEST.json"];
|
||||
const missing = required.filter((entry) => !fs.existsSync(path.join(root, entry)));
|
||||
if (missing.length) {
|
||||
console.error(`Missing public source files: ${missing.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const privatePath of [".agents", ".codex", "artifacts", "design", "planning", "prompts"]) {
|
||||
if (fs.existsSync(path.join(root, privatePath))) {
|
||||
console.error(`Private-only path present in public export: ${privatePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "PUBLIC_SOURCE_MANIFEST.json"), "utf8"));
|
||||
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||
console.error("PUBLIC_SOURCE_MANIFEST.json contains no files.");
|
||||
process.exit(1);
|
||||
}
|
||||
for (const entry of manifest.files) {
|
||||
const target = path.join(root, ...entry.path.split("/"));
|
||||
if (!fs.existsSync(target)) {
|
||||
console.error(`Manifest file is missing: ${entry.path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const bytes = fs.readFileSync(target);
|
||||
const digest = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
if (digest !== entry.sha256 || bytes.length !== entry.bytes) {
|
||||
console.error(`Manifest mismatch: ${entry.path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log(`Public source structure and manifest passed (${manifest.files.length} allowlisted files).`);
|
||||
@@ -0,0 +1,71 @@
|
||||
param(
|
||||
[ValidateRange(0.01, 168)] [double]$DurationHours = 24,
|
||||
[ValidateRange(5, 3600)] [int]$SampleSeconds = 60,
|
||||
[ValidateRange(30, 86400)] [int]$ReconnectSeconds = 3600,
|
||||
[string]$OutputDirectory = 'artifacts/evidence/M10-14/raw'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-Location (Split-Path -Parent $PSScriptRoot)
|
||||
|
||||
function Get-FreeLoopbackPort {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw 'Docker is required for the isolated wallboard soak.' }
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { throw 'Node.js is required for the wallboard soak runner.' }
|
||||
$goBin = Join-Path $env:LOCALAPPDATA 'ITWorx-Pulse\toolchains\go1.26.6\bin'
|
||||
if (-not (Get-Command go -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $goBin 'go.exe'))) { $env:PATH = $goBin + ';' + $env:PATH }
|
||||
|
||||
$project = 'itworx-pulse-soak-' + $PID
|
||||
$webPort = Get-FreeLoopbackPort
|
||||
$apiPort = Get-FreeLoopbackPort
|
||||
$dbPort = Get-FreeLoopbackPort
|
||||
$baseURL = "http://127.0.0.1:$webPort"
|
||||
$compose = @('-p', $project, '-f', 'deploy/compose.yaml', '-f', 'deploy/compose.dev.yaml', '-f', 'deploy/compose.smoke.yaml')
|
||||
$resolvedOutput = [System.IO.Path]::GetFullPath((Join-Path (Get-Location) $OutputDirectory))
|
||||
if (Test-Path -LiteralPath $resolvedOutput) {
|
||||
$existingArtifact = Get-ChildItem -LiteralPath $resolvedOutput -Force | Select-Object -First 1
|
||||
if ($existingArtifact) { throw "Refusing to mix soak evidence in non-empty output directory: $resolvedOutput" }
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $resolvedOutput | Out-Null
|
||||
$sourceCommit = (git rev-parse HEAD).Trim()
|
||||
$sourceDirty = [bool](git status --porcelain)
|
||||
|
||||
$env:PULSE_POSTGRES_PASSWORD = 'soak-process-only'
|
||||
$env:PULSE_DATABASE_URL = 'postgres://pulse:soak-process-only@pulse-postgres:5432/pulse?sslmode=disable'
|
||||
$env:PULSE_ENV = 'development'
|
||||
$env:PULSE_AUTH_MODE = 'mock'
|
||||
$env:PULSE_DEV_WEB_PORT = [string]$webPort
|
||||
$env:PULSE_DEV_API_PORT = [string]$apiPort
|
||||
$env:PULSE_DEV_DB_PORT = [string]$dbPort
|
||||
$env:PULSE_SMOKE_WEBHOOK_TOKEN = 'soak-runtime-only'
|
||||
$env:PULSE_SOAK_BASE_URL = $baseURL
|
||||
$env:PULSE_SOAK_OUTPUT_DIR = $resolvedOutput
|
||||
$env:PULSE_SOAK_DURATION_HOURS = [string]$DurationHours
|
||||
$env:PULSE_SOAK_SAMPLE_SECONDS = [string]$SampleSeconds
|
||||
$env:PULSE_SOAK_RECONNECT_SECONDS = [string]$ReconnectSeconds
|
||||
|
||||
try {
|
||||
Write-Host "== isolated 24-hour wallboard deployment ($project) ==" -ForegroundColor Cyan
|
||||
docker compose @compose config --quiet
|
||||
docker compose @compose build --quiet
|
||||
docker compose @compose up -d --wait --wait-timeout 240
|
||||
docker compose @compose images --format json | Set-Content -Path (Join-Path $resolvedOutput 'launch-images.jsonl') -Encoding utf8
|
||||
@{ project = $project; baseURL = $baseURL; startedAt = (Get-Date).ToUniversalTime().ToString('o'); durationHours = $DurationHours; sampleSeconds = $SampleSeconds; reconnectSeconds = $ReconnectSeconds; sourceCommit = $sourceCommit; sourceDirty = $sourceDirty } | ConvertTo-Json | Set-Content -Path (Join-Path $resolvedOutput 'stack.json') -Encoding utf8
|
||||
node tools/wallboard-soak.mjs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'wallboard soak runner failed' }
|
||||
docker compose @compose ps --format json | Set-Content -Path (Join-Path $resolvedOutput 'final-compose-ps.jsonl') -Encoding utf8
|
||||
docker compose @compose logs --no-color --timestamps | Set-Content -Path (Join-Path $resolvedOutput 'final-compose.log') -Encoding utf8
|
||||
Write-Host 'WALLBOARD SOAK: PASS' -ForegroundColor Green
|
||||
} finally {
|
||||
if (-not (Test-Path (Join-Path $resolvedOutput 'final-compose.log'))) {
|
||||
docker compose @compose ps --format json 2>$null | Set-Content -Path (Join-Path $resolvedOutput 'failure-compose-ps.jsonl') -Encoding utf8
|
||||
docker compose @compose logs --no-color --timestamps 2>$null | Set-Content -Path (Join-Path $resolvedOutput 'failure-compose.log') -Encoding utf8
|
||||
}
|
||||
docker compose @compose down --volumes --remove-orphans 2>$null
|
||||
Remove-Item Env:PULSE_POSTGRES_PASSWORD,Env:PULSE_DATABASE_URL,Env:PULSE_ENV,Env:PULSE_AUTH_MODE,Env:PULSE_DEV_WEB_PORT,Env:PULSE_DEV_API_PORT,Env:PULSE_DEV_DB_PORT,Env:PULSE_SMOKE_WEBHOOK_TOKEN,Env:PULSE_SOAK_BASE_URL,Env:PULSE_SOAK_OUTPUT_DIR,Env:PULSE_SOAK_DURATION_HOURS,Env:PULSE_SOAK_SAMPLE_SECONDS,Env:PULSE_SOAK_RECONNECT_SECONDS -ErrorAction SilentlyContinue
|
||||
}
|
||||
Reference in New Issue
Block a user