You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

198 regels
14 KiB

  1. """Development review support: credentials stay in the local YAML, never in output."""
  2. import argparse
  3. import hashlib
  4. import json
  5. from pathlib import Path
  6. from urllib.parse import urlparse
  7. import datetime
  8. import psycopg
  9. from psycopg.rows import dict_row
  10. from psycopg import sql
  11. import yaml
  12. import sqlparse
  13. import secrets
  14. import bcrypt
  15. ROOT = Path(__file__).resolve().parents[2]
  16. API = ROOT / 'unreal_tran_api'
  17. WORK = ROOT.parent / '.codex-tmp' / 'review-20260905'
  18. REPORT = ROOT / 'ute2e' / 'reports' / 'review-20260905'
  19. MIGRATIONS = ['20260904_033_teaching_command_receipt.sql', '20260905_035_teaching_step_run_kingbase_constraints.sql']
  20. PARENTS = ['ut_teaching_assignment', 'ut_teaching_run', 'ut_teaching_run_member', 'ut_teaching_event']
  21. TABLES = ['ut_teaching_command_receipt', 'ut_teaching_step_run']
  22. def connect():
  23. conf = yaml.safe_load((API / 'config/application-auth-local.yml').read_text(encoding='utf-8-sig'))
  24. source = conf['spring']['datasource']
  25. # Existing configuration can wrap the JDBC properties in Druid.
  26. if 'url' not in source:
  27. source = source['druid']
  28. url = urlparse(source['url'].replace('jdbc:kingbase8:', 'postgresql:', 1))
  29. connection = psycopg.connect(host=url.hostname, port=url.port, dbname=url.path.lstrip('/'), user=source['username'], password=source['password'], row_factory=dict_row, autocommit=True)
  30. with connection.cursor() as cursor:
  31. cursor.execute('select current_database() as db, current_schema() as schema')
  32. identity = cursor.fetchone()
  33. if identity != {'db': 'unreal_tran', 'schema': 'public'}:
  34. raise RuntimeError('Unexpected development database/schema; stopped')
  35. return connection
  36. def apply(cursor, name):
  37. source = (API / 'database' / name).read_text(encoding='utf-8-sig')
  38. results = []
  39. for statement in sqlparse.split(source):
  40. cursor.execute(statement)
  41. if cursor.description:
  42. results.extend(cursor.fetchall())
  43. if any(value != 0 for row in results for value in row.values()):
  44. raise RuntimeError(f'Migration post-check did not return zero: {name}')
  45. return {'file': name, 'sha256': hashlib.sha256((API / 'database' / name).read_bytes()).hexdigest(), 'checks': results}
  46. def isolate(cursor, schema, interrupt=False):
  47. if schema not in {'review_20260905_first', 'review_20260905_resume'}:
  48. raise RuntimeError('Unapproved temporary schema')
  49. cursor.execute(f'DROP SCHEMA IF EXISTS {schema} CASCADE')
  50. cursor.execute(f'CREATE SCHEMA {schema}')
  51. cursor.execute(f'SET search_path TO {schema}')
  52. cursor.execute('CREATE TABLE ut_teaching_assignment(id BIGINT PRIMARY KEY); CREATE TABLE ut_teaching_run(id BIGINT PRIMARY KEY, assignment_id BIGINT NOT NULL); CREATE TABLE ut_teaching_run_member(id BIGINT PRIMARY KEY, run_id BIGINT NOT NULL); CREATE TABLE ut_teaching_event(id BIGINT PRIMARY KEY, run_id BIGINT NOT NULL, assignment_id BIGINT NOT NULL)')
  53. if interrupt:
  54. # Simulate a committed interruption after the first table / first unique index.
  55. for name, marker in [(MIGRATIONS[0], "COMMENT='教学写命令幂等回执';"), (MIGRATIONS[1], '$$;')]:
  56. source = (API / 'database' / name).read_text(encoding='utf-8-sig')
  57. partial = source[:source.index(marker) + len(marker)]
  58. for statement in sqlparse.split(partial + '\nCOMMIT;'):
  59. cursor.execute(statement)
  60. first = [apply(cursor, name) for name in MIGRATIONS]
  61. repeat = [apply(cursor, name) for name in MIGRATIONS]
  62. cursor.execute('SET search_path TO public')
  63. cursor.execute(f'DROP SCHEMA {schema} CASCADE')
  64. return {'interrupted': interrupt, 'first': first, 'repeat': repeat}
  65. def main():
  66. parser = argparse.ArgumentParser()
  67. parser.add_argument('action', choices=['prepare', 'check', 'test-admin', 'cleanup', 'clone-physical'])
  68. parser.add_argument('--code')
  69. args = parser.parse_args()
  70. WORK.mkdir(parents=True, exist_ok=True)
  71. REPORT.mkdir(parents=True, exist_ok=True)
  72. with connect() as db, db.cursor() as cursor:
  73. if args.action == 'cleanup':
  74. backup = json.loads((WORK / 'database-before.json').read_text(encoding='utf-8'))
  75. original_ids = [row['id'] for row in backup['tables']['ut_teaching_assignment']]
  76. cursor.execute("SELECT id,assignment_code FROM ut_teaching_assignment WHERE assignment_code LIKE 'UTE2E-REVIEW-%%' AND NOT (id = ANY(%s))", (original_ids,))
  77. temporary = cursor.fetchall()
  78. task_ids = [row['id'] for row in temporary]
  79. result = {'tasks': temporary, 'deleted': {}, 'time': datetime.datetime.now().isoformat()}
  80. with db.transaction():
  81. if task_ids:
  82. cursor.execute('SELECT id FROM ut_teaching_run WHERE assignment_id = ANY(%s)', (task_ids,))
  83. run_ids = [row['id'] for row in cursor.fetchall()]
  84. # Every affected row is tied to a test task absent from the recovery snapshot.
  85. for table, column, ids in [
  86. ('ut_teaching_step_run','assignment_id',task_ids),
  87. ('ut_teaching_command_receipt','assignment_id',task_ids),
  88. ('ut_teaching_xr_session','assignment_id',task_ids),
  89. ('ut_assistance_alert','assignment_id',task_ids),
  90. ('ut_assistance_evaluation','assignment_id',task_ids),
  91. ('ut_data_observation','run_id',run_ids),
  92. ('ut_teaching_event','assignment_id',task_ids),
  93. ('ut_teaching_intervention','assignment_id',task_ids),
  94. ('ut_teaching_assignment_fault','assignment_id',task_ids),
  95. ('ut_teaching_run_member','run_id',run_ids),
  96. ('ut_teaching_run','assignment_id',task_ids),
  97. ('ut_teaching_member','assignment_id',task_ids),
  98. ('ut_teaching_assignment','id',task_ids),
  99. ]:
  100. cursor.execute(sql.SQL('DELETE FROM {} WHERE {} = ANY(%s)').format(sql.Identifier(table),sql.Identifier(column)), (ids,))
  101. result['deleted'][table] = cursor.rowcount
  102. since = int(datetime.datetime.fromisoformat(backup['time']).timestamp())
  103. cursor.execute("SELECT id FROM ut_sys_user WHERE remark LIKE 'UTE2E-REVIEW-%%' AND username LIKE 'rv_%%' AND add_time >= %s", (since,))
  104. user_ids = [row['id'] for row in cursor.fetchall()]
  105. temp_admin = WORK / 'test-admin.json'
  106. if temp_admin.exists():
  107. admin = json.loads(temp_admin.read_text(encoding='utf-8'))
  108. cursor.execute('SELECT id FROM ut_sys_user WHERE id=%s AND username=%s AND is_protected=0', (admin['id'],admin['username']))
  109. matched = cursor.fetchone()
  110. if matched:
  111. user_ids.append(matched['id'])
  112. cursor.execute("UPDATE ut_auth_session SET is_revoked=1,revoked_time=UNIX_TIMESTAMP(),revoke_reason='review cleanup' WHERE user_id = ANY(%s)", (user_ids,))
  113. for table in ['ut_sys_user_role','ut_auth_user_credential','ut_sys_user']:
  114. column = 'id' if table == 'ut_sys_user' else 'user_id'
  115. extra = ', status=0, token_version=token_version+1' if table == 'ut_sys_user' else ''
  116. cursor.execute(sql.SQL('UPDATE {} SET is_delete=1,delete_token=id' + extra + ' WHERE {} = ANY(%s) AND is_delete=0').format(sql.Identifier(table),sql.Identifier(column)), (user_ids,))
  117. result['testUsersDisabled'] = user_ids
  118. result['originalRowsUnchanged'] = {}
  119. for table, rows in backup['tables'].items():
  120. ids = [row['id'] for row in rows]
  121. cursor.execute(sql.SQL('SELECT * FROM {} WHERE id = ANY(%s)').format(sql.Identifier(table)), (ids,))
  122. after = cursor.fetchall()
  123. normalize = lambda values: json.dumps(sorted(values,key=lambda row:row['id']),sort_keys=True,default=str)
  124. result['originalRowsUnchanged'][table] = normalize(rows) == normalize(after)
  125. cursor.execute("SELECT COUNT(*) AS count FROM ut_sys_user WHERE username LIKE 'rv_%%' AND remark LIKE 'UTE2E-REVIEW-%%' AND is_delete=0 AND add_time >= %s", (since,))
  126. result['remainingActiveTestUsers'] = cursor.fetchone()['count']
  127. (REPORT / 'cleanup.json').write_text(json.dumps(result,ensure_ascii=False,indent=2,default=str),encoding='utf-8')
  128. if not all(result['originalRowsUnchanged'].values()) or result['remainingActiveTestUsers'] != 0:
  129. raise RuntimeError('Cleanup verification failed; inspect cleanup.json before continuing')
  130. if temp_admin.exists():
  131. temp_admin.unlink()
  132. print(json.dumps(result,ensure_ascii=True))
  133. return
  134. if args.action == 'clone-physical':
  135. if not args.code or not args.code.startswith('UTE2E-REVIEW-') or not args.code.endswith('-PHYSICAL'):
  136. raise RuntimeError('Invalid isolated fixture prefix')
  137. cursor.execute("SELECT * FROM ut_teaching_assignment WHERE assignment_code='UAT-TEACH-PHYSICAL-001' AND is_delete=0")
  138. record = cursor.fetchone()
  139. record.pop('id')
  140. record.update(assignment_code=args.code, assignment_name=args.code, audience_type='COMMON', collaboration_mode='INDIVIDUAL', status_code='PUBLISHED', data_version=0, schedule_start_at=None, due_at=None, digital_human_allowed=0)
  141. definition = json.loads(record['definition_snapshot'])
  142. steps = definition['steps']
  143. for index, step in enumerate(steps):
  144. step['physicalEventRule'] = {'source':'MANUAL','eventType':'review.step.confirmed','progressPercent':round((index+1)*100/len(steps))}
  145. definition['modeConfig'] = {'type':'PHYSICAL','manualConfirmAllowed':True}
  146. record['definition_snapshot'] = json.dumps(definition,ensure_ascii=False,separators=(',',':'))
  147. record['definition_checksum'] = hashlib.sha256(record['definition_snapshot'].encode()).hexdigest()
  148. cursor.execute(sql.SQL('INSERT INTO ut_teaching_assignment ({}) VALUES ({}) RETURNING id').format(sql.SQL(',').join(map(sql.Identifier, record)),sql.SQL(',').join(sql.Placeholder() for _ in record)),list(record.values()))
  149. result = cursor.fetchone()
  150. print(json.dumps({'id':str(result['id']),'code':args.code,'version':0}))
  151. return
  152. if args.action == 'test-admin':
  153. username = 'rv_admin_' + secrets.token_hex(4)
  154. password = 'Rv!' + secrets.token_hex(7)
  155. with db.transaction():
  156. cursor.execute("SELECT id,department_id FROM ut_sys_user WHERE is_delete=0 AND is_protected=1 LIMIT 1")
  157. department = cursor.fetchone()['department_id']
  158. cursor.execute("SELECT id FROM ut_sys_role WHERE role_code='admin' AND status=1 AND is_delete=0")
  159. role = cursor.fetchone()['id']
  160. cursor.execute("INSERT INTO ut_sys_user(username,username_normalized,display_name,department_id,default_role_id,status,is_protected,must_change_password,remark) VALUES(%s,%s,%s,%s,%s,1,0,0,%s) RETURNING id", (username,username,'UTE2E-REVIEW-ADMIN',department,role,'Temporary review administrator'))
  161. user_id = cursor.fetchone()['id']
  162. cursor.execute('INSERT INTO ut_sys_user_role(user_id,role_id) VALUES(%s,%s)', (user_id,role))
  163. digest = '{bcrypt}' + bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
  164. cursor.execute('INSERT INTO ut_auth_user_credential(user_id,password_hash) VALUES(%s,%s)', (user_id,digest))
  165. (WORK / 'test-admin.json').write_text(json.dumps({'id':user_id,'username':username,'password':password}),encoding='utf-8')
  166. print('Created isolated temporary review administrator; credentials stored only in local scratch file')
  167. return
  168. if args.action == 'prepare':
  169. cursor.execute("SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name = ANY(%s)", (TABLES,))
  170. if cursor.fetchall():
  171. raise RuntimeError('Target tables already exist; inspect instead of reapplying')
  172. backup = {'time': datetime.datetime.now().isoformat(), 'tables': {}}
  173. for name in PARENTS:
  174. cursor.execute(f'SELECT * FROM {name}')
  175. backup['tables'][name] = cursor.fetchall()
  176. cursor.execute("SELECT tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='public' AND tablename = ANY(%s)", (PARENTS,))
  177. backup['indexes'] = cursor.fetchall()
  178. cursor.execute("SELECT table_name,column_name,data_type,column_default,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name = ANY(%s) ORDER BY table_name,ordinal_position", (PARENTS,))
  179. backup['columns'] = cursor.fetchall()
  180. (WORK / 'database-before.json').write_text(json.dumps(backup, ensure_ascii=False, indent=2, default=str), encoding='utf-8')
  181. result = {'environment': 'development unreal_tran/public', 'time': backup['time'], 'backup': str(WORK / 'database-before.json'), 'validation': []}
  182. for schema, interrupted in [('review_20260905_first', False), ('review_20260905_resume', True)]:
  183. result['validation'].append(isolate(cursor, schema, interrupted))
  184. result['executed'] = [apply(cursor, name) for name in MIGRATIONS]
  185. (REPORT / 'database-migration.json').write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
  186. print('PASS: recovery snapshot, isolated first/repeat/interruption recovery, development migrations 033/035')
  187. for name in TABLES:
  188. cursor.execute(f'SELECT COUNT(*) AS count FROM {name}')
  189. print(name, cursor.fetchone())
  190. if __name__ == '__main__':
  191. main()