diff --git a/Dockerfile b/Dockerfile index 143ef7e..69bfb7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +ARG SOURCE_COMMIT=unknown + # Stage 1: Vite SPA build FROM node:22-slim AS build WORKDIR /app @@ -11,6 +13,8 @@ RUN npm run build # indirme için gerekli. Sunucu darlığında DT_ENABLED=0 ile kapatılabilir; # uygulama ve diğer tüm veri kaynakları Chromium olmadan da çalışır. FROM node:22-slim +ARG SOURCE_COMMIT +ENV APP_RELEASE=${SOURCE_COMMIT} ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends nginx ca-certificates curl \ diff --git a/docs/quality/deployment-runbook.md b/docs/quality/deployment-runbook.md index 510ee08..4e3e266 100644 --- a/docs/quality/deployment-runbook.md +++ b/docs/quality/deployment-runbook.md @@ -9,6 +9,7 @@ Bu çalışma notu üretim dağıtımında kullanılacak kontrol sırasını tan - `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri. - Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri. - `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler. +- Coolify Advanced ayarında **Include Source Commit in Build** açık olmalıdır. Docker imajı `SOURCE_COMMIT` değerini `/api/version` yanıtındaki `release` alanına taşır; bu değer gizli değildir. ## Compose ile staging smoke testi @@ -34,6 +35,16 @@ Coolify uygulaması `ihaleist:main` için sağlık kontrolü `GET http://localho 3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır. 4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir. +Hedef commit ve yerel frontend asset hash’i verilerek salt okunur canlı kabul çalıştırılır: + +```sh +IHALEIST_EXPECTED_RELEASE="$(git rev-parse HEAD)" \ +IHALEIST_EXPECTED_ASSET_SHA256="$(sha256sum dist/assets/index-*.js | awk '{print $1}')" \ +npm run qa:live -- https://ihaleist.com +``` + +macOS üzerinde `sha256sum` yerine `shasum -a 256` kullanılabilir. Probe `/api/version` release değerini, ana sayfadaki gerçek asset’i ve container içinden bildirilen asset hash’ini birlikte karşılaştırır. + ## Kademeli yayın Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz. diff --git a/server/index.js b/server/index.js index eaa506d..1d0d4eb 100644 --- a/server/index.js +++ b/server/index.js @@ -65,6 +65,7 @@ import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolv import { bildirimleriIsle } from './notifier.mjs'; import { scanSite } from './site-scan.mjs'; import { createDetailCache } from './detail-cache.mjs'; +import { releaseInfo } from './release-info.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -75,6 +76,7 @@ const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA]; const PORT = process.env.PORT || 3001; const ILAN_BASE = 'https://www.ilan.gov.tr'; const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean); +const RUNTIME_RELEASE = releaseInfo(); function readiness() { const directories = { @@ -1732,6 +1734,7 @@ const server = http.createServer(async (req, res) => { } if (url.pathname === '/api/health') return json(res, 200, { ok: true }); + if (url.pathname === '/api/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE }); if (url.pathname === '/api/ready') { const status = readiness(); return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status }); diff --git a/server/release-info.mjs b/server/release-info.mjs new file mode 100644 index 0000000..d480dfe --- /dev/null +++ b/server/release-info.mjs @@ -0,0 +1,50 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_ASSET_ROOTS = [ + '/usr/share/nginx/html', + path.resolve(process.cwd(), 'dist'), +]; + +function cleanRelease(value) { + const release = String(value || '').trim(); + if (!release || release === 'unknown' || release.length > 128) return null; + return release; +} + +export function deployedRelease(env = process.env) { + return cleanRelease(env.APP_RELEASE) || cleanRelease(env.SOURCE_COMMIT); +} + +export function frontendAssetInfo(roots = DEFAULT_ASSET_ROOTS) { + for (const root of roots) { + try { + const indexPath = path.join(root, 'index.html'); + const html = fs.readFileSync(indexPath, 'utf8'); + const match = html.match(/]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i); + if (!match) continue; + const publicPath = new URL(match[1], 'https://ihaleist.invalid/').pathname; + const assetPath = path.resolve(root, `.${publicPath}`); + const rootPath = path.resolve(root) + path.sep; + if (!assetPath.startsWith(rootPath)) continue; + const bytes = fs.readFileSync(assetPath); + return { + path: publicPath, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + bytes: bytes.length, + }; + } catch { + // Bu kökte derlenmiş arayüz yoksa sıradaki olası köke bakılır. + } + } + return null; +} + +export function releaseInfo({ env = process.env, roots = DEFAULT_ASSET_ROOTS } = {}) { + return { + release: deployedRelease(env), + version: '1.0.0', + frontendAsset: frontendAssetInfo(roots), + }; +} diff --git a/tests/http-state.test.mjs b/tests/http-state.test.mjs index 845a103..b4ed617 100644 --- a/tests/http-state.test.mjs +++ b/tests/http-state.test.mjs @@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{ before(async()=>{ const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r)); base='http://127.0.0.1:'+port; - child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'}); + child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',APP_RELEASE:'0123456789abcdef',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'}); for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}} throw new Error('QA API did not start'); }); @@ -55,6 +55,18 @@ test('readiness endpoint checks persistent data directories',async()=>{ assert.equal(response.data.checks.userData.ok,true); assert.equal(response.data.checks.sharedData.ok,true); }); +test('version endpoint identifies the running release and frontend asset',async()=>{ + const response=await request('/api/version','GET',null,null); + assert.equal(response.status,200); + assert.equal(response.data.ok,true); + assert.equal(response.data.release,'0123456789abcdef'); + assert.equal(response.data.version,'1.0.0'); + if(response.data.frontendAsset){ + assert.match(response.data.frontendAsset.path,/^\/assets\/.+\.js$/); + assert.match(response.data.frontendAsset.sha256,/^[a-f0-9]{64}$/); + assert.ok(response.data.frontendAsset.bytes>0); + } +}); test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{ const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]); const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]); diff --git a/tests/live-runtime-probe.mjs b/tests/live-runtime-probe.mjs index ea77d96..88ee2a0 100644 --- a/tests/live-runtime-probe.mjs +++ b/tests/live-runtime-probe.mjs @@ -2,6 +2,7 @@ // provider calls are performed. It intentionally fails when a required // deployment contract is missing. import { mkdir, writeFile } from 'node:fs/promises'; +import crypto from 'node:crypto'; const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, ''); const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json'; @@ -13,8 +14,10 @@ const requiredHeaders = [ ]; const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])]; const checks = {}; +const expectedRelease = String(process.env.IHALEIST_EXPECTED_RELEASE || '').trim(); +const expectedAssetSha256 = String(process.env.IHALEIST_EXPECTED_ASSET_SHA256 || '').trim(); -async function probe(path, successField = null) { +async function probe(path, successField = null, captureText = false) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { @@ -24,10 +27,13 @@ async function probe(path, successField = null) { contentType: response.headers.get('content-type'), headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])), }; - if (successField) { + if (successField || captureText) { const raw = await response.text(); - try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; } - result.apiSuccess = result.json?.[successField] === true; + if (captureText) result.text = raw; + if (successField) { + try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; } + result.apiSuccess = result.json?.[successField] === true; + } } return result; } catch (error) { @@ -37,22 +43,63 @@ async function probe(path, successField = null) { } } -checks.home = await probe('/'); +async function probeFrontendAsset(publicPath) { + if (!publicPath) return { error: 'Ana sayfada Vite JavaScript asset yolu bulunamadı.' }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(new URL(publicPath, base), { signal: controller.signal, redirect: 'manual' }); + const bytes = Buffer.from(await response.arrayBuffer()); + return { + status: response.status, + contentType: response.headers.get('content-type'), + headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])), + path: new URL(publicPath, base).pathname, + bytes: bytes.length, + sha256: crypto.createHash('sha256').update(bytes).digest('hex'), + }; + } catch (error) { + return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message }; + } finally { + clearTimeout(timer); + } +} + +checks.home = await probe('/', null, true); +const assetMatch = checks.home.text?.match(/]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i); +delete checks.home.text; checks.health = await probe('/api/health', 'ok'); // /api/health and /api/ready use the same { ok: true } probe contract. The // readiness payload also includes a separate `ready` diagnostic field. checks.ready = await probe('/api/ready', 'ok'); +checks.version = await probe('/api/version', 'ok'); // The EKAP document fallback depends on the bundled browser being available; // check its public capability endpoint without opening a document or mutating // any account data. checks.documents = await probe('/api/documents/config', 'tarayiciVar'); -const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready, checks.documents].some(check => check?.headers?.[name] == null)); +checks.frontendAsset = await probeFrontendAsset(assetMatch?.[1]); +const release = checks.version?.json?.release; +const releaseValid = typeof release === 'string' && /^[a-f0-9]{7,64}$/i.test(release); +const releaseMatches = !expectedRelease || release === expectedRelease; +const reportedAsset = checks.version?.json?.frontendAsset; +const assetMatchesRuntime = Boolean(reportedAsset?.sha256) + && reportedAsset.sha256 === checks.frontendAsset?.sha256 + && reportedAsset.path === checks.frontendAsset?.path; +const assetMatchesExpected = !expectedAssetSha256 || checks.frontendAsset?.sha256 === expectedAssetSha256; +const publicChecks = [checks.home, checks.health, checks.ready, checks.version, checks.documents, checks.frontendAsset]; +const missingHeaders = requiredHeaders.filter(name => publicChecks.some(check => check?.headers?.[name] == null)); const report = { base, readOnly: true, checkedAt: new Date().toISOString(), + expectedRelease: expectedRelease || null, + expectedAssetSha256: expectedAssetSha256 || null, checks, - pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && missingHeaders.length === 0, + releaseValid, + releaseMatches, + assetMatchesRuntime, + assetMatchesExpected, + pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.version?.status === 200 && checks.version.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && checks.frontendAsset?.status === 200 && releaseValid && releaseMatches && assetMatchesRuntime && assetMatchesExpected && missingHeaders.length === 0, missingHeaders, }; diff --git a/tests/release-info.test.mjs b/tests/release-info.test.mjs new file mode 100644 index 0000000..0a49be5 --- /dev/null +++ b/tests/release-info.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deployedRelease, frontendAssetInfo } from '../server/release-info.mjs'; + +test('release metadata rejects missing build markers', () => { + assert.equal(deployedRelease({}), null); + assert.equal(deployedRelease({ APP_RELEASE: 'unknown' }), null); + assert.equal(deployedRelease({ SOURCE_COMMIT: 'abc1234' }), 'abc1234'); + assert.equal(deployedRelease({ APP_RELEASE: 'release-a', SOURCE_COMMIT: 'release-b' }), 'release-a'); +}); + +test('frontend asset metadata hashes the script referenced by index.html', () => { + const root = mkdtempSync(join(tmpdir(), 'ihaleist-release-')); + try { + mkdirSync(join(root, 'assets')); + writeFileSync(join(root, 'index.html'), ''); + writeFileSync(join(root, 'assets/index-test.js'), 'console.log("release");\n'); + assert.deepEqual(frontendAssetInfo([root]), { + path: '/assets/index-test.js', + sha256: 'ed81365d3ce25c2e499a43c7d91c72cbc05c6475a19423f18fc3917c96857e80', + bytes: 24, + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});