queryapigate 0.26.0__tar.gz → 0.27.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. {queryapigate-0.26.0/queryapigate.egg-info → queryapigate-0.27.0}/PKG-INFO +1 -1
  2. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/__init__.py +1 -1
  3. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/adminroles.py +3 -1
  4. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/apikeys.py +72 -32
  5. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/app.py +27 -0
  6. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/cli.py +165 -0
  7. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/config.py +33 -0
  8. queryapigate-0.27.0/queryapigate/configcode.py +1035 -0
  9. queryapigate-0.26.0/queryapigate/console_dist/assets/AccessMapPage-rRSiv2mm.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AccessMapPage-B3WpxIky.js +3 -3
  10. queryapigate-0.26.0/queryapigate/console_dist/assets/AdministratorsPage-DoeYjW1i.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AdministratorsPage-B64xDs7h.js +1 -1
  11. queryapigate-0.26.0/queryapigate/console_dist/assets/AgentsPage-DerF6ZBb.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AgentsPage-CLvbAeMM.js +1 -1
  12. queryapigate-0.26.0/queryapigate/console_dist/assets/AlertsPage-BWROcwNn.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AlertsPage-DMT7ahQ0.js +1 -1
  13. queryapigate-0.27.0/queryapigate/console_dist/assets/ApiKeysPage-tDe3VPtQ.js +1 -0
  14. queryapigate-0.26.0/queryapigate/console_dist/assets/ApprovalsPage-DSKisl9D.js → queryapigate-0.27.0/queryapigate/console_dist/assets/ApprovalsPage-AQ1RBm3Y.js +1 -1
  15. queryapigate-0.26.0/queryapigate/console_dist/assets/AuditLogPage-Etti--Y7.js → queryapigate-0.27.0/queryapigate/console_dist/assets/AuditLogPage-DSIK5iAl.js +2 -2
  16. queryapigate-0.26.0/queryapigate/console_dist/assets/CachingPage-BZ61rPfT.js → queryapigate-0.27.0/queryapigate/console_dist/assets/CachingPage-BTYUmEQF.js +1 -1
  17. queryapigate-0.26.0/queryapigate/console_dist/assets/CodeBox-GGv4fKoR.js → queryapigate-0.27.0/queryapigate/console_dist/assets/CodeBox-CLn9Isny.js +1 -1
  18. queryapigate-0.27.0/queryapigate/console_dist/assets/ConnectionsPage-CU3lwLIy.js +4 -0
  19. queryapigate-0.27.0/queryapigate/console_dist/assets/DesignerPage-BDbMWCV_.js +4 -0
  20. queryapigate-0.27.0/queryapigate/console_dist/assets/DestinationsPage-DWvgv64P.js +1 -0
  21. queryapigate-0.26.0/queryapigate/console_dist/assets/Experimental-BB0g6F3s.js → queryapigate-0.27.0/queryapigate/console_dist/assets/Experimental-Ci4sxNHA.js +1 -1
  22. queryapigate-0.27.0/queryapigate/console_dist/assets/ExportsPage-Bd6RtYfq.js +1 -0
  23. queryapigate-0.26.0/queryapigate/console_dist/assets/HelpPage-CUTTBoH6.js → queryapigate-0.27.0/queryapigate/console_dist/assets/HelpPage-Vs990qgv.js +2 -2
  24. queryapigate-0.26.0/queryapigate/console_dist/assets/HomePage-DtOzlurj.js → queryapigate-0.27.0/queryapigate/console_dist/assets/HomePage-D9fA6CUM.js +3 -3
  25. queryapigate-0.27.0/queryapigate/console_dist/assets/Managed-D1nsYxSb.js +1 -0
  26. queryapigate-0.26.0/queryapigate/console_dist/assets/MetricsPage-o3kSXmOq.js → queryapigate-0.27.0/queryapigate/console_dist/assets/MetricsPage-FbI8yJ_V.js +1 -1
  27. queryapigate-0.27.0/queryapigate/console_dist/assets/RepositoryPage-BCeeBwLm.js +4 -0
  28. queryapigate-0.26.0/queryapigate/console_dist/assets/Results-xBStmWdp.js → queryapigate-0.27.0/queryapigate/console_dist/assets/Results-TOzveYeD.js +2 -2
  29. queryapigate-0.27.0/queryapigate/console_dist/assets/RolesPage-C-enuQa1.js +1 -0
  30. queryapigate-0.26.0/queryapigate/console_dist/assets/SchemaBrowser-DykbAObL.js → queryapigate-0.27.0/queryapigate/console_dist/assets/SchemaBrowser-BMa0-8U2.js +4 -4
  31. queryapigate-0.26.0/queryapigate/console_dist/assets/SettingsPage-mw196Ecu.js → queryapigate-0.27.0/queryapigate/console_dist/assets/SettingsPage-Du4eemQO.js +2 -2
  32. queryapigate-0.26.0/queryapigate/console_dist/assets/StatTile-5XA5PKWp.js → queryapigate-0.27.0/queryapigate/console_dist/assets/StatTile-CxCBu4uV.js +2 -2
  33. queryapigate-0.26.0/queryapigate/console_dist/assets/Time-zMnS1kSm.js → queryapigate-0.27.0/queryapigate/console_dist/assets/Time-rOk1OPXf.js +1 -1
  34. queryapigate-0.27.0/queryapigate/console_dist/assets/forms-CWRg8NCI.js +1 -0
  35. queryapigate-0.26.0/queryapigate/console_dist/assets/forms-Cl7JC3vt.js → queryapigate-0.27.0/queryapigate/console_dist/assets/forms-DWiJkRV3.js +2 -2
  36. queryapigate-0.26.0/queryapigate/console_dist/assets/icons-B_wbfO4Z.js → queryapigate-0.27.0/queryapigate/console_dist/assets/icons-CaGQH4Og.js +1 -1
  37. queryapigate-0.26.0/queryapigate/console_dist/assets/index-D9oV4vVP.css → queryapigate-0.27.0/queryapigate/console_dist/assets/index-BLd0t6vZ.css +1 -1
  38. queryapigate-0.26.0/queryapigate/console_dist/assets/index-D0zbyWWM.js → queryapigate-0.27.0/queryapigate/console_dist/assets/index-KpcrIrQ6.js +4 -4
  39. queryapigate-0.26.0/queryapigate/console_dist/assets/maskRules-Cw-l_UG8.js → queryapigate-0.27.0/queryapigate/console_dist/assets/maskRules-DYn9Uy4e.js +1 -1
  40. queryapigate-0.26.0/queryapigate/console_dist/assets/reach-DFqBXYli.js → queryapigate-0.27.0/queryapigate/console_dist/assets/reach-BiJLHQVp.js +1 -1
  41. queryapigate-0.27.0/queryapigate/console_dist/assets/shared-DhbY3zlT.js +1 -0
  42. queryapigate-0.26.0/queryapigate/console_dist/assets/useMutation-BCy3Br67.js → queryapigate-0.27.0/queryapigate/console_dist/assets/useMutation-CKRawbcZ.js +1 -1
  43. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console_dist/index.html +2 -2
  44. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/db.py +82 -4
  45. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/experimental.py +8 -0
  46. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/exports.py +19 -8
  47. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/history.py +2 -0
  48. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/store.py +40 -15
  49. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/v1.py +58 -1
  50. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/v1_spec.py +78 -0
  51. {queryapigate-0.26.0 → queryapigate-0.27.0/queryapigate.egg-info}/PKG-INFO +1 -1
  52. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/SOURCES.txt +36 -33
  53. queryapigate-0.27.0/tests/test_configcode.py +924 -0
  54. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_helm.py +33 -1
  55. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_settings.py +1 -1
  56. queryapigate-0.26.0/queryapigate/console_dist/assets/ApiKeysPage-DxEySu_Z.js +0 -1
  57. queryapigate-0.26.0/queryapigate/console_dist/assets/ConnectionsPage-CFCKdJ9-.js +0 -4
  58. queryapigate-0.26.0/queryapigate/console_dist/assets/DesignerPage-DL1ysNb9.js +0 -4
  59. queryapigate-0.26.0/queryapigate/console_dist/assets/DestinationsPage-Du2l56ln.js +0 -1
  60. queryapigate-0.26.0/queryapigate/console_dist/assets/ExportsPage-Cs2BpNim.js +0 -1
  61. queryapigate-0.26.0/queryapigate/console_dist/assets/RepositoryPage-Cooj3jby.js +0 -4
  62. queryapigate-0.26.0/queryapigate/console_dist/assets/RolesPage-CcR_ttpB.js +0 -1
  63. queryapigate-0.26.0/queryapigate/console_dist/assets/forms-C5L_fKDm.js +0 -1
  64. queryapigate-0.26.0/queryapigate/console_dist/assets/shared-DygTbsGs.js +0 -1
  65. {queryapigate-0.26.0 → queryapigate-0.27.0}/LICENSE +0 -0
  66. {queryapigate-0.26.0 → queryapigate-0.27.0}/README.md +0 -0
  67. {queryapigate-0.26.0 → queryapigate-0.27.0}/pyproject.toml +0 -0
  68. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/__main__.py +0 -0
  69. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/admins.py +0 -0
  70. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/agentwrites.py +0 -0
  71. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/alerts.py +0 -0
  72. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/auditchain.py +0 -0
  73. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/auditstream.py +0 -0
  74. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/broadcast.py +0 -0
  75. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/bundle.py +0 -0
  76. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/cache.py +0 -0
  77. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/collection_admin.py +0 -0
  78. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console.py +0 -0
  79. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
  80. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/console_dist/favicon.svg +0 -0
  81. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/cors.py +0 -0
  82. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/databases.py +0 -0
  83. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/definitions.py +0 -0
  84. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/deprecations.py +0 -0
  85. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/descriptions.py +0 -0
  86. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/destinations.py +0 -0
  87. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/duckfiles.py +0 -0
  88. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/engine.py +0 -0
  89. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/errors.py +0 -0
  90. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/events.py +0 -0
  91. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/examples.py +0 -0
  92. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/exporting.py +0 -0
  93. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/formats.py +0 -0
  94. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/governance.py +0 -0
  95. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/instances.py +0 -0
  96. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/jwtauth.py +0 -0
  97. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
  98. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/limits.py +0 -0
  99. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/logging_setup.py +0 -0
  100. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/masking.py +0 -0
  101. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/mcp_server.py +0 -0
  102. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/metrics.py +0 -0
  103. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/mongotools.py +0 -0
  104. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/oidc.py +0 -0
  105. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/openapi.py +0 -0
  106. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/params.py +0 -0
  107. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/parquet.py +0 -0
  108. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/pool.py +0 -0
  109. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/postman.py +0 -0
  110. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/quotas.py +0 -0
  111. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/ratelimit.py +0 -0
  112. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/rediscache.py +0 -0
  113. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/rekey.py +0 -0
  114. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/runners.py +0 -0
  115. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/schema.py +0 -0
  116. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/secheaders.py +0 -0
  117. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/__init__.py +0 -0
  118. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/access.py +0 -0
  119. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/administrators.py +0 -0
  120. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/audit.py +0 -0
  121. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/collections.py +0 -0
  122. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/connections.py +0 -0
  123. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/descriptions.py +0 -0
  124. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/destinations.py +0 -0
  125. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/exports.py +0 -0
  126. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/mcp.py +0 -0
  127. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/services/queries.py +0 -0
  128. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/sqlflow.py +0 -0
  129. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/sqltools.py +0 -0
  130. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/sso.py +0 -0
  131. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/tableguard.py +0 -0
  132. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/tracing.py +0 -0
  133. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate/usage.py +0 -0
  134. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/dependency_links.txt +0 -0
  135. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/entry_points.txt +0 -0
  136. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/requires.txt +0 -0
  137. {queryapigate-0.26.0 → queryapigate-0.27.0}/queryapigate.egg-info/top_level.txt +0 -0
  138. {queryapigate-0.26.0 → queryapigate-0.27.0}/setup.cfg +0 -0
  139. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_adhoc_history.py +0 -0
  140. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_adminroles.py +0 -0
  141. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_admins.py +0 -0
  142. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_agent_writes.py +0 -0
  143. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_alerts.py +0 -0
  144. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_api_administrators.py +0 -0
  145. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_api_keys.py +0 -0
  146. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_api_v1.py +0 -0
  147. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_audit_chain.py +0 -0
  148. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_audit_log.py +0 -0
  149. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_audit_stream.py +0 -0
  150. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_backup_restore.py +0 -0
  151. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_broadcast.py +0 -0
  152. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_cli.py +0 -0
  153. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_collection_bundle.py +0 -0
  154. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_collection_postman.py +0 -0
  155. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_collections.py +0 -0
  156. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_connection_encryption.py +0 -0
  157. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_console.py +0 -0
  158. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_cors_and_rate_limit.py +0 -0
  159. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_databases.py +0 -0
  160. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_db.py +0 -0
  161. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_dependency_floors.py +0 -0
  162. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_deprecations.py +0 -0
  163. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_descriptions.py +0 -0
  164. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_destinations.py +0 -0
  165. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_duckdb.py +0 -0
  166. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_duckdb_files.py +0 -0
  167. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_errors.py +0 -0
  168. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_events.py +0 -0
  169. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_events_server.py +0 -0
  170. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_examples.py +0 -0
  171. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_experimental.py +0 -0
  172. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_export_security.py +0 -0
  173. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_exporting.py +0 -0
  174. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_exports.py +0 -0
  175. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_history.py +0 -0
  176. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_howto.py +0 -0
  177. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_instances.py +0 -0
  178. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_integration.py +0 -0
  179. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_jdbc.py +0 -0
  180. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_jwt.py +0 -0
  181. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_key_rotation.py +0 -0
  182. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_limits.py +0 -0
  183. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_masking.py +0 -0
  184. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_masking_requests.py +0 -0
  185. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mcp.py +0 -0
  186. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mcp_keycloak.py +0 -0
  187. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mcp_oauth.py +0 -0
  188. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_mongotools.py +0 -0
  189. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_multinode.py +0 -0
  190. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_observability.py +0 -0
  191. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_oidc.py +0 -0
  192. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_params.py +0 -0
  193. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_parquet.py +0 -0
  194. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_pool.py +0 -0
  195. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_publishing.py +0 -0
  196. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_query_window.py +0 -0
  197. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_queryapigate.py +0 -0
  198. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_quotas.py +0 -0
  199. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_rekey.py +0 -0
  200. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_release_check.py +0 -0
  201. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_response_cache.py +0 -0
  202. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_roles.py +0 -0
  203. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_rolling_upgrade.py +0 -0
  204. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_security_headers.py +0 -0
  205. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_shared_rate_limit.py +0 -0
  206. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sql_guard_fuzz.py +0 -0
  207. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sql_guard_writes.py +0 -0
  208. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sqlflow.py +0 -0
  209. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sso.py +0 -0
  210. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_sso_keycloak.py +0 -0
  211. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_tableguard.py +0 -0
  212. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_tracing.py +0 -0
  213. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_upgrades.py +0 -0
  214. {queryapigate-0.26.0 → queryapigate-0.27.0}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.26.0
3
+ Version: 0.27.0
4
4
  Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: FSL-1.1-MIT
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = "0.26.0"
2
+ __version__ = "0.27.0"
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -33,12 +33,13 @@ CAPABILITIES = {
33
33
  'exports.write': 'create, change and delete exports',
34
34
  'exports.run': 'run an export',
35
35
  'destinations.write': 'create, change, test and delete destinations',
36
+ 'config.apply': 'see what a configuration file would change, and apply it - any of the above (ADR 0014)',
36
37
  }
37
38
 
38
39
  _EVERYONE = {'queries.read', 'connections.read', 'observe', 'self'}
39
40
  ROLES = {
40
41
  'owner': set(CAPABILITIES),
41
- 'admin': set(CAPABILITIES) - {'admins.read', 'admins.write', 'secrets.rotate'},
42
+ 'admin': set(CAPABILITIES) - {'admins.read', 'admins.write', 'secrets.rotate', 'config.apply'},
42
43
  'developer': _EVERYONE | {'queries.write', 'exports.read', 'exports.run'},
43
44
  'auditor': _EVERYONE | {'access.read', 'settings.read', 'exports.read'},
44
45
  }
@@ -90,6 +91,7 @@ OPERATIONS = {
90
91
  'v1.list_exports': 'exports.read', 'v1.get_export': 'exports.read', 'v1.list_export_runs': 'exports.read',
91
92
  'v1.create_export': 'exports.write', 'v1.update_export': 'exports.write', 'v1.delete_export': 'exports.write',
92
93
  'v1.run_export': 'exports.run',
94
+ 'v1.config_diff': 'config.apply', 'v1.config_apply': 'config.apply', 'v1.config_managed': 'observe',
93
95
  }
94
96
 
95
97
 
@@ -417,6 +417,29 @@ def _validate_allowed_tables(allowed_tables):
417
417
  return sorted({v.lower() for v in allowed_tables})
418
418
 
419
419
 
420
+ def normalize_grants(connections=None, allow_writes=False, queries=None, collections=None, rate_limit=None,
421
+ allowed_ips=None, allowed_write_ops=None, allowed_tables=None, mask=None, max_concurrent=None,
422
+ query_timeout=None, max_rows=None, quota=None):
423
+ """A key's or role's grants, checked and normalised as they're stored - without writing anything, or checking that
424
+ the collections named exist (_require_existing_collections() does, where they're saved). The one place a grant's
425
+ rules are applied: creating a key or role, and reading one from a configuration file (configcode.py)."""
426
+ return {
427
+ 'connections': _normalize_connections(connections),
428
+ 'allow_writes': bool(allow_writes),
429
+ 'queries': _normalize_queries(queries),
430
+ 'collections': _normalize_collections(collections),
431
+ 'rate_limit': _validate_rate_limit(rate_limit),
432
+ 'allowed_ips': _validate_allowed_ips(allowed_ips),
433
+ 'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
434
+ 'allowed_tables': _validate_allowed_tables(allowed_tables),
435
+ 'mask': masking.validate_rules(mask),
436
+ 'max_concurrent': _validate_limit('max_concurrent', max_concurrent),
437
+ 'query_timeout': _validate_limit('query_timeout', query_timeout),
438
+ 'max_rows': _validate_limit('max_rows', max_rows),
439
+ 'quota': quotas.validate(quota),
440
+ }
441
+
442
+
420
443
  def is_expired(entry):
421
444
  """Whether a stored key entry's expires_at date has passed - checked live on every authenticate() call,
422
445
  not swept by a background job: an expired key simply stops matching, the same way flipping `active` to
@@ -476,6 +499,13 @@ def list_keys():
476
499
  for name, entry in _read().items()}
477
500
 
478
501
 
502
+ def secret_hashes():
503
+ """{name: SHA-256 of the key's secret} - for a configuration file only (ADR 0014), which declares a key by its
504
+ hash. Not a secret: a generated secret is 32 random bytes, so its hash can't be reversed. The API never shows it
505
+ anyway, like the rest of what authenticates a key."""
506
+ return {name: entry['hash'] for name, entry in _read().items()}
507
+
508
+
479
509
  def create_key(name, connections=None, allow_writes=None, queries=None, expires_at=None, rate_limit=None,
480
510
  allowed_ips=None, allowed_write_ops=None, role=None, collections=None, allowed_tables=None,
481
511
  example=False, mask=None, max_concurrent=None, query_timeout=None, max_rows=None, quota=None):
@@ -517,20 +547,10 @@ def create_key(name, connections=None, allow_writes=None, queries=None, expires_
517
547
  raise ApiError(f"An API key named '{name}' already exists", 409, code='key_exists')
518
548
  entry = {
519
549
  'hash': _hash(secret),
520
- 'connections': _normalize_connections(connections),
521
- 'allow_writes': bool(allow_writes),
522
- 'queries': _normalize_queries(queries),
523
- 'collections': collections,
550
+ **normalize_grants(connections, allow_writes, queries, collections, rate_limit, allowed_ips,
551
+ allowed_write_ops, allowed_tables, mask, max_concurrent, query_timeout, max_rows,
552
+ quota),
524
553
  'expires_at': _validate_expiry(expires_at),
525
- 'rate_limit': _validate_rate_limit(rate_limit),
526
- 'allowed_ips': _validate_allowed_ips(allowed_ips),
527
- 'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
528
- 'allowed_tables': _validate_allowed_tables(allowed_tables),
529
- 'mask': masking.validate_rules(mask),
530
- 'max_concurrent': _validate_limit('max_concurrent', max_concurrent),
531
- 'query_timeout': _validate_limit('query_timeout', query_timeout),
532
- 'max_rows': _validate_limit('max_rows', max_rows),
533
- 'quota': quotas.validate(quota),
534
554
  'created_from_role': role,
535
555
  'active': True,
536
556
  'created_at': store.now(),
@@ -610,15 +630,45 @@ def rotate_key(name, grace_minutes=None):
610
630
  '- end that first (DELETE .../previous), or wait for it', 409,
611
631
  code='key_rotation_pending', previous_expires_at=row['previous_expires_at'])
612
632
  secret = 'sk_' + secrets.token_urlsafe(32)
613
- until = (datetime.now() + timedelta(minutes=grace)).strftime('%Y-%m-%d %H:%M:%S') if grace else None
614
- details = json.loads(row['details_json'])
615
- details.pop('previous_last_used_at', None)
616
- conn.execute('UPDATE api_keys SET hash = ?, previous_hash = ?, previous_expires_at = ?, details_json = ? '
617
- 'WHERE name = ?', (_hash(secret), row['hash'] if until else None, until, json.dumps(details),
618
- name))
633
+ until = _replace_hash(conn, name, row, _hash(secret), grace)
619
634
  return secret, until
620
635
 
621
636
 
637
+ def _replace_hash(conn, name, row, new_hash, grace):
638
+ """The key's secret becomes the one `new_hash` is the hash of; the old one keeps working `grace` minutes."""
639
+ until = (datetime.now() + timedelta(minutes=grace)).strftime('%Y-%m-%d %H:%M:%S') if grace else None
640
+ details = json.loads(row['details_json'])
641
+ details.pop('previous_last_used_at', None)
642
+ conn.execute('UPDATE api_keys SET hash = ?, previous_hash = ?, previous_expires_at = ?, details_json = ? '
643
+ 'WHERE name = ?', (new_hash, row['hash'] if until else None, until, json.dumps(details), name))
644
+ return until
645
+
646
+
647
+ def set_secret_hash(name, secret_hash, grace_minutes=None):
648
+ """A key's secret set to the one `secret_hash` is the SHA-256 of - a configuration file's `secret_sha256`
649
+ (ADR 0014) - with the old secret kept working for `grace_minutes`, as rotate_key() does. The secret itself is
650
+ never seen here. Refused when another key already has it: one secret must name one key. Returns when the old
651
+ secret stops working."""
652
+ grace = _validate_grace(grace_minutes)
653
+ with db.transaction() as conn:
654
+ row = conn.execute('SELECT hash, details_json, previous_hash, previous_expires_at FROM api_keys WHERE name = ?',
655
+ (name,)).fetchone()
656
+ if row is None:
657
+ raise ApiError(f"API key '{name}' not found", 404, code='key_not_found')
658
+ taken = conn.execute('SELECT name FROM api_keys WHERE (hash = ? OR previous_hash = ?) AND name <> ?',
659
+ (secret_hash, secret_hash, name)).fetchone()
660
+ if taken is not None:
661
+ raise ApiError("Another key already has this secret_sha256 - each key needs its own secret",
662
+ 409, code='key_secret_taken')
663
+ if row['hash'] == secret_hash:
664
+ return None
665
+ if grace and row['previous_hash'] and row['previous_expires_at'] and row['previous_expires_at'] > store.now():
666
+ raise ApiError(f"The secret this key had before its last change works until {row['previous_expires_at']} "
667
+ '- end that first (DELETE .../previous), or wait for it', 409,
668
+ code='key_rotation_pending', previous_expires_at=row['previous_expires_at'])
669
+ return _replace_hash(conn, name, row, secret_hash, grace)
670
+
671
+
622
672
  def end_previous(name):
623
673
  """Stop the old secret now, before its grace period ends. Returns when it would have stopped."""
624
674
  with db.transaction() as conn:
@@ -685,19 +735,9 @@ def create_role(name, connections=None, allow_writes=False, queries=None, rate_l
685
735
  if conn.execute('SELECT 1 FROM roles WHERE name = ?', (name,)).fetchone() is not None:
686
736
  raise ApiError(f"A role named '{name}' already exists", 409, code='role_exists')
687
737
  entry = {
688
- 'connections': _normalize_connections(connections),
689
- 'allow_writes': bool(allow_writes),
690
- 'queries': _normalize_queries(queries),
691
- 'collections': collections,
692
- 'rate_limit': _validate_rate_limit(rate_limit),
693
- 'allowed_ips': _validate_allowed_ips(allowed_ips),
694
- 'allowed_write_ops': _validate_allowed_write_ops(allowed_write_ops),
695
- 'allowed_tables': _validate_allowed_tables(allowed_tables),
696
- 'mask': masking.validate_rules(mask),
697
- 'max_concurrent': _validate_limit('max_concurrent', max_concurrent),
698
- 'query_timeout': _validate_limit('query_timeout', query_timeout),
699
- 'max_rows': _validate_limit('max_rows', max_rows),
700
- 'quota': quotas.validate(quota),
738
+ **normalize_grants(connections, allow_writes, queries, collections, rate_limit, allowed_ips,
739
+ allowed_write_ops, allowed_tables, mask, max_concurrent, query_timeout, max_rows,
740
+ quota),
701
741
  'created_at': store.now(),
702
742
  **({'example': True} if example else {}), # installed by `examples load` - see examples.py
703
743
  }
@@ -91,6 +91,32 @@ class JSONProvider(DefaultJSONProvider):
91
91
  sort_keys = False # keep result columns in the order the query returned them
92
92
 
93
93
 
94
+ def apply_config_at_startup():
95
+ """QUERYAPIGATE_CONFIG_FILE (ADR 0014): apply it before serving - once per file, so with several instances the first
96
+ to start applies it and the others find it done. A file that can't be applied stops startup, changing nothing:
97
+ in a rolling update the new pods don't come up, and the ones still serving keep the configuration they have."""
98
+ from . import configcode
99
+ path = config.config_file()
100
+ if path is None:
101
+ return
102
+ try:
103
+ doc = configcode.load(path.read_text())
104
+ result = configcode.apply(doc, prune=config.config_prune(), actor='startup', once=True)
105
+ except OSError as error:
106
+ log.error('QUERYAPIGATE_CONFIG_FILE %s could not be read: %s', path, error)
107
+ raise ValueError(f'QUERYAPIGATE_CONFIG_FILE {path} could not be read: {error}') from None
108
+ except configcode.ConfigInvalid as error:
109
+ message = f'QUERYAPIGATE_CONFIG_FILE {path} was not applied - nothing in it was. ' + '; '.join(error.problems)
110
+ log.error('%s', message) # one line in the log, whatever prints the exception after (gunicorn: a traceback)
111
+ raise ValueError(message) from None
112
+ if result.get('already_applied'):
113
+ log.info('QUERYAPIGATE_CONFIG_FILE %s (sha256 %s) is already applied', path, result['file_sha256'][:12])
114
+ else:
115
+ counts = {a: sum(1 for c in result['changes'] if c['action'] == a) for a in ('create', 'update', 'delete')}
116
+ log.info('Applied QUERYAPIGATE_CONFIG_FILE %s (sha256 %s): %d created, %d changed, %d deleted', path,
117
+ result['file_sha256'][:12], counts['create'], counts['update'], counts['delete'])
118
+
119
+
94
120
  def load_examples_at_startup():
95
121
  """QUERYAPIGATE_LOAD_EXAMPLES=yes: make sure the example APIs are installed. A problem (something of yours
96
122
  holds an example's name, the home is read-only) is a warning, not a startup failure - the examples are a
@@ -190,6 +216,7 @@ def create_app():
190
216
  _note_audit_key_rotation()
191
217
  from . import auditstream
192
218
  auditstream.start() # the audit log to a SIEM, when an output is set (ADR 0012)
219
+ apply_config_at_startup()
193
220
  if config.load_examples():
194
221
  load_examples_at_startup()
195
222
  app.json = JSONProvider(app)
@@ -2,9 +2,11 @@
2
2
  ``collection export|import``."""
3
3
  import argparse
4
4
  import contextlib
5
+ import hashlib
5
6
  import json
6
7
  import logging
7
8
  import os
9
+ import secrets
8
10
  import sys
9
11
  import time
10
12
  from datetime import datetime, timedelta
@@ -15,6 +17,7 @@ from . import (
15
17
  admins,
16
18
  bundle,
17
19
  config,
20
+ configcode,
18
21
  db,
19
22
  examples,
20
23
  experimental,
@@ -333,6 +336,130 @@ def _collection_import(args):
333
336
  return 0
334
337
 
335
338
 
339
+ def _config_command(action):
340
+ """Shared shell for the config subcommands (ADR 0014). Every failure exits 1, settings included: `config diff`
341
+ exits 2 for "there are changes", so a script can tell the two apart."""
342
+ def run(args):
343
+ try:
344
+ config.check_settings()
345
+ return action(args)
346
+ except configcode.ConfigInvalid as error:
347
+ print(f'queryapigate config {args.action}: {len(error.problems)} problem(s) in the file:', file=sys.stderr)
348
+ for problem in error.problems:
349
+ print(f' {problem}', file=sys.stderr)
350
+ return 1
351
+ except ApiError as error:
352
+ print(f'queryapigate config {args.action}: {error.message}', file=sys.stderr)
353
+ return 1
354
+ except (OSError, ValueError) as error:
355
+ print(f'queryapigate config {args.action}: {error}', file=sys.stderr)
356
+ return 1
357
+ return run
358
+
359
+
360
+ def _read_config_file(path):
361
+ if path == '-':
362
+ return configcode.load(sys.stdin.read())
363
+ with open(path) as f:
364
+ return configcode.load(f.read())
365
+
366
+
367
+ @_config_command
368
+ def _config_export(args):
369
+ kinds = tuple(k.strip() for k in args.kinds.split(',')) if args.kinds else configcode.KINDS
370
+ doc, variables, skipped = configcode.export(kinds, examples=args.examples)
371
+ text = configcode.dump(doc, variables)
372
+ if args.out is None:
373
+ sys.stdout.write(text)
374
+ else:
375
+ out_dir = os.path.dirname(args.out) or '.'
376
+ tmp_path = os.path.join(out_dir, f'.{os.path.basename(args.out)}.part')
377
+ try:
378
+ with open(tmp_path, 'w') as f:
379
+ f.write(text)
380
+ os.replace(tmp_path, args.out) # a failed run never leaves a partial file at the destination
381
+ finally:
382
+ if os.path.exists(tmp_path):
383
+ os.remove(tmp_path)
384
+ print(f'Wrote the configuration to {args.out}', file=sys.stderr)
385
+ for variable in variables:
386
+ print(f"Set {variable['name']} where the server runs: {variable['for']}'s password, stored "
387
+ f"{variable['stored']} here", file=sys.stderr)
388
+ for name in skipped:
389
+ print(f'Left out saved query {name}: it has no published version', file=sys.stderr)
390
+ return 0
391
+
392
+
393
+ def _remote(args, operation, **flags):
394
+ """POST the file to a running server's /api/v1/config/<operation> (`--server`), with the key in QAG_API_KEY - an
395
+ environment variable, so it never shows in a CI log or a process list."""
396
+ import urllib.error
397
+ import urllib.request
398
+ key = os.environ.get('QAG_API_KEY', '').strip()
399
+ if not key:
400
+ raise ApiError('--server needs the API key in the QAG_API_KEY environment variable')
401
+ doc = _read_config_file(args.file)
402
+ query = '&'.join(f'{k}=true' for k, v in flags.items() if v)
403
+ url = args.server.rstrip('/') + f'/api/v1/config/{operation}' + (f'?{query}' if query else '')
404
+ request = urllib.request.Request(url, data=json.dumps(doc).encode(), method='POST',
405
+ headers={'X-API-Key': key, 'Content-Type': 'application/json'})
406
+ try:
407
+ with urllib.request.urlopen(request, timeout=120) as response:
408
+ return json.load(response)
409
+ except urllib.error.HTTPError as error:
410
+ try:
411
+ body = json.load(error)
412
+ except ValueError:
413
+ raise ApiError(f'{url} answered {error.code}') from None
414
+ if body.get('code') == 'invalid_config':
415
+ raise configcode.ConfigInvalid(body.get('problems') or [body.get('error')]) from None
416
+ raise ApiError(f"{url} answered {error.code}: {body.get('error')}") from None
417
+ except urllib.error.URLError as error:
418
+ raise ApiError(f'{url}: {error.reason}') from None
419
+
420
+
421
+ @_config_command
422
+ def _config_diff(args):
423
+ if args.server:
424
+ result = _remote(args, 'diff', prune=args.prune)
425
+ else:
426
+ result = configcode.diff(_read_config_file(args.file), prune=args.prune)
427
+ if args.json:
428
+ print(json.dumps(result, indent=2, default=str))
429
+ else:
430
+ print(configcode.render(result))
431
+ return 2 if result['changes'] else 0
432
+
433
+
434
+ @_config_command
435
+ def _config_apply(args):
436
+ if args.server:
437
+ result = _remote(args, 'apply', prune=args.prune, dry_run=args.dry_run)
438
+ elif args.dry_run:
439
+ result = {**configcode.diff(_read_config_file(args.file), prune=args.prune), 'applied': False}
440
+ else:
441
+ result = configcode.apply(_read_config_file(args.file), prune=args.prune, actor='cli')
442
+ if args.json:
443
+ print(json.dumps(result, indent=2, default=str))
444
+ return 0
445
+ print(configcode.render(result))
446
+ if args.dry_run:
447
+ print('Dry run: nothing was changed.')
448
+ elif result['applied']:
449
+ print(f"Applied: the server matches the file (sha256 {result['file_sha256'][:12]}).")
450
+ return 0
451
+
452
+
453
+ def _config_new_key(args):
454
+ """A fresh API key secret and its SHA-256, for a key declared in a configuration file. Nothing is stored."""
455
+ secret = 'sk_' + secrets.token_urlsafe(32)
456
+ print(f'secret: {secret}')
457
+ print(f'secret_sha256: {hashlib.sha256(secret.encode()).hexdigest()}')
458
+ print('Give the secret to whoever uses the key, keep it in your secrets manager, and put only secret_sha256 in '
459
+ 'the file.', file=sys.stderr)
460
+ return 0
461
+
462
+
336
463
  def _examples_command(action):
337
464
  """Shared shell for the examples subcommands - the same settings check and clean-error handling as the
338
465
  collection ones."""
@@ -686,6 +813,42 @@ def build_parser():
686
813
  collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
687
814
  collection_import.set_defaults(func=_collection_import)
688
815
 
816
+ config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014, experimental): "
817
+ 'export it, see what applying one would change, apply it')
818
+ config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
819
+ config_actions = config_parser.add_subparsers(dest='action')
820
+ config_export = config_actions.add_parser(
821
+ 'export', help='write connections, destinations, roles, saved queries, API keys, descriptions and exports as '
822
+ 'one YAML file - passwords as ${VAR} references, keys as their hashes, never a secret')
823
+ config_export.add_argument('--kinds', help=f"only these, comma-separated (of: {', '.join(configcode.KINDS)})")
824
+ config_export.add_argument('--examples', action='store_true', help='include the bundled examples too')
825
+ config_export.add_argument('--out', help='write to this file instead of stdout')
826
+ config_export.set_defaults(func=_config_export)
827
+ config_diff = config_actions.add_parser(
828
+ 'diff', help='what applying FILE would create and change, field by field - writes nothing. Exit 0: no '
829
+ 'changes; 2: changes; 1: the file has problems')
830
+ config_diff.add_argument('file', help='a configuration file (YAML or JSON), or - for stdin')
831
+ config_diff.add_argument('--json', action='store_true', help='the changes as JSON, for a script')
832
+ config_diff.add_argument('--prune', action='store_true', help='also show what --prune would delete')
833
+ config_diff.add_argument('--server', metavar='URL', help="a running server's base URL, instead of this home's "
834
+ 'store - the key in QAG_API_KEY (CI against prod)')
835
+ config_diff.set_defaults(func=_config_diff)
836
+ config_apply = config_actions.add_parser(
837
+ 'apply', help='make the server match FILE, all or nothing - every change through the same rules as the API, '
838
+ 'audited; what the file does not name is left alone')
839
+ config_apply.add_argument('file', help='a configuration file (YAML or JSON), or - for stdin')
840
+ config_apply.add_argument('--prune', action='store_true',
841
+ help='also delete what a file manages (created, changed or named before) and this one '
842
+ 'no longer has - never what was made in the Console or the API')
843
+ config_apply.add_argument('--dry-run', action='store_true', help='say what would change; change nothing')
844
+ config_apply.add_argument('--json', action='store_true', help='the result as JSON, for a script')
845
+ config_apply.add_argument('--server', metavar='URL', help="a running server's base URL, instead of this home's "
846
+ 'store - the key in QAG_API_KEY (an owner\'s)')
847
+ config_apply.set_defaults(func=_config_apply)
848
+ config_new_key = config_actions.add_parser(
849
+ 'new-key', help="a fresh API key secret and its secret_sha256, for a key declared in a file - nothing stored")
850
+ config_new_key.set_defaults(func=_config_new_key)
851
+
689
852
  example_parser = commands.add_parser('examples', help='load, remove or check the bundled example APIs '
690
853
  '(reporting, dashboard, export, partner)')
691
854
  example_parser.set_defaults(func=lambda a: example_parser.print_help() or 2)
@@ -759,6 +922,8 @@ def main(argv=None):
759
922
  args = parser.parse_args(['serve', *(argv or [])])
760
923
  if getattr(args, 'home', None):
761
924
  os.environ['QUERYAPIGATE_HOME'] = args.home
925
+ if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
926
+ return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
762
927
  try:
763
928
  config.check_database_url()
764
929
  db.init_schema()
@@ -64,6 +64,25 @@ def load_examples():
64
64
  return env_flag('QUERYAPIGATE_LOAD_EXAMPLES')
65
65
 
66
66
 
67
+ def config_file():
68
+ """QUERYAPIGATE_CONFIG_FILE: a configuration file (ADR 0014) applied at startup, before the server serves - once per
69
+ file: an instance that finds it already applied leaves the store alone. None when unset."""
70
+ raw = os.environ.get('QUERYAPIGATE_CONFIG_FILE', '').strip()
71
+ return Path(raw) if raw else None
72
+
73
+
74
+ def config_prune():
75
+ """QUERYAPIGATE_CONFIG_PRUNE: applying QUERYAPIGATE_CONFIG_FILE at startup also deletes what a file manages and this
76
+ one no longer has (`config apply --prune`)."""
77
+ return env_flag('QUERYAPIGATE_CONFIG_PRUNE')
78
+
79
+
80
+ def config_locked():
81
+ """QUERYAPIGATE_CONFIG_LOCKED: what a configuration file manages can be changed only through a file - the API and
82
+ the Console answer 409 config_managed (ADR 0014)."""
83
+ return env_flag('QUERYAPIGATE_CONFIG_LOCKED')
84
+
85
+
67
86
  def home():
68
87
  """Folder holding queryapigate.db (QUERYAPIGATE_HOME, default: current directory)."""
69
88
  return _resolved_home(os.environ.get('QUERYAPIGATE_HOME') or '', os.getcwd())
@@ -335,6 +354,13 @@ def check_settings():
335
354
  raw = os.environ.get('QUERYAPIGATE_LOAD_EXAMPLES', '').strip().lower()
336
355
  if raw not in _FLAG_WORDS:
337
356
  raise ValueError(f"QUERYAPIGATE_LOAD_EXAMPLES must be yes or no (or 1/0, true/false, on/off), not '{raw}'")
357
+ for name in ('QUERYAPIGATE_CONFIG_PRUNE', 'QUERYAPIGATE_CONFIG_LOCKED'):
358
+ raw = os.environ.get(name, '').strip().lower()
359
+ if raw not in _FLAG_WORDS:
360
+ raise ValueError(f"{name} must be yes or no (or 1/0, true/false, on/off), not '{raw}'")
361
+ if config_prune() and config_file() is None:
362
+ raise ValueError('QUERYAPIGATE_CONFIG_PRUNE is set but QUERYAPIGATE_CONFIG_FILE is not - prune what, against '
363
+ 'which file?')
338
364
  raw = os.environ.get('QUERYAPIGATE_RATE_LIMIT', '').strip()
339
365
  if raw:
340
366
  parse_rate_limit(raw)
@@ -1004,6 +1030,13 @@ def describe_settings():
1004
1030
  else f'PostgreSQL ({redact_url(database_url())})', secret=True),
1005
1031
  row('Load examples', 'Load the example APIs at startup. Idempotent.',
1006
1032
  'QUERYAPIGATE_LOAD_EXAMPLES', on_off(load_examples())),
1033
+ row('Configuration file', 'A configuration file applied at startup, once per file - connections, '
1034
+ 'queries, roles, keys and the rest as code (ADR 0014).', 'QUERYAPIGATE_CONFIG_FILE',
1035
+ str(config_file()) if config_file() else 'none'),
1036
+ row('Prune at startup', 'Applying it also deletes what a file manages and this one no longer has.',
1037
+ 'QUERYAPIGATE_CONFIG_PRUNE', on_off(config_prune())),
1038
+ row('Locked to the file', 'What a file manages can be changed only through a file, not here or the API.',
1039
+ 'QUERYAPIGATE_CONFIG_LOCKED', on_off(config_locked())),
1007
1040
  row('H2 driver', 'JAR used for h2 connections.', 'QUERYAPIGATE_H2_JAR',
1008
1041
  Path(h2_jar()).name + ('' if os.environ.get('QUERYAPIGATE_H2_JAR') else ' (bundled)'))]},
1009
1042
  {'id': 'security', 'title': 'Security',