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.
- keboola_agent_cli/__init__.py +34 -0
- keboola_agent_cli/__main__.py +5 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/arc-DhFYIddx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/architecture-7EHR7CIX-hNCijx_H.js +1 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js +37 -0
- keboola_agent_cli/_ui_dist/assets/architectureDiagram-3BPJPVTR-C6hUlprM.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js +2 -0
- keboola_agent_cli/_ui_dist/assets/array-BifhSqXX.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js +133 -0
- keboola_agent_cli/_ui_dist/assets/blockDiagram-GPEHLZMM-DC7qY9i4.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js +11 -0
- keboola_agent_cli/_ui_dist/assets/c4Diagram-AAUBKEIU-5Lh44evt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js +2 -0
- keboola_agent_cli/_ui_dist/assets/channel-DBMrXlxx.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-2J33WTMH-Coy82EBh.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js +63 -0
- keboola_agent_cli/_ui_dist/assets/chunk-3OPIFGDE-BQC5CRHI.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-4BX2VUAB-DUuEt70o.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-55IACEB6-BvR-6chF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js +3 -0
- keboola_agent_cli/_ui_dist/assets/chunk-5ZQYHXKU-BjcTN7ul.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js +207 -0
- keboola_agent_cli/_ui_dist/assets/chunk-727SXJPM-C0zxqqRN.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js +232 -0
- keboola_agent_cli/_ui_dist/assets/chunk-AQP2D5EJ-CXf7rIlZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-BSJP7CBP-Oj_FO9Q7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js +124 -0
- keboola_agent_cli/_ui_dist/assets/chunk-CSCIHK7Q-CcTsLrFc.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js +16 -0
- keboola_agent_cli/_ui_dist/assets/chunk-FMBD7UC4-FH-zLkkW.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-L5ZTLDWV-B1Ky_e7O.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-ND2GUHAM-BHz1rpbm.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +160 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NNHCCRGN-DlpIbxXb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-NZK2D7GU-tnrSoegS.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js +71 -0
- keboola_agent_cli/_ui_dist/assets/chunk-O5CBEL6O-DxxqDH0l.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js +2 -0
- keboola_agent_cli/_ui_dist/assets/chunk-QZHKN3VN-CSjc2gjj.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-4FO5ZUOK-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js +2 -0
- keboola_agent_cli/_ui_dist/assets/classDiagram-v2-Q7XG4LA2-BuZcZu85.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js +2 -0
- keboola_agent_cli/_ui_dist/assets/cose-bilkent-S5V4N54A-Y0L8LDMa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js +322 -0
- keboola_agent_cli/_ui_dist/assets/cytoscape.esm-C8YCVR3_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js +5 -0
- keboola_agent_cli/_ui_dist/assets/dagre-BM42HDAG-UZ-9BTqF.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dagre-Bx709z4p.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js +2 -0
- keboola_agent_cli/_ui_dist/assets/defaultLocale-C8Fc0cco.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js +44 -0
- keboola_agent_cli/_ui_dist/assets/diagram-2AECGRRQ-DoDQ60wi.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js +11 -0
- keboola_agent_cli/_ui_dist/assets/diagram-5GNKFQAL-CMGFxpUs.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js +4 -0
- keboola_agent_cli/_ui_dist/assets/diagram-KO2AKTUF-1uGDa-Iu.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-LMA3HP47-XtFH7B51.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js +25 -0
- keboola_agent_cli/_ui_dist/assets/diagram-OG6HWLK6-B4_Te1T5.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/dist-Di6zmlv0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js +86 -0
- keboola_agent_cli/_ui_dist/assets/erDiagram-TEJ5UH35-NjQkrdFt.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/eventmodeling-FCH6USID-BrJMIks8.js +1 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js +163 -0
- keboola_agent_cli/_ui_dist/assets/flowDiagram-I6XJVG4X-CIr8DWl7.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js +293 -0
- keboola_agent_cli/_ui_dist/assets/ganttDiagram-6RSMTGT7-C1VY_xbQ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraph-WXDBUCRP-COacYjo-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js +107 -0
- keboola_agent_cli/_ui_dist/assets/gitGraphDiagram-PVQCEYII-DQT8-kg2.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js +2 -0
- keboola_agent_cli/_ui_dist/assets/graphlib-B8gBHxth.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/index-CMq50kkV.css +1 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js +118 -0
- keboola_agent_cli/_ui_dist/assets/index-D8W97DAz.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/info-J43DQDTF-DdCTRIzU.js +1 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js +3 -0
- keboola_agent_cli/_ui_dist/assets/infoDiagram-5YYISTIA-C77rsoTp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/init-D6jRqBbL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js +71 -0
- keboola_agent_cli/_ui_dist/assets/ishikawaDiagram-YF4QCWOH-BcTbXaLy.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js +140 -0
- keboola_agent_cli/_ui_dist/assets/journeyDiagram-JHISSGLW-BejeAJQ_.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js +90 -0
- keboola_agent_cli/_ui_dist/assets/kanban-definition-UN3LZRKU-BRNz_UrH.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js +258 -0
- keboola_agent_cli/_ui_dist/assets/katex-C4eR7coU.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js +2 -0
- keboola_agent_cli/_ui_dist/assets/line-CzAQKFbJ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js +2 -0
- keboola_agent_cli/_ui_dist/assets/linear-DUNFFdck.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js +5 -0
- keboola_agent_cli/_ui_dist/assets/mermaid-parser.core-CpuBOkFa.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js +97 -0
- keboola_agent_cli/_ui_dist/assets/mindmap-definition-RKZ34NQL-9EJQNjH0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js +2 -0
- keboola_agent_cli/_ui_dist/assets/ordinal-hYBb2elL.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/packet-YPE3B663-DLiiw_B2.js +1 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js +2 -0
- keboola_agent_cli/_ui_dist/assets/path-BWPyau1x.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/pie-LRSECV5Y-CRoO8G1g.js +1 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js +31 -0
- keboola_agent_cli/_ui_dist/assets/pieDiagram-4H26LBE5-XH4cy6Cb.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js +8 -0
- keboola_agent_cli/_ui_dist/assets/quadrantDiagram-W4KKPZXB-fdhc93U8.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/radar-GUYGQ44K-DAlLVJHm.js +1 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js +85 -0
- keboola_agent_cli/_ui_dist/assets/requirementDiagram-4Y6WPE33-a94eP3R9.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js +2 -0
- keboola_agent_cli/_ui_dist/assets/rough.esm-CSKSodPl.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js +41 -0
- keboola_agent_cli/_ui_dist/assets/sankeyDiagram-5OEKKPKP-jcBa02sp.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js +163 -0
- keboola_agent_cli/_ui_dist/assets/sequenceDiagram-3UESZ5HK-A5-GGM-e.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js +2 -0
- keboola_agent_cli/_ui_dist/assets/src-ZI-V_AF0.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-AJRCARHV-BKAA5rqE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js +2 -0
- keboola_agent_cli/_ui_dist/assets/stateDiagram-v2-BHNVJYJU-DnJwJBsE.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js +121 -0
- keboola_agent_cli/_ui_dist/assets/timeline-definition-PNZ67QCA-Cy39jp8b.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/treeView-BLDUP644-DbLYl23-.js +1 -0
- keboola_agent_cli/_ui_dist/assets/treemap-LRROVOQU-Bp0eGlOt.js +1 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js +35 -0
- keboola_agent_cli/_ui_dist/assets/vennDiagram-CIIHVFJN-BGECKubd.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/wardley-L42UT6IY-D4yH4jqS.js +1 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js +79 -0
- keboola_agent_cli/_ui_dist/assets/wardleyDiagram-YWT4CUSO-D6XRG3cZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js +8 -0
- keboola_agent_cli/_ui_dist/assets/xychartDiagram-2RQKCTM6-DRre-pfZ.js.map +1 -0
- keboola_agent_cli/_ui_dist/index.html +50 -0
- keboola_agent_cli/ai_client.py +83 -0
- keboola_agent_cli/auto_update.py +550 -0
- keboola_agent_cli/changelog.py +1198 -0
- keboola_agent_cli/cli.py +448 -0
- keboola_agent_cli/client.py +3422 -0
- keboola_agent_cli/commands/__init__.py +0 -0
- keboola_agent_cli/commands/_data_app_git.py +343 -0
- keboola_agent_cli/commands/_helpers.py +377 -0
- keboola_agent_cli/commands/_metadata_input.py +49 -0
- keboola_agent_cli/commands/_semantic_layer_crud.py +632 -0
- keboola_agent_cli/commands/_semantic_layer_helpers.py +44 -0
- keboola_agent_cli/commands/_semantic_layer_reference_data.py +247 -0
- keboola_agent_cli/commands/agent.py +968 -0
- keboola_agent_cli/commands/branch.py +423 -0
- keboola_agent_cli/commands/changelog.py +168 -0
- keboola_agent_cli/commands/component.py +216 -0
- keboola_agent_cli/commands/config.py +2442 -0
- keboola_agent_cli/commands/context.py +1481 -0
- keboola_agent_cli/commands/data_app.py +1279 -0
- keboola_agent_cli/commands/dev_portal.py +584 -0
- keboola_agent_cli/commands/doctor.py +37 -0
- keboola_agent_cli/commands/encrypt.py +145 -0
- keboola_agent_cli/commands/feature.py +311 -0
- keboola_agent_cli/commands/flow.py +948 -0
- keboola_agent_cli/commands/http_client.py +157 -0
- keboola_agent_cli/commands/init.py +279 -0
- keboola_agent_cli/commands/job.py +661 -0
- keboola_agent_cli/commands/kai.py +301 -0
- keboola_agent_cli/commands/lineage.py +1464 -0
- keboola_agent_cli/commands/org.py +292 -0
- keboola_agent_cli/commands/permissions.py +360 -0
- keboola_agent_cli/commands/project.py +1192 -0
- keboola_agent_cli/commands/repl.py +243 -0
- keboola_agent_cli/commands/schedule.py +340 -0
- keboola_agent_cli/commands/search.py +178 -0
- keboola_agent_cli/commands/semantic_layer.py +939 -0
- keboola_agent_cli/commands/serve.py +272 -0
- keboola_agent_cli/commands/sharing.py +340 -0
- keboola_agent_cli/commands/storage.py +2630 -0
- keboola_agent_cli/commands/stream.py +266 -0
- keboola_agent_cli/commands/sync.py +1277 -0
- keboola_agent_cli/commands/tool.py +206 -0
- keboola_agent_cli/commands/version.py +186 -0
- keboola_agent_cli/commands/workspace.py +635 -0
- keboola_agent_cli/config_store.py +582 -0
- keboola_agent_cli/constants.py +528 -0
- keboola_agent_cli/data_science_client.py +342 -0
- keboola_agent_cli/dev_portal_client.py +323 -0
- keboola_agent_cli/errors.py +248 -0
- keboola_agent_cli/http_base.py +315 -0
- keboola_agent_cli/json_utils.py +126 -0
- keboola_agent_cli/lib.py +536 -0
- keboola_agent_cli/manage_client.py +324 -0
- keboola_agent_cli/metastore_client.py +214 -0
- keboola_agent_cli/models.py +427 -0
- keboola_agent_cli/output.py +1084 -0
- keboola_agent_cli/permissions.py +469 -0
- keboola_agent_cli/py.typed +3 -0
- keboola_agent_cli/result_models.py +271 -0
- keboola_agent_cli/server/__init__.py +34 -0
- keboola_agent_cli/server/agent_runner.py +1289 -0
- keboola_agent_cli/server/agents_store.py +325 -0
- keboola_agent_cli/server/app.py +764 -0
- keboola_agent_cli/server/auth.py +117 -0
- keboola_agent_cli/server/dependencies.py +149 -0
- keboola_agent_cli/server/pricing.py +303 -0
- keboola_agent_cli/server/routers/__init__.py +1 -0
- keboola_agent_cli/server/routers/agents.py +616 -0
- keboola_agent_cli/server/routers/ai_chat.py +129 -0
- keboola_agent_cli/server/routers/branches.py +133 -0
- keboola_agent_cli/server/routers/components.py +48 -0
- keboola_agent_cli/server/routers/configs.py +507 -0
- keboola_agent_cli/server/routers/data_apps.py +384 -0
- keboola_agent_cli/server/routers/dev_portal.py +67 -0
- keboola_agent_cli/server/routers/encrypt.py +35 -0
- keboola_agent_cli/server/routers/feature.py +179 -0
- keboola_agent_cli/server/routers/flows.py +204 -0
- keboola_agent_cli/server/routers/health.py +53 -0
- keboola_agent_cli/server/routers/jobs.py +175 -0
- keboola_agent_cli/server/routers/kai.py +80 -0
- keboola_agent_cli/server/routers/lineage.py +226 -0
- keboola_agent_cli/server/routers/mcp.py +70 -0
- keboola_agent_cli/server/routers/members.py +170 -0
- keboola_agent_cli/server/routers/org.py +96 -0
- keboola_agent_cli/server/routers/projects.py +106 -0
- keboola_agent_cli/server/routers/schedules.py +54 -0
- keboola_agent_cli/server/routers/search.py +30 -0
- keboola_agent_cli/server/routers/semantic_layer.py +650 -0
- keboola_agent_cli/server/routers/sharing.py +86 -0
- keboola_agent_cli/server/routers/storage.py +574 -0
- keboola_agent_cli/server/routers/stream.py +100 -0
- keboola_agent_cli/server/routers/workspaces.py +302 -0
- keboola_agent_cli/server/run_broadcaster.py +329 -0
- keboola_agent_cli/server/sse.py +25 -0
- keboola_agent_cli/services/__init__.py +0 -0
- keboola_agent_cli/services/_encryption.py +217 -0
- keboola_agent_cli/services/_semantic_layer_cascade.py +147 -0
- keboola_agent_cli/services/_semantic_layer_crud.py +382 -0
- keboola_agent_cli/services/_semantic_layer_internals.py +1078 -0
- keboola_agent_cli/services/_semantic_layer_lookup.py +181 -0
- keboola_agent_cli/services/_semantic_layer_reference_data.py +217 -0
- keboola_agent_cli/services/_sync_bindings.py +456 -0
- keboola_agent_cli/services/_sync_branch.py +191 -0
- keboola_agent_cli/services/_sync_bulk.py +228 -0
- keboola_agent_cli/services/_sync_clone.py +163 -0
- keboola_agent_cli/services/_sync_models.py +97 -0
- keboola_agent_cli/services/_sync_push_ops.py +369 -0
- keboola_agent_cli/services/_sync_storage.py +376 -0
- keboola_agent_cli/services/_sync_writeback.py +167 -0
- keboola_agent_cli/services/agent_service.py +458 -0
- keboola_agent_cli/services/base.py +175 -0
- keboola_agent_cli/services/branch_service.py +588 -0
- keboola_agent_cli/services/component_service.py +694 -0
- keboola_agent_cli/services/config_service.py +2099 -0
- keboola_agent_cli/services/data_app_git_service.py +224 -0
- keboola_agent_cli/services/data_app_service.py +2082 -0
- keboola_agent_cli/services/deep_lineage_service.py +1322 -0
- keboola_agent_cli/services/dev_portal_service.py +345 -0
- keboola_agent_cli/services/doctor_service.py +445 -0
- keboola_agent_cli/services/encrypt_service.py +87 -0
- keboola_agent_cli/services/feature_service.py +268 -0
- keboola_agent_cli/services/flow_service.py +769 -0
- keboola_agent_cli/services/flow_validation.py +188 -0
- keboola_agent_cli/services/http_forwarder_service.py +236 -0
- keboola_agent_cli/services/job_idempotency_store.py +285 -0
- keboola_agent_cli/services/job_service.py +797 -0
- keboola_agent_cli/services/kai_service.py +367 -0
- keboola_agent_cli/services/lineage_service.py +274 -0
- keboola_agent_cli/services/mcp_service.py +1498 -0
- keboola_agent_cli/services/mcp_transport.py +259 -0
- keboola_agent_cli/services/member_service.py +593 -0
- keboola_agent_cli/services/org_service.py +619 -0
- keboola_agent_cli/services/project_service.py +947 -0
- keboola_agent_cli/services/repo_validate_service.py +767 -0
- keboola_agent_cli/services/schedule_service.py +731 -0
- keboola_agent_cli/services/search_service.py +331 -0
- keboola_agent_cli/services/semantic_layer_service.py +1497 -0
- keboola_agent_cli/services/sharing_service.py +307 -0
- keboola_agent_cli/services/storage_service.py +2524 -0
- keboola_agent_cli/services/stream_service.py +395 -0
- keboola_agent_cli/services/sync_service.py +2244 -0
- keboola_agent_cli/services/variables_service.py +447 -0
- keboola_agent_cli/services/version_service.py +1038 -0
- keboola_agent_cli/services/workspace_service.py +1103 -0
- keboola_agent_cli/stream_client.py +217 -0
- keboola_agent_cli/sync/__init__.py +1 -0
- keboola_agent_cli/sync/branch_mapping.py +174 -0
- keboola_agent_cli/sync/clone.py +211 -0
- keboola_agent_cli/sync/code_extraction.py +655 -0
- keboola_agent_cli/sync/config_format.py +290 -0
- keboola_agent_cli/sync/diff_engine.py +566 -0
- keboola_agent_cli/sync/git_utils.py +93 -0
- keboola_agent_cli/sync/manifest.py +162 -0
- keboola_agent_cli/sync/naming.py +90 -0
- keboola_agent_cli/sync/secrets.py +62 -0
- keboola_agent_cli/sync/sql_split.py +134 -0
- keboola_cli-0.63.4.dist-info/METADATA +308 -0
- keboola_cli-0.63.4.dist-info/RECORD +306 -0
- keboola_cli-0.63.4.dist-info/WHEEL +4 -0
- 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}."
|