NAS 정식 스택과 배포 파이프라인 도구를 고정

This commit is contained in:
Yun Chan 2026-09-01 11:45:23 +09:00
parent f1b80676c1
commit d6d9dc5f61
8 changed files with 812 additions and 1 deletions

39
scripts/dist-snap.py Normal file
View file

@ -0,0 +1,39 @@
"""dist 결정성 스냅샷 — sha256 파일 지도를 만들고 두 빌드를 비교한다.
사용: python -X utf8 dist-snap.py snap1 | python -X utf8 dist-snap.py compare dist-snap1.json
"""
import hashlib
import json
import os
import sys
def snap(root: str) -> dict:
out = {}
for dp, _, fns in os.walk(root):
for fn in fns:
p = os.path.join(dp, fn)
rel = os.path.relpath(p, root).replace(os.sep, "/")
with open(p, "rb") as fh:
out[rel] = hashlib.sha256(fh.read()).hexdigest()
return out
if sys.argv[1] == "snap":
s = snap("dist")
name = sys.argv[2]
with open(name, "w", encoding="utf-8") as fh:
json.dump(s, fh)
print("files:", len(s))
elif sys.argv[1] == "compare":
with open(sys.argv[2], encoding="utf-8") as fh:
s1 = json.load(fh)
s2 = snap("dist")
only1 = sorted(set(s1) - set(s2))
only2 = sorted(set(s2) - set(s1))
diff = sorted(k for k in set(s1) & set(s2) if s1[k] != s2[k])
print("build1 files:", len(s1), "build2 files:", len(s2))
print("only_in_build1:", only1[:10])
print("only_in_build2:", only2[:10])
print("content_diff:", diff[:10])
print("DETERMINISTIC" if not (only1 or only2 or diff) else "NON_DETERMINISTIC")

View file

@ -0,0 +1,90 @@
/**
* 이전 Pages 배포 세대의 자산 보존 immutable preview URL() HTML/JS/CSS 의존
* 그래프를 따라 내려받아, 빌드 dist 없는 파일만 추가한다(기존 파일은 덮어쓰지 않는다).
* 사용: node preserve-assets.mjs
*/
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";
const ORIGINS = [
"https://vignette.chanpaca.net",
"https://5a525329.vignette-b1q.pages.dev",
"https://e1c73735.vignette-b1q.pages.dev",
"https://bbebb2d3.vignette-b1q.pages.dev",
"https://44edecc8.vignette-b1q.pages.dev",
"https://0ffdc694.vignette-b1q.pages.dev",
"https://7bd59055.vignette-b1q.pages.dev",
];
const DIST = "dist";
async function exists(p) {
try {
await readFile(p);
return true;
} catch {
return false;
}
}
async function collect(origin) {
const seen = new Map(); // path -> sha256 hex
const html = await (await fetch(origin + "/")).text();
// index.html 이 참조하는 엔트리 자산
const refs = new Set();
for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css))"/g)) refs.add(m[1]);
for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:png|svg|ico|webmanifest|json|woff2?))"/g))
refs.add(m[1]);
// JS 청크 의존 그래프 폐쇄
const queue = [...refs];
while (queue.length) {
const ref = queue.pop();
if (seen.has(ref)) continue;
const res = await fetch(origin + ref);
if (!res.ok) {
seen.set(ref, "FETCH_FAILED_" + res.status);
continue;
}
const buf = Buffer.from(await res.arrayBuffer());
seen.set(ref, createHash("sha256").update(buf).digest("hex"));
if (ref.endsWith(".js")) {
const text = buf.toString("utf8");
for (const m of text.matchAll(/["'`](\.\/[A-Za-z0-9_.-]+\.(?:js|css))["'`]/g)) {
const p = path.posix.normalize(path.posix.join(path.posix.dirname(ref), m[1]));
if (!seen.has(p)) queue.push(p);
}
}
}
return seen;
}
const perOrigin = [];
let added = 0;
const addedList = [];
for (const origin of ORIGINS) {
const map = await collect(origin);
let ok = 0;
for (const [p, sha] of map) {
if (sha.startsWith("FETCH_FAILED")) continue;
const rel = p.replace(/^\//, "");
const target = path.join(DIST, rel);
if (await exists(target)) {
ok += 1;
continue;
}
await mkdir(path.dirname(target), { recursive: true });
const res = await fetch(origin + p);
await writeFile(target, Buffer.from(await res.arrayBuffer()));
added += 1;
addedList.push(rel);
}
perOrigin.push({ origin, assets: map.size, reused_in_new_build: ok });
console.log(`origin ${origin}: ${map.size} assets, ${ok} already in new build`);
}
console.log(`assets_added: ${added}`);
await writeFile(
"preserve-report.json",
JSON.stringify({ origins: perOrigin, assets_added: added, added: addedList }, null, 1),
"utf8",
);

View file

@ -124,8 +124,17 @@ RELEASE_DB_MIGRATIONS = (
"17_improvement_workbook_contracts.sql",
"18_admin_usage_ledger_index.sql",
"19_auth_identity_alias.sql",
"20_public_bootstrap_ticket_events.sql",
"21_single_active_session.sql",
"22_case_profile_multi_case.sql",
)
RELEASE_DB_ONLINE_MIGRATIONS = frozenset(
{
"18_admin_usage_ledger_index.sql",
"21_single_active_session.sql",
"22_case_profile_multi_case.sql",
}
)
RELEASE_DB_ONLINE_MIGRATIONS = frozenset({"18_admin_usage_ledger_index.sql"})
# These files are runtime-critical but were added after the first manifest
# snapshot. A release must classify them as whole-file payloads; otherwise a
@ -138,6 +147,9 @@ REQUIRED_RELEASE_PAYLOAD_PATHS = (
"infra/db/init/17_improvement_workbook_contracts.sql",
"infra/db/init/18_admin_usage_ledger_index.sql",
"infra/db/init/19_auth_identity_alias.sql",
"infra/db/init/20_public_bootstrap_ticket_events.sql",
"infra/db/init/21_single_active_session.sql",
"infra/db/init/22_case_profile_multi_case.sql",
)
REQUIRED_OPENAPI_PATHS = {

View file

@ -419,6 +419,122 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
)
def test_single_active_session_index_is_online_and_release_required(self) -> None:
migration_path = (
SCRIPT_PATH.parent.parent
/ "infra"
/ "db"
/ "init"
/ "21_single_active_session.sql"
)
migration = migration_path.read_text(encoding="utf-8")
self.assertNotIn("BEGIN;", migration)
self.assertIn(
"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_sessions_one_active_learner_persona",
migration,
)
self.assertIn("ON app.sessions (learner_id, persona_id)", migration)
self.assertIn("WHERE ended_at IS NULL AND persona_id IS NOT NULL;", migration)
self.assertIn(
"duplicate active learner-persona sessions exist",
migration,
)
self.assertIn(
"target index exists but is invalid or has a different definition",
migration,
)
self.assertIn("index_meta.indisvalid", migration)
self.assertIn("index_meta.indisready", migration)
self.assertIn("valid target unique index was not created", migration)
self.assertNotIn("COMMIT;", migration)
self.assertIn(
"21_single_active_session.sql",
self.agent_module.RELEASE_DB_MIGRATIONS,
)
self.assertIn(
"21_single_active_session.sql",
self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS,
)
self.assertIn(
"infra/db/init/21_single_active_session.sql",
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
)
self.assertIn(
"20_public_bootstrap_ticket_events.sql",
self.agent_module.RELEASE_DB_MIGRATIONS,
)
self.assertNotIn(
"20_public_bootstrap_ticket_events.sql",
self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS,
)
self.assertIn(
"infra/db/init/20_public_bootstrap_ticket_events.sql",
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
)
def test_case_profile_multi_case_migration_is_online_safe_and_release_required(
self,
) -> None:
migration_path = (
SCRIPT_PATH.parent.parent
/ "infra"
/ "db"
/ "init"
/ "22_case_profile_multi_case.sql"
)
migration = migration_path.read_text(encoding="utf-8")
self.assertNotIn("BEGIN;", migration)
self.assertNotIn("COMMIT;", migration)
self.assertIn("legacy_constraints text[];", migration)
self.assertIn("constraint_meta.contype = 'u'", migration)
self.assertIn(
"ARRAY['persona_id', 'learner_id']::text[]",
migration,
)
self.assertIn(
"multiple legacy case_profile persona-learner unique constraints exist",
migration,
)
self.assertIn(
"ALTER TABLE app.case_profile DROP CONSTRAINT %I",
migration,
)
self.assertIn(
"legacy case_profile persona-learner unique constraint remains",
migration,
)
self.assertIn(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_case_profile_learner_persona_activity",
migration,
)
self.assertIn(
"ON app.case_profile (learner_id, persona_id, updated_at DESC, case_id);",
migration,
)
self.assertIn("index_meta.indisvalid", migration)
self.assertIn("index_meta.indisready", migration)
self.assertIn("NOT index_meta.indisunique", migration)
self.assertIn("index_meta.indnkeyatts = 4", migration)
self.assertIn(
"target index exists but is invalid or has a different definition",
migration,
)
self.assertIn("valid case activity index was not created", migration)
self.assertIn(
"22_case_profile_multi_case.sql",
self.agent_module.RELEASE_DB_MIGRATIONS,
)
self.assertIn(
"22_case_profile_multi_case.sql",
self.agent_module.RELEASE_DB_ONLINE_MIGRATIONS,
)
self.assertIn(
"infra/db/init/22_case_profile_multi_case.sql",
self.agent_module.REQUIRED_RELEASE_PAYLOAD_PATHS,
)
def test_two_patch_runs_must_be_byte_identical_before_any_deploy(self) -> None:
agent, runner, deployment, _, _ = self.make_agent(
active_sha=None,
@ -773,6 +889,9 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
"17_improvement_workbook_contracts.sql",
"18_admin_usage_ledger_index.sql",
"19_auth_identity_alias.sql",
"20_public_bootstrap_ticket_events.sql",
"21_single_active_session.sql",
"22_case_profile_multi_case.sql",
),
self.agent_module.RELEASE_DB_MIGRATIONS,
)
@ -968,8 +1087,26 @@ class OutcomeReleaseAgentTests(unittest.TestCase):
if "18_admin_usage_ledger_index.sql" in line
and "psql -v ON_ERROR_STOP=1" in line
)
active_session_online_migration_command = next(
line
for line in promote_command.splitlines()
if "21_single_active_session.sql" in line
and "psql -v ON_ERROR_STOP=1" in line
)
case_profile_online_migration_command = next(
line
for line in promote_command.splitlines()
if "22_case_profile_multi_case.sql" in line
and "psql -v ON_ERROR_STOP=1" in line
)
self.assertIn("--single-transaction", transactional_migration_command)
self.assertNotIn("--single-transaction", online_migration_command)
self.assertNotIn(
"--single-transaction", active_session_online_migration_command
)
self.assertNotIn(
"--single-transaction", case_profile_online_migration_command
)
self.assertEqual(f"{root}/release/infra/.env", state["env_file"])
def test_adopted_legacy_state_infers_compose_adjacent_env_file(self) -> None:

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Vignette 배포 파이프라인 관리 도구.
소스 SSOT = Forgejo git.chanpaca.net (master)
배포 대상 = NAS Production /volume1/docker/vignette-prod (docker-compose.nas.yml)
PC = 개발 전용
역할:
- Forgejo master == 로컬 master == 배포 기준선 무결성 확인
- github(origin, private 백업 미러) 존재 확인
- NAS production 컨테이너/이미지 compose ref 조회 (read-only)
- 배포 검증 게이트(SSOT checker) 존재 확인
실제 NAS 이미지 빌드·주입·compose up은 소유자 승인 별도 실행.
도구는 배포 파이프라인의 관리/점검(read-only) 담당한다.
사용법:
python -X utf8 scripts/vignette-pipeline.py --check
python -X utf8 scripts/vignette-pipeline.py --config
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SSOT_DASHBOARD = REPO_ROOT / "docs" / "dev_dashboard.html"
FORGEJO_SSOT_BRANCH = "master"
FORGEJO_REMOTE = "forgejo"
GITHUB_REMOTE = "origin"
NAS_COMPOSE_HOST = "yunchan-nas" # ssh config Host (192.168.0.38, user yunchan)
NAS_COMPOSE_PATH = "/volume1/docker/vignette-prod/docker-compose.nas.yml"
NAS_COMPOSE_FILE = "docker-compose.nas.yml"
NAS_PROJECT = "vignette-prod"
def _run(cmd: list[str], cwd: Path | None = None, ok_codes=(0,)) -> str:
r = subprocess.run(cmd, cwd=cwd or REPO_ROOT, capture_output=True, text=True)
if r.returncode not in ok_codes:
raise RuntimeError(f"cmd failed ({r.returncode}): {' '.join(cmd)}\n{r.stderr[-800:]}")
return r.stdout
def _git(*args: str) -> str:
return _run(["git", *args]).strip()
def check(forgejo_available: bool = True) -> int:
problems = 0
def ok(msg: str) -> None:
print(f" [PASS] {msg}")
def warn(msg: str) -> None:
nonlocal problems
problems += 1
print(f" [FAIL] {msg}")
print("== 배포 파이프라인 점검 ==")
# 1) Forgejo SSOT == 로컬 master
local_master = _git("rev-parse", FORGEJO_SSOT_BRANCH)
ok(f"로컬 {FORGEJO_SSOT_BRANCH} = {local_master[:12]}")
if forgejo_available:
remote_master = _git("ls-remote", f"{FORGEJO_REMOTE}", "refs/heads/master").split("\t")[0]
if remote_master and remote_master == local_master:
ok(f"Forgejo master = {remote_master[:12]} == 로컬 (배포 기준선 일치)")
else:
warn(f"Forgejo master {remote_master[:12] if remote_master else 'MISSING'} != 로컬 {local_master[:12]}. push 필요.")
else:
warn("Forgejo 원격/접근 없음 — 이관 미완료")
# 2) remote 목록
remotes = _git("remote", "-v")
if github := [f.split()[1] for f in remotes.splitlines() if f.startswith("origin\t")]:
ok(f"github origin(private 백업) = {github[0]}")
else:
warn("github origin 없음")
if f"{FORGEJO_REMOTE}\t" in remotes:
ok(f"Forgejo {FORGEJO_REMOTE} 원격 존재 (SSOT)")
else:
warn(f"Forgejo {FORGEJO_REMOTE} 원격 없음")
# 3) SSOT checker 존재
checker = REPO_ROOT / "scripts" / "test_dev_dashboard_ssot.py"
if checker.exists():
ok(f"SSOT checker 존재: {checker.name}")
else:
warn("SSOT checker 없음")
# 4) 배포 파이프라인 문서
pipe_doc = REPO_ROOT / "docs" / "ops" / "deployment-pipeline.md"
if pipe_doc.exists():
ok(f"배포 파이프라인 문서 존재")
else:
warn("deployment-pipeline.md 없음")
return problems
def config() -> None:
env = REPO_ROOT / "infra" / ".env.deploy"
print(f"== 배포 파이프라인 구성 ==")
print(f" Forgejo SSOT : {FORGEJO_REMOTE} (ssh://git@git.chanpaca.net:2222/yunchan/vignette.git)")
print(f" github 백업 : {GITHUB_REMOTE} (https://github.com/yunchan8804-blip/vignette.git, private)")
print(f" NAS 대상 : {NAS_COMPOSE_HOST}:{NAS_COMPOSE_PATH} ({NAS_PROJECT})")
print(f" 로컬 마스터 : {_git('rev-parse', FORGEJO_SSOT_BRANCH)[:12]}")
print(f" .env.deploy : {env} (토큰 gitignore, 채움 필요)")
def main() -> int:
ap = argparse.ArgumentParser(description="Vignette 배포 파이프라인 관리")
ap.add_argument("--check", action="store_true", help="배포 기준선 무결성 점검")
ap.add_argument("--config", action="store_true", help="파이프라인 구성 요약")
ap.add_argument("--skip-forgejo", action="store_true", help="Forgejo 원격 접근 생략(오프라인 점검)")
args = ap.parse_args()
if args.config:
config()
return 0
problems = check(forgejo_available=not args.skip_forgejo)
print(f"\n결과: {'ALL OK' if problems == 0 else f'{problems} 개 문제'}")
return 0 if problems == 0 else 1
if __name__ == "__main__":
sys.exit(main())