library_channel.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """Private SSH stdio channel to the authoritative library. No HTTP write endpoint."""
  2. import sys
  3. sys.dont_write_bytecode = True
  4. import argparse
  5. import base64
  6. import io
  7. import json
  8. import os
  9. from pathlib import Path, PurePosixPath
  10. import re
  11. import subprocess
  12. import zipfile
  13. import voc_db as v
  14. VERSION = 'ksf.library-channel.v1'
  15. READS = ('contract','schema','query','search','aggregate','facets','get','source','verify')
  16. MAX_ARCHIVE = 64 * 1024 * 1024
  17. MAX_EXPANDED = 256 * 1024 * 1024
  18. MAX_WIRE = 90 * 1024 * 1024
  19. class ChannelError(v.LibraryError):
  20. """Only bounded metadata may leave the channel; never echo SSH stderr/config."""
  21. def __init__(self, code, **diagnostics):
  22. super().__init__(code)
  23. self.diagnostics = diagnostics
  24. def read_input(value):
  25. """--input is a UTF-8 JSON filename or stdin, not an inline JSON argument."""
  26. if value == '-':
  27. text = sys.stdin.read()
  28. else:
  29. v.require(not value.lstrip().startswith(('{', '[')), 'READ_INPUT_PATH_EXPECTED')
  30. try:
  31. text = Path(value).read_text(encoding='utf-8-sig')
  32. except FileNotFoundError:
  33. raise v.LibraryError('READ_INPUT_FILE_NOT_FOUND')
  34. except UnicodeError:
  35. raise v.LibraryError('READ_INPUT_INVALID_UTF8')
  36. except OSError:
  37. raise v.LibraryError('READ_INPUT_FILE_UNREADABLE')
  38. try:
  39. result = json.loads(text.lstrip('\ufeff'))
  40. except ValueError:
  41. raise v.LibraryError('READ_INPUT_INVALID_JSON')
  42. v.require(isinstance(result, dict), 'READ_INPUT_OBJECT_REQUIRED')
  43. return result
  44. def private_file(path):
  45. p = Path(path).absolute()
  46. v.require(p.is_file() and not any(x.is_symlink() for x in (p,*p.parents)), 'CHANNEL_FILE_NOT_FOUND_OR_REDIRECTED')
  47. if os.name != 'nt':
  48. v.require(p.stat().st_uid == os.getuid() and p.stat().st_mode & 0o077 == 0, 'CHANNEL_FILE_NOT_PRIVATE')
  49. return p
  50. def config(path):
  51. data = json.loads(private_file(path).read_text(encoding='utf-8-sig'))
  52. v.require(isinstance(data,dict) and set(data)=={'schema','host','port','user','identityFile','knownHostsFile'} and data['schema']==VERSION, 'INVALID_CHANNEL_CONFIG')
  53. v.require(isinstance(data['host'],str) and re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.:-]{0,252}',data['host']), 'INVALID_CHANNEL_HOST')
  54. v.require(isinstance(data['user'],str) and re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}',data['user']), 'INVALID_CHANNEL_USER')
  55. v.require(type(data['port']) is int and 0<data['port']<65536, 'INVALID_CHANNEL_PORT')
  56. for k in ('identityFile','knownHostsFile'):
  57. v.require(isinstance(data[k],str) and Path(data[k]).is_absolute(), 'ABSOLUTE_CHANNEL_PATH_REQUIRED')
  58. private_file(data[k])
  59. return data
  60. def send(path, request, runner=subprocess.run):
  61. data = config(path)
  62. command = ['ssh','-T','-o','BatchMode=yes','-o','IdentitiesOnly=yes','-o','StrictHostKeyChecking=yes',
  63. '-o','ClearAllForwardings=yes','-o','ConnectTimeout=10','-o','ServerAliveInterval=15','-o','ServerAliveCountMax=2',
  64. '-o','UserKnownHostsFile='+data['knownHostsFile'],'-i',data['identityFile'],'-p',str(data['port']),
  65. data['user']+'@'+data['host'],'ksf-library-v1']
  66. wire=v.canonical({'schema':VERSION,**request}).encode('utf-8')
  67. v.require(len(wire)<=MAX_WIRE,'CHANNEL_REQUEST_TOO_LARGE')
  68. try:
  69. run=runner(command,input=wire,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=300,check=False)
  70. except subprocess.TimeoutExpired:
  71. raise ChannelError('CHANNEL_RESULT_UNCERTAIN' if request.get('params',{}).get('apply') else 'CHANNEL_TIMEOUT', stage='ssh-execution')
  72. except OSError:
  73. raise ChannelError('CHANNEL_RESULT_UNCERTAIN' if request.get('params',{}).get('apply') else 'CHANNEL_UNAVAILABLE', stage='ssh-execution')
  74. diagnostics = dict(stage='ssh-response', exitCode=run.returncode,
  75. stdoutBytes=len(run.stdout), stderrBytes=len(run.stderr),
  76. stdoutSha256=v.sha(run.stdout), stderrSha256=v.sha(run.stderr))
  77. applying = request.get('params',{}).get('apply') is True
  78. if len(run.stdout)>32*1024*1024:
  79. raise ChannelError('CHANNEL_RESULT_UNCERTAIN' if applying else 'CHANNEL_RESPONSE_TOO_LARGE', **diagnostics)
  80. try:
  81. result=json.loads(run.stdout.decode('utf-8-sig'))
  82. except (ValueError,UnicodeError):
  83. raise ChannelError('CHANNEL_RESULT_UNCERTAIN' if applying else 'CHANNEL_READ_FAILED', **diagnostics)
  84. if not isinstance(result,dict) or type(result.get('ok')) is not bool:
  85. raise ChannelError('CHANNEL_RESULT_UNCERTAIN' if applying else 'INVALID_CHANNEL_RESPONSE', **diagnostics)
  86. if run.returncode!=0 and result['ok']:
  87. raise ChannelError('CHANNEL_RESULT_UNCERTAIN' if applying else 'CHANNEL_EXIT_MISMATCH', **diagnostics)
  88. return result
  89. def pack(batch_path):
  90. from structured_batch import load
  91. path,manifest,batch,rows,assets=load(batch_path)
  92. v.require(len(manifest)+sum(p.stat().st_size for p,h in assets.values())<=MAX_EXPANDED,'CHANNEL_BATCH_TOO_LARGE')
  93. output=io.BytesIO()
  94. with zipfile.ZipFile(output,'w',zipfile.ZIP_DEFLATED) as z:
  95. def add(name,body):
  96. info=zipfile.ZipInfo(name,(2026,1,1,0,0,0));info.external_attr=0o100600<<16
  97. z.writestr(info,body,compress_type=zipfile.ZIP_DEFLATED)
  98. v.require(path.read_bytes()==manifest,'BATCH_CHANGED_DURING_PACK');add('batch.json',manifest)
  99. for name,(p,h) in sorted(assets.items()):
  100. raw=p.read_bytes();v.require(v.sha(raw)==h,'SOURCE_CHANGED_DURING_PACK');add(name,raw)
  101. raw=output.getvalue();v.require(len(raw)<=MAX_ARCHIVE,'CHANNEL_ARCHIVE_TOO_LARGE')
  102. return raw,v.sha(raw)
  103. def read_main(argv):
  104. p=argparse.ArgumentParser(description=__doc__);p.add_argument('command',choices=READS)
  105. p.add_argument('--channel',default=os.environ.get('KSF_LIBRARY_CHANNEL'));p.add_argument('--generation')
  106. p.add_argument('--table');p.add_argument('--profile',action='store_true');p.add_argument('--explain',action='store_true')
  107. p.add_argument('--entity');p.add_argument('--id');p.add_argument('--input')
  108. a=p.parse_args(argv)
  109. try:
  110. v.require(a.channel,'CHANNEL_NOT_CONFIGURED')
  111. params={k:getattr(a,k) for k in ('generation','table','entity','id') if getattr(a,k) is not None}
  112. if a.profile:params['profile']=True
  113. if a.explain:params['explain']=True
  114. if a.input:params['input']=read_input(a.input)
  115. result=send(a.channel,{'action':'read','command':a.command,'params':params})
  116. print(json.dumps(result,ensure_ascii=False,indent=2));return 0 if result['ok'] else 1
  117. except (ValueError,OSError) as e:
  118. code=str(e) if isinstance(e,v.LibraryError) else 'CHANNEL_READ_FAILED'
  119. error={'code':code}
  120. if isinstance(e,ChannelError):error['diagnostics']=e.diagnostics
  121. print(v.canonical({'ok':False,'error':error,'readOnly':True,'databaseWrites':0}),file=sys.stderr);return 1
  122. def main():
  123. p=argparse.ArgumentParser(description=__doc__);p.add_argument('command',choices=['publish','correct-review','status'])
  124. p.add_argument('--channel',default=os.environ.get('KSF_LIBRARY_CHANNEL'));p.add_argument('--batch');p.add_argument('--archive-sha256')
  125. p.add_argument('--expected-generation');p.add_argument('--label',default='Agent资料入库');p.add_argument('--apply',action='store_true')
  126. a=p.parse_args();fingerprint=None
  127. try:
  128. v.require(a.channel,'CHANNEL_NOT_CONFIGURED')
  129. if a.command=='status':
  130. v.require(not a.batch and not a.apply and a.archive_sha256,'STATUS_ARGUMENTS_REQUIRED')
  131. fingerprint=a.archive_sha256;request={'action':'status','params':{'archiveSha256':fingerprint}}
  132. else:
  133. v.require(a.batch and a.expected_generation and not a.archive_sha256,'BATCH_AND_EXPECTED_GENERATION_REQUIRED')
  134. raw,fingerprint=pack(a.batch)
  135. request={'action':'correct-review' if a.command=='correct-review' else 'intake',
  136. 'params':{'archiveSha256':fingerprint,'expectedGeneration':a.expected_generation,'label':a.label,'apply':a.apply},'archiveBase64':base64.b64encode(raw).decode('ascii')}
  137. result=send(a.channel,request);print(json.dumps(result,ensure_ascii=False,indent=2));return 0 if result['ok'] else 1
  138. except (ValueError,OSError) as e:
  139. code=str(e) if isinstance(e,v.LibraryError) else 'CHANNEL_REQUEST_FAILED'
  140. print(v.canonical({'ok':False,'error':{'code':code},'archiveSha256':fingerprint,'publicationOutcome':'unknown' if code=='CHANNEL_RESULT_UNCERTAIN' else 'not-confirmed'}),file=sys.stderr);return 1
  141. if __name__=='__main__':sys.exit(main())