keboola-cli 0.63.4__py3-none-any.whl

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 (306) hide show
  1. keboola_agent_cli/__init__.py +34 -0
  2. keboola_agent_cli/__main__.py +5 -0
  3. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
  4. keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
  5. keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
  6. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
  7. keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
  8. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
  9. keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
  10. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
  11. keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
  12. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
  13. keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
  14. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
  15. keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
  16. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
  17. keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
  18. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
  19. keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
  20. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
  21. keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
  22. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
  23. keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
  24. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
  25. keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
  26. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
  27. keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
  28. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
  29. keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
  30. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
  31. keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
  32. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
  33. keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
  34. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
  35. keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
  36. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
  37. keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
  38. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
  39. keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
  40. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
  41. keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
  42. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
  43. keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
  44. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
  45. keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
  46. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
  47. keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
  48. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
  49. keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
  50. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
  51. keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
  52. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
  53. keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
  54. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
  55. keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
  56. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
  57. keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
  58. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
  59. keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
  60. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
  61. keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
  62. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
  63. keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
  64. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
  65. keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
  66. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
  67. keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
  68. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
  69. keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
  70. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
  71. keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
  72. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
  73. keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
  74. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
  75. keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
  76. keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
  77. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
  78. keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
  79. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
  80. keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
  81. keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
  82. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
  83. keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
  84. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
  85. keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
  86. keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
  87. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
  88. keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
  89. keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
  90. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
  91. keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
  92. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
  93. keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
  94. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
  95. keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
  96. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
  97. keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
  98. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
  99. keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
  100. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
  101. keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
  102. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
  103. keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
  104. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
  105. keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
  106. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
  107. keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
  108. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
  109. keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
  110. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
  111. keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
  112. keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
  113. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
  114. keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
  115. keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
  116. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
  117. keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
  118. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
  119. keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
  120. keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
  121. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
  122. keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
  123. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
  124. keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
  125. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
  126. keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
  127. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
  128. keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
  129. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
  130. keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
  131. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
  132. keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
  133. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
  134. keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
  135. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
  136. keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
  137. keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
  138. keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
  139. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
  140. keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
  141. keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
  142. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
  143. keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
  144. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
  145. keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
  146. keboola_agent_cli/_ui_dist/index.html +50 -0
  147. keboola_agent_cli/ai_client.py +83 -0
  148. keboola_agent_cli/auto_update.py +550 -0
  149. keboola_agent_cli/changelog.py +1198 -0
  150. keboola_agent_cli/cli.py +448 -0
  151. keboola_agent_cli/client.py +3422 -0
  152. keboola_agent_cli/commands/__init__.py +0 -0
  153. keboola_agent_cli/commands/_data_app_git.py +343 -0
  154. keboola_agent_cli/commands/_helpers.py +377 -0
  155. keboola_agent_cli/commands/_metadata_input.py +49 -0
  156. keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
  157. keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
  158. keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
  159. keboola_agent_cli/commands/agent.py +968 -0
  160. keboola_agent_cli/commands/branch.py +423 -0
  161. keboola_agent_cli/commands/changelog.py +168 -0
  162. keboola_agent_cli/commands/component.py +216 -0
  163. keboola_agent_cli/commands/config.py +2442 -0
  164. keboola_agent_cli/commands/context.py +1481 -0
  165. keboola_agent_cli/commands/data_app.py +1279 -0
  166. keboola_agent_cli/commands/dev_portal.py +584 -0
  167. keboola_agent_cli/commands/doctor.py +37 -0
  168. keboola_agent_cli/commands/encrypt.py +145 -0
  169. keboola_agent_cli/commands/feature.py +311 -0
  170. keboola_agent_cli/commands/flow.py +948 -0
  171. keboola_agent_cli/commands/http_client.py +157 -0
  172. keboola_agent_cli/commands/init.py +279 -0
  173. keboola_agent_cli/commands/job.py +661 -0
  174. keboola_agent_cli/commands/kai.py +301 -0
  175. keboola_agent_cli/commands/lineage.py +1464 -0
  176. keboola_agent_cli/commands/org.py +292 -0
  177. keboola_agent_cli/commands/permissions.py +360 -0
  178. keboola_agent_cli/commands/project.py +1192 -0
  179. keboola_agent_cli/commands/repl.py +243 -0
  180. keboola_agent_cli/commands/schedule.py +340 -0
  181. keboola_agent_cli/commands/search.py +178 -0
  182. keboola_agent_cli/commands/semantic_layer.py +939 -0
  183. keboola_agent_cli/commands/serve.py +272 -0
  184. keboola_agent_cli/commands/sharing.py +340 -0
  185. keboola_agent_cli/commands/storage.py +2630 -0
  186. keboola_agent_cli/commands/stream.py +266 -0
  187. keboola_agent_cli/commands/sync.py +1277 -0
  188. keboola_agent_cli/commands/tool.py +206 -0
  189. keboola_agent_cli/commands/version.py +186 -0
  190. keboola_agent_cli/commands/workspace.py +635 -0
  191. keboola_agent_cli/config_store.py +582 -0
  192. keboola_agent_cli/constants.py +528 -0
  193. keboola_agent_cli/data_science_client.py +342 -0
  194. keboola_agent_cli/dev_portal_client.py +323 -0
  195. keboola_agent_cli/errors.py +248 -0
  196. keboola_agent_cli/http_base.py +315 -0
  197. keboola_agent_cli/json_utils.py +126 -0
  198. keboola_agent_cli/lib.py +536 -0
  199. keboola_agent_cli/manage_client.py +324 -0
  200. keboola_agent_cli/metastore_client.py +214 -0
  201. keboola_agent_cli/models.py +427 -0
  202. keboola_agent_cli/output.py +1084 -0
  203. keboola_agent_cli/permissions.py +469 -0
  204. keboola_agent_cli/py.typed +3 -0
  205. keboola_agent_cli/result_models.py +271 -0
  206. keboola_agent_cli/server/__init__.py +34 -0
  207. keboola_agent_cli/server/agent_runner.py +1289 -0
  208. keboola_agent_cli/server/agents_store.py +325 -0
  209. keboola_agent_cli/server/app.py +764 -0
  210. keboola_agent_cli/server/auth.py +117 -0
  211. keboola_agent_cli/server/dependencies.py +149 -0
  212. keboola_agent_cli/server/pricing.py +303 -0
  213. keboola_agent_cli/server/routers/__init__.py +1 -0
  214. keboola_agent_cli/server/routers/agents.py +616 -0
  215. keboola_agent_cli/server/routers/ai_chat.py +129 -0
  216. keboola_agent_cli/server/routers/branches.py +133 -0
  217. keboola_agent_cli/server/routers/components.py +48 -0
  218. keboola_agent_cli/server/routers/configs.py +507 -0
  219. keboola_agent_cli/server/routers/data_apps.py +384 -0
  220. keboola_agent_cli/server/routers/dev_portal.py +67 -0
  221. keboola_agent_cli/server/routers/encrypt.py +35 -0
  222. keboola_agent_cli/server/routers/feature.py +179 -0
  223. keboola_agent_cli/server/routers/flows.py +204 -0
  224. keboola_agent_cli/server/routers/health.py +53 -0
  225. keboola_agent_cli/server/routers/jobs.py +175 -0
  226. keboola_agent_cli/server/routers/kai.py +80 -0
  227. keboola_agent_cli/server/routers/lineage.py +226 -0
  228. keboola_agent_cli/server/routers/mcp.py +70 -0
  229. keboola_agent_cli/server/routers/members.py +170 -0
  230. keboola_agent_cli/server/routers/org.py +96 -0
  231. keboola_agent_cli/server/routers/projects.py +106 -0
  232. keboola_agent_cli/server/routers/schedules.py +54 -0
  233. keboola_agent_cli/server/routers/search.py +30 -0
  234. keboola_agent_cli/server/routers/semantic_layer.py +650 -0
  235. keboola_agent_cli/server/routers/sharing.py +86 -0
  236. keboola_agent_cli/server/routers/storage.py +574 -0
  237. keboola_agent_cli/server/routers/stream.py +100 -0
  238. keboola_agent_cli/server/routers/workspaces.py +302 -0
  239. keboola_agent_cli/server/run_broadcaster.py +329 -0
  240. keboola_agent_cli/server/sse.py +25 -0
  241. keboola_agent_cli/services/__init__.py +0 -0
  242. keboola_agent_cli/services/_encryption.py +217 -0
  243. keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
  244. keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
  245. keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
  246. keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
  247. keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
  248. keboola_agent_cli/services/_sync_bindings.py +456 -0
  249. keboola_agent_cli/services/_sync_branch.py +191 -0
  250. keboola_agent_cli/services/_sync_bulk.py +228 -0
  251. keboola_agent_cli/services/_sync_clone.py +163 -0
  252. keboola_agent_cli/services/_sync_models.py +97 -0
  253. keboola_agent_cli/services/_sync_push_ops.py +369 -0
  254. keboola_agent_cli/services/_sync_storage.py +376 -0
  255. keboola_agent_cli/services/_sync_writeback.py +167 -0
  256. keboola_agent_cli/services/agent_service.py +458 -0
  257. keboola_agent_cli/services/base.py +175 -0
  258. keboola_agent_cli/services/branch_service.py +588 -0
  259. keboola_agent_cli/services/component_service.py +694 -0
  260. keboola_agent_cli/services/config_service.py +2099 -0
  261. keboola_agent_cli/services/data_app_git_service.py +224 -0
  262. keboola_agent_cli/services/data_app_service.py +2082 -0
  263. keboola_agent_cli/services/deep_lineage_service.py +1322 -0
  264. keboola_agent_cli/services/dev_portal_service.py +345 -0
  265. keboola_agent_cli/services/doctor_service.py +445 -0
  266. keboola_agent_cli/services/encrypt_service.py +87 -0
  267. keboola_agent_cli/services/feature_service.py +268 -0
  268. keboola_agent_cli/services/flow_service.py +769 -0
  269. keboola_agent_cli/services/flow_validation.py +188 -0
  270. keboola_agent_cli/services/http_forwarder_service.py +236 -0
  271. keboola_agent_cli/services/job_idempotency_store.py +285 -0
  272. keboola_agent_cli/services/job_service.py +797 -0
  273. keboola_agent_cli/services/kai_service.py +367 -0
  274. keboola_agent_cli/services/lineage_service.py +274 -0
  275. keboola_agent_cli/services/mcp_service.py +1498 -0
  276. keboola_agent_cli/services/mcp_transport.py +259 -0
  277. keboola_agent_cli/services/member_service.py +593 -0
  278. keboola_agent_cli/services/org_service.py +619 -0
  279. keboola_agent_cli/services/project_service.py +947 -0
  280. keboola_agent_cli/services/repo_validate_service.py +767 -0
  281. keboola_agent_cli/services/schedule_service.py +731 -0
  282. keboola_agent_cli/services/search_service.py +331 -0
  283. keboola_agent_cli/services/semantic_layer_service.py +1497 -0
  284. keboola_agent_cli/services/sharing_service.py +307 -0
  285. keboola_agent_cli/services/storage_service.py +2524 -0
  286. keboola_agent_cli/services/stream_service.py +395 -0
  287. keboola_agent_cli/services/sync_service.py +2244 -0
  288. keboola_agent_cli/services/variables_service.py +447 -0
  289. keboola_agent_cli/services/version_service.py +1038 -0
  290. keboola_agent_cli/services/workspace_service.py +1103 -0
  291. keboola_agent_cli/stream_client.py +217 -0
  292. keboola_agent_cli/sync/__init__.py +1 -0
  293. keboola_agent_cli/sync/branch_mapping.py +174 -0
  294. keboola_agent_cli/sync/clone.py +211 -0
  295. keboola_agent_cli/sync/code_extraction.py +655 -0
  296. keboola_agent_cli/sync/config_format.py +290 -0
  297. keboola_agent_cli/sync/diff_engine.py +566 -0
  298. keboola_agent_cli/sync/git_utils.py +93 -0
  299. keboola_agent_cli/sync/manifest.py +162 -0
  300. keboola_agent_cli/sync/naming.py +90 -0
  301. keboola_agent_cli/sync/secrets.py +62 -0
  302. keboola_agent_cli/sync/sql_split.py +134 -0
  303. keboola_cli-0.63.4.dist-info/METADATA +308 -0
  304. keboola_cli-0.63.4.dist-info/RECORD +306 -0
  305. keboola_cli-0.63.4.dist-info/WHEEL +4 -0
  306. keboola_cli-0.63.4.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2082 @@
1
+ """Data-app service — Keboola Data Science API + ``keboola.data-apps``.
2
+
3
+ Owns the orchestration that the underlying APIs do *not* provide:
4
+
5
+ - the §9 redeploy contract (read latest Storage version, then PATCH with
6
+ ``{desiredState=running, configVersion, restartIfRunning=true}`` together)
7
+ - per-project KMS encryption of git PATs via :class:`EncryptService`
8
+ - cleanup-in-finally on initial-deploy failure so a failed
9
+ ``data-app create`` does not leak an empty deployment shell
10
+ - a poll loop that refuses to treat ``state == stopped`` as terminal while
11
+ ``desiredState == running`` (writeup §8 pitfall row 1 — the transient
12
+ ``stopped`` between the initial container teardown and runtime spin-up)
13
+
14
+ Naming convention: callers pass an integer-like ``app_id``; we coerce to
15
+ str so paths build cleanly regardless of input type.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ import time
24
+ from collections.abc import Callable
25
+ from datetime import datetime
26
+ from typing import Any
27
+
28
+ from ..constants import DEFAULT_JOB_RUN_TIMEOUT
29
+ from ..data_science_client import DataScienceClient
30
+ from ..errors import ConfigError, ErrorCode, KeboolaApiError
31
+ from ..models import ProjectConfig
32
+ from .base import BaseService, ClientFactory
33
+ from .encrypt_service import EncryptService
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ DataScienceClientFactory = Callable[[str, str], DataScienceClient]
39
+
40
+
41
+ def _default_ds_client_factory(stack_url: str, token: str) -> DataScienceClient:
42
+ return DataScienceClient(stack_url=stack_url, token=token)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Constants encoded from the writeup
47
+ # ---------------------------------------------------------------------------
48
+
49
+ DATA_APP_COMPONENT_ID = "keboola.data-apps"
50
+
51
+ VALID_TYPES: tuple[str, ...] = (
52
+ "python-js",
53
+ "python",
54
+ "streamlit",
55
+ "r",
56
+ "python-databricks",
57
+ "python-snowpark",
58
+ "python-mlflow",
59
+ )
60
+ DEFAULT_TYPE = "python-js"
61
+
62
+ VALID_SIZES: tuple[str, ...] = ("tiny", "small", "medium", "large")
63
+ DEFAULT_SIZE = "tiny"
64
+
65
+ DEFAULT_AUTO_SUSPEND_SECONDS = 900
66
+
67
+ # Slug must match the URL-safe segment used in the auto-minted hostname.
68
+ SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$")
69
+
70
+ # Encrypted-secret prefixes produced by the Encryption API for project-scoped
71
+ # (KMS) ciphertext. The platform emits both ``KBC::ProjectSecure`` (legacy)
72
+ # and ``KBC::ProjectSecureGKMS`` (GCP); both are project-bound and decrypt
73
+ # only with the originating project's KMS key.
74
+ ENCRYPTED_PASSWORD_PREFIXES: tuple[str, ...] = (
75
+ "KBC::ProjectSecure::",
76
+ "KBC::ProjectSecureGKMS::",
77
+ "KBC::ProjectSecureKMS::",
78
+ )
79
+
80
+ # Defence-in-depth caps for free-form user input. The platform may accept
81
+ # longer values, but kbagent refuses anything beyond these bounds at the
82
+ # service boundary so an external caller using the service directly cannot
83
+ # exfiltrate giant payloads or smuggle control characters into audit logs.
84
+ MAX_NAME_LENGTH = 255
85
+ MAX_DESCRIPTION_LENGTH = 65_536 # 64 KiB
86
+ MAX_GIT_REPO_LENGTH = 1024
87
+ MAX_GIT_BRANCH_LENGTH = 255
88
+ MAX_GIT_USERNAME_LENGTH = 255
89
+
90
+ POLL_INTERVAL_SECONDS = 5.0
91
+ TERMINAL_ERROR_STATE = "error"
92
+ RUNNING_STATE = "running"
93
+ STOPPED_STATE = "stopped"
94
+
95
+
96
+ def _has_control_chars(value: str, *, allow_whitespace: bool = False) -> bool:
97
+ """Return True if ``value`` contains any ASCII control byte (0x00-0x1f / 0x7f).
98
+
99
+ Set ``allow_whitespace=True`` to permit ``\\t \\n \\r`` (description markdown);
100
+ everything else under 0x20 + 0x7f is still rejected.
101
+ """
102
+ allowed = {0x09, 0x0A, 0x0D} if allow_whitespace else set()
103
+ for ch in value:
104
+ code = ord(ch)
105
+ if code in allowed:
106
+ continue
107
+ if code < 0x20 or code == 0x7F:
108
+ return True
109
+ return False
110
+
111
+
112
+ # URL schemes accepted for ``--git-repo``. Anything else (file://, gopher://,
113
+ # bare ssh syntax like ``git@host:path``) is rejected at the service
114
+ # boundary -- the data-app runner only ever talks https / http / git / ssh.
115
+ ALLOWED_GIT_REPO_SCHEMES: tuple[str, ...] = (
116
+ "https://",
117
+ "http://",
118
+ "ssh://",
119
+ "git://",
120
+ )
121
+
122
+ # Secret-key shape accepted under ``parameters.dataApp.secrets``. Must start
123
+ # with ``#`` (Keboola encryption convention); the rest must form a valid
124
+ # environment-variable identifier after the runtime translation rule
125
+ # (uppercased, ``-`` -> ``_``, ``#`` stripped). Cap at 64 chars so the
126
+ # derived env var stays under typical shell limits.
127
+ SECRET_KEY_PATTERN = re.compile(r"^#[A-Za-z][A-Za-z0-9_-]{0,63}$")
128
+
129
+ # Same shape as SECRET_KEY_PATTERN but the leading ``#`` is optional. The
130
+ # ``parameters.dataApp.secrets`` block legitimately holds BOTH ``#``-prefixed
131
+ # encrypted secrets and plain (unencrypted) env-var config values; read/remove
132
+ # operations must accept either, since ``secrets-list`` enumerates both. Only
133
+ # the write path (``secrets-set``) keeps requiring ``#`` -- it encrypts.
134
+ SECRET_OR_PLAIN_KEY_PATTERN = re.compile(r"^#?[A-Za-z][A-Za-z0-9_-]{0,63}$")
135
+
136
+ # Env vars the data-app runtime auto-injects. Setting a secret whose
137
+ # derived env-var name collides with one of these is silently shadowed
138
+ # at runtime by the platform value. See storage-access canon at
139
+ # https://help.keboola.com/data-apps/storage-access/.
140
+ #
141
+ # TODO(0.28.x): verify exhaustive list against running data-app env in
142
+ # follow-up. The runtime almost certainly injects more (BRANCH_ID,
143
+ # QUERY_SERVICE_URL, KBC_WORKSPACE_MANIFEST_PATH appear in the
144
+ # storage-access page; others may exist) but the canon-documented floor
145
+ # is KBC_TOKEN + KBC_URL. Expanding this set adds WARNs that are less
146
+ # likely to be false positives once verified live.
147
+ RESERVED_RUNTIME_ENV_VARS: frozenset[str] = frozenset(
148
+ {
149
+ "KBC_TOKEN",
150
+ "KBC_URL",
151
+ }
152
+ )
153
+
154
+
155
+ def _derive_runtime_env_var_name(secret_key: str) -> str:
156
+ """Translate a ``#``-prefixed secret key into the runtime env-var name.
157
+
158
+ Rule from help.keboola.com/data-apps/python-js/: strip the leading
159
+ ``#``, replace ``-`` with ``_``, uppercase. Examples (verbatim from
160
+ the help canon):
161
+
162
+ - ``#KBC_TOKEN`` -> ``KBC_TOKEN``
163
+ - ``#my-custom-var`` -> ``MY_CUSTOM_VAR``
164
+ """
165
+ stripped = secret_key.lstrip("#")
166
+ return stripped.replace("-", "_").upper()
167
+
168
+
169
+ def _secret_fingerprint(ciphertext: str) -> str:
170
+ """First 8 chars of the ciphertext payload after the ``KBC::*::`` prefix.
171
+
172
+ The full ciphertext is not a secret in the cryptographic sense (it
173
+ can only be decrypted by the project's KMS), but echoing it in full
174
+ invites copy-paste leakage into tickets and chat. The fingerprint is
175
+ enough to compare two ciphertexts without exposing the payload.
176
+ Returns empty string for non-ciphertext input.
177
+ """
178
+ if not isinstance(ciphertext, str):
179
+ return ""
180
+ for prefix in ENCRYPTED_PASSWORD_PREFIXES:
181
+ if ciphertext.startswith(prefix):
182
+ payload = ciphertext[len(prefix) :]
183
+ return payload[:8]
184
+ return ""
185
+
186
+
187
+ def _build_simple_auth_block() -> dict[str, Any]:
188
+ """Authorization block for password-gated apps (writeup §11.2)."""
189
+ return {
190
+ "app_proxy": {
191
+ "auth_providers": [{"id": "simpleAuth", "type": "password"}],
192
+ "auth_rules": [
193
+ {
194
+ "type": "pathPrefix",
195
+ "value": "/",
196
+ "auth_required": True,
197
+ "auth": ["simpleAuth"],
198
+ }
199
+ ],
200
+ },
201
+ }
202
+
203
+
204
+ def _build_public_auth_block() -> dict[str, Any]:
205
+ """Authorization block for publicly-accessible apps (no auth gate).
206
+
207
+ Mirrors the kbc-ui ``noneProxyAuthorization`` constant exactly.
208
+ Authoritative source — the public backend validator at
209
+ ``keboola/job-queue-job-configuration``
210
+ ``src/JobDefinition/Configuration/Authorization/AppProxyDefinition.php``
211
+ (when ``auth_required=false``, ``auth`` MUST NOT be set; see
212
+ https://github.com/keboola/job-queue-job-configuration). The
213
+ ``keboola/ui`` repo (private; Keboola org members only) corroborates:
214
+ its ``apps/kbc-ui/src/scripts/modules/data-apps/constants.ts``
215
+ exports this exact shape as ``noneProxyAuthorization`` for the
216
+ "None" UI option.
217
+
218
+ Without this block, ``--auth public`` shipped in 0.27.0 wrote no
219
+ ``authorization`` key at all -- the Keboola app-proxy refused to
220
+ route traffic and the UI's "Authentication Type" selector showed
221
+ blank. Fixed in 0.28.0.
222
+ """
223
+ return {
224
+ "app_proxy": {
225
+ "auth_providers": [],
226
+ "auth_rules": [
227
+ {
228
+ "type": "pathPrefix",
229
+ "value": "/",
230
+ "auth_required": False,
231
+ }
232
+ ],
233
+ },
234
+ }
235
+
236
+
237
+ def _auth_block_for(auth: str) -> dict[str, Any]:
238
+ """Dispatch on the validated --auth value.
239
+
240
+ The validator at :meth:`DataAppService._validate_create_inputs`
241
+ rejects anything other than ``password`` / ``public`` at the service
242
+ boundary, so this code path should only ever see those two values in
243
+ production. We raise loudly on an unexpected value rather than
244
+ silently writing no ``authorization`` block (the v0.27.0 bug this
245
+ helper exists to prevent — see the (since v0.28.0) gotcha entry).
246
+ """
247
+ if auth == "password":
248
+ return _build_simple_auth_block()
249
+ if auth == "public":
250
+ return _build_public_auth_block()
251
+ raise ValueError(
252
+ f"_auth_block_for missing dispatch for {auth!r}; "
253
+ "_validate_create_inputs should have rejected this upstream."
254
+ )
255
+
256
+
257
+ def _redact_secret(value: Any) -> Any:
258
+ """Replace encrypted ``#`` values with a placeholder for human output."""
259
+ if isinstance(value, str) and value.startswith("KBC::"):
260
+ return "<encrypted>"
261
+ return value
262
+
263
+
264
+ def _redact_git_block(git: dict[str, Any]) -> dict[str, Any]:
265
+ """Return a copy of the git block with the encrypted password redacted."""
266
+ redacted = dict(git)
267
+ if "#password" in redacted:
268
+ redacted["#password"] = _redact_secret(redacted["#password"])
269
+ return redacted
270
+
271
+
272
+ def _redact_secrets_block(secrets: dict[str, Any]) -> dict[str, Any]:
273
+ """Return a copy of ``parameters.dataApp.secrets`` with each ciphertext redacted.
274
+
275
+ Used by ``get_data_app`` so the ``raw.storage_config`` echo cannot
276
+ leak any secret's encrypted value into ``--json`` output. Same
277
+ defence-in-depth rationale as :func:`_redact_git_block`.
278
+ """
279
+ if not isinstance(secrets, dict):
280
+ return secrets
281
+ return {key: _redact_secret(value) for key, value in secrets.items()}
282
+
283
+
284
+ def _redact_storage_config(storage_config: dict[str, Any]) -> dict[str, Any]:
285
+ """Deep-copy the Storage config dict and redact any nested encrypted PAT.
286
+
287
+ Used by ``get_data_app`` so the ``raw.storage_config`` echo cannot leak
288
+ the encrypted git PAT verbatim into ``--json`` output. The redaction is
289
+ cosmetic (the ciphertext is not a secret in the cryptographic sense --
290
+ it can only be decrypted by Keboola's KMS), but defense-in-depth:
291
+ keeping ciphertext out of consumed JSON limits its blast radius if a
292
+ downstream consumer logs it.
293
+ """
294
+ if not isinstance(storage_config, dict):
295
+ return storage_config
296
+ redacted = dict(storage_config)
297
+ configuration = redacted.get("configuration")
298
+ if isinstance(configuration, dict):
299
+ configuration = dict(configuration)
300
+ parameters = configuration.get("parameters")
301
+ if isinstance(parameters, dict):
302
+ parameters = dict(parameters)
303
+ data_app = parameters.get("dataApp")
304
+ if isinstance(data_app, dict):
305
+ data_app = dict(data_app)
306
+ git = data_app.get("git")
307
+ if isinstance(git, dict):
308
+ data_app["git"] = _redact_git_block(git)
309
+ secrets = data_app.get("secrets")
310
+ if isinstance(secrets, dict):
311
+ data_app["secrets"] = _redact_secrets_block(secrets)
312
+ parameters["dataApp"] = data_app
313
+ configuration["parameters"] = parameters
314
+ redacted["configuration"] = configuration
315
+ return redacted
316
+
317
+
318
+ class DataAppService(BaseService):
319
+ """Lifecycle service for Keboola data apps.
320
+
321
+ Wires together the Data Science API (deployment record), the Storage
322
+ API (``keboola.data-apps`` config), and the Encryption API (per-project
323
+ KMS for git PATs).
324
+ """
325
+
326
+ def __init__(
327
+ self,
328
+ config_store: Any,
329
+ client_factory: ClientFactory | None = None,
330
+ ds_client_factory: DataScienceClientFactory | None = None,
331
+ encrypt_service: EncryptService | None = None,
332
+ ) -> None:
333
+ super().__init__(config_store=config_store, client_factory=client_factory)
334
+ self._ds_client_factory = ds_client_factory or _default_ds_client_factory
335
+ self._encrypt_service = encrypt_service or EncryptService(
336
+ config_store=config_store, client_factory=client_factory
337
+ )
338
+
339
+ # ------------------------------------------------------------------
340
+ # Public lifecycle methods (one per CLI subcommand)
341
+ # ------------------------------------------------------------------
342
+
343
+ def list_data_apps(
344
+ self,
345
+ aliases: list[str] | None = None,
346
+ branch_id: int | None = None,
347
+ ) -> dict[str, Any]:
348
+ """Return data apps across one or more projects.
349
+
350
+ Returns ``{"apps": [...], "errors": [...]}`` to match the envelope
351
+ used by ``ConfigService.list_configs`` / ``StorageService.list_buckets``.
352
+ Per-project failures are captured in ``errors``; they never abort
353
+ the others.
354
+ """
355
+ projects = self.resolve_projects(aliases)
356
+
357
+ def worker(
358
+ alias: str, project: ProjectConfig
359
+ ) -> tuple[str, list[dict[str, Any]], bool] | tuple[str, dict[str, str]]:
360
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
361
+ storage_client = self._client_factory(project.stack_url, project.token)
362
+ try:
363
+ apps = ds_client.list_apps()
364
+ config_names = self._fetch_data_app_config_names(storage_client, branch_id)
365
+ merged: list[dict[str, Any]] = []
366
+ for app in apps:
367
+ # The Data Science ``/apps`` collection returns EVERY
368
+ # deployment in the project, not just data apps -- that
369
+ # includes workspace/sandbox deployments
370
+ # (``componentId=keboola.sandboxes``, ``type=snowflake`` /
371
+ # ``bigquery``, no name, Snowflake URL). The Apps UI filters
372
+ # to ``keboola.data-apps``; mirror that so ``data-app list``
373
+ # does not leak sandboxes. Defensive: an item that omits
374
+ # ``componentId`` (older API shape) is kept rather than
375
+ # hidden -- we never drop a row we cannot classify.
376
+ component_id = str(app.get("componentId") or "")
377
+ if component_id and component_id != DATA_APP_COMPONENT_ID:
378
+ continue
379
+ config_id = str(app.get("configId") or "")
380
+ merged.append(
381
+ {
382
+ "project_alias": alias,
383
+ "app_id": str(app.get("id", "")),
384
+ "config_id": config_id,
385
+ "component_id": component_id,
386
+ "name": config_names.get(config_id, app.get("name", "")),
387
+ "type": app.get("type", ""),
388
+ "state": app.get("state", ""),
389
+ "desired_state": app.get("desiredState", ""),
390
+ "config_version": str(app.get("configVersion", "") or ""),
391
+ "url": app.get("url", ""),
392
+ "size": app.get("size", ""),
393
+ "auto_suspend_after_seconds": app.get("autoSuspendAfterSeconds"),
394
+ "last_start_timestamp": app.get("lastStartTimestamp"),
395
+ }
396
+ )
397
+ # Per-project sort dropped: the global sort below
398
+ # subsumes it after we concatenate every worker's output.
399
+ return (alias, merged, True)
400
+ except KeboolaApiError as exc:
401
+ return (
402
+ alias,
403
+ {
404
+ "project_alias": alias,
405
+ "error_code": str(exc.error_code),
406
+ "message": exc.message,
407
+ },
408
+ )
409
+ finally:
410
+ ds_client.close()
411
+ storage_client.close()
412
+
413
+ successes, errors = self._run_parallel(projects, worker)
414
+ all_apps: list[dict[str, Any]] = []
415
+ for _alias, apps, _ok in successes:
416
+ all_apps.extend(apps)
417
+ all_apps.sort(key=lambda a: (a["project_alias"], a.get("app_id", "")))
418
+ errors.sort(key=lambda e: e.get("project_alias", ""))
419
+ return {"apps": all_apps, "errors": errors}
420
+
421
+ def get_data_app(
422
+ self,
423
+ alias: str,
424
+ app_id: str,
425
+ branch_id: int | None = None,
426
+ ) -> dict[str, Any]:
427
+ """Merge the Data Science deployment record with the Storage config.
428
+
429
+ The Data Science record is the source of truth for state / URL /
430
+ configVersion; the Storage config carries slug / git settings /
431
+ runtime size / human description. Callers normally want both.
432
+ """
433
+ projects = self.resolve_projects([alias])
434
+ project = projects[alias]
435
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
436
+ storage_client = self._client_factory(project.stack_url, project.token)
437
+ try:
438
+ app = ds_client.get_app(app_id)
439
+ config_id = str(app.get("configId") or "")
440
+ storage_config: dict[str, Any] = {}
441
+ if config_id:
442
+ try:
443
+ storage_config = storage_client.get_config_detail(
444
+ DATA_APP_COMPONENT_ID, config_id, branch_id=branch_id
445
+ )
446
+ except KeboolaApiError as exc:
447
+ if exc.error_code != ErrorCode.NOT_FOUND:
448
+ raise
449
+ finally:
450
+ ds_client.close()
451
+ storage_client.close()
452
+
453
+ configuration = storage_config.get("configuration") or {}
454
+ if isinstance(configuration, str):
455
+ try:
456
+ configuration = json.loads(configuration)
457
+ except (ValueError, TypeError):
458
+ configuration = {}
459
+ parameters = configuration.get("parameters", {}) if isinstance(configuration, dict) else {}
460
+ data_app_block = parameters.get("dataApp", {}) if isinstance(parameters, dict) else {}
461
+ git_block = data_app_block.get("git", {}) if isinstance(data_app_block, dict) else {}
462
+
463
+ return {
464
+ "project_alias": alias,
465
+ "app_id": str(app.get("id", "")),
466
+ "config_id": config_id,
467
+ "config_version_storage": str(storage_config.get("version", "") or ""),
468
+ "config_version_deployed": str(app.get("configVersion", "") or ""),
469
+ "name": storage_config.get("name", app.get("name", "")),
470
+ "description": storage_config.get("description", ""),
471
+ "type": app.get("type", ""),
472
+ "state": app.get("state", ""),
473
+ "desired_state": app.get("desiredState", ""),
474
+ "url": app.get("url", ""),
475
+ "size": app.get("size", "")
476
+ or (
477
+ configuration.get("runtime", {}).get("backend", {}).get("size", "")
478
+ if isinstance(configuration, dict)
479
+ else ""
480
+ ),
481
+ "auto_suspend_after_seconds": app.get(
482
+ "autoSuspendAfterSeconds",
483
+ parameters.get("autoSuspendAfterSeconds"),
484
+ ),
485
+ "last_start_timestamp": app.get("lastStartTimestamp"),
486
+ "slug": data_app_block.get("slug", ""),
487
+ "git": _redact_git_block(git_block) if git_block else {},
488
+ "raw": {
489
+ "deployment": app,
490
+ "storage_config": _redact_storage_config(storage_config),
491
+ },
492
+ }
493
+
494
+ def create_data_app(
495
+ self,
496
+ *,
497
+ alias: str,
498
+ name: str,
499
+ description: str,
500
+ slug: str,
501
+ git_repo: str,
502
+ git_branch: str = "main",
503
+ git_public: bool = False,
504
+ git_username: str | None = None,
505
+ git_pat_plaintext: str | None = None,
506
+ git_pat_encrypted: str | None = None,
507
+ auth: str = "password",
508
+ size: str = DEFAULT_SIZE,
509
+ auto_suspend_after_seconds: int = DEFAULT_AUTO_SUSPEND_SECONDS,
510
+ type_: str = DEFAULT_TYPE,
511
+ branch_id: int | None = None,
512
+ deploy: bool = True,
513
+ wait: bool = False,
514
+ timeout_seconds: float = DEFAULT_JOB_RUN_TIMEOUT,
515
+ keep_on_failure: bool = False,
516
+ dry_run: bool = False,
517
+ ) -> dict[str, Any]:
518
+ """End-to-end create flow per writeup §6/§10/§11.
519
+
520
+ Steps in order:
521
+
522
+ 1. Validate inputs (slug, size, type, git auth combination).
523
+ 2. POST a minimal shell to ``/apps`` to mint id + configId.
524
+ 3. If the repo is private, encrypt the PAT under THIS project's KMS
525
+ via :class:`EncryptService`.
526
+ 4. PUT the full Storage config body, including the auto-injected
527
+ ``parameters.id`` back-pointer (writeup §5 — required).
528
+ 5. If ``deploy``: read the latest Storage version, PATCH the
529
+ deployment record with the §9 trio.
530
+ 6. If ``wait``: poll until terminal (running / error / timeout).
531
+ 7. On failure between (2) and (5), DELETE the orphan shell unless
532
+ ``keep_on_failure`` is set.
533
+ """
534
+ self._validate_create_inputs(
535
+ type_=type_,
536
+ slug=slug,
537
+ size=size,
538
+ auth=auth,
539
+ name=name,
540
+ description=description,
541
+ git_repo=git_repo,
542
+ git_branch=git_branch,
543
+ git_public=git_public,
544
+ git_username=git_username,
545
+ git_pat_plaintext=git_pat_plaintext,
546
+ git_pat_encrypted=git_pat_encrypted,
547
+ )
548
+
549
+ if dry_run:
550
+ return self._build_dry_run_payload(
551
+ alias=alias,
552
+ name=name,
553
+ description=description,
554
+ slug=slug,
555
+ git_repo=git_repo,
556
+ git_branch=git_branch,
557
+ git_public=git_public,
558
+ git_username=git_username,
559
+ auth=auth,
560
+ size=size,
561
+ auto_suspend_after_seconds=auto_suspend_after_seconds,
562
+ type_=type_,
563
+ branch_id=branch_id,
564
+ deploy=deploy,
565
+ )
566
+
567
+ projects = self.resolve_projects([alias])
568
+ project = projects[alias]
569
+
570
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
571
+ storage_client = self._client_factory(project.stack_url, project.token)
572
+
573
+ shell: dict[str, Any] | None = None
574
+ app_id: str | None = None
575
+ config_id: str | None = None
576
+
577
+ try:
578
+ # Step 2: create the shell. Smallest body the API will accept.
579
+ initial_config = {
580
+ "parameters": {
581
+ "size": size,
582
+ "autoSuspendAfterSeconds": auto_suspend_after_seconds,
583
+ "dataApp": {"slug": slug},
584
+ },
585
+ }
586
+ initial_config["authorization"] = _auth_block_for(auth)
587
+
588
+ shell = ds_client.create_app(
589
+ type_=type_,
590
+ name=name,
591
+ description="", # full description goes onto the Storage config below
592
+ config=initial_config,
593
+ branch_id=branch_id,
594
+ )
595
+ app_id = str(shell.get("id", ""))
596
+ config_id = str(shell.get("configId", ""))
597
+ if not app_id or not config_id:
598
+ raise KeboolaApiError(
599
+ message="POST /apps response missing id or configId",
600
+ status_code=500,
601
+ error_code=ErrorCode.API_ERROR,
602
+ retryable=False,
603
+ )
604
+
605
+ # Step 3: encrypt PAT under target-project KMS if private repo.
606
+ git_block = self._build_git_block(
607
+ alias=alias,
608
+ git_repo=git_repo,
609
+ git_branch=git_branch,
610
+ git_public=git_public,
611
+ git_username=git_username,
612
+ git_pat_plaintext=git_pat_plaintext,
613
+ git_pat_encrypted=git_pat_encrypted,
614
+ )
615
+
616
+ # Step 4: PUT Storage config with full body + parameters.id back-pointer.
617
+ full_config = self._build_storage_config_body(
618
+ size=size,
619
+ auto_suspend_after_seconds=auto_suspend_after_seconds,
620
+ slug=slug,
621
+ git_block=git_block,
622
+ auth=auth,
623
+ app_id=app_id,
624
+ )
625
+ storage_response = storage_client.update_config(
626
+ component_id=DATA_APP_COMPONENT_ID,
627
+ config_id=config_id,
628
+ name=name,
629
+ description=description,
630
+ configuration=full_config,
631
+ change_description=f"Initial data-app config via kbagent data-app create ({slug})",
632
+ branch_id=branch_id,
633
+ )
634
+ storage_version = str(storage_response.get("version", "") or "")
635
+
636
+ deployed_record: dict[str, Any] | None = None
637
+ poll_result: dict[str, Any] | None = None
638
+
639
+ if deploy:
640
+ # Step 5: §9 redeploy contract.
641
+ if not storage_version:
642
+ raise KeboolaApiError(
643
+ message=(
644
+ "Storage API did not return a version after PUT; "
645
+ "cannot pin configVersion for deploy."
646
+ ),
647
+ status_code=500,
648
+ error_code=ErrorCode.API_ERROR,
649
+ retryable=False,
650
+ )
651
+ deployed_record = ds_client.patch_app(
652
+ app_id,
653
+ desired_state=RUNNING_STATE,
654
+ config_version=storage_version,
655
+ restart_if_running=True,
656
+ )
657
+
658
+ if wait:
659
+ poll_result = self._poll_until_terminal(
660
+ ds_client,
661
+ app_id,
662
+ target_desired_state=RUNNING_STATE,
663
+ timeout_seconds=timeout_seconds,
664
+ )
665
+
666
+ url_record = deployed_record or shell
667
+ state_record = poll_result or deployed_record or shell
668
+ return {
669
+ "project_alias": alias,
670
+ "app_id": app_id,
671
+ "config_id": config_id,
672
+ "name": name,
673
+ "slug": slug,
674
+ "type": type_,
675
+ "size": size,
676
+ "auto_suspend_after_seconds": auto_suspend_after_seconds,
677
+ "auth": auth,
678
+ "git": _redact_git_block(git_block),
679
+ "branch_id": branch_id,
680
+ "config_version": storage_version,
681
+ "deployed": bool(deploy),
682
+ "wait": bool(wait),
683
+ "url": url_record.get("url", ""),
684
+ "state": state_record.get("state", ""),
685
+ "desired_state": state_record.get("desiredState", ""),
686
+ "last_start_timestamp": (poll_result or deployed_record or {}).get(
687
+ "lastStartTimestamp"
688
+ ),
689
+ "message": self._format_create_message(
690
+ name=name,
691
+ auth=auth,
692
+ deployed=bool(deploy),
693
+ wait=bool(wait),
694
+ state=state_record.get("state", ""),
695
+ ),
696
+ }
697
+ except Exception:
698
+ # Step 7: clean up the orphan shell unless caller asked us to
699
+ # preserve it for forensics.
700
+ if app_id and not keep_on_failure:
701
+ try:
702
+ ds_client.delete_app(app_id)
703
+ logger.info("Cleaned up orphan data-app shell %s after failure", app_id)
704
+ except Exception:
705
+ logger.exception("Failed to clean up orphan data-app shell %s", app_id)
706
+ raise
707
+ finally:
708
+ ds_client.close()
709
+ storage_client.close()
710
+
711
+ def deploy_data_app(
712
+ self,
713
+ alias: str,
714
+ app_id: str,
715
+ config_version: str | None = None,
716
+ wait: bool = False,
717
+ timeout_seconds: float = DEFAULT_JOB_RUN_TIMEOUT,
718
+ branch_id: int | None = None,
719
+ ) -> dict[str, Any]:
720
+ """Encapsulate the §9 redeploy contract.
721
+
722
+ Default reads the latest Storage version and pins to it; pass
723
+ ``config_version`` to deploy an older version. ALWAYS sends
724
+ ``restartIfRunning=true`` together with ``configVersion`` -- the
725
+ server returns HTTP 422 for any other shape.
726
+ """
727
+ projects = self.resolve_projects([alias])
728
+ project = projects[alias]
729
+
730
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
731
+ storage_client: Any | None = None
732
+ try:
733
+ app = ds_client.get_app(app_id)
734
+ config_id = str(app.get("configId") or "")
735
+ if not config_id:
736
+ raise KeboolaApiError(
737
+ message=f"Data app {app_id} has no associated configId",
738
+ status_code=500,
739
+ error_code=ErrorCode.API_ERROR,
740
+ retryable=False,
741
+ )
742
+
743
+ effective_version = config_version
744
+ if effective_version is None:
745
+ # Only build the Storage client when we actually need to
746
+ # read the latest version. Callers that pass an explicit
747
+ # --config-version skip this path and the second client.
748
+ storage_client = self._client_factory(project.stack_url, project.token)
749
+ storage_config = storage_client.get_config_detail(
750
+ DATA_APP_COMPONENT_ID, config_id, branch_id=branch_id
751
+ )
752
+ effective_version = str(storage_config.get("version", "") or "")
753
+ if not effective_version:
754
+ raise KeboolaApiError(
755
+ message=(
756
+ f"Cannot resolve a Storage configVersion for app {app_id}; "
757
+ "Storage config returned no version."
758
+ ),
759
+ status_code=500,
760
+ error_code=ErrorCode.API_ERROR,
761
+ retryable=False,
762
+ )
763
+
764
+ deployed = ds_client.patch_app(
765
+ app_id,
766
+ desired_state=RUNNING_STATE,
767
+ config_version=str(effective_version),
768
+ restart_if_running=True,
769
+ )
770
+ poll_result: dict[str, Any] | None = None
771
+ if wait:
772
+ poll_result = self._poll_until_terminal(
773
+ ds_client,
774
+ app_id,
775
+ target_desired_state=RUNNING_STATE,
776
+ timeout_seconds=timeout_seconds,
777
+ )
778
+ return self._format_lifecycle_result(
779
+ alias=alias,
780
+ app_id=app_id,
781
+ action="deploy",
782
+ deployed=deployed,
783
+ poll_result=poll_result,
784
+ config_version=str(effective_version),
785
+ )
786
+ finally:
787
+ ds_client.close()
788
+ if storage_client is not None:
789
+ storage_client.close()
790
+
791
+ def start_data_app(
792
+ self,
793
+ alias: str,
794
+ app_id: str,
795
+ wait: bool = False,
796
+ timeout_seconds: float = DEFAULT_JOB_RUN_TIMEOUT,
797
+ ) -> dict[str, Any]:
798
+ """Wake an auto-suspended app at its currently-pinned configVersion.
799
+
800
+ Distinct from :meth:`deploy_data_app`: ``start`` does NOT bump the
801
+ deployed version. This is the cheap restart path for the
802
+ auto-suspend wake (writeup §8 pitfall row 2).
803
+ """
804
+ projects = self.resolve_projects([alias])
805
+ project = projects[alias]
806
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
807
+ try:
808
+ deployed = ds_client.patch_app(
809
+ app_id,
810
+ desired_state=RUNNING_STATE,
811
+ restart_if_running=True,
812
+ )
813
+ poll_result: dict[str, Any] | None = None
814
+ if wait:
815
+ poll_result = self._poll_until_terminal(
816
+ ds_client,
817
+ app_id,
818
+ target_desired_state=RUNNING_STATE,
819
+ timeout_seconds=timeout_seconds,
820
+ )
821
+ return self._format_lifecycle_result(
822
+ alias=alias,
823
+ app_id=app_id,
824
+ action="start",
825
+ deployed=deployed,
826
+ poll_result=poll_result,
827
+ )
828
+ finally:
829
+ ds_client.close()
830
+
831
+ def stop_data_app(
832
+ self,
833
+ alias: str,
834
+ app_id: str,
835
+ wait: bool = False,
836
+ timeout_seconds: float = DEFAULT_JOB_RUN_TIMEOUT,
837
+ ) -> dict[str, Any]:
838
+ projects = self.resolve_projects([alias])
839
+ project = projects[alias]
840
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
841
+ try:
842
+ deployed = ds_client.patch_app(app_id, desired_state=STOPPED_STATE)
843
+ poll_result: dict[str, Any] | None = None
844
+ if wait:
845
+ poll_result = self._poll_until_terminal(
846
+ ds_client,
847
+ app_id,
848
+ target_desired_state=STOPPED_STATE,
849
+ timeout_seconds=timeout_seconds,
850
+ )
851
+ return self._format_lifecycle_result(
852
+ alias=alias,
853
+ app_id=app_id,
854
+ action="stop",
855
+ deployed=deployed,
856
+ poll_result=poll_result,
857
+ )
858
+ finally:
859
+ ds_client.close()
860
+
861
+ def delete_data_app(self, alias: str, app_id: str) -> dict[str, Any]:
862
+ """Delete the deployment AND the Storage config (cascade)."""
863
+ projects = self.resolve_projects([alias])
864
+ project = projects[alias]
865
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
866
+ try:
867
+ ds_client.delete_app(app_id)
868
+ finally:
869
+ ds_client.close()
870
+ return {
871
+ "project_alias": alias,
872
+ "app_id": str(app_id),
873
+ "deleted": True,
874
+ "message": (
875
+ f"Data app {app_id} deleted from project '{alias}'. "
876
+ "Both the deployment record and the Storage config are gone; "
877
+ "the URL is permanently retired."
878
+ ),
879
+ }
880
+
881
+ def get_data_app_password(
882
+ self,
883
+ alias: str,
884
+ app_id: str,
885
+ manage_token: str,
886
+ ) -> dict[str, Any]:
887
+ """Return the auto-generated simpleAuth password.
888
+
889
+ Requires both project Storage token and a Manage API token. The
890
+ Manage token is passed per-call -- it is never persisted, never
891
+ attached to the long-lived client, and never logged.
892
+ """
893
+ if not manage_token:
894
+ raise KeboolaApiError(
895
+ message=(
896
+ "Manage API token is required to read the data-app simpleAuth "
897
+ "password. Run interactively (default since v0.28.0), or pass "
898
+ "--allow-env-manage-token + set KBC_MANAGE_API_TOKEN for CI."
899
+ ),
900
+ status_code=0,
901
+ error_code=ErrorCode.INVALID_TOKEN,
902
+ retryable=False,
903
+ )
904
+ projects = self.resolve_projects([alias])
905
+ project = projects[alias]
906
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
907
+ try:
908
+ payload = ds_client.get_app_password(app_id, manage_token=manage_token)
909
+ finally:
910
+ ds_client.close()
911
+ password = payload.get("password", "") if isinstance(payload, dict) else ""
912
+ return {
913
+ "project_alias": alias,
914
+ "app_id": str(app_id),
915
+ "password": password,
916
+ "message": (
917
+ f"Retrieved simpleAuth password for data app {app_id}. "
918
+ "This password is auto-generated and cannot be rotated; "
919
+ "delete and recreate the app to mint a new one."
920
+ ),
921
+ }
922
+
923
+ def get_app_logs(
924
+ self,
925
+ alias: str,
926
+ app_id: str,
927
+ *,
928
+ lines: int | None = None,
929
+ since: str | None = None,
930
+ ) -> dict[str, Any]:
931
+ """Fetch the container log tail for a deployed data app.
932
+
933
+ Wraps ``GET data-science/apps/{id}/logs/tail``. The two query
934
+ parameters are mutually exclusive on the server -- the service
935
+ rejects the combination locally with ``INVALID_ARGUMENT`` rather
936
+ than letting it round-trip. Passing ``lines=None, since=None``
937
+ returns the full current container buffer; this is the CLI's
938
+ ``--lines 0`` semantics.
939
+
940
+ The command layer enforces the same mutex with a clean exit-2
941
+ usage error; this service-layer guard is the contract for
942
+ programmatic callers (e.g. the ``kbagent serve`` route).
943
+
944
+ Returns a dict with the raw text, the request echo (so callers
945
+ can correlate envelopes to invocations), and a line count
946
+ derived from splitting on newlines.
947
+
948
+ Note: the log buffer can echo runtime secrets the app printed
949
+ to stdout/stderr (tracebacks, debug ``os.environ`` dumps).
950
+ ``--json`` callers piping into AI agent context should consider
951
+ secret hygiene. The service does NOT post-process or attempt to
952
+ mask the response -- false confidence is worse than honest
953
+ passthrough; see ``gotchas.md``.
954
+ """
955
+ if lines is not None and since is not None:
956
+ raise KeboolaApiError(
957
+ message=(
958
+ "get_app_logs: 'lines' and 'since' are mutually exclusive; "
959
+ "pass exactly one (or neither for the full buffer)."
960
+ ),
961
+ status_code=0,
962
+ error_code=ErrorCode.INVALID_ARGUMENT,
963
+ retryable=False,
964
+ )
965
+ # Validation paths below are duplicated in the CLI command
966
+ # (exit-2 USAGE_ERROR for a clean Click UX). The service-layer
967
+ # guards are the contract for the ``kbagent serve``
968
+ # GET /data-apps/{p}/{id}/logs route; without them, those
969
+ # audiences would round-trip a 400 the server can phrase only
970
+ # as "Invalid value".
971
+ if lines is not None and lines < 0:
972
+ raise KeboolaApiError(
973
+ message=(
974
+ "get_app_logs: 'lines' must be 0 (full buffer) or a positive "
975
+ f"integer; got {lines}."
976
+ ),
977
+ status_code=0,
978
+ error_code=ErrorCode.INVALID_ARGUMENT,
979
+ retryable=False,
980
+ )
981
+ if since is not None:
982
+ try:
983
+ parsed_since = datetime.fromisoformat(since)
984
+ except ValueError as exc:
985
+ raise KeboolaApiError(
986
+ message=(
987
+ "get_app_logs: 'since' must be ISO 8601 "
988
+ f"(e.g. '2026-05-21T13:00:00Z'): {exc}"
989
+ ),
990
+ status_code=0,
991
+ error_code=ErrorCode.INVALID_ARGUMENT,
992
+ retryable=False,
993
+ ) from None
994
+ if parsed_since.tzinfo is None:
995
+ raise KeboolaApiError(
996
+ message=(
997
+ "get_app_logs: 'since' must include a timezone (e.g. 'Z' "
998
+ "or '+00:00'); the Data Science endpoint rejects naive "
999
+ "datetimes with 'Invalid value'."
1000
+ ),
1001
+ status_code=0,
1002
+ error_code=ErrorCode.INVALID_ARGUMENT,
1003
+ retryable=False,
1004
+ )
1005
+ projects = self.resolve_projects([alias])
1006
+ project = projects[alias]
1007
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
1008
+ try:
1009
+ text = ds_client.tail_app_logs(app_id, lines=lines, since=since)
1010
+ finally:
1011
+ ds_client.close()
1012
+
1013
+ # ``splitlines()`` with no separator drops the trailing empty
1014
+ # string the final ``\n`` would create. A 3-line response with a
1015
+ # trailing newline is 3 lines, not 4.
1016
+ line_count = len(text.splitlines()) if text else 0
1017
+ return {
1018
+ "project_alias": alias,
1019
+ "app_id": str(app_id),
1020
+ "lines_requested": lines,
1021
+ "since_requested": since,
1022
+ "lines_returned": line_count,
1023
+ "text": text,
1024
+ }
1025
+
1026
+ # ------------------------------------------------------------------
1027
+ # Secrets lifecycle (parameters.dataApp.secrets in the Storage config)
1028
+ # ------------------------------------------------------------------
1029
+
1030
+ def _load_data_app_storage_config(
1031
+ self,
1032
+ *,
1033
+ ds_client: DataScienceClient,
1034
+ storage_client: Any,
1035
+ app_id: str,
1036
+ branch_id: int | None,
1037
+ ) -> tuple[str, dict[str, Any], dict[str, Any]]:
1038
+ """Resolve ``configId`` from the Data Science app and load the Storage config.
1039
+
1040
+ Returns ``(config_id, storage_envelope, body)`` where ``body`` is a
1041
+ deep-copy of ``storage_envelope.configuration`` so callers can
1042
+ mutate it freely. Mirrors the pattern at
1043
+ :meth:`deploy_data_app` (data_app_service.py:586-594).
1044
+ """
1045
+ app = ds_client.get_app(app_id)
1046
+ config_id = str(app.get("configId") or "")
1047
+ if not config_id:
1048
+ raise KeboolaApiError(
1049
+ message=f"Data app {app_id} has no associated configId",
1050
+ status_code=500,
1051
+ error_code=ErrorCode.API_ERROR,
1052
+ retryable=False,
1053
+ )
1054
+ envelope = storage_client.get_config_detail(
1055
+ DATA_APP_COMPONENT_ID, config_id, branch_id=branch_id
1056
+ )
1057
+ if not isinstance(envelope, dict):
1058
+ envelope = {}
1059
+ configuration = envelope.get("configuration")
1060
+ if not isinstance(configuration, dict):
1061
+ configuration = {}
1062
+ # Deep-copy so caller mutations don't reach the cached upstream.
1063
+ body = json.loads(json.dumps(configuration))
1064
+ return config_id, envelope, body
1065
+
1066
+ def _read_secrets_block(self, body: dict[str, Any]) -> dict[str, str]:
1067
+ """Return ``parameters.dataApp.secrets`` from a config body, or ``{}``."""
1068
+ if not isinstance(body, dict):
1069
+ return {}
1070
+ params = body.get("parameters")
1071
+ if not isinstance(params, dict):
1072
+ return {}
1073
+ data_app = params.get("dataApp")
1074
+ if not isinstance(data_app, dict):
1075
+ return {}
1076
+ secrets = data_app.get("secrets")
1077
+ return dict(secrets) if isinstance(secrets, dict) else {}
1078
+
1079
+ def set_data_app_secrets(
1080
+ self,
1081
+ *,
1082
+ alias: str,
1083
+ app_id: str,
1084
+ secrets: dict[str, str],
1085
+ branch_id: int | None = None,
1086
+ allow_plaintext_on_encrypt_failure: bool = False,
1087
+ dry_run: bool = False,
1088
+ ) -> dict[str, Any]:
1089
+ """Encrypt and write ``#``-prefixed secrets to the linked Storage config.
1090
+
1091
+ Read-modify-write at the service layer. The Storage API's
1092
+ ``configuration`` field is a full-document overwrite; relying on
1093
+ Storage merge to preserve nested siblings under
1094
+ ``parameters.dataApp.secrets`` would clobber unrelated keys (the
1095
+ merge is shallow at the top level only). We GET the full config,
1096
+ modify the secrets sub-dict in place, and PUT the unchanged
1097
+ remainder + the new secrets back.
1098
+
1099
+ Fail-closed: any encryption failure aborts before Storage is
1100
+ touched. ``allow_plaintext_on_encrypt_failure`` is bootstrap/debug
1101
+ only and emits a stderr warning when used.
1102
+ """
1103
+ if not secrets:
1104
+ raise KeboolaApiError(
1105
+ message="At least one --secret '#KEY=VALUE' is required.",
1106
+ status_code=0,
1107
+ error_code=ErrorCode.DATA_APP_INVALID_SECRET,
1108
+ retryable=False,
1109
+ )
1110
+
1111
+ # Validate every key + value at the service boundary; we don't
1112
+ # trust the command layer to have caught everything.
1113
+ validated: dict[str, str] = {}
1114
+ for key, value in secrets.items():
1115
+ self._validate_secret_key(key)
1116
+ if not isinstance(value, str):
1117
+ raise KeboolaApiError(
1118
+ message=(f"Secret '{key}' value must be a string; got {type(value).__name__}."),
1119
+ status_code=0,
1120
+ error_code=ErrorCode.DATA_APP_INVALID_SECRET,
1121
+ retryable=False,
1122
+ )
1123
+ if value.startswith("KBC::"):
1124
+ raise KeboolaApiError(
1125
+ message=(
1126
+ f"Secret '{key}' value starts with 'KBC::', which suggests an "
1127
+ "already-encrypted ciphertext. The --secret flag expects "
1128
+ "plaintext; pass pre-encrypted values via --secrets-file or "
1129
+ "re-encrypt under THIS project's KMS via "
1130
+ "`kbagent encrypt values --component-id keboola.data-apps`."
1131
+ ),
1132
+ status_code=0,
1133
+ error_code=ErrorCode.DATA_APP_INVALID_SECRET,
1134
+ retryable=False,
1135
+ )
1136
+ validated[key] = value
1137
+
1138
+ # Reserved-name warnings -- WARN (not BLOCKING). The platform
1139
+ # silently shadows colliding env vars at runtime. We still write
1140
+ # the secret so the user can recover by removing it, but we
1141
+ # surface the collision in the response.
1142
+ shadowed: list[str] = sorted(
1143
+ _derive_runtime_env_var_name(key)
1144
+ for key in validated
1145
+ if _derive_runtime_env_var_name(key) in RESERVED_RUNTIME_ENV_VARS
1146
+ )
1147
+
1148
+ projects = self.resolve_projects([alias])
1149
+ project = projects[alias]
1150
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
1151
+ storage_client = self._client_factory(project.stack_url, project.token)
1152
+
1153
+ try:
1154
+ config_id, envelope, current_body = self._load_data_app_storage_config(
1155
+ ds_client=ds_client,
1156
+ storage_client=storage_client,
1157
+ app_id=str(app_id),
1158
+ branch_id=branch_id,
1159
+ )
1160
+ existing_secrets = self._read_secrets_block(current_body)
1161
+
1162
+ unchanged = sorted(
1163
+ _derive_runtime_env_var_name(k) for k in existing_secrets if k not in validated
1164
+ )
1165
+
1166
+ if dry_run:
1167
+ preview_secrets = dict(existing_secrets)
1168
+ for key in validated:
1169
+ preview_secrets[key] = "<encrypted at runtime>"
1170
+ preview_body = self._merge_secrets_into_body(current_body, preview_secrets)
1171
+ return {
1172
+ "dry_run": True,
1173
+ "project_alias": alias,
1174
+ "app_id": str(app_id),
1175
+ "config_id": config_id,
1176
+ "secrets_set": sorted(_derive_runtime_env_var_name(k) for k in validated),
1177
+ "secrets_unchanged": unchanged,
1178
+ "shadowed_by_runtime": shadowed,
1179
+ "encryption_request_keys": sorted(validated.keys()),
1180
+ "put_storage_config_preview": _redact_storage_config(
1181
+ {"configuration": preview_body}
1182
+ ),
1183
+ "message": (
1184
+ "Dry run -- no API calls made. Inspect the encryption "
1185
+ "request keys and the proposed Storage PUT body above."
1186
+ ),
1187
+ }
1188
+
1189
+ # Encrypt every plaintext value under THIS project's KMS.
1190
+ try:
1191
+ encrypted = self._encrypt_service.encrypt(
1192
+ alias=alias,
1193
+ component_id=DATA_APP_COMPONENT_ID,
1194
+ input_data=validated,
1195
+ )
1196
+ except ConfigError as exc:
1197
+ raise KeboolaApiError(
1198
+ message=f"Failed to prepare secrets for encryption: {exc.message}",
1199
+ status_code=0,
1200
+ error_code=ErrorCode.ENCRYPTION_FAILED,
1201
+ retryable=False,
1202
+ ) from exc
1203
+
1204
+ # Validate every returned ciphertext starts with a project-scoped
1205
+ # prefix. Mirror the fail-closed check from _build_git_block at
1206
+ # data_app_service.py:1000-1008.
1207
+ problems: list[str] = []
1208
+ for key, ciphertext in encrypted.items():
1209
+ if not isinstance(ciphertext, str) or not any(
1210
+ ciphertext.startswith(p) for p in ENCRYPTED_PASSWORD_PREFIXES
1211
+ ):
1212
+ problems.append(key)
1213
+ if problems and not allow_plaintext_on_encrypt_failure:
1214
+ raise KeboolaApiError(
1215
+ message=(
1216
+ "Encryption API did not return a project-scoped ciphertext "
1217
+ f"for key(s): {', '.join(sorted(problems))}. Refusing to "
1218
+ "write plaintext to Storage. Re-run with "
1219
+ "--allow-plaintext-on-encrypt-failure for bootstrap/debug ONLY."
1220
+ ),
1221
+ status_code=0,
1222
+ error_code=ErrorCode.ENCRYPTION_FAILED,
1223
+ retryable=False,
1224
+ details={
1225
+ "project_alias": alias,
1226
+ "failed_keys": sorted(problems),
1227
+ },
1228
+ )
1229
+ if problems:
1230
+ logger.warning(
1231
+ "Encryption returned non-ciphertext for keys %s; writing anyway "
1232
+ "because --allow-plaintext-on-encrypt-failure was set.",
1233
+ sorted(problems),
1234
+ )
1235
+
1236
+ # Read-modify-write: deep-copy of the body has the secrets sub-dict
1237
+ # replaced; everything else is preserved bit-identical.
1238
+ updated_secrets = dict(existing_secrets)
1239
+ updated_secrets.update(encrypted)
1240
+ new_body = self._merge_secrets_into_body(current_body, updated_secrets)
1241
+
1242
+ put_response = storage_client.update_config(
1243
+ component_id=DATA_APP_COMPONENT_ID,
1244
+ config_id=config_id,
1245
+ configuration=new_body,
1246
+ change_description=(
1247
+ f"Set {len(validated)} secret(s) via kbagent data-app secrets set"
1248
+ ),
1249
+ branch_id=branch_id,
1250
+ )
1251
+ new_version = str(put_response.get("version", "") or "")
1252
+ old_version = str(envelope.get("version", "") or "")
1253
+
1254
+ secrets_set = sorted(_derive_runtime_env_var_name(k) for k in validated)
1255
+ return {
1256
+ "project_alias": alias,
1257
+ "app_id": str(app_id),
1258
+ "config_id": config_id,
1259
+ "secrets_set": secrets_set,
1260
+ "secrets_unchanged": unchanged,
1261
+ "shadowed_by_runtime": shadowed,
1262
+ "config_version_before": old_version,
1263
+ "config_version_after": new_version,
1264
+ "deploy_required": True,
1265
+ "next_step": (
1266
+ f"kbagent data-app deploy --project {alias} --app-id {app_id} --wait"
1267
+ ),
1268
+ "message": (
1269
+ f"{len(secrets_set)} secret(s) encrypted and written. "
1270
+ "The running container keeps the old config until you redeploy."
1271
+ ),
1272
+ }
1273
+ finally:
1274
+ ds_client.close()
1275
+ storage_client.close()
1276
+
1277
+ def list_data_app_secrets(
1278
+ self,
1279
+ *,
1280
+ alias: str,
1281
+ app_id: str,
1282
+ branch_id: int | None = None,
1283
+ show_fingerprint: bool = False,
1284
+ ) -> dict[str, Any]:
1285
+ """Return metadata for every key in ``parameters.dataApp.secrets``.
1286
+
1287
+ The block holds both ``#``-prefixed encrypted secrets and plain
1288
+ (unencrypted) env-var values; both are enumerated. Never returns an
1289
+ encrypted ciphertext in full and never attempts to decrypt -- the
1290
+ Encryption API is one-way; decryption from the CLI is impossible by
1291
+ design.
1292
+ """
1293
+ projects = self.resolve_projects([alias])
1294
+ project = projects[alias]
1295
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
1296
+ storage_client = self._client_factory(project.stack_url, project.token)
1297
+ try:
1298
+ config_id, _envelope, body = self._load_data_app_storage_config(
1299
+ ds_client=ds_client,
1300
+ storage_client=storage_client,
1301
+ app_id=str(app_id),
1302
+ branch_id=branch_id,
1303
+ )
1304
+ raw_secrets = self._read_secrets_block(body)
1305
+
1306
+ entries: list[dict[str, Any]] = []
1307
+ for key in sorted(raw_secrets.keys()):
1308
+ env_var = _derive_runtime_env_var_name(key)
1309
+ entry: dict[str, Any] = {
1310
+ "key": key,
1311
+ "env_var": env_var,
1312
+ "shadowed_by_runtime": env_var in RESERVED_RUNTIME_ENV_VARS,
1313
+ }
1314
+ if show_fingerprint:
1315
+ ciphertext = raw_secrets[key]
1316
+ entry["fingerprint"] = _secret_fingerprint(ciphertext)
1317
+ entry["encryption_prefix"] = self._derive_encryption_prefix(ciphertext)
1318
+ entries.append(entry)
1319
+
1320
+ return {
1321
+ "project_alias": alias,
1322
+ "app_id": str(app_id),
1323
+ "config_id": config_id,
1324
+ "secrets": entries,
1325
+ "count": len(entries),
1326
+ }
1327
+ finally:
1328
+ ds_client.close()
1329
+ storage_client.close()
1330
+
1331
+ def get_data_app_secret(
1332
+ self,
1333
+ *,
1334
+ alias: str,
1335
+ app_id: str,
1336
+ key: str,
1337
+ branch_id: int | None = None,
1338
+ ) -> dict[str, Any]:
1339
+ """Return metadata for ONE key in ``parameters.dataApp.secrets``.
1340
+
1341
+ Two cases, dispatched on whether the stored value is a ``KBC::``
1342
+ ciphertext:
1343
+
1344
+ - **Encrypted** (``#`` secret): metadata-only. The decrypted
1345
+ plaintext NEVER appears in the return dict, stderr, the log
1346
+ stream, or the change description. The Encryption API exposes no
1347
+ decrypt endpoint; the CLI cannot decrypt even if it wanted to.
1348
+ This metadata-only contract is the security boundary and is
1349
+ asserted by the test suite.
1350
+ - **Plain** (unencrypted env-var config value): the value is
1351
+ returned verbatim. It is not a secret -- it is already stored in
1352
+ clear in the config and visible via ``config detail`` -- so
1353
+ echoing it leaks nothing the caller could not already read.
1354
+
1355
+ Accepts keys with OR without a leading ``#`` (``require_hash=False``),
1356
+ mirroring ``secrets-list``, which enumerates both kinds.
1357
+ """
1358
+ self._validate_secret_key(key, require_hash=False)
1359
+
1360
+ projects = self.resolve_projects([alias])
1361
+ project = projects[alias]
1362
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
1363
+ storage_client = self._client_factory(project.stack_url, project.token)
1364
+ try:
1365
+ config_id, _envelope, body = self._load_data_app_storage_config(
1366
+ ds_client=ds_client,
1367
+ storage_client=storage_client,
1368
+ app_id=str(app_id),
1369
+ branch_id=branch_id,
1370
+ )
1371
+ raw_secrets = self._read_secrets_block(body)
1372
+ if key not in raw_secrets:
1373
+ # Don't enumerate sibling keys -- avoid leaking neighbour
1374
+ # presence to a caller who knows only this key's name.
1375
+ raise KeboolaApiError(
1376
+ message=(
1377
+ f"Secret '{key}' not found on data app {app_id} in project '{alias}'."
1378
+ ),
1379
+ status_code=404,
1380
+ error_code=ErrorCode.NOT_FOUND,
1381
+ retryable=False,
1382
+ )
1383
+ stored_value = raw_secrets[key]
1384
+ env_var = _derive_runtime_env_var_name(key)
1385
+ is_encrypted = isinstance(stored_value, str) and stored_value.startswith("KBC::")
1386
+ result: dict[str, Any] = {
1387
+ "project_alias": alias,
1388
+ "app_id": str(app_id),
1389
+ "config_id": config_id,
1390
+ "key": key,
1391
+ "env_var": env_var,
1392
+ "shadowed_by_runtime": env_var in RESERVED_RUNTIME_ENV_VARS,
1393
+ "encrypted": is_encrypted,
1394
+ "present": True,
1395
+ }
1396
+ if is_encrypted:
1397
+ result["value"] = None
1398
+ result["fingerprint"] = _secret_fingerprint(stored_value)
1399
+ result["encryption_prefix"] = self._derive_encryption_prefix(stored_value)
1400
+ result["message"] = (
1401
+ f"Secret '{key}' is set on data app {app_id}. "
1402
+ "Decrypted plaintext is NOT exposed by the CLI."
1403
+ )
1404
+ else:
1405
+ result["value"] = stored_value
1406
+ result["fingerprint"] = ""
1407
+ result["encryption_prefix"] = ""
1408
+ result["message"] = (
1409
+ f"'{key}' is a plaintext (unencrypted) config value on data app "
1410
+ f"{app_id}; it is stored in clear in the config."
1411
+ )
1412
+ return result
1413
+ finally:
1414
+ ds_client.close()
1415
+ storage_client.close()
1416
+
1417
+ def remove_data_app_secrets(
1418
+ self,
1419
+ *,
1420
+ alias: str,
1421
+ app_id: str,
1422
+ keys: list[str],
1423
+ branch_id: int | None = None,
1424
+ dry_run: bool = False,
1425
+ ) -> dict[str, Any]:
1426
+ """Remove one or more keys from ``parameters.dataApp.secrets``. Idempotent.
1427
+
1428
+ Accepts both ``#``-prefixed secrets and plain (unencrypted) env-var
1429
+ keys (``require_hash=False``), mirroring ``secrets-list`` -- anything
1430
+ that can be listed can be removed.
1431
+ """
1432
+ if not keys:
1433
+ raise KeboolaApiError(
1434
+ message="At least one --key 'KEY' is required ('#' optional).",
1435
+ status_code=0,
1436
+ error_code=ErrorCode.DATA_APP_INVALID_SECRET,
1437
+ retryable=False,
1438
+ )
1439
+ for key in keys:
1440
+ self._validate_secret_key(key, require_hash=False)
1441
+
1442
+ projects = self.resolve_projects([alias])
1443
+ project = projects[alias]
1444
+ ds_client = self._ds_client_factory(project.stack_url, project.token)
1445
+ storage_client = self._client_factory(project.stack_url, project.token)
1446
+ try:
1447
+ config_id, envelope, body = self._load_data_app_storage_config(
1448
+ ds_client=ds_client,
1449
+ storage_client=storage_client,
1450
+ app_id=str(app_id),
1451
+ branch_id=branch_id,
1452
+ )
1453
+ existing_secrets = self._read_secrets_block(body)
1454
+
1455
+ removed = sorted(_derive_runtime_env_var_name(k) for k in keys if k in existing_secrets)
1456
+ not_found = sorted(
1457
+ _derive_runtime_env_var_name(k) for k in keys if k not in existing_secrets
1458
+ )
1459
+ current_version = str(envelope.get("version", "") or "")
1460
+
1461
+ if not removed:
1462
+ # Idempotent: removing a non-existent key is success.
1463
+ return {
1464
+ "project_alias": alias,
1465
+ "app_id": str(app_id),
1466
+ "config_id": config_id,
1467
+ "removed": [],
1468
+ "not_found": not_found,
1469
+ "config_version_before": current_version,
1470
+ "config_version_after": current_version,
1471
+ "deploy_required": False,
1472
+ "message": (
1473
+ f"No matching secrets to remove on data app {app_id}. "
1474
+ f"Keys not present: {', '.join(not_found) or '<none>'}."
1475
+ ),
1476
+ }
1477
+
1478
+ if dry_run:
1479
+ preview = {k: v for k, v in existing_secrets.items() if k not in keys}
1480
+ preview_body = self._merge_secrets_into_body(body, preview)
1481
+ return {
1482
+ "dry_run": True,
1483
+ "project_alias": alias,
1484
+ "app_id": str(app_id),
1485
+ "config_id": config_id,
1486
+ "to_remove": removed,
1487
+ "not_found": not_found,
1488
+ "put_storage_config_preview": _redact_storage_config(
1489
+ {"configuration": preview_body}
1490
+ ),
1491
+ "message": (
1492
+ f"Dry run -- would remove {len(removed)} secret(s) and PUT "
1493
+ "the body above. No API call made."
1494
+ ),
1495
+ }
1496
+
1497
+ updated_secrets = {k: v for k, v in existing_secrets.items() if k not in keys}
1498
+ new_body = self._merge_secrets_into_body(body, updated_secrets)
1499
+
1500
+ put_response = storage_client.update_config(
1501
+ component_id=DATA_APP_COMPONENT_ID,
1502
+ config_id=config_id,
1503
+ configuration=new_body,
1504
+ change_description=(
1505
+ f"Remove {len(removed)} secret(s) via kbagent data-app secrets remove"
1506
+ ),
1507
+ branch_id=branch_id,
1508
+ )
1509
+ new_version = str(put_response.get("version", "") or "")
1510
+ return {
1511
+ "project_alias": alias,
1512
+ "app_id": str(app_id),
1513
+ "config_id": config_id,
1514
+ "removed": removed,
1515
+ "not_found": not_found,
1516
+ "config_version_before": current_version,
1517
+ "config_version_after": new_version,
1518
+ "deploy_required": True,
1519
+ "next_step": (
1520
+ f"kbagent data-app deploy --project {alias} --app-id {app_id} --wait"
1521
+ ),
1522
+ "message": (
1523
+ f"{len(removed)} secret(s) removed. The running container keeps "
1524
+ "the old config until you redeploy."
1525
+ ),
1526
+ }
1527
+ finally:
1528
+ ds_client.close()
1529
+ storage_client.close()
1530
+
1531
+ # ------------------------------------------------------------------
1532
+ # Internal helpers
1533
+ # ------------------------------------------------------------------
1534
+
1535
+ def _validate_secret_key(self, key: str, *, require_hash: bool = True) -> None:
1536
+ """Reject any key that is not a valid data-app env-var identifier.
1537
+
1538
+ With ``require_hash=True`` (the default, used by the encrypting
1539
+ ``secrets-set`` path) the ``#``-prefix convention is mandatory.
1540
+ With ``require_hash=False`` (read/remove paths) the ``#`` is
1541
+ optional, so plain unencrypted env-var keys -- which
1542
+ ``secrets-list`` enumerates alongside ``#`` secrets -- can be read
1543
+ and removed. Either way the rest of the key must form a valid
1544
+ env-var identifier after the runtime translation rule.
1545
+
1546
+ Service-boundary check; the command layer also validates so the
1547
+ error surfaces with the friendliest exit code.
1548
+ """
1549
+ pattern = SECRET_KEY_PATTERN if require_hash else SECRET_OR_PLAIN_KEY_PATTERN
1550
+ if not isinstance(key, str) or not pattern.match(key):
1551
+ message = (
1552
+ (
1553
+ f"Invalid secret key '{key}'. Keys must start with '#' and "
1554
+ "the rest must match [A-Za-z][A-Za-z0-9_-]{0,63} so the "
1555
+ "derived runtime env-var name (uppercase, '-' to '_') is a "
1556
+ "valid identifier."
1557
+ )
1558
+ if require_hash
1559
+ else (
1560
+ f"Invalid key '{key}'. The key must form a valid env-var "
1561
+ "identifier -- [A-Za-z][A-Za-z0-9_-]{0,63} with an optional "
1562
+ "leading '#' -- so the derived runtime env-var name "
1563
+ "(uppercase, '-' to '_') is valid."
1564
+ )
1565
+ )
1566
+ raise KeboolaApiError(
1567
+ message=message,
1568
+ status_code=0,
1569
+ error_code=ErrorCode.DATA_APP_INVALID_SECRET,
1570
+ retryable=False,
1571
+ )
1572
+ if _has_control_chars(key):
1573
+ raise KeboolaApiError(
1574
+ message=f"Secret key '{key}' contains disallowed control characters.",
1575
+ status_code=0,
1576
+ error_code=ErrorCode.DATA_APP_INVALID_SECRET,
1577
+ retryable=False,
1578
+ )
1579
+
1580
+ def _merge_secrets_into_body(
1581
+ self,
1582
+ body: dict[str, Any],
1583
+ secrets: dict[str, str],
1584
+ ) -> dict[str, Any]:
1585
+ """Return a deep-copy of ``body`` with ``parameters.dataApp.secrets`` replaced.
1586
+
1587
+ Every untouched sibling -- under ``parameters.dataApp.secrets``
1588
+ (none, since the whole sub-dict is replaced), under
1589
+ ``parameters.dataApp`` (slug, git, id, etc.), under ``parameters``
1590
+ (everything else), and at the top level (``runtime``,
1591
+ ``authorization``, ``storage``) -- is preserved bit-identical.
1592
+ """
1593
+ new_body = json.loads(json.dumps(body)) if isinstance(body, dict) else {}
1594
+ if not isinstance(new_body, dict):
1595
+ new_body = {}
1596
+ params = new_body.setdefault("parameters", {})
1597
+ if not isinstance(params, dict):
1598
+ params = {}
1599
+ new_body["parameters"] = params
1600
+ data_app = params.setdefault("dataApp", {})
1601
+ if not isinstance(data_app, dict):
1602
+ data_app = {}
1603
+ params["dataApp"] = data_app
1604
+ if secrets:
1605
+ data_app["secrets"] = dict(secrets)
1606
+ elif "secrets" in data_app:
1607
+ # Remove the secrets key entirely if the new map is empty so
1608
+ # the diff reads "key dropped" rather than "key set to {}".
1609
+ del data_app["secrets"]
1610
+ return new_body
1611
+
1612
+ def _derive_encryption_prefix(self, ciphertext: str) -> str:
1613
+ """Return the ``KBC::*`` prefix matched on this ciphertext, or '' if none."""
1614
+ if not isinstance(ciphertext, str):
1615
+ return ""
1616
+ for prefix in ENCRYPTED_PASSWORD_PREFIXES:
1617
+ if ciphertext.startswith(prefix):
1618
+ return prefix.rstrip(":")
1619
+ return ""
1620
+
1621
+ def _validate_create_inputs(
1622
+ self,
1623
+ *,
1624
+ type_: str,
1625
+ slug: str,
1626
+ size: str,
1627
+ auth: str,
1628
+ name: str,
1629
+ description: str,
1630
+ git_repo: str,
1631
+ git_branch: str,
1632
+ git_public: bool,
1633
+ git_username: str | None,
1634
+ git_pat_plaintext: str | None,
1635
+ git_pat_encrypted: str | None,
1636
+ ) -> None:
1637
+ # Defence-in-depth length / control-char checks at the service
1638
+ # boundary. The service can be invoked directly (via the
1639
+ # ``kbagent serve`` REST API or external Python callers) so we do
1640
+ # not rely on the command layer alone.
1641
+ for field_name, field_value, max_len, allow_ws in (
1642
+ ("--name", name, MAX_NAME_LENGTH, False),
1643
+ ("--description", description, MAX_DESCRIPTION_LENGTH, True),
1644
+ ("--git-repo", git_repo, MAX_GIT_REPO_LENGTH, False),
1645
+ ("--git-branch", git_branch, MAX_GIT_BRANCH_LENGTH, False),
1646
+ ("--git-username", git_username or "", MAX_GIT_USERNAME_LENGTH, False),
1647
+ ):
1648
+ if not isinstance(field_value, str):
1649
+ continue
1650
+ if len(field_value) > max_len:
1651
+ raise KeboolaApiError(
1652
+ message=(
1653
+ f"{field_name} exceeds the {max_len}-character limit "
1654
+ "enforced at the service boundary."
1655
+ ),
1656
+ status_code=0,
1657
+ error_code=ErrorCode.VALIDATION_ERROR,
1658
+ retryable=False,
1659
+ )
1660
+ # Description allows tab/LF/CR (markdown); other fields reject any
1661
+ # control char including CR/LF (would break URL host derivation,
1662
+ # JSON serialization, or audit-log change descriptions).
1663
+ if _has_control_chars(field_value, allow_whitespace=allow_ws):
1664
+ raise KeboolaApiError(
1665
+ message=(f"{field_name} contains disallowed control characters."),
1666
+ status_code=0,
1667
+ error_code=ErrorCode.VALIDATION_ERROR,
1668
+ retryable=False,
1669
+ )
1670
+
1671
+ # Reject git_repo URLs that don't use a known clone scheme. The
1672
+ # data-app runner only handles https / http / git / ssh; anything
1673
+ # else (file://, gopher://, etc.) is either nonsense or an SSRF /
1674
+ # local-file-read footgun and must not reach Storage.
1675
+ if git_repo and not any(git_repo.startswith(scheme) for scheme in ALLOWED_GIT_REPO_SCHEMES):
1676
+ raise KeboolaApiError(
1677
+ message=(
1678
+ f"--git-repo must use one of {', '.join(ALLOWED_GIT_REPO_SCHEMES)}."
1679
+ " Bare ssh syntax (git@host:path) and other schemes are not"
1680
+ " accepted."
1681
+ ),
1682
+ status_code=0,
1683
+ error_code=ErrorCode.DATA_APP_INVALID_GIT,
1684
+ retryable=False,
1685
+ )
1686
+
1687
+ if type_ not in VALID_TYPES:
1688
+ raise KeboolaApiError(
1689
+ message=(f"Invalid --type '{type_}'. Valid values: {', '.join(VALID_TYPES)}"),
1690
+ status_code=0,
1691
+ error_code=ErrorCode.VALIDATION_ERROR,
1692
+ retryable=False,
1693
+ )
1694
+ if size not in VALID_SIZES:
1695
+ raise KeboolaApiError(
1696
+ message=(f"Invalid --size '{size}'. Valid values: {', '.join(VALID_SIZES)}"),
1697
+ status_code=0,
1698
+ error_code=ErrorCode.VALIDATION_ERROR,
1699
+ retryable=False,
1700
+ )
1701
+ if auth not in ("password", "public"):
1702
+ raise KeboolaApiError(
1703
+ message=f"Invalid --auth '{auth}'. Valid values: password, public",
1704
+ status_code=0,
1705
+ error_code=ErrorCode.VALIDATION_ERROR,
1706
+ retryable=False,
1707
+ )
1708
+ if not SLUG_PATTERN.match(slug):
1709
+ raise KeboolaApiError(
1710
+ message=(
1711
+ f"Invalid --slug '{slug}'. Slug must be lowercase alphanumeric "
1712
+ "with hyphens, 2-64 chars, and cannot start or end with a hyphen."
1713
+ ),
1714
+ status_code=0,
1715
+ error_code=ErrorCode.VALIDATION_ERROR,
1716
+ retryable=False,
1717
+ )
1718
+
1719
+ if git_public:
1720
+ if git_username or git_pat_plaintext or git_pat_encrypted:
1721
+ raise KeboolaApiError(
1722
+ message=(
1723
+ "--git-public is incompatible with --git-username / "
1724
+ "--git-pat-env / --git-pat-file / --git-pat-encrypted."
1725
+ ),
1726
+ status_code=0,
1727
+ error_code=ErrorCode.VALIDATION_ERROR,
1728
+ retryable=False,
1729
+ )
1730
+ else:
1731
+ if not git_username:
1732
+ raise KeboolaApiError(
1733
+ message="--git-username is required for private repositories.",
1734
+ status_code=0,
1735
+ error_code=ErrorCode.VALIDATION_ERROR,
1736
+ retryable=False,
1737
+ )
1738
+ provided = sum(
1739
+ 1 for v in (git_pat_plaintext, git_pat_encrypted) if v is not None and v != ""
1740
+ )
1741
+ if provided == 0:
1742
+ raise KeboolaApiError(
1743
+ message=(
1744
+ "Private repository requires a PAT. Pass one of: "
1745
+ "--git-pat-env VAR / --git-pat-file PATH / "
1746
+ "--git-pat-encrypted KBC::Project..."
1747
+ ),
1748
+ status_code=0,
1749
+ error_code=ErrorCode.VALIDATION_ERROR,
1750
+ retryable=False,
1751
+ )
1752
+ if provided > 1:
1753
+ raise KeboolaApiError(
1754
+ message=(
1755
+ "Specify exactly one of --git-pat-env / --git-pat-file / "
1756
+ "--git-pat-encrypted; they are mutually exclusive."
1757
+ ),
1758
+ status_code=0,
1759
+ error_code=ErrorCode.VALIDATION_ERROR,
1760
+ retryable=False,
1761
+ )
1762
+ if git_pat_plaintext is not None and git_pat_plaintext.startswith("KBC::"):
1763
+ # A plaintext input that already looks like a Keboola
1764
+ # ciphertext is almost certainly someone pasting an
1765
+ # encrypted value into --git-pat-env / --git-pat-file by
1766
+ # mistake. EncryptService.encrypt() short-circuits any
1767
+ # ``KBC::``-prefixed value and would pass it through
1768
+ # unchanged, so a stray ciphertext from another project
1769
+ # could reach Storage and silently fail at runtime
1770
+ # decrypt. Reject up front.
1771
+ raise KeboolaApiError(
1772
+ message=(
1773
+ "--git-pat-env / --git-pat-file expect plaintext PATs. "
1774
+ "The value starts with 'KBC::' which suggests an "
1775
+ "already-encrypted ciphertext; if so, pass it via "
1776
+ "--git-pat-encrypted instead so the prefix is validated."
1777
+ ),
1778
+ status_code=0,
1779
+ error_code=ErrorCode.DATA_APP_INVALID_GIT,
1780
+ retryable=False,
1781
+ )
1782
+ if git_pat_encrypted is not None and not any(
1783
+ git_pat_encrypted.startswith(p) for p in ENCRYPTED_PASSWORD_PREFIXES
1784
+ ):
1785
+ raise KeboolaApiError(
1786
+ message=(
1787
+ "--git-pat-encrypted must be a project-scoped Encryption "
1788
+ f"API ciphertext (one of: {', '.join(ENCRYPTED_PASSWORD_PREFIXES)}). "
1789
+ "Re-encrypt with `kbagent encrypt values --component-id "
1790
+ f"{DATA_APP_COMPONENT_ID}` against THIS project; ciphertext "
1791
+ "from another project will not decrypt (writeup §8)."
1792
+ ),
1793
+ status_code=0,
1794
+ error_code=ErrorCode.VALIDATION_ERROR,
1795
+ retryable=False,
1796
+ )
1797
+
1798
+ def _build_git_block(
1799
+ self,
1800
+ *,
1801
+ alias: str,
1802
+ git_repo: str,
1803
+ git_branch: str,
1804
+ git_public: bool,
1805
+ git_username: str | None,
1806
+ git_pat_plaintext: str | None,
1807
+ git_pat_encrypted: str | None,
1808
+ ) -> dict[str, Any]:
1809
+ if git_public:
1810
+ return {
1811
+ "repository": git_repo,
1812
+ "private": False,
1813
+ "branch": git_branch,
1814
+ }
1815
+
1816
+ # Plaintext PATs are encrypted under the target project's KMS via
1817
+ # EncryptService. Pre-encrypted ciphertext was prefix-validated in
1818
+ # _validate_create_inputs; EncryptService short-circuits anything
1819
+ # starting with ``KBC::`` (encrypt_service.py) and returns the value
1820
+ # unchanged, so we do not pay the encryption round-trip on a
1821
+ # caller-supplied ciphertext. We still re-validate the result below
1822
+ # so a misconfigured input cannot reach Storage as plaintext.
1823
+ secret_input = git_pat_plaintext or git_pat_encrypted or ""
1824
+ try:
1825
+ encrypted = self._encrypt_service.encrypt(
1826
+ alias=alias,
1827
+ component_id=DATA_APP_COMPONENT_ID,
1828
+ input_data={"#password": secret_input},
1829
+ )
1830
+ except ConfigError as exc:
1831
+ raise KeboolaApiError(
1832
+ message=f"Failed to prepare git PAT for encryption: {exc.message}",
1833
+ status_code=0,
1834
+ error_code=ErrorCode.ENCRYPTION_FAILED,
1835
+ retryable=False,
1836
+ ) from exc
1837
+ encrypted_pat = encrypted.get("#password", "")
1838
+ if not encrypted_pat or not any(
1839
+ encrypted_pat.startswith(p) for p in ENCRYPTED_PASSWORD_PREFIXES
1840
+ ):
1841
+ raise KeboolaApiError(
1842
+ message=(
1843
+ "Encryption API did not return a project-scoped ciphertext "
1844
+ "for the git PAT; refusing to write plaintext to Storage."
1845
+ ),
1846
+ status_code=0,
1847
+ error_code=ErrorCode.ENCRYPTION_FAILED,
1848
+ retryable=False,
1849
+ )
1850
+ return {
1851
+ "repository": git_repo,
1852
+ "private": True,
1853
+ "username": git_username or "",
1854
+ "#password": encrypted_pat,
1855
+ "branch": git_branch,
1856
+ }
1857
+
1858
+ def _build_storage_config_body(
1859
+ self,
1860
+ *,
1861
+ size: str,
1862
+ auto_suspend_after_seconds: int,
1863
+ slug: str,
1864
+ git_block: dict[str, Any],
1865
+ auth: str,
1866
+ app_id: str,
1867
+ ) -> dict[str, Any]:
1868
+ body: dict[str, Any] = {
1869
+ "parameters": {
1870
+ "autoSuspendAfterSeconds": auto_suspend_after_seconds,
1871
+ "dataApp": {
1872
+ "slug": slug,
1873
+ "git": git_block,
1874
+ },
1875
+ "id": str(app_id), # writeup §5: required back-pointer
1876
+ },
1877
+ "runtime": {"backend": {"size": size}},
1878
+ }
1879
+ body["authorization"] = _auth_block_for(auth)
1880
+ return body
1881
+
1882
+ def _build_dry_run_payload(
1883
+ self,
1884
+ **kwargs: Any,
1885
+ ) -> dict[str, Any]:
1886
+ """Render the three request bodies without making any API call."""
1887
+ size = kwargs["size"]
1888
+ slug = kwargs["slug"]
1889
+ auth = kwargs["auth"]
1890
+ auto_suspend = kwargs["auto_suspend_after_seconds"]
1891
+ type_ = kwargs["type_"]
1892
+
1893
+ post_body = {
1894
+ "branchId": kwargs["branch_id"],
1895
+ "type": type_,
1896
+ "name": kwargs["name"],
1897
+ "description": "",
1898
+ "config": {
1899
+ "parameters": {
1900
+ "size": size,
1901
+ "autoSuspendAfterSeconds": auto_suspend,
1902
+ "dataApp": {"slug": slug},
1903
+ },
1904
+ },
1905
+ }
1906
+ post_body["config"]["authorization"] = _auth_block_for(auth)
1907
+
1908
+ # We can't know the app_id pre-create; show the placeholder.
1909
+ git_block_preview: dict[str, Any]
1910
+ if kwargs["git_public"]:
1911
+ git_block_preview = {
1912
+ "repository": kwargs["git_repo"],
1913
+ "private": False,
1914
+ "branch": kwargs["git_branch"],
1915
+ }
1916
+ else:
1917
+ git_block_preview = {
1918
+ "repository": kwargs["git_repo"],
1919
+ "private": True,
1920
+ "username": kwargs["git_username"] or "<from input>",
1921
+ "#password": "<encrypted at runtime>",
1922
+ "branch": kwargs["git_branch"],
1923
+ }
1924
+
1925
+ put_body = {
1926
+ "parameters": {
1927
+ "autoSuspendAfterSeconds": auto_suspend,
1928
+ "dataApp": {"slug": slug, "git": git_block_preview},
1929
+ "id": "<server-assigned numeric id>",
1930
+ },
1931
+ "runtime": {"backend": {"size": size}},
1932
+ }
1933
+ put_body["authorization"] = _auth_block_for(auth)
1934
+
1935
+ patch_body: dict[str, Any] = {}
1936
+ if kwargs["deploy"]:
1937
+ patch_body = {
1938
+ "desiredState": "running",
1939
+ "configVersion": "<latest after PUT>",
1940
+ "restartIfRunning": True,
1941
+ }
1942
+
1943
+ return {
1944
+ "dry_run": True,
1945
+ "project_alias": kwargs["alias"],
1946
+ "requests": {
1947
+ "post_apps": post_body,
1948
+ "put_storage_config": put_body,
1949
+ "patch_apps": patch_body,
1950
+ },
1951
+ "message": (
1952
+ "Dry run -- no API calls made. "
1953
+ "Inspect the three request bodies above before re-running without --dry-run."
1954
+ ),
1955
+ }
1956
+
1957
+ def _fetch_data_app_config_names(
1958
+ self,
1959
+ storage_client: Any,
1960
+ branch_id: int | None,
1961
+ ) -> dict[str, str]:
1962
+ """Map ``configId -> name`` for ``keboola.data-apps`` configs.
1963
+
1964
+ Used by ``list_data_apps`` to enrich the thin Data Science index
1965
+ with the human-readable names that live on the Storage config.
1966
+ """
1967
+ try:
1968
+ configs = storage_client.list_component_configs(
1969
+ DATA_APP_COMPONENT_ID, branch_id=branch_id
1970
+ )
1971
+ return {str(cfg.get("id", "")): cfg.get("name", "") for cfg in configs}
1972
+ except Exception:
1973
+ return {}
1974
+
1975
+ def _poll_until_terminal(
1976
+ self,
1977
+ ds_client: DataScienceClient,
1978
+ app_id: str,
1979
+ *,
1980
+ target_desired_state: str,
1981
+ timeout_seconds: float,
1982
+ ) -> dict[str, Any]:
1983
+ """Poll ``GET /apps/{id}`` until terminal.
1984
+
1985
+ Terminal definition:
1986
+
1987
+ - ``state == target_desired_state`` (the deploy succeeded), OR
1988
+ - ``state == "error"`` (the build / runtime failed).
1989
+
1990
+ IMPORTANT: while ``desiredState == "running"``, observing
1991
+ ``state == "stopped"`` is NOT terminal -- the platform transitions
1992
+ ``created -> stopped -> starting -> running`` during initial
1993
+ deploy, and a naive poll exits prematurely (writeup §8 pitfall 1).
1994
+ """
1995
+ deadline = time.monotonic() + timeout_seconds
1996
+ last_record: dict[str, Any] = {}
1997
+ while True:
1998
+ last_record = ds_client.get_app(app_id)
1999
+ state = str(last_record.get("state", ""))
2000
+ if state == TERMINAL_ERROR_STATE:
2001
+ raise KeboolaApiError(
2002
+ message=(
2003
+ f"Data app {app_id} reached state=error during deploy. "
2004
+ "See the app's Terminal Log in the Keboola UI for the build "
2005
+ "error -- the Data Science API does not expose it as JSON."
2006
+ ),
2007
+ status_code=0,
2008
+ error_code=ErrorCode.DATA_APP_BUILD_FAILED,
2009
+ retryable=False,
2010
+ )
2011
+ if state == target_desired_state:
2012
+ return last_record
2013
+ if time.monotonic() >= deadline:
2014
+ raise KeboolaApiError(
2015
+ message=(
2016
+ f"Timed out after {timeout_seconds:.0f}s waiting for data app "
2017
+ f"{app_id} to reach state={target_desired_state} "
2018
+ f"(last observed: state={state}, desired={last_record.get('desiredState')})."
2019
+ ),
2020
+ status_code=0,
2021
+ error_code=ErrorCode.DATA_APP_DEPLOY_TIMEOUT,
2022
+ retryable=True,
2023
+ )
2024
+ time.sleep(POLL_INTERVAL_SECONDS)
2025
+
2026
+ def _format_lifecycle_result(
2027
+ self,
2028
+ *,
2029
+ alias: str,
2030
+ app_id: str,
2031
+ action: str,
2032
+ deployed: dict[str, Any],
2033
+ poll_result: dict[str, Any] | None,
2034
+ config_version: str | None = None,
2035
+ ) -> dict[str, Any]:
2036
+ record = poll_result or deployed
2037
+ return {
2038
+ "project_alias": alias,
2039
+ "app_id": str(app_id),
2040
+ "action": action,
2041
+ "state": record.get("state", ""),
2042
+ "desired_state": record.get("desiredState", ""),
2043
+ "config_version": str(record.get("configVersion", "") or "") or (config_version or ""),
2044
+ "url": record.get("url", ""),
2045
+ "last_start_timestamp": record.get("lastStartTimestamp"),
2046
+ "message": (
2047
+ f"Data app {app_id} {action} requested in project '{alias}'. "
2048
+ f"state={record.get('state', '?')}, "
2049
+ f"desiredState={record.get('desiredState', '?')}."
2050
+ ),
2051
+ }
2052
+
2053
+ def _format_create_message(
2054
+ self,
2055
+ *,
2056
+ name: str,
2057
+ auth: str,
2058
+ deployed: bool,
2059
+ wait: bool,
2060
+ state: str,
2061
+ ) -> str:
2062
+ if not deployed:
2063
+ return (
2064
+ f"Data app '{name}' created and configured. "
2065
+ "No deploy attempted (--no-deploy). Run `kbagent data-app deploy` "
2066
+ "to start the app."
2067
+ )
2068
+ if not wait:
2069
+ return (
2070
+ f"Data app '{name}' created and deploy requested. "
2071
+ "Use `kbagent data-app detail` to track state, or pass --wait "
2072
+ "to block until running."
2073
+ )
2074
+ # wait=True
2075
+ if state == RUNNING_STATE:
2076
+ tail = (
2077
+ " Run `kbagent data-app password` to retrieve the simpleAuth password."
2078
+ if auth == "password"
2079
+ else ""
2080
+ )
2081
+ return f"Data app '{name}' is running.{tail}"
2082
+ return f"Data app '{name}' deploy reached state={state}."