한국어 배포판 2.0.0 통합과 신규 설치 및 업데이트 복구 검증
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import json,zipfile,pathlib,collections,re,posixpath,hashlib
|
||||
repo=pathlib.Path(__file__).resolve().parents[1];b=repo/'tools/demo-source';out=repo/'release';out.mkdir(exist_ok=True)
|
||||
z=zipfile.ZipFile(b/'original-demo.zip');meta=json.loads(z.read('!!!meta.json'));old=json.loads((b/'scoped/notes.json').read_text(encoding='utf-8'));edges=json.loads((b/'demo-edges.json').read_text());byold={n['noteId']:n for n in old};parents=collections.defaultdict(set)
|
||||
for n,p in edges:parents[n].add(p)
|
||||
root='lq5FhqEInunb';archive={};apaths=collections.defaultdict(set);afiles={}
|
||||
def walk(nodes,parent='',titles=()):
|
||||
for n in nodes:
|
||||
if 'title' not in n:continue
|
||||
ident=n['noteId'];seq=titles+(n['title'],)
|
||||
if not n.get('isClone'):
|
||||
archive[ident]=n
|
||||
if n.get('dataFileName'):afiles[ident]=posixpath.join(parent,n['dataFileName'])
|
||||
apaths[ident].add(seq)
|
||||
walk(n.get('children',[]),posixpath.join(parent,n.get('dirFileName','')),seq)
|
||||
walk(meta['files'])
|
||||
def sourcepaths(n,seen=frozenset()):
|
||||
if n==root:return {('root','Trilium Demo')}
|
||||
if n in seen:return set()
|
||||
return {p+(byold[n]['title'],) for parent in parents[n] for p in sourcepaths(parent,seen|{n})}
|
||||
mapping={};ambiguities=[]
|
||||
for n in old:
|
||||
candidates=[i for i,a in archive.items() if (a.get('title'),a.get('type'),a.get('mime'))==(n['title'],n['type'],n['mime'])]
|
||||
if n['noteId']==root:candidates=[i for i,a in archive.items() if a.get('title')=='Trilium Demo']
|
||||
if len(candidates)>1:candidates=[i for i in candidates if apaths[i]&sourcepaths(n['noteId'])]
|
||||
if len(candidates)!=1:ambiguities.append((n['noteId'],n['title'],candidates))
|
||||
else:mapping[n['noteId']]=candidates[0]
|
||||
assert not ambiguities,ambiguities
|
||||
assert len(set(mapping.values()))==170
|
||||
changes={c['noteId']:c for c in json.loads((b/'release/demo-migration.json').read_text(encoding='utf-8'))['changes']}
|
||||
for c in json.loads((b/'phase4/korean-phase4-pending.json').read_text(encoding='utf-8'))['changes']:changes[c['noteId']]={**changes.get(c['noteId'],{}),**c}
|
||||
files={n:z.read(n) for n in z.namelist() if not n.endswith('/')};count=0
|
||||
for oldid,newid in mapping.items():
|
||||
a=archive[newid];n=byold[oldid];c=changes.get(oldid)
|
||||
if not c:continue
|
||||
a['title']=c['title'];count+=1
|
||||
if 'content' not in c:continue
|
||||
content=c['content'];filename=afiles[newid]
|
||||
if a.get('type')=='text' or a.get('mime')=='text/html':
|
||||
original=files[filename].decode('utf-8');oldsrc=re.findall(r'\bsrc="([^"]+)"',n.get('content') or '');newsrc=re.findall(r'\bsrc="([^"]+)"',original)
|
||||
if oldsrc and len(oldsrc)==len(newsrc):
|
||||
for x,y in zip(oldsrc,newsrc):content=content.replace('src="'+x+'"','src="'+y+'"')
|
||||
def link(m):
|
||||
target=m.group(1).split('/')[-1]
|
||||
if target in mapping and mapping[target] in afiles:return 'href="'+posixpath.relpath(afiles[mapping[target]],posixpath.dirname(filename))+'"'
|
||||
return m.group(0)
|
||||
content=re.sub(r'href="#root/([^"#?]+)"',link,content)
|
||||
for x,y in mapping.items():content=content.replace(x,y)
|
||||
files[filename]=content.encode('utf-8')
|
||||
phase4=json.loads((b/'phase4/korean-phase4-pending.json').read_text(encoding='utf-8'))
|
||||
for c in phase4['attributes']:
|
||||
for a in archive[mapping[c['noteId']]].get('attributes',[]):
|
||||
if a['name']==c['name']:a['value']=c['value']
|
||||
def prefixes(nodes):
|
||||
for n in nodes:
|
||||
if n.get('prefix') in ['TODO','DONE','28. 11. 2017']:n['prefix']={'TODO':'할 일','DONE':'완료','28. 11. 2017':'2017년 11월 28일'}[n['prefix']]
|
||||
prefixes(n.get('children',[]))
|
||||
prefixes(meta['files'])
|
||||
for a in archive.values():
|
||||
if a.get('title')=='root':a['title']='내 노트'
|
||||
if a.get('title')=='Journal':a['title']='일지'
|
||||
pdfid=mapping['swKGGdlek7o6'];files[afiles[pdfid]]=(b/'phase5/마지막-질문-한국어.pdf').read_bytes() if (b/'phase5/마지막-질문-한국어.pdf').exists() else next((b/'phase5').glob('*.pdf')).read_bytes()
|
||||
files['!!!meta.json']=json.dumps(meta,ensure_ascii=False,indent=2).encode('utf-8')
|
||||
with zipfile.ZipFile(out/'demo-ko.zip','w',zipfile.ZIP_DEFLATED) as dest:
|
||||
for info in z.infolist():dest.writestr(info,b'' if info.is_dir() else files[info.filename])
|
||||
(out/'demo-map.json').write_text(json.dumps(mapping,indent=2),encoding='utf-8');print(json.dumps({'mappedNotes':len(mapping),'translatedNotes':count,'archiveFiles':len(files),'sha256':hashlib.sha256((out/'demo-ko.zip').read_bytes()).hexdigest()}))
|
||||
@@ -0,0 +1,16 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {fileURLToPath} from 'node:url';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),snapshot=path.join(root,'updates/2026-09-05'),dest=path.join(root,'release/app');
|
||||
const copy=(src,to)=>{fs.mkdirSync(path.dirname(to),{recursive:true});fs.cpSync(src,to,{recursive:true});};
|
||||
copy(snapshot+'/deployed/main.cjs',dest+'/main.cjs');
|
||||
let main=fs.readFileSync(dest+'/main.cjs','utf8');const marker='"use strict";var ibn=Object.create;';
|
||||
if(main.split(marker).length!==2)throw Error('Unexpected bundle');main=main.slice(main.indexOf(marker));
|
||||
const call=';triliumApplyKoreanNotes();triliumApplyKoreanPhase4();triliumApplyKoreanPdf();triliumApplyKoreanApiLinks()';
|
||||
if(main.split(call).length!==2)throw Error('Unexpected migration calls');main=main.replace(call,'');
|
||||
if(/triliumApplyKorean/.test(main))throw Error('One-off migration remained');fs.writeFileSync(dest+'/main.cjs',main);
|
||||
copy(snapshot+'/deployed/translation.json',dest+'/public/translations/ko/translation.json');copy(snapshot+'/deployed/server.json',dest+'/assets/translations/ko/server.json');
|
||||
for(const n of ['layout_commons.js','mobile_layout.js','ko.js','ckeditor-ko.js','ckeditor5.js','MindElixir.js','emoji-ko.json'])copy(snapshot+'/deployed/'+n,dest+'/public/src/'+n);
|
||||
for(const phase of [3,8,9])copy(snapshot+'/phase'+phase+'/doc_notes/en',dest+'/assets/doc_notes/en');
|
||||
copy(root+'/release/demo-ko.zip',dest+'/assets/db/demo.zip');
|
||||
const metadata={release:'2.0.0',appVersion:'0.95.0',baseImage:'triliumnext/notes@sha256:611a430016585d35b170eb9a0fa300ce76318f04a8ec0067549c4c52561b0c36',assetVersion:'v0.95.0-ko-20260913-r8',scope:'한국어 UI와 도움말 및 신규 설치용 기본 예제',files:{}};
|
||||
for(const name of fs.readdirSync(dest,{recursive:true}).filter(n=>fs.statSync(path.join(dest,n)).isFile()).sort()){const bytes=fs.readFileSync(path.join(dest,name));metadata.files[name.replaceAll('\\','/')]={bytes:bytes.length,sha256:crypto.createHash('sha256').update(bytes).digest('hex')};}
|
||||
fs.writeFileSync(root+'/release/manifest.json',JSON.stringify(metadata,null,2)+'\n');console.log({files:Object.keys(metadata.files).length,release:metadata.release});
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"version": 4,
|
||||
"changes": [
|
||||
{
|
||||
"noteId": "EW0u2LoAZTYv",
|
||||
"oldTitle": "표 표시 (renderTable)",
|
||||
"title": "표 표시 (renderTable)",
|
||||
"oldContentSha256": "26e6f64bc522c1d9077082929c16a0f11ccdf33b935b3c3d3a288bd15dc3bf1d",
|
||||
"content": "module.exports = counts => {\n const $statsTable = api.$container.find('.stats-table');\n \n addRow('합계', \n counts.reduce((acc, cur) => acc + cur.countNotDeleted, 0), \n counts.reduce((acc, cur) => acc + cur.countDeleted, 0)\n );\n \n for (const count of counts) {\n addRow(({\"text\":\"텍스트\",\"code\":\"코드\",\"file\":\"파일\",\"image\":\"이미지\",\"search\":\"저장된 검색\",\"book\":\"책\",\"relationMap\":\"관계도\",\"noteMap\":\"노트 지도\",\"render\":\"렌더링 노트\",\"mermaid\":\"Mermaid 도표\",\"canvas\":\"캔버스\",\"webView\":\"웹 보기\",\"mindMap\":\"마인드맵\",\"geoMap\":\"지리 지도\",\"doc\":\"도움말\",\"launcher\":\"실행 도구\"})[count.type] || count.type, count.countNotDeleted, count.countDeleted);\n }\n\n function addRow(type, countNotDeleted, countDeleted) {\n $statsTable.append(\n $(\"<tr>\")\n .append($(\"<td>\").text(type))\n .append($(\"<td>\").text(countNotDeleted))\n .append($(\"<td>\").text(countDeleted))\n );\n }\n}"
|
||||
},
|
||||
{
|
||||
"noteId": "WJaFVVj6QO1J",
|
||||
"oldTitle": "자바스크립트 (js)",
|
||||
"title": "자바스크립트 (js)",
|
||||
"oldContentSha256": "47ea6714048328372870e445b73794e6710045412c5ecc6a6601f4b1ec41a04c",
|
||||
"content": "const noteCounts = await api.runOnBackend(() => {\n return api.sql.getRows(`\n SELECT\n type,\n isDeleted,\n SUM(CASE WHEN isDeleted=0 THEN 1 ELSE 0 END) AS countNotDeleted,\n SUM(CASE WHEN isDeleted=1 THEN 1 ELSE 0 END) AS countDeleted\n FROM notes\n GROUP BY type\n ORDER BY countNotDeleted DESC`);\n});\n\nrenderPieChart(noteCounts.map(nc => {\n return {\n name: ({\"text\":\"텍스트\",\"code\":\"코드\",\"file\":\"파일\",\"image\":\"이미지\",\"search\":\"저장된 검색\",\"book\":\"책\",\"relationMap\":\"관계도\",\"noteMap\":\"노트 지도\",\"render\":\"렌더링 노트\",\"mermaid\":\"Mermaid 도표\",\"canvas\":\"캔버스\",\"webView\":\"웹 보기\",\"mindMap\":\"마인드맵\",\"geoMap\":\"지리 지도\",\"doc\":\"도움말\",\"launcher\":\"실행 도구\"})[nc.type] || nc.type,\n count: nc.countNotDeleted\n };\n}));\n\nrenderTable(noteCounts);"
|
||||
},
|
||||
{
|
||||
"noteId": "ga590IKu7zmy",
|
||||
"oldTitle": "속성 변경 처리 (attributechanged)",
|
||||
"title": "속성 변경 처리 (attributechanged)",
|
||||
"oldContentSha256": "6ee6ecf4954ce691d03e2e3f1cfa163652557800ed85a392dfc6c06a7371bc2f",
|
||||
"content": "if (![\"task\", \"location\", \"tag\", \"todoDate\", \"doneDate\"].includes(api.originEntity.name)) {\n return;\n}\n\nconst tagRootNote = api.getNoteWithLabel('taskTagRoot');\nconst doneRootNote = api.getNoteWithLabel('taskDoneRoot');\nconst todoRootNote = api.getNoteWithLabel('taskTodoRoot');\n\nif (!tagRootNote || !doneRootNote || !todoRootNote) {\n console.log(\"태그 또는 완료 목록 또는 할 일 목록의 기준 노트를 찾을 수 없습니다.\");\n return;\n}\n\nconst note = api.originEntity.getNote();\n\nif (note.isDeleted) {\n return;\n}\n \nconst attributes = note.getAttributes();\n\nconst todoDate = note.getLabelValue('todoDate');\nconst doneDate = note.getLabelValue('doneDate');\n\nfunction isWithinExpectedRange(date) {\n if (!date) {\n return true;\n }\n \n const year = parseInt(date.substr(0, 4));\n \n return year >= 2010 && year < 2050;\n}\n\nif (!isWithinExpectedRange(todoDate) || !isWithinExpectedRange(doneDate)) {\n console.log(`날짜가 허용 범위를 벗어났습니다. 예정일: ${todoDate} / 완료일: ${doneDate}`);\n \n return;\n}\n\nconst isTaskDone = !!doneDate;\n\napi.toggleNoteInParent(isTaskDone, note.noteId, doneRootNote.noteId);\napi.toggleNoteInParent(!isTaskDone, note.noteId, todoRootNote.noteId);\n\nconst location = note.getLabelValue('location');\nconst locationRootNote = api.getNoteWithLabel('taskLocationRoot');\n\nreconcileAssignments(note, locationRootNote, location ? [location] : [], 'taskLocationNote', isTaskDone);\n\nconst tags = attributes.filter(attr => attr.type === 'label' && attr.name === 'tag').map(attr => attr.value);\n\nreconcileAssignments(note, tagRootNote, tags, 'taskTagNote', isTaskDone);\n\nnote.toggleLabel(isTaskDone, \"cssClass\", \"done\");\n\nconst doneTargetNoteId = isTaskDone ? api.getDayNote(doneDate).noteId : null;\napi.setNoteToParent(note.noteId, '완료', doneTargetNoteId);\n\nnote.toggleLabel(!isTaskDone, \"cssClass\", \"todo\");\n\nconst todoTargetNoteId = (!isTaskDone && todoDate) ? api.getDayNote(todoDate).noteId : null;\napi.setNoteToParent(note.noteId, '할 일', todoTargetNoteId);"
|
||||
}
|
||||
],
|
||||
"attributes": [
|
||||
{
|
||||
"attributeId": "teXYgLEYyqww",
|
||||
"noteId": "7NfbiSmiVFyR",
|
||||
"name": "label:weight",
|
||||
"value": "promoted,number,single,precision=1,alias=체중",
|
||||
"oldValue": "promoted,number,single,precision=1"
|
||||
},
|
||||
{
|
||||
"attributeId": "Gt3n5YmjWd92",
|
||||
"noteId": "1A2pDj0rZWRk",
|
||||
"name": "label:geolocation",
|
||||
"value": "promoted,single,text,alias=위치 좌표",
|
||||
"oldValue": "promoted,alias=Geolocation,single,text"
|
||||
},
|
||||
{
|
||||
"attributeId": "mNukzJtxNy9F",
|
||||
"noteId": "PF0ofV83QNq9",
|
||||
"name": "label:readingEnd",
|
||||
"value": "promoted,single,date,alias=독서 종료일",
|
||||
"oldValue": "promoted,single,date"
|
||||
},
|
||||
{
|
||||
"attributeId": "84Eslb7zduKM",
|
||||
"noteId": "PF0ofV83QNq9",
|
||||
"name": "label:readingStart",
|
||||
"value": "promoted,single,date,alias=독서 시작일",
|
||||
"oldValue": "promoted,single,date"
|
||||
},
|
||||
{
|
||||
"attributeId": "oOhOmvlQA1l7",
|
||||
"noteId": "PF0ofV83QNq9",
|
||||
"name": "label:author",
|
||||
"value": "promoted,single,text,alias=저자",
|
||||
"oldValue": "promoted,single,text"
|
||||
},
|
||||
{
|
||||
"attributeId": "ovlzSwPHBESP",
|
||||
"noteId": "PF0ofV83QNq9",
|
||||
"name": "label:link",
|
||||
"value": "promoted,single,url,alias=링크",
|
||||
"oldValue": "promoted,single,url"
|
||||
},
|
||||
{
|
||||
"attributeId": "1YTM1UQRQtnt",
|
||||
"noteId": "rjbXo0acF0uE",
|
||||
"name": "label:location",
|
||||
"value": "promoted,text,single,alias=장소",
|
||||
"oldValue": "promoted,text,single"
|
||||
},
|
||||
{
|
||||
"attributeId": "KbYhsredrwOt",
|
||||
"noteId": "rjbXo0acF0uE",
|
||||
"name": "label:tag",
|
||||
"value": "promoted,text,multi,alias=태그",
|
||||
"oldValue": "promoted,text,multi"
|
||||
},
|
||||
{
|
||||
"attributeId": "UE1YHe51q07k",
|
||||
"noteId": "rjbXo0acF0uE",
|
||||
"name": "label:todoDate",
|
||||
"value": "promoted,date,single,alias=예정일",
|
||||
"oldValue": "promoted,date,single"
|
||||
},
|
||||
{
|
||||
"attributeId": "PmBHwLv8Dtf3",
|
||||
"noteId": "rjbXo0acF0uE",
|
||||
"name": "label:doneDate",
|
||||
"value": "promoted,date,single,alias=완료일",
|
||||
"oldValue": "promoted,date,single"
|
||||
}
|
||||
],
|
||||
"branches": [
|
||||
{
|
||||
"branchId": "E1e6RXYb7KDR_HlLfbi37EMDe",
|
||||
"noteId": "HlLfbi37EMDe",
|
||||
"prefix": "할 일",
|
||||
"oldPrefix": "TODO"
|
||||
},
|
||||
{
|
||||
"branchId": "Zqbx4QzZLAw2_0za9R4spbuOD",
|
||||
"noteId": "0za9R4spbuOD",
|
||||
"prefix": "완료",
|
||||
"oldPrefix": "DONE"
|
||||
},
|
||||
{
|
||||
"branchId": "GIfEhKrDNdnn_2JsGaSEmZSku",
|
||||
"noteId": "2JsGaSEmZSku",
|
||||
"prefix": "완료",
|
||||
"oldPrefix": "DONE"
|
||||
},
|
||||
{
|
||||
"branchId": "GIfEhKrDNdnn_hpxIn2I2IdAJ",
|
||||
"noteId": "hpxIn2I2IdAJ",
|
||||
"prefix": "할 일",
|
||||
"oldPrefix": "TODO"
|
||||
},
|
||||
{
|
||||
"branchId": "m6DbuIecq3cC_BdeGRs0x9xSN",
|
||||
"noteId": "BdeGRs0x9xSN",
|
||||
"prefix": "2017년 11월 28일",
|
||||
"oldPrefix": "28. 11. 2017"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
|
||||
command -v docker >/dev/null || { echo 'Docker가 필요합니다.' >&2; exit 1; }
|
||||
docker compose version >/dev/null
|
||||
# Compose가 해석한 설정만 사용합니다. .env를 셸 코드로 실행하지 않습니다.
|
||||
resolved="$(docker compose config --environment)"
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
TRILIUM_KO_PORT|TRILIUM_KO_BIND|TRILIUM_KO_DATA_DIR|TRILIUM_KO_IMAGE) export "$key=$value" ;;
|
||||
esac
|
||||
done <<< "$resolved"
|
||||
unset resolved
|
||||
export TRILIUM_KO_PORT="${TRILIUM_KO_PORT:-8082}"
|
||||
export TRILIUM_KO_BIND="${TRILIUM_KO_BIND:-127.0.0.1}"
|
||||
export TRILIUM_KO_DATA_DIR="${TRILIUM_KO_DATA_DIR:-./data}"
|
||||
[[ "$TRILIUM_KO_PORT" =~ ^[0-9]+$ ]] && ((TRILIUM_KO_PORT>0 && TRILIUM_KO_PORT<65536)) || { echo '포트는 1~65535의 숫자여야 합니다.' >&2; exit 1; }
|
||||
docker compose config --quiet
|
||||
if [[ -d "$TRILIUM_KO_DATA_DIR" ]]; then
|
||||
data_absolute="$(cd -- "$TRILIUM_KO_DATA_DIR" && pwd)"
|
||||
own="$(docker compose ps -q trilium)"
|
||||
for running in $(docker ps -q --no-trunc); do
|
||||
[[ "$running" == "$own" ]] && continue
|
||||
mounted="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/home/node/trilium-data"}}{{.Source}}{{end}}{{end}}' "$running")"
|
||||
if [[ "$mounted" == "$data_absolute" ]]; then echo '다른 컨테이너가 이 데이터를 사용 중입니다. 해당 컨테이너를 먼저 중지하세요.' >&2; exit 1; fi
|
||||
done
|
||||
fi
|
||||
old_container="$(docker compose ps -aq trilium)"
|
||||
old_image=''
|
||||
rollback_image=''
|
||||
if [[ -n "$old_container" ]]; then
|
||||
old_image="$(docker inspect --format '{{.Image}}' "$old_container")"
|
||||
rollback_image="trilium-korean-rollback:$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
docker image tag "$old_image" "$rollback_image"
|
||||
fi
|
||||
echo '검증된 버전의 한국어 이미지를 빌드합니다.'
|
||||
docker compose build trilium
|
||||
backup=''
|
||||
stopped=0
|
||||
trap 'if [[ "$stopped" == 1 && -n "$old_container" ]]; then docker start "$old_container" >/dev/null || true; fi' ERR
|
||||
if [[ -d "$TRILIUM_KO_DATA_DIR" ]]; then
|
||||
if [[ -n "$old_container" ]]; then docker compose stop trilium; stopped=1; fi
|
||||
backup="$PWD/backups/$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
mkdir -p -- "$backup"; chmod 700 "$backup"
|
||||
data_absolute="$(cd -- "$TRILIUM_KO_DATA_DIR" && pwd)"
|
||||
docker run --rm --user 0 --entrypoint sh -v "$data_absolute:/backup-source:ro" -v "$backup:/backup-target" "${TRILIUM_KO_IMAGE:-trilium-korean:2.0.0}" -c 'umask 077; tar -czpf /backup-target/data.tar.gz -C /backup-source .'
|
||||
printf '%s\n' "$rollback_image" > "$backup/previous-image.txt"
|
||||
printf '%s\n' "$old_image" > "$backup/previous-image-id.txt"
|
||||
cp docker-compose.yml "$backup/docker-compose.yml"
|
||||
[[ ! -f .env ]] || cp .env "$backup/.env"
|
||||
echo "데이터 백업: $backup"
|
||||
fi
|
||||
if docker compose up -d --no-build --wait --wait-timeout 180; then
|
||||
stopped=0
|
||||
echo "배포 완료: http://${TRILIUM_KO_BIND}:${TRILIUM_KO_PORT}"
|
||||
echo '새 설치라면 초기 설정에서 새 문서를 만든 뒤 한국어를 선택하세요.'
|
||||
else
|
||||
stopped=0
|
||||
echo '기동 검증에 실패했습니다.' >&2
|
||||
if [[ -n "$old_image" ]]; then
|
||||
echo '이전 이미지로 되돌립니다. 데이터 백업은 유지합니다.' >&2
|
||||
TRILIUM_KO_IMAGE="$rollback_image" docker compose up -d --no-build --pull never --wait --wait-timeout 180 || true
|
||||
fi
|
||||
[[ -z "$backup" ]] || echo "데이터 복구용 백업: $backup/data.tar.gz" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,20 @@
|
||||
"""격리된 배포 검사 폴더의 DB만 읽습니다. 운영 DB 경로는 허용하지 않습니다."""
|
||||
import sqlite3,pathlib,json,hashlib,re,sys
|
||||
root=pathlib.Path('/tmp/trilium-release-test-20260913-r1').resolve();dbpath=(root/'data-r2/document.db').resolve();assert dbpath.is_relative_to(root)
|
||||
db=sqlite3.connect('file:'+str(dbpath)+'?mode=ro',uri=True);db.row_factory=sqlite3.Row
|
||||
notes=[dict(n) for n in db.execute('SELECT noteId,title,type,blobId FROM notes WHERE isDeleted=0')];ids={n['noteId'] for n in notes}
|
||||
pdfs=[dict(n) for n in db.execute("SELECT n.title,b.content FROM notes n JOIN blobs b ON n.blobId=b.blobId WHERE n.type='file' AND n.mime='application/pdf' AND n.isDeleted=0")]
|
||||
expected='1141ea4fb9d619fd55d75d0bdcd6bbd5678b161633586764d0eadfc4b9235400';assert any(hashlib.sha256(n['content']).hexdigest()==expected for n in pdfs)
|
||||
missing=[]
|
||||
for n in db.execute("SELECT n.title,b.content FROM notes n JOIN blobs b ON n.blobId=b.blobId WHERE n.type='text' AND n.isDeleted=0"):
|
||||
text=n['content'].decode() if isinstance(n['content'],bytes) else n['content'] or ''
|
||||
for link in re.findall(r'href="#root/([^"#?]+)"',text):
|
||||
if link.split('/')[-1] not in ids:missing.append([n['title'],link])
|
||||
assert not missing,missing
|
||||
assert sum(1 for n in notes if n['noteId'].startswith('_help_'))>=181
|
||||
assert any(n['title']=='일지' for n in notes)
|
||||
digest=hashlib.sha256(json.dumps(sorted((n['noteId'],n['title'],n['blobId']) for n in notes),ensure_ascii=False).encode()).hexdigest()
|
||||
result={'activeNotes':len(notes),'koreanTitles':sum(bool(re.search('[가-힣]',n['title'])) for n in notes),'helpNotes':sum(n['noteId'].startswith('_help_') for n in notes),'koreanPdf':True,'danglingInternalLinks':len(missing),'noteSnapshotSha256':digest}
|
||||
if len(sys.argv)>1 and sys.argv[1]=='save':(root/'before-update.json').write_text(json.dumps(result))
|
||||
elif len(sys.argv)>1 and sys.argv[1]=='compare':assert json.loads((root/'before-update.json').read_text())['noteSnapshotSha256']==digest
|
||||
print(json.dumps(result));db.close()
|
||||
@@ -0,0 +1,27 @@
|
||||
import pathlib,subprocess,json,urllib.request,time,sqlite3,hashlib,os
|
||||
root=pathlib.Path('/tmp/trilium-release-test-20260913-r1');assert root.resolve()==root
|
||||
env={**os.environ,'COMPOSE_PROJECT_NAME':'trilium-ko-release-test','TRILIUM_KO_IMAGE':'trilium-korean:2.0.0-test','TRILIUM_KO_PORT':'19092','TRILIUM_KO_BIND':'127.0.0.1','TRILIUM_KO_DATA_DIR':'./data-r2'}
|
||||
dockerfile=root/'Dockerfile';original=dockerfile.read_bytes();old=subprocess.check_output(['docker','inspect','--format','{{.Image}}','trilium-ko-release-test-trilium-1'],text=True).strip()
|
||||
try:
|
||||
dockerfile.write_bytes(original+b'\nCMD ["/bin/sh", "-c", "exit 42"]\n')
|
||||
with open(root/'rollback-test.log','w') as log:r=subprocess.run(['bash','install.sh'],cwd=root,env=env,stdout=log,stderr=subprocess.STDOUT,timeout=240)
|
||||
assert r.returncode!=0,'Bad image unexpectedly succeeded'
|
||||
current=subprocess.check_output(['docker','inspect','--format','{{.Image}}','trilium-ko-release-test-trilium-1'],text=True).strip();assert old==current,'Rollback image differs'
|
||||
with urllib.request.urlopen('http://127.0.0.1:19092/api/health-check',timeout=5) as response:assert response.status==200
|
||||
finally:dockerfile.write_bytes(original)
|
||||
# 가장 최근에 이 검사에서 만든 백업을 별도 경로에 복원합니다.
|
||||
backup=sorted((root/'backups').glob('*/data.tar.gz'))[-1];restore=root/'restore-check';restore.mkdir(exist_ok=False)
|
||||
subprocess.run(['tar','-xzpf',str(backup),'-C',str(restore)],check=True)
|
||||
db=sqlite3.connect('file:'+str(restore/'document.db')+'?mode=ro',uri=True)
|
||||
rows=list(db.execute('SELECT noteId,title,blobId FROM notes WHERE isDeleted=0'));digest=hashlib.sha256(json.dumps(sorted(rows),ensure_ascii=False).encode()).hexdigest();db.close()
|
||||
assert digest==json.loads((root/'before-update.json').read_text())['noteSnapshotSha256']
|
||||
subprocess.run(['docker','run','-d','--name','trilium-ko-restore-test','-p','127.0.0.1:19093:8080','-v',str(restore)+':/home/node/trilium-data',old],check=True,stdout=subprocess.DEVNULL)
|
||||
for i in range(60):
|
||||
try:
|
||||
with urllib.request.urlopen('http://127.0.0.1:19093/api/setup/status',timeout=2) as response:assert json.load(response)['isInitialized']
|
||||
break
|
||||
except Exception:
|
||||
if i==59:raise
|
||||
time.sleep(1)
|
||||
report={'forcedFailureRejected':True,'previousImageRestored':True,'backupDataMatched':True,'restoredContainerStarted':True,'backup':str(backup),'restoredNoteSnapshotSha256':digest}
|
||||
(root/'recovery-result.json').write_text(json.dumps(report,indent=2));print(json.dumps(report))
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {fileURLToPath} from 'node:url';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),m=JSON.parse(fs.readFileSync(root+'/release/manifest.json'));
|
||||
for(const [n,expected] of Object.entries(m.files)){const data=fs.readFileSync(root+'/release/app/'+n);if(data.length!==expected.bytes||crypto.createHash('sha256').update(data).digest('hex')!==expected.sha256)throw Error('Release mismatch '+n);}
|
||||
const actual=fs.readdirSync(root+'/release/app',{recursive:true}).filter(n=>fs.statSync(root+'/release/app/'+n).isFile());if(actual.length!==Object.keys(m.files).length)throw Error('Unexpected release files');
|
||||
console.log('배포 파일 '+actual.length+'개 검증 완료');
|
||||
Reference in New Issue
Block a user