diff --git a/docs/deployment/CT_DATA_MAINTENANCE.ko.md b/docs/deployment/CT_DATA_MAINTENANCE.ko.md index da541e7..2308a3c 100644 --- a/docs/deployment/CT_DATA_MAINTENANCE.ko.md +++ b/docs/deployment/CT_DATA_MAINTENANCE.ko.md @@ -101,6 +101,9 @@ edit의 Enter는 현재값 유지다. apply는 분석 정지 상태에서 `APPLY `ct -> field_data/field_storage -> data_host -> 제한된 worker -> 기존 원장/정리 모듈` 순서다. worker는 GPU 없이 CPU2·메모리768MiB·PID64·read-only root·권한 제거로 실행하며 현재 project의 승인된 데이터 mount만 사용한다. 외부 Event 전송을 요청하지 않는다. +승인된 bind source의 GID는 worker 보조그룹으로 전달한다. capability를 제거한 root도 +si 소유770 폴더에는 그룹 접근이 필요하며, 이를 chmod나 privileged로 우회하지 않는다. +실패 시 private job/error.json의 안전한 예외 종류·errno·소스 위치를 확인한다. Runtime start/stop과 정리 작업을 같은 lock으로 직렬화하고 기존 작성/retention lock도 지킨다. 실행 ct·인증 private key·DB 내부·active 기준은 root 보호를 유지한다. 일반 설정·inbox/outbox· diff --git a/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md b/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md index 9619fcb..21a4979 100644 --- a/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md +++ b/docs/profiles/ltb1021/topics/runtime-lifecycle/README.ko.md @@ -4,6 +4,14 @@ Status: `CT DATA TOOLS INSTALLED · AI2 STOPPED · RUNTIME ACTIVATION / USER ACC Owner: `(test, ltb1021, work-session)` on `task/runtime-lifecycle-refactor` +## AI2 수동시험: 자료 미리보기 + +Last reviewed: `2026-10-07 13:08:48 KST`. +[worker 그룹 권한과 진단 보완](validation/20261007T130848+0900-validation-ai2-data-preview.md). +사용자 si 메뉴/server/storage PASS. plan의 errno13은 제한 worker의 data GID 누락으로 확인해 수정했다. +표적2건·현장plan PASS: 총3098/보호462/정리가능4.10GB. 삭제0, 기존5서비스 exited, helper0. +다음은 사용자 ct start 결과 확인이다. 새 APP Runtime·브라우저 수용·실제 정리는 아직 미실행이다. + ## AI 서버2 VSCode 탐색 보호 Last reviewed: `2026-10-07 11:57:00 KST`. 기준 HEAD `f2443b6f2976496cc67e5e57f4fe3b4c829d48bb`. diff --git a/tools/icerti_offline/maintenance/data_host.py b/tools/icerti_offline/maintenance/data_host.py index 3d8cbc0..b58c345 100644 --- a/tools/icerti_offline/maintenance/data_host.py +++ b/tools/icerti_offline/maintenance/data_host.py @@ -93,18 +93,24 @@ class Host: '--tmpfs','/tmp:rw,nosuid,nodev,noexec,size=64m', '--mount','type=bind,src='+str(Path(__file__).parent)+',dst=/ct-tools,readonly', '--mount','type=bind,src='+str(job)+',dst=/ct-job'] - seen=set() + seen=set();groups=set() for host in self.paths().values(): m=next(m for m in self.mounts if m['type']=='bind' and (Path(m['source'])==host or Path(m['source']) in host.parents)) if m['target'] in seen:continue if ',' in m['source'] or ',' in m['target']:raise ValueError('MOUNT_SEPARATOR') + # Root with ALL capabilities dropped still needs the data owner's group. + gid=str(Path(m['source']).stat().st_gid) + if gid not in groups: + args+=['--group-add',gid];groups.add(gid) args+=['--mount','type=bind,src='+m['source']+',dst='+m['target']];seen.add(m['target']) args+=['--entrypoint','python3',self.image,'-B','/ct-tools/data_worker.py'] try: r=run(args,input=json.dumps(request).encode(),capture_output=True,timeout=240) try:out=json.loads(r.stdout) except (ValueError,UnicodeError):raise ValueError('WORKER_OUTPUT_INVALID') from None - if r.returncode:raise ValueError(out.get('code','WORKER_FAILED')) + if r.returncode: + atomic(job/'error.json',out) + raise ValueError(json.dumps(out,ensure_ascii=True)) return out finally: check=run(['docker','inspect',name],capture_output=True,timeout=10) diff --git a/tools/icerti_offline/maintenance/data_worker.py b/tools/icerti_offline/maintenance/data_worker.py index 550f66b..e2514ee 100644 --- a/tools/icerti_offline/maintenance/data_worker.py +++ b/tools/icerti_offline/maintenance/data_worker.py @@ -9,6 +9,7 @@ import shutil import sqlite3 import sys import time +import traceback from data_safety import atomic, child, digest, lock, remove_training, safe, tree_size @@ -298,6 +299,21 @@ class Worker: return dict(complete=True, deleted=len(state['done']), protected=sum(bool(x['blocked']) for x in plan['rows'])) +def safe_error(error): + result = {'error': type(error).__name__, + 'code': str(error) if re.fullmatch('[A-Z_]+', str(error)) else 'MAINTENANCE_FAILED'} + if isinstance(error, OSError) and isinstance(error.errno, int): + result['errno'] = error.errno + pgcode = getattr(error, 'pgcode', None) + if isinstance(pgcode, str) and re.fullmatch('[A-Z0-9]{5}', pgcode): + result['sqlstate'] = pgcode + frames = traceback.extract_tb(error.__traceback__) + if frames: + frame = frames[-1] + result['location'] = Path(frame.filename).name + ':' + str(frame.lineno) + return result + + def main(): os.umask(0o077) request = json.load(sys.stdin) @@ -311,5 +327,5 @@ if __name__ == '__main__': try: main() except Exception as e: # No SQL/DSN/credential-bearing path from exception text is printed. - print(json.dumps({'error':type(e).__name__, 'code':str(e) if re.fullmatch('[A-Z_]+',str(e)) else 'MAINTENANCE_FAILED'})) + print(json.dumps(safe_error(e))) sys.exit(2) diff --git a/tools/icerti_offline/maintenance/test_data.py b/tools/icerti_offline/maintenance/test_data.py index a59e5f9..1ab6826 100644 --- a/tools/icerti_offline/maintenance/test_data.py +++ b/tools/icerti_offline/maintenance/test_data.py @@ -4,6 +4,7 @@ import json import os from pathlib import Path import sqlite3 +import subprocess import tempfile import unittest from unittest.mock import patch @@ -14,11 +15,45 @@ sys.path.insert(0,str(ROOT));sys.path.insert(0,str(Path(__file__).parent));sys.p from app.storage_policy import validate, load, NAME from app.training_store import TrainingStore, MARKER from data_safety import atomic, child, digest, lock, tree_size, remove_training -from data_worker import Worker, eligible_event, selected +from data_worker import Worker, eligible_event, selected, safe_error from tool_permissions import tool_access class DataTests(unittest.TestCase): + def test_worker_gets_owned_mount_group_without_capability_override(self): + import data_host + import maintenance_process + host=data_host.Host.__new__(data_host.Host) + host.env={};host.image='fixture';host.active={} + host.mounts=[dict(type='bind',source=str(self.root),target='/fixture')] + host.paths=lambda:dict(artifact=self.artifact) + host.writers=lambda:[] + rows=[{'Config':{'Labels':{'com.docker.compose.service':'postgresql'}}, + 'NetworkSettings':{'Networks':{'fixture':{}}}}] + job=self.root/'job';job.mkdir() + calls=[] + def run(args,**kwargs): + calls.append(args) + return subprocess.CompletedProcess(args,1 if args[:2]==['docker','inspect'] else 0,b'{"rows":[]}',b'') + with patch.object(data_host.backend,'active',return_value={}),patch.object(data_host.backend,'containers',return_value=rows),patch.object(data_host.server,'guard_disks'),patch.object(data_host.server,'service',side_effect=lambda x:x),patch.object(maintenance_process,'run',side_effect=run): + self.assertEqual(host.run_worker(job,{'operation':'plan'}),{'rows':[]}) + args=calls[0] + self.assertIn('--cap-drop=ALL',args) + self.assertNotIn('--privileged',args) + self.assertEqual(args[args.index('--group-add')+1],str(self.root.stat().st_gid)) + self.assertFalse((job/'worker.env').exists()) + + def test_worker_error_redacts_message_and_path(self): + try: + raise PermissionError(13, 'secret-password', '/private/secret-file') + except PermissionError as error: + result = safe_error(error) + self.assertEqual(result['error'], 'PermissionError') + self.assertEqual(result['errno'], 13) + self.assertEqual(result['code'], 'MAINTENANCE_FAILED') + self.assertTrue(result['location'].startswith('test_data.py:')) + self.assertNotIn('secret', json.dumps(result)) + def setUp(self): self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name) self.artifact=self.root/'artifact';self.artifact.mkdir()