probe-unified-target.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. """Compare private configuration, sealed runtime constants and actual read-only identities.
  2. This diagnostic never changes the migration guard, configuration or database.
  3. Only database/role names and comparison results are returned, never connection data.
  4. """
  5. import argparse
  6. import ast
  7. import hashlib
  8. import json
  9. import os
  10. from pathlib import Path
  11. import sys
  12. sys.dont_write_bytecode = True
  13. def need(ok, code):
  14. if not ok:
  15. raise ValueError(code)
  16. def private_configuration(path):
  17. need(path.is_absolute() and path.is_file() and not path.is_symlink(), 'PRIVATE_CONFIG_REQUIRED')
  18. if os.name != 'nt':
  19. need(path.stat().st_mode & 0o077 == 0, 'PRIVATE_CONFIG_PERMISSIONS_REQUIRED')
  20. value = json.loads(path.read_text('utf-8-sig'))
  21. need(set(value) == {'schema', 'appId', 'database', 'connection'} and
  22. value['schema'] == 'ksf.unified-db.v1' and value['appId'] == 'FKieRbIoGt_main', 'CONFIG_APPLICATION_MISMATCH')
  23. need(isinstance(value['database'], str) and value['database'], 'CONFIG_DATABASE_REQUIRED')
  24. allowed = {'service', 'host', 'port', 'user', 'passfile', 'sslmode', 'sslrootcert', 'connect_timeout'}
  25. connection = value['connection']
  26. need(isinstance(connection, dict) and connection.get('service') and not set(connection) - allowed,
  27. 'EXISTING_SERVICE_CONFIGURATION_REQUIRED')
  28. return value
  29. def runtime_identity(root):
  30. need(root.is_absolute(), 'ABSOLUTE_RUNTIME_ROOT_REQUIRED')
  31. file = root / 'server/voc-library/unified_store.py'
  32. raw = file.read_bytes()
  33. tree = ast.parse(raw.decode('utf-8-sig'))
  34. values = {}
  35. for statement in tree.body:
  36. if isinstance(statement, ast.Assign):
  37. for target in statement.targets:
  38. if isinstance(target, ast.Name) and target.id in ('VERSION', 'DATABASE', 'ROLE'):
  39. values[target.id] = ast.literal_eval(statement.value)
  40. need(set(values) == {'VERSION', 'DATABASE', 'ROLE'} and values['VERSION'] == 'ksf.unified-db.v1'
  41. and all(isinstance(v, str) and v for v in values.values()), 'UNRECOGNIZED_RUNTIME_CONSTANTS')
  42. return {'database': values['DATABASE'], 'role': values['ROLE'],
  43. 'moduleSha256': hashlib.sha256(raw).hexdigest()}
  44. def identity(connection, connector, database=None):
  45. pg = None
  46. try:
  47. kwargs = dict(connection)
  48. # The service read observes its actual database without substituting the package name.
  49. if database is not None:
  50. kwargs['dbname'] = database
  51. kwargs.update(connect_timeout=10, application_name='ksf-unified-target-readonly-probe',
  52. options='-c default_transaction_read_only=on')
  53. pg = connector(**kwargs)
  54. row = pg.execute("SELECT current_database(),current_user,session_user,"
  55. "current_setting('transaction_read_only'),current_setting('server_version'),"
  56. "(SELECT count(*) FROM pg_namespace WHERE nspname='ksf_voc_control' "
  57. "OR left(nspname,5)='ksfv_')").fetchone()
  58. need(row[3] == 'on', 'READ_ONLY_TRANSACTION_REQUIRED')
  59. return {'ok': True, 'database': row[0], 'role': row[1], 'sessionRole': row[2],
  60. 'transactionReadOnly': True, 'serverVersion': row[4], 'unifiedSchemaCount': row[5]}
  61. except Exception:
  62. # Database errors can contain DSNs or passwords; never return their original messages.
  63. return {'ok': False, 'errorCode': 'READ_ONLY_IDENTITY_QUERY_FAILED'}
  64. finally:
  65. if pg is not None:
  66. pg.rollback()
  67. pg.close()
  68. def run(config, root, connector=None):
  69. value = private_configuration(config)
  70. expected = runtime_identity(root)
  71. if connector is None:
  72. import psycopg
  73. connector = psycopg.connect
  74. service = identity(value['connection'], connector)
  75. # This is the exact database override performed by the sealed migration runtime.
  76. runtime = identity(value['connection'], connector, expected['database'])
  77. result = {'schema': 'ksf.unified-target-readonly-probe.v1', 'ok': service['ok'] and runtime['ok'],
  78. 'configuredDatabase': value['database'], 'packageExpected': expected,
  79. 'serviceIdentity': service, 'runtimeConnectionIdentity': runtime,
  80. 'configMatchesPackage': value['database'] == expected['database'],
  81. 'readOnly': True, 'databaseWrites': 0, 'configurationModified': False,
  82. 'migrationGuardModified': False, 'migrationExecuted': False}
  83. for label, actual in (('serviceMatchesPackage', service), ('runtimeIdentityMatchesPackage', runtime)):
  84. result[label] = actual['ok'] and (actual['database'], actual['role']) == (expected['database'], expected['role'])
  85. result['targetIdentityVerified'] = result['ok'] and result['configMatchesPackage'] and result['serviceMatchesPackage'] and result['runtimeIdentityMatchesPackage']
  86. return result
  87. def main():
  88. parser = argparse.ArgumentParser(description=__doc__)
  89. parser.add_argument('--config', required=True, type=Path)
  90. parser.add_argument('--runtime-root', required=True, type=Path)
  91. args = parser.parse_args()
  92. try:
  93. result = run(args.config, args.runtime_root)
  94. except Exception:
  95. result = {'ok': False, 'errorCode': 'TARGET_PROBE_PRECONDITION_FAILED',
  96. 'readOnly': True, 'databaseWrites': 0, 'migrationExecuted': False}
  97. print(json.dumps(result, ensure_ascii=True, indent=2))
  98. return 0 if result.get('ok') else 1
  99. if __name__ == '__main__':
  100. sys.exit(main())