From 7ff45fb4ecef5dc0beb3f2efa2388641d2463f12 Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Mon, 27 Jul 2026 16:01:18 +0900 Subject: [PATCH] chore: add ACS deployment check --- package.json | 1 + scripts/deployment-check.mjs | 186 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 scripts/deployment-check.mjs diff --git a/package.json b/package.json index f631410..d069beb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run test:node", "test:node": "npm run build:server && node --test --test-isolation=none dist/server/**/*.test.js", + "deploy:check": "node scripts/deployment-check.mjs", "db:check": "node scripts/check-db.mjs", "minio:check": "node scripts/check-minio.mjs", "dev": "tsx watch server/index.ts", diff --git a/scripts/deployment-check.mjs b/scripts/deployment-check.mjs new file mode 100644 index 0000000..255f270 --- /dev/null +++ b/scripts/deployment-check.mjs @@ -0,0 +1,186 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const DEFAULT_BASE = 'https://acs.apps.bokdev.in'; +const projectRoot = process.cwd(); + +function argValue(name, fallback) { + const prefix = `--${name}=`; + const inline = process.argv.find((arg) => arg.startsWith(prefix)); + if (inline) return inline.slice(prefix.length); + const index = process.argv.indexOf(`--${name}`); + if (index >= 0 && process.argv[index + 1]) return process.argv[index + 1]; + return fallback; +} + +function ok(message) { + console.log(`[ok] ${message}`); +} + +function fail(message) { + console.error(`[fail] ${message}`); + process.exitCode = 1; +} + +function warn(message) { + console.warn(`[warn] ${message}`); +} + +function readLocal(relativePath) { + return readFileSync(path.join(projectRoot, relativePath), 'utf8'); +} + +function git(args) { + return execFileSync('git', args, { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +async function checkJson(base, route) { + const url = new URL(route, base).toString(); + try { + const response = await fetch(url); + const body = await response.text(); + if (!response.ok) { + fail(`${route} returned HTTP ${response.status}: ${body.slice(0, 160)}`); + return; + } + const parsed = JSON.parse(body); + if (parsed?.ok !== true) { + fail(`${route} response did not include ok=true: ${body.slice(0, 160)}`); + return; + } + ok(`${route} ${response.status}`); + } catch (error) { + fail(`${route} check failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function cookieHeaderFrom(response) { + if (typeof response.headers.getSetCookie === 'function') { + return response.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).join('; '); + } + const cookie = response.headers.get('set-cookie'); + return cookie ? cookie.split(',').map((part) => part.split(';')[0]).join('; ') : ''; +} + +async function login(base, username, password) { + if (!username || !password) return ''; + + const url = new URL('/api/auth/login', base).toString(); + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const body = await response.text(); + if (!response.ok) { + fail(`/api/auth/login returned HTTP ${response.status}: ${body.slice(0, 160)}`); + return ''; + } + ok('/api/auth/login 200'); + return cookieHeaderFrom(response); + } catch (error) { + fail(`/api/auth/login check failed: ${error instanceof Error ? error.message : String(error)}`); + return ''; + } +} + +async function checkTemplate(base, cookie) { + const route = '/api/visit-requests/template'; + const url = new URL(route, base).toString(); + try { + const response = await fetch(url, cookie ? { headers: { cookie } } : undefined); + const contentType = response.headers.get('content-type') ?? ''; + const disposition = response.headers.get('content-disposition') ?? ''; + + if (!response.ok) { + const body = await response.text(); + fail(`${route} returned HTTP ${response.status}: ${body.slice(0, 200)}`); + return; + } + if (!contentType.includes('spreadsheet') && !disposition.includes('.xlsx')) { + fail(`${route} did not look like an xlsx download: content-type=${contentType || '-'}, content-disposition=${disposition || '-'}`); + return; + } + ok(`${route} xlsx download`); + } catch (error) { + fail(`${route} check failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function checkLocalTemplate() { + const templatePath = 'docs/form_sample.xlsx'; + if (!existsSync(path.join(projectRoot, templatePath))) { + fail(`${templatePath} is missing locally`); + return; + } + ok(`${templatePath} exists locally`); + + try { + git(['ls-files', '--error-unmatch', templatePath]); + ok(`${templatePath} is tracked by git`); + } catch { + fail(`${templatePath} is not tracked by git`); + } +} + +function checkDockerPackaging() { + const dockerignore = readLocal('.dockerignore'); + const dockerfile = readLocal('Dockerfile'); + + if (!dockerignore.includes('!docs/form_sample.xlsx')) { + fail('.dockerignore does not allow docs/form_sample.xlsx into the build context'); + } else { + ok('.dockerignore allows docs/form_sample.xlsx'); + } + + if (!dockerfile.includes('COPY docs/form_sample.xlsx ./docs/form_sample.xlsx')) { + fail('Dockerfile does not copy docs/form_sample.xlsx into the runtime image'); + } else { + ok('Dockerfile copies docs/form_sample.xlsx into the runtime image'); + } +} + +function checkGitState() { + try { + const head = git(['rev-parse', '--short', 'HEAD']); + ok(`local HEAD ${head}`); + + const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']); + const aheadBehind = git(['rev-list', '--left-right', '--count', `${upstream}...HEAD`]).split(/\s+/); + const behind = Number(aheadBehind[0] ?? 0); + const ahead = Number(aheadBehind[1] ?? 0); + if (ahead || behind) { + warn(`branch differs from ${upstream}: ahead ${ahead}, behind ${behind}`); + } else { + ok(`branch matches ${upstream}`); + } + } catch (error) { + warn(`git state check skipped: ${error instanceof Error ? error.message : String(error)}`); + } +} + +const base = argValue('base', DEFAULT_BASE).replace(/\/+$/, '/'); +const username = argValue('username', process.env.ACS_CHECK_USER ?? ''); +const password = argValue('password', process.env.ACS_CHECK_PASSWORD ?? ''); +console.log(`[acs-deploy-check] base=${base}`); + +checkLocalTemplate(); +checkDockerPackaging(); +checkGitState(); +await checkJson(base, '/healthz'); +await checkJson(base, '/db'); +const cookie = await login(base, username, password); +await checkTemplate(base, cookie); + +if (process.exitCode) { + console.error('[acs-deploy-check] failed'); + process.exit(process.exitCode); +} + +console.log('[acs-deploy-check] passed');