graph-agents-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. graph_agents_cli/__init__.py +26 -0
  2. graph_agents_cli/_api_policy.py +2145 -0
  3. graph_agents_cli/_approvals.py +400 -0
  4. graph_agents_cli/_build.py +186 -0
  5. graph_agents_cli/_build_info.json +7 -0
  6. graph_agents_cli/_chat_client.py +462 -0
  7. graph_agents_cli/_click.py +157 -0
  8. graph_agents_cli/_defaults.py +139 -0
  9. graph_agents_cli/_experiments.py +64 -0
  10. graph_agents_cli/_http.py +192 -0
  11. graph_agents_cli/_output.py +83 -0
  12. graph_agents_cli/_project.py +462 -0
  13. graph_agents_cli/_remote.py +220 -0
  14. graph_agents_cli/_response_schema.py +264 -0
  15. graph_agents_cli/_runner.py +319 -0
  16. graph_agents_cli/_skills_check.py +274 -0
  17. graph_agents_cli/_tools.py +189 -0
  18. graph_agents_cli/_trust.py +66 -0
  19. graph_agents_cli/api/__init__.py +15 -0
  20. graph_agents_cli/api/_changes.py +506 -0
  21. graph_agents_cli/api/_files.py +658 -0
  22. graph_agents_cli/api/cmd_api.py +2480 -0
  23. graph_agents_cli/deploy/__init__.py +15 -0
  24. graph_agents_cli/deploy/_config.py +171 -0
  25. graph_agents_cli/deploy/_image.py +128 -0
  26. graph_agents_cli/deploy/_kube.py +286 -0
  27. graph_agents_cli/deploy/_modes.py +234 -0
  28. graph_agents_cli/deploy/_preflight.py +370 -0
  29. graph_agents_cli/deploy/_values.py +168 -0
  30. graph_agents_cli/deploy/cmd_deploy.py +1866 -0
  31. graph_agents_cli/deploy/gitops.py +562 -0
  32. graph_agents_cli/deploy/local_load.py +273 -0
  33. graph_agents_cli/dev/__init__.py +13 -0
  34. graph_agents_cli/dev/cmd_build.py +131 -0
  35. graph_agents_cli/dev/cmd_install.py +78 -0
  36. graph_agents_cli/dev/cmd_lint.py +119 -0
  37. graph_agents_cli/dev/cmd_playground.py +297 -0
  38. graph_agents_cli/dev/policy_check.py +1287 -0
  39. graph_agents_cli/eval/__init__.py +22 -0
  40. graph_agents_cli/eval/_client.py +670 -0
  41. graph_agents_cli/eval/_common.py +177 -0
  42. graph_agents_cli/eval/_judge.py +168 -0
  43. graph_agents_cli/eval/_judge_runner.py +238 -0
  44. graph_agents_cli/eval/_paths.py +212 -0
  45. graph_agents_cli/eval/checks.py +581 -0
  46. graph_agents_cli/eval/cmd_analyze.py +278 -0
  47. graph_agents_cli/eval/cmd_compare.py +284 -0
  48. graph_agents_cli/eval/cmd_eval_group.py +80 -0
  49. graph_agents_cli/eval/cmd_generate.py +558 -0
  50. graph_agents_cli/eval/cmd_grade.py +466 -0
  51. graph_agents_cli/eval/cmd_metric.py +156 -0
  52. graph_agents_cli/eval/cmd_run.py +370 -0
  53. graph_agents_cli/eval/cmd_submit.py +400 -0
  54. graph_agents_cli/eval/config.py +435 -0
  55. graph_agents_cli/eval/dataset.py +350 -0
  56. graph_agents_cli/eval/gate.py +420 -0
  57. graph_agents_cli/eval/transcript.py +192 -0
  58. graph_agents_cli/extension/__init__.py +13 -0
  59. graph_agents_cli/extension/_compat.py +86 -0
  60. graph_agents_cli/extension/_loader.py +293 -0
  61. graph_agents_cli/extension/_manifest.py +135 -0
  62. graph_agents_cli/extension/_overrides.py +195 -0
  63. graph_agents_cli/extension/_paths.py +91 -0
  64. graph_agents_cli/extension/_refs.py +193 -0
  65. graph_agents_cli/extension/_resolver.py +453 -0
  66. graph_agents_cli/extension/_schema.py +106 -0
  67. graph_agents_cli/extension/_spec.py +253 -0
  68. graph_agents_cli/extension/_sync.py +102 -0
  69. graph_agents_cli/extension/_trust.py +58 -0
  70. graph_agents_cli/extension/cmd_extension_add.py +259 -0
  71. graph_agents_cli/extension/cmd_extension_group.py +57 -0
  72. graph_agents_cli/extension/cmd_extension_list.py +56 -0
  73. graph_agents_cli/extension/cmd_extension_remove.py +61 -0
  74. graph_agents_cli/extension/cmd_extension_update.py +195 -0
  75. graph_agents_cli/info/__init__.py +13 -0
  76. graph_agents_cli/info/cmd_info.py +222 -0
  77. graph_agents_cli/infra/__init__.py +15 -0
  78. graph_agents_cli/infra/checks.py +1169 -0
  79. graph_agents_cli/infra/cmd_infra.py +103 -0
  80. graph_agents_cli/main.py +591 -0
  81. graph_agents_cli/peer/__init__.py +15 -0
  82. graph_agents_cli/peer/_generate.py +254 -0
  83. graph_agents_cli/peer/cmd_peer.py +1151 -0
  84. graph_agents_cli/run/__init__.py +13 -0
  85. graph_agents_cli/run/_local_server.py +1157 -0
  86. graph_agents_cli/run/_signals.py +141 -0
  87. graph_agents_cli/run/cmd_approvals.py +530 -0
  88. graph_agents_cli/run/cmd_run.py +1421 -0
  89. graph_agents_cli/scaffold/__init__.py +19 -0
  90. graph_agents_cli/scaffold/agents/README.md +24 -0
  91. graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
  92. graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
  93. graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
  94. graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
  95. graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
  96. graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
  97. graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
  98. graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
  99. graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
  100. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
  101. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
  102. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
  103. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
  104. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
  105. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
  106. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
  107. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
  108. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
  109. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
  110. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
  111. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
  112. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
  113. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
  114. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
  115. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
  116. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
  117. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
  118. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
  119. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
  120. graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
  121. graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
  122. graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
  123. graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
  124. graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
  125. graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
  126. graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
  127. graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
  128. graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
  129. graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
  130. graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
  131. graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
  132. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
  133. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
  134. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
  135. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
  136. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
  137. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
  138. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
  139. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
  140. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
  141. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
  142. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
  143. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
  144. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
  145. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
  146. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
  147. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
  148. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
  149. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
  150. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
  151. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
  152. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
  153. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
  154. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
  155. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
  156. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
  157. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
  158. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
  159. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
  160. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
  161. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
  162. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
  163. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
  164. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
  165. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
  166. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
  167. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
  168. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
  169. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
  170. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
  171. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
  172. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
  173. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
  174. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
  175. graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
  176. graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
  177. graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
  178. graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
  179. graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
  180. graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
  181. graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
  182. graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
  183. graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
  184. graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
  185. graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
  186. graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
  187. graph_agents_cli/scaffold/commands/__init__.py +13 -0
  188. graph_agents_cli/scaffold/commands/create.py +1424 -0
  189. graph_agents_cli/scaffold/commands/enhance.py +1652 -0
  190. graph_agents_cli/scaffold/commands/upgrade.py +570 -0
  191. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
  192. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
  193. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
  194. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
  195. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
  196. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
  197. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
  198. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
  199. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
  200. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
  201. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
  202. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
  203. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
  204. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
  205. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
  206. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
  207. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
  208. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
  209. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
  210. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
  211. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
  212. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
  213. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
  214. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
  215. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
  216. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
  217. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
  218. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
  219. graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
  220. graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
  221. graph_agents_cli/scaffold/utils/__init__.py +13 -0
  222. graph_agents_cli/scaffold/utils/backup.py +212 -0
  223. graph_agents_cli/scaffold/utils/build_record.py +257 -0
  224. graph_agents_cli/scaffold/utils/cli_options.py +184 -0
  225. graph_agents_cli/scaffold/utils/fs.py +83 -0
  226. graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
  227. graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
  228. graph_agents_cli/scaffold/utils/keyedit.py +768 -0
  229. graph_agents_cli/scaffold/utils/keymerge.py +537 -0
  230. graph_agents_cli/scaffold/utils/language.py +138 -0
  231. graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
  232. graph_agents_cli/scaffold/utils/logging.py +77 -0
  233. graph_agents_cli/scaffold/utils/manifest.py +292 -0
  234. graph_agents_cli/scaffold/utils/merge.py +970 -0
  235. graph_agents_cli/scaffold/utils/merge3.py +216 -0
  236. graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
  237. graph_agents_cli/scaffold/utils/remote_template.py +376 -0
  238. graph_agents_cli/scaffold/utils/template.py +1352 -0
  239. graph_agents_cli/scaffold/utils/upgrade.py +894 -0
  240. graph_agents_cli/scaffold/utils/version.py +438 -0
  241. graph_agents_cli/secrets/__init__.py +15 -0
  242. graph_agents_cli/secrets/_apply.py +954 -0
  243. graph_agents_cli/secrets/_required.py +188 -0
  244. graph_agents_cli/secrets/cmd_secrets.py +211 -0
  245. graph_agents_cli/setup/__init__.py +13 -0
  246. graph_agents_cli/setup/_antigravity.py +221 -0
  247. graph_agents_cli/setup/cmd_auth.py +1030 -0
  248. graph_agents_cli/setup/cmd_dev_token.py +513 -0
  249. graph_agents_cli/setup/cmd_setup.py +428 -0
  250. graph_agents_cli/setup/cmd_update.py +140 -0
  251. graph_agents_cli/skills/__init__.py +13 -0
  252. graph_agents_cli/skills/_bundle.py +65 -0
  253. graph_agents_cli/skills/data/README.md +19 -0
  254. graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
  255. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
  256. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
  257. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
  258. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
  259. graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
  260. graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
  261. graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
  262. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
  263. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
  264. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
  265. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
  266. graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
  267. graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
  268. graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
  269. graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
  270. graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
  271. graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
  272. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
  273. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
  274. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
  275. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
  276. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
  277. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
  278. graph_agents_cli/system/__init__.py +15 -0
  279. graph_agents_cli/system/_apply.py +519 -0
  280. graph_agents_cli/system/_checks.py +1023 -0
  281. graph_agents_cli/system/_deploy.py +215 -0
  282. graph_agents_cli/system/_model.py +363 -0
  283. graph_agents_cli/system/_system.py +664 -0
  284. graph_agents_cli/system/_views.py +208 -0
  285. graph_agents_cli/system/cmd_system.py +423 -0
  286. graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
  287. graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
  288. graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
  289. graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
  290. graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
  291. graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
@@ -0,0 +1,954 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Shared Secret provisioning used by ``secrets apply`` and direct-mode ``deploy``.
15
+
16
+ Which env file: ``--env-file`` when given, else ``.env.<env>``. Only ``dev``
17
+ falls back to ``.env``: that file holds a developer's local keys (``login
18
+ --write-env`` writes a local ``API_KEY`` into it), so reading it for staging or
19
+ prod would push local values over the environment's Secret.
20
+
21
+ What goes in: only the manifest's allow-listed keys, merged over the live
22
+ Secret. A key the env file sets replaces the live value, except ``API_KEY``:
23
+ the live key always wins unless the env file sets a different one *and*
24
+ ``--rotate-api-key`` is passed, because replacing it logs out every client of
25
+ the environment. An allow-listed key the env file does not set is kept from the
26
+ live Secret, so a partial env file never deletes the rest. ``API_KEY`` is
27
+ generated (32 random bytes, hex) only for the ``shared-bearer`` policy (the
28
+ only one that reads it) and only when it is in neither, and is then written
29
+ into the env file (mode 0600) after the apply succeeds, never printed.
30
+
31
+ ``METRICS_TOKEN``, when the Secret holds it, is also written alone into a
32
+ second Secret ``<name>-metrics``: the Prometheus ServiceMonitor reads its
33
+ bearer token from there, so the scraper never needs access to the app Secret
34
+ and every other credential in it.
35
+
36
+ How: the manifest is built with ``kubectl create secret generic
37
+ --from-env-file=<0600 temp file> --dry-run=client -o yaml`` and piped into
38
+ ``kubectl apply --server-side`` under the ``graph-agents-cli`` field manager,
39
+ so no value appears on a command line and none is copied into the
40
+ ``last-applied-configuration`` annotation that a client-side apply writes (an
41
+ annotation left by an older client-side apply is removed). The namespace is
42
+ created first when it does not exist. Values must be single-line:
43
+ ``--from-env-file`` splits on newlines.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import base64
49
+ import binascii
50
+ import io
51
+ import json
52
+ import os
53
+ import re
54
+ import tempfile
55
+ from dataclasses import dataclass, field
56
+ from pathlib import Path
57
+ from typing import Any
58
+
59
+ import yaml
60
+
61
+ from graph_agents_cli._output import Console
62
+ from graph_agents_cli.deploy import _kube, _modes
63
+ from graph_agents_cli.deploy._kube import ConfigError, Target, echo_cmd
64
+
65
+ GENERATED_KEY = "API_KEY"
66
+ METRICS_TOKEN_KEY = "METRICS_TOKEN"
67
+ # Placeholder shown in a --dry-run manifest for a key that the real run keeps
68
+ # from the live Secret or generates (the cluster is not read under --dry-run).
69
+ PENDING_PLACEHOLDER = "<kept-or-generated>"
70
+ FIELD_MANAGER = "graph-agents-cli"
71
+ LAST_APPLIED_ANNOTATION = "kubectl.kubernetes.io/last-applied-configuration"
72
+
73
+
74
+ def resolve_env_file(env: str, explicit: str | None) -> Path | None:
75
+ """``--env-file`` when given (must exist), else ``.env.<env>``; ``dev`` also tries ``.env``."""
76
+ if explicit:
77
+ path = Path(explicit)
78
+ if not path.is_file():
79
+ raise ConfigError(f"Env file not found: {path}")
80
+ return path
81
+ candidates = [Path(f".env.{env}")]
82
+ if _modes.is_dev_env(env):
83
+ candidates.append(Path(".env"))
84
+ for candidate in candidates:
85
+ if candidate.is_file():
86
+ return candidate
87
+ return None
88
+
89
+
90
+ def provision_hint(env: str) -> str:
91
+ """The command that provisions the Secret, as it can be run from the project root.
92
+
93
+ ``secrets apply`` finds the environment's env file itself (``.env.<env>``;
94
+ dev also falls back to ``.env``); naming a file that does not exist would
95
+ make the printed command fail.
96
+ """
97
+ command = f"graph-agents-cli secrets apply --env {env}"
98
+ if resolve_env_file(env, None) is not None:
99
+ return command
100
+ where = f".env.{env} (or .env)" if _modes.is_dev_env(env) else f".env.{env}"
101
+ return f"create {where} with the allow-listed keys, then run {command}"
102
+
103
+
104
+ def missing_env_file_error(env: str, allowed: list[str]) -> ConfigError:
105
+ """The exit-3 error for an environment with no env file."""
106
+ if _modes.is_dev_env(env):
107
+ where = "create .env.dev (or .env)"
108
+ else:
109
+ where = (
110
+ f"create .env.{env} (the local .env is never used for {env}: it holds your "
111
+ "development keys)"
112
+ )
113
+ return ConfigError(
114
+ f"No env file for {env}: pass --env-file or {where} with the allow-listed keys: "
115
+ + ", ".join(allowed)
116
+ )
117
+
118
+
119
+ def read_env_file(path: Path) -> dict[str, str]:
120
+ from dotenv import dotenv_values
121
+
122
+ content = path.read_text(encoding="utf-8")
123
+ return {k: v for k, v in dotenv_values(stream=io.StringIO(content)).items() if v is not None}
124
+
125
+
126
+ def select_allowed(values: dict[str, str], allowed: list[str]) -> dict[str, str]:
127
+ """Keep only allow-listed, non-empty values (never the whole file)."""
128
+ return {k: values[k] for k in allowed if k in values and values[k] != ""}
129
+
130
+
131
+ def generate_api_key() -> str:
132
+ return os.urandom(32).hex()
133
+
134
+
135
+ def _reject_multiline(data: dict[str, str], *, where: str = "") -> None:
136
+ """Refuse values ``--from-env-file`` cannot carry (one single-line value per key).
137
+
138
+ kubectl splits the env file on newlines: the value would be truncated and
139
+ the remaining lines read as extra keys (a bare ``NAME`` line is even looked
140
+ up in the local environment). Only key names are reported, never values.
141
+ """
142
+ bad = [k for k, v in data.items() if "\n" in v or "\r" in v]
143
+ if bad:
144
+ raise ConfigError(
145
+ f"Secret values must be single-line{where}: kubectl --from-env-file splits on "
146
+ "newlines, truncating the value and reading the remaining lines as extra keys. "
147
+ f"Offending key(s): {', '.join(bad)}. Put the value on one line (for example "
148
+ "base64-encode it) or create the Secret with kubectl directly."
149
+ )
150
+
151
+
152
+ def check_file_values(
153
+ values: dict[str, str], allowed: list[str], *, rotate_api_key: bool, source: Path | None
154
+ ) -> None:
155
+ """Checks on the env file alone, run before the cluster is touched (exit 3)."""
156
+ _reject_multiline(select_allowed(values, allowed))
157
+ if not rotate_api_key:
158
+ return
159
+ if GENERATED_KEY not in allowed:
160
+ raise ConfigError("--rotate-api-key: API_KEY is not in the manifest's secrets.keys.")
161
+ if not select_allowed(values, allowed).get(GENERATED_KEY):
162
+ raise ConfigError(
163
+ f"--rotate-api-key replaces the live API_KEY with the one in the env file, but "
164
+ f"{source or 'the env file'} sets no API_KEY."
165
+ )
166
+
167
+
168
+ @dataclass
169
+ class LiveSecret:
170
+ """What the cluster holds for the app Secret (values are never printed)."""
171
+
172
+ exists: bool = False
173
+ values: dict[str, str] = field(default_factory=dict) # UTF-8 text values
174
+ undecodable: set[str] = field(default_factory=set) # binary values (cannot be carried)
175
+ has_last_applied: bool = False
176
+ # The base64 ``data`` exactly as the cluster holds it, to restore it byte for byte.
177
+ raw: dict[str, str] = field(default_factory=dict)
178
+ resource_version: str = ""
179
+ # ``data`` keys the graph-agents-cli field manager applied: a server-side apply
180
+ # that leaves one out removes it.
181
+ owned: set[str] = field(default_factory=set)
182
+
183
+ @property
184
+ def keys(self) -> set[str]:
185
+ return set(self.values) | self.undecodable
186
+
187
+
188
+ def read_live_secret(name: str, target: Target) -> LiveSecret:
189
+ """The live Secret, ``exists=False`` when it (or its namespace) is absent.
190
+
191
+ Only kubectl's ``(NotFound)`` means absent; any other failure (connection,
192
+ credentials, RBAC) is a tool failure (exit 2), never a reason to generate or
193
+ overwrite keys.
194
+ """
195
+ result = _kube.kubectl(["get", "secret", name, "-o", "json"], target, check=False, quiet=True)
196
+ if result.returncode != 0:
197
+ if "(NotFound)" in (result.stderr or ""):
198
+ return LiveSecret()
199
+ raise _kube.ToolFailed(
200
+ f"kubectl get secret {name} failed (exit code {result.returncode}) in namespace "
201
+ f"{target.namespace}:\n{(result.stderr or result.stdout or '').strip()}"
202
+ )
203
+ if not (result.stdout or "").strip():
204
+ return LiveSecret()
205
+ try:
206
+ body = json.loads(result.stdout)
207
+ except json.JSONDecodeError as e:
208
+ raise _kube.ToolFailed(f"kubectl returned invalid JSON: {e}") from e
209
+ live = LiveSecret(exists=True)
210
+ live.resource_version = str((body.get("metadata") or {}).get("resourceVersion") or "")
211
+ annotations = (body.get("metadata") or {}).get("annotations") or {}
212
+ live.has_last_applied = LAST_APPLIED_ANNOTATION in annotations
213
+ for key, raw in (body.get("data") or {}).items():
214
+ live.raw[key] = str(raw)
215
+ try:
216
+ live.values[key] = base64.b64decode(str(raw), validate=True).decode("utf-8")
217
+ except (binascii.Error, UnicodeDecodeError, ValueError):
218
+ live.undecodable.add(key)
219
+ for key, raw in (body.get("stringData") or {}).items():
220
+ live.values[key] = str(raw)
221
+ live.raw[key] = base64.b64encode(str(raw).encode("utf-8")).decode("ascii")
222
+ live.owned = _owned_data_keys(body)
223
+ return live
224
+
225
+
226
+ def _owned_data_keys(body: dict[str, Any]) -> set[str]:
227
+ """The ``data`` keys the graph-agents-cli field manager owns (from ``managedFields``)."""
228
+ owned: set[str] = set()
229
+ for entry in (body.get("metadata") or {}).get("managedFields") or []:
230
+ if not isinstance(entry, dict) or entry.get("manager") != FIELD_MANAGER:
231
+ continue
232
+ data = (entry.get("fieldsV1") or {}).get("f:data")
233
+ if isinstance(data, dict):
234
+ owned |= {k[2:] for k in data if isinstance(k, str) and k.startswith("f:")}
235
+ return owned
236
+
237
+
238
+ @dataclass
239
+ class SecretPlan:
240
+ name: str
241
+ target: Target
242
+ data: dict[str, str]
243
+ generated: list[str] = field(default_factory=list)
244
+ skipped: list[str] = field(default_factory=list) # allow-listed keys in neither file nor Secret
245
+ reused: list[str] = field(default_factory=list) # kept from the live Secret
246
+ pending: list[str] = field(default_factory=list) # --dry-run: kept or generated by the real run
247
+ # --dry-run: allow-listed keys the file does not set, kept when the live Secret has them
248
+ kept_if_live: list[str] = field(default_factory=list)
249
+ api_key_conflict: bool = False # the file's API_KEY differs; the live one was kept
250
+ api_key_rotated: bool = False # the file's API_KEY replaced the live one (--rotate-api-key)
251
+ rotate_requested: bool = False
252
+ source: Path | None = None # the env file (a generated API_KEY is saved there)
253
+ env: str = "" # the environment, for the hints printed after the apply
254
+ # --dry-run with the live Secret read: API_KEY is in neither and would be generated.
255
+ would_generate: list[str] = field(default_factory=list)
256
+ # --dry-run: why the live Secret could not be read (its keys are then unknown).
257
+ live_unread: str = ""
258
+ # The Secret that receives METRICS_TOKEN alone (for the ServiceMonitor); "" for none.
259
+ metrics_name: str = ""
260
+
261
+ @property
262
+ def metrics_token(self) -> str | None:
263
+ """The METRICS_TOKEN this apply writes (``None`` when absent or unknown)."""
264
+ token = self.data.get(METRICS_TOKEN_KEY)
265
+ return token if token and token != PENDING_PLACEHOLDER else None
266
+
267
+ def manifest(self, *, redact: bool = True) -> str:
268
+ body = {
269
+ "apiVersion": "v1",
270
+ "kind": "Secret",
271
+ "metadata": {"name": self.name, "namespace": self.target.namespace},
272
+ "type": "Opaque",
273
+ "stringData": {k: ("<redacted>" if redact else v) for k, v in self.data.items()},
274
+ }
275
+ return yaml.safe_dump(body, sort_keys=False)
276
+
277
+
278
+ def build_plan(
279
+ *,
280
+ name: str,
281
+ target: Target,
282
+ allowed: list[str],
283
+ values: dict[str, str],
284
+ existing: dict[str, str] | None = None,
285
+ dry_run: bool = False,
286
+ rotate_api_key: bool = False,
287
+ source: Path | None = None,
288
+ mint_api_key: bool = True,
289
+ ) -> SecretPlan:
290
+ """Decide the Secret's content from the env file ``values`` and the live ``existing`` values.
291
+
292
+ Pure: the cluster is read by the caller (``read_live_secret``); ``existing``
293
+ is ``None`` when it was not read (``--dry-run``). File values win over live
294
+ ones except ``API_KEY`` (the live key wins unless ``rotate_api_key``);
295
+ allow-listed live keys the file does not set are kept; a missing
296
+ ``API_KEY`` is generated when ``mint_api_key`` (the ``shared-bearer``
297
+ policy; recorded as pending or would-generate under ``dry_run``).
298
+ """
299
+ file_values = select_allowed(values, allowed)
300
+ _reject_multiline(file_values)
301
+ live = {k: v for k, v in (existing or {}).items() if k in allowed and v != ""}
302
+ plan = SecretPlan(
303
+ name=name, target=target, data={}, rotate_requested=rotate_api_key, source=source
304
+ )
305
+ for key in allowed:
306
+ if key in file_values:
307
+ live_value = live.get(key)
308
+ if key == GENERATED_KEY and live_value and live_value != file_values[key]:
309
+ if rotate_api_key:
310
+ plan.data[key] = file_values[key]
311
+ plan.api_key_rotated = True
312
+ else:
313
+ plan.data[key] = live_value
314
+ plan.api_key_conflict = True
315
+ else:
316
+ plan.data[key] = file_values[key]
317
+ elif key in live:
318
+ plan.data[key] = live[key]
319
+ plan.reused.append(key)
320
+ elif existing is None and dry_run and key != GENERATED_KEY:
321
+ plan.kept_if_live.append(key)
322
+ _reject_multiline({k: plan.data[k] for k in plan.reused}, where=" (kept from the live Secret)")
323
+ if mint_api_key and GENERATED_KEY in allowed and GENERATED_KEY not in plan.data:
324
+ if dry_run:
325
+ plan.data[GENERATED_KEY] = PENDING_PLACEHOLDER
326
+ (plan.pending if existing is None else plan.would_generate).append(GENERATED_KEY)
327
+ else:
328
+ plan.data[GENERATED_KEY] = generate_api_key()
329
+ plan.generated.append(GENERATED_KEY)
330
+ plan.skipped = [k for k in allowed if k not in plan.data and k not in plan.kept_if_live]
331
+ return plan
332
+
333
+
334
+ def _create_cmd(plan: SecretPlan, env_file: str) -> list[str]:
335
+ return _kube.kubectl_args(
336
+ [
337
+ "create",
338
+ "secret",
339
+ "generic",
340
+ plan.name,
341
+ f"--from-env-file={env_file}",
342
+ "--dry-run=client",
343
+ "-o",
344
+ "yaml",
345
+ ],
346
+ plan.target,
347
+ )
348
+
349
+
350
+ def _apply_cmd(plan: SecretPlan) -> list[str]:
351
+ return _kube.kubectl_args(
352
+ [
353
+ "apply",
354
+ "--server-side",
355
+ f"--field-manager={FIELD_MANAGER}",
356
+ "--force-conflicts",
357
+ "-f",
358
+ "-",
359
+ ],
360
+ plan.target,
361
+ )
362
+
363
+
364
+ def _remove_last_applied_cmd(plan: SecretPlan) -> list[str]:
365
+ return _kube.kubectl_args(
366
+ ["annotate", "secret", plan.name, f"{LAST_APPLIED_ANNOTATION}-"], plan.target
367
+ )
368
+
369
+
370
+ _KEY_LINE = re.compile(r"^(?P<prefix>\s*(?:export\s+)?)(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=")
371
+
372
+
373
+ def save_key_to_env_file(path: Path, key: str, value: str) -> None:
374
+ """Set ``key=value`` in the env file (replacing the line dotenv reads, else appending), mode 0600.
375
+
376
+ Written to a 0600 temp file in the same directory and renamed over the
377
+ original, so the file is never readable by others, even briefly.
378
+ """
379
+ path = path.resolve() # a symlinked env file keeps its link; its target is rewritten
380
+ text = path.read_text(encoding="utf-8") if path.is_file() else ""
381
+ lines = text.splitlines(keepends=True)
382
+ index = None
383
+ for i, line in enumerate(lines):
384
+ match = _KEY_LINE.match(line)
385
+ if match and match.group("key") == key:
386
+ index = i # the last assignment is the one dotenv honours
387
+ if index is not None:
388
+ prefix = _KEY_LINE.match(lines[index]).group("prefix") # type: ignore[union-attr]
389
+ lines[index] = f"{prefix}{key}={value}\n"
390
+ else:
391
+ if lines and not lines[-1].endswith("\n"):
392
+ lines[-1] += "\n"
393
+ lines.append(f"{key}={value}\n")
394
+ fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
395
+ try:
396
+ os.chmod(tmp, 0o600)
397
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
398
+ handle.write("".join(lines))
399
+ os.replace(tmp, path)
400
+ except BaseException:
401
+ try:
402
+ os.unlink(tmp)
403
+ except OSError:
404
+ pass
405
+ raise
406
+ os.chmod(path, 0o600)
407
+
408
+
409
+ def _print_plan_notes(plan: SecretPlan, *, dry_run: bool, console: Console) -> None:
410
+ source = plan.source or "the env file"
411
+ if plan.reused:
412
+ console.print(
413
+ f" Kept from the live Secret (not in {source}): {', '.join(plan.reused)}.",
414
+ style="dim",
415
+ markup=False,
416
+ )
417
+ if plan.api_key_conflict:
418
+ console.print(
419
+ f" API_KEY in {source} differs from the live Secret; the live key is kept. Pass "
420
+ "--rotate-api-key to replace it (clients using the old key then get 401).",
421
+ style="yellow",
422
+ markup=False,
423
+ )
424
+ if plan.api_key_rotated:
425
+ console.print(f" API_KEY rotated to the value in {source}.", style="yellow", markup=False)
426
+ if not dry_run:
427
+ return
428
+ if plan.live_unread:
429
+ console.print(
430
+ f" [dry-run] could not read the live Secret ({plan.live_unread}); the keys it "
431
+ "holds are unknown.",
432
+ style="yellow",
433
+ markup=False,
434
+ )
435
+ for key in plan.would_generate:
436
+ console.print(
437
+ f" {key} is in neither the env file nor the live Secret: the real run generates "
438
+ "one and saves it to the env file (nothing is written under --dry-run).",
439
+ style="yellow",
440
+ markup=False,
441
+ )
442
+ for key in plan.pending:
443
+ console.print(
444
+ f" {key} is not in the env file: it would be kept from the live Secret if "
445
+ "present, otherwise generated and saved to the env file (nothing is written "
446
+ "under --dry-run).",
447
+ style="yellow",
448
+ )
449
+ if plan.kept_if_live:
450
+ console.print(
451
+ " Allow-listed keys absent from the env file are kept from the live Secret if "
452
+ f"present: {', '.join(plan.kept_if_live)}.",
453
+ style="yellow",
454
+ markup=False,
455
+ )
456
+ if (
457
+ GENERATED_KEY in plan.data
458
+ and GENERATED_KEY not in plan.pending + plan.would_generate
459
+ and GENERATED_KEY not in plan.reused
460
+ ):
461
+ verb = "replaces" if plan.rotate_requested else "does not replace"
462
+ console.print(
463
+ f" API_KEY from the env file {verb} a different live API_KEY"
464
+ + ("" if plan.rotate_requested else " (pass --rotate-api-key to replace it)")
465
+ + ".",
466
+ style="dim",
467
+ markup=False,
468
+ )
469
+
470
+
471
+ def _server_side_apply(plan: SecretPlan, *, console: Console) -> None:
472
+ """``kubectl create secret generic --from-env-file | kubectl apply --server-side``.
473
+
474
+ The values go through a 0600 temp file and a pipe, never a command line.
475
+ """
476
+ fd, tmp_path = tempfile.mkstemp(prefix="graph-agents-cli-secret-", suffix=".env")
477
+ try:
478
+ os.chmod(tmp_path, 0o600)
479
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
480
+ for key, value in plan.data.items():
481
+ handle.write(f"{key}={value}\n")
482
+ create = _create_cmd(plan, tmp_path)
483
+ apply = _apply_cmd(plan)
484
+ echo_cmd(create, console=console)
485
+ rendered = _kube.run_cmd(create, quiet=True)
486
+ echo_cmd(apply, console=console)
487
+ _kube.run_cmd(apply, input_text=rendered.stdout, quiet=True)
488
+ finally:
489
+ try:
490
+ os.unlink(tmp_path)
491
+ except OSError:
492
+ pass
493
+
494
+
495
+ def metrics_plan(plan: SecretPlan) -> SecretPlan | None:
496
+ """The ``<name>-metrics`` Secret this apply also writes (METRICS_TOKEN alone), if any."""
497
+ token = plan.metrics_token
498
+ if not plan.metrics_name or token is None:
499
+ return None
500
+ return SecretPlan(
501
+ name=plan.metrics_name,
502
+ target=plan.target,
503
+ data={METRICS_TOKEN_KEY: token},
504
+ env=plan.env,
505
+ source=plan.source,
506
+ )
507
+
508
+
509
+ def apply_plan(
510
+ plan: SecretPlan,
511
+ *,
512
+ dry_run: bool = False,
513
+ console: Console | None = None,
514
+ live: LiveSecret | None = None,
515
+ ) -> None:
516
+ """Create the namespace if needed, then create or update the Secret (server-side apply).
517
+
518
+ With ``dry_run`` print the pipeline and a redacted manifest. A generated
519
+ ``API_KEY`` is saved to ``plan.source`` only after the apply succeeded.
520
+ When the Secret holds ``METRICS_TOKEN`` and ``plan.metrics_name`` is set,
521
+ the token alone is applied to that Secret too (the ServiceMonitor's).
522
+ """
523
+ console = console or Console()
524
+ if not plan.data:
525
+ raise ConfigError(
526
+ "No allow-listed secret values to apply. The env file has none of: "
527
+ + ", ".join(plan.skipped)
528
+ )
529
+ _print_plan_notes(plan, dry_run=dry_run, console=console)
530
+ metrics = metrics_plan(plan)
531
+ if dry_run:
532
+ _kube.ensure_namespace(plan.target, dry_run=True, console=console)
533
+ for each in (plan, metrics):
534
+ if each is None:
535
+ continue
536
+ create = _create_cmd(each, "TMP_ENV_FILE_WITH_ALLOW_LISTED_KEYS")
537
+ console.print(
538
+ f" [dry-run] {_kube.pipe_description(create, _apply_cmd(each))}",
539
+ style="cyan",
540
+ highlight=False,
541
+ markup=False,
542
+ )
543
+ console.print(each.manifest(redact=True), highlight=False, markup=False)
544
+ return
545
+ _kube.ensure_namespace(plan.target, console=console)
546
+ _server_side_apply(plan, console=console)
547
+ if live is not None and live.has_last_applied:
548
+ # Written by an earlier client-side `kubectl apply`: it holds a copy of the values.
549
+ _kube.run_cmd(_remove_last_applied_cmd(plan), console=console)
550
+ console.print(
551
+ f" Removed the {LAST_APPLIED_ANNOTATION} annotation (it held a copy of the values).",
552
+ style="dim",
553
+ markup=False,
554
+ )
555
+ console.print(
556
+ f" Secret {plan.name} applied in namespace {plan.target.namespace} "
557
+ f"({len(plan.data)} key(s): {', '.join(plan.data)})."
558
+ )
559
+ if metrics is not None:
560
+ _server_side_apply(metrics, console=console)
561
+ console.print(
562
+ f" Secret {metrics.name} applied ({METRICS_TOKEN_KEY} only: the Prometheus "
563
+ "ServiceMonitor reads its bearer token there, never the app Secret)."
564
+ )
565
+ for key in plan.generated:
566
+ _save_generated(plan, key, console=console)
567
+ if plan.skipped:
568
+ console.print(
569
+ f" Allow-listed keys in neither the env file nor the live Secret: "
570
+ f"{', '.join(plan.skipped)}",
571
+ style="yellow",
572
+ )
573
+ if plan.api_key_rotated:
574
+ console.print(
575
+ " Running pods read the Secret at start: restart them with "
576
+ f"`graph-agents-cli deploy --env {plan.env or '<env>'} --restart` to use the new "
577
+ "API_KEY.",
578
+ style="yellow",
579
+ markup=False,
580
+ )
581
+
582
+
583
+ def _save_generated(plan: SecretPlan, key: str, *, console: Console) -> None:
584
+ read_back = (
585
+ f"kubectl get secret {plan.name} -n {plan.target.namespace}"
586
+ + (f" --context {plan.target.context}" if plan.target.context else "")
587
+ + f" -o jsonpath='{{.data.{key}}}' | base64 -d"
588
+ )
589
+ if plan.source is None:
590
+ console.print(
591
+ f" Generated {key} (it was in neither the env file nor the live Secret); read it "
592
+ f"with: {read_back}",
593
+ style="yellow",
594
+ markup=False,
595
+ )
596
+ return
597
+ try:
598
+ save_key_to_env_file(plan.source, key, plan.data[key])
599
+ except OSError as e:
600
+ console.print(
601
+ f" Generated {key} but could not save it to {plan.source} ({e.strerror or e}); "
602
+ f"read it with: {read_back}",
603
+ style="yellow",
604
+ markup=False,
605
+ )
606
+ return
607
+ console.print(
608
+ f" Generated {key} (it was in neither the env file nor the live Secret) and saved it "
609
+ f"to {plan.source} (mode 0600).",
610
+ style="yellow",
611
+ markup=False,
612
+ )
613
+
614
+
615
+ def prepare(
616
+ *,
617
+ name: str,
618
+ env: str,
619
+ target: Target,
620
+ allowed: list[str],
621
+ path: Path,
622
+ values: dict[str, str],
623
+ rotate_api_key: bool,
624
+ dry_run: bool,
625
+ mint_api_key: bool = True,
626
+ metrics_name: str = "",
627
+ read_live_on_dry_run: bool = False,
628
+ ) -> tuple[SecretPlan, LiveSecret | None]:
629
+ """Read the live Secret and plan the apply; changes nothing.
630
+
631
+ Under ``dry_run`` the live Secret is read only with ``read_live_on_dry_run``
632
+ (``deploy --dry-run``, whose required-key check needs it), and a failed read
633
+ is then reported instead of raised: the keys it holds are unknown.
634
+ """
635
+ live: LiveSecret | None = None
636
+ unread = ""
637
+ if not dry_run:
638
+ live = read_live_secret(name, target)
639
+ elif read_live_on_dry_run:
640
+ try:
641
+ live = read_live_secret(name, target)
642
+ except _kube.ToolFailed as e:
643
+ lines = [line.strip() for line in str(e).splitlines() if line.strip()]
644
+ unread = lines[-1] if lines else "kubectl failed"
645
+ if live is not None:
646
+ from_file = select_allowed(values, allowed)
647
+ carried = [k for k in allowed if k in live.undecodable and k not in from_file]
648
+ if carried:
649
+ raise ConfigError(
650
+ f"Secret {name} holds a value for {', '.join(carried)} that is not UTF-8 text; "
651
+ "it cannot be carried through --from-env-file. Set it in the env file or manage "
652
+ "the Secret with kubectl."
653
+ )
654
+ if GENERATED_KEY in live.undecodable and GENERATED_KEY in from_file and not rotate_api_key:
655
+ # It cannot be compared with the file's key, so it is never replaced silently.
656
+ raise ConfigError(
657
+ f"Secret {name} holds an API_KEY that is not UTF-8 text and the env file sets "
658
+ "another; pass --rotate-api-key to replace it, or drop API_KEY from the env file."
659
+ )
660
+ plan = build_plan(
661
+ name=name,
662
+ target=target,
663
+ allowed=allowed,
664
+ values=values,
665
+ existing=None if live is None else live.values,
666
+ dry_run=dry_run,
667
+ rotate_api_key=rotate_api_key,
668
+ source=path,
669
+ mint_api_key=mint_api_key,
670
+ )
671
+ plan.env = env
672
+ plan.live_unread = unread
673
+ plan.metrics_name = metrics_name
674
+ return plan, live
675
+
676
+
677
+ def provision(
678
+ *,
679
+ name: str,
680
+ env: str,
681
+ target: Target,
682
+ allowed: list[str],
683
+ path: Path,
684
+ values: dict[str, str],
685
+ rotate_api_key: bool,
686
+ dry_run: bool,
687
+ console: Console,
688
+ mint_api_key: bool = True,
689
+ metrics_name: str = "",
690
+ ) -> SecretPlan:
691
+ """Plan and apply (``secrets apply``): :func:`prepare` then :func:`apply_plan`."""
692
+ plan, live = prepare(
693
+ name=name,
694
+ env=env,
695
+ target=target,
696
+ allowed=allowed,
697
+ path=path,
698
+ values=values,
699
+ rotate_api_key=rotate_api_key,
700
+ dry_run=dry_run,
701
+ mint_api_key=mint_api_key,
702
+ metrics_name=metrics_name,
703
+ )
704
+ apply_plan(plan, dry_run=dry_run, console=console, live=live)
705
+ return plan
706
+
707
+
708
+ def secret_keys_present(
709
+ name: str, target: Target, *, dry_run: bool = False, console: Console | None = None
710
+ ) -> set[str] | None:
711
+ """Keys present in the Secret, ``None`` when it does not exist (or on dry-run).
712
+
713
+ Only kubectl's ``(NotFound)`` reason means "absent" (a missing Secret or
714
+ namespace); a connection, credential or RBAC failure is a tool failure
715
+ (exit 2) with kubectl's own message, never a fabricated "missing" report.
716
+ """
717
+ cmd = _kube.kubectl_args(["get", "secret", name, "-o", "json"], target)
718
+ result = _kube.run_cmd(cmd, check=False, dry_run=dry_run, console=console)
719
+ if dry_run:
720
+ return None
721
+ stderr = (result.stderr or "").strip()
722
+ if result.returncode != 0:
723
+ if "(NotFound)" in stderr:
724
+ return None
725
+ detail = stderr or (result.stdout or "").strip()
726
+ raise _kube.ToolFailed(
727
+ f"Command failed (exit code {result.returncode}): {_kube.format_cmd(cmd)}"
728
+ + (f"\n{detail}" if detail else "")
729
+ )
730
+ if not (result.stdout or "").strip():
731
+ raise _kube.ToolFailed(f"kubectl get secret {name} returned no output")
732
+
733
+ try:
734
+ body = json.loads(result.stdout)
735
+ except json.JSONDecodeError as e:
736
+ raise _kube.ToolFailed(f"kubectl returned invalid JSON: {e}") from e
737
+ data = body.get("data") or {}
738
+ string_data = body.get("stringData") or {}
739
+ return set(data) | set(string_data)
740
+
741
+
742
+ def _env_file_note(env: str) -> str:
743
+ fallback = ", else .env" if _modes.is_dev_env(env) else ""
744
+ return f"reads .env.{env}{fallback}; --env-file <file> reads another"
745
+
746
+
747
+ # --------------------------------------------------------------------------- snapshot / restore
748
+
749
+
750
+ @dataclass
751
+ class Snapshot:
752
+ """A Secret as it was before this run applied it, to restore after a failed deploy.
753
+
754
+ ``applied`` is what this run wrote (plain values, never printed) and
755
+ ``managed`` the keys it manages (the allow-list, or ``METRICS_TOKEN``).
756
+ """
757
+
758
+ name: str
759
+ target: Target
760
+ before: LiveSecret
761
+ applied: dict[str, str]
762
+ managed: frozenset[str]
763
+ env: str = ""
764
+
765
+ @property
766
+ def changed(self) -> list[str]:
767
+ """Keys this run added or changed (names only)."""
768
+ return sorted(
769
+ k
770
+ for k, v in self.applied.items()
771
+ if k in self.before.undecodable or self.before.values.get(k) != v
772
+ )
773
+
774
+ @property
775
+ def removed(self) -> list[str]:
776
+ """Keys this run's server-side apply removes: ones graph-agents-cli applied before
777
+ and this run leaves out (a key dropped from the allow-list)."""
778
+ return sorted(
779
+ k for k in self.before.raw if k in self.before.owned and k not in self.applied
780
+ )
781
+
782
+ @property
783
+ def touched(self) -> list[str]:
784
+ """Keys this run added, changed or removed (names only)."""
785
+ return sorted({*self.changed, *self.removed})
786
+
787
+
788
+ def snapshot(plan: SecretPlan, live: LiveSecret | None, *, managed: list[str]) -> list[Snapshot]:
789
+ """The app Secret (``live``, read by :func:`prepare`) and the metrics Secret before the apply.
790
+
791
+ Changes nothing; the metrics Secret is read only when this apply writes it.
792
+ """
793
+ snaps: list[Snapshot] = []
794
+ if live is not None:
795
+ snaps.append(
796
+ Snapshot(plan.name, plan.target, live, dict(plan.data), frozenset(managed), plan.env)
797
+ )
798
+ metrics = metrics_plan(plan)
799
+ if metrics is not None:
800
+ snaps.append(
801
+ Snapshot(
802
+ metrics.name,
803
+ metrics.target,
804
+ read_live_secret(metrics.name, metrics.target),
805
+ dict(metrics.data),
806
+ frozenset({METRICS_TOKEN_KEY}),
807
+ plan.env,
808
+ )
809
+ )
810
+ return snaps
811
+
812
+
813
+ def _restore_manifest(snap: Snapshot, current: LiveSecret) -> str:
814
+ """The previous values of the keys this run manages, plus any key it removed.
815
+
816
+ Keys this run added are left out, so the server-side apply removes them (the
817
+ graph-agents-cli field manager owns them). ``resourceVersion`` makes the apply
818
+ fail rather than overwrite a change made after the check.
819
+ """
820
+ data = {
821
+ k: raw for k, raw in snap.before.raw.items() if k in snap.managed or k not in current.raw
822
+ }
823
+ body: dict[str, object] = {
824
+ "apiVersion": "v1",
825
+ "kind": "Secret",
826
+ "metadata": {"name": snap.name, "namespace": snap.target.namespace},
827
+ "type": "Opaque",
828
+ "data": data,
829
+ }
830
+ if current.resource_version:
831
+ body["metadata"]["resourceVersion"] = current.resource_version # type: ignore[index]
832
+ return yaml.safe_dump(body, sort_keys=False)
833
+
834
+
835
+ def _is_metrics(snap: Snapshot) -> bool:
836
+ return snap.managed == frozenset({METRICS_TOKEN_KEY})
837
+
838
+
839
+ def _readers(snap: Snapshot) -> str:
840
+ """Who reads the Secret's new values, for the lines about a Secret left in place."""
841
+ if _is_metrics(snap):
842
+ return "Prometheus sends it at its next scrape"
843
+ return "running pods read them at their next restart"
844
+
845
+
846
+ def restore(snaps: list[Snapshot], *, console: Console) -> list[str]:
847
+ """Put each Secret back as it was before this run; returns one line per Secret for the error.
848
+
849
+ A Secret is restored only while it still holds exactly what this run applied:
850
+ if anything changed it since (another deploy, a ``secrets apply``), it is left
851
+ alone and the line says so. One this run created is deleted. Keys this run
852
+ removed (dropped from the allow-list) are put back too. The metrics Secret
853
+ follows the app Secret: when the app Secret is left with this run's values,
854
+ so is the metrics Secret, so both keep the same ``METRICS_TOKEN``. Never raises.
855
+ """
856
+ notes: list[str] = []
857
+ app_left: Snapshot | None = None # the app Secret, when it keeps this run's values
858
+ for snap in sorted(snaps, key=_is_metrics): # the app Secret first
859
+ if not snap.touched:
860
+ continue
861
+ keys = ", ".join(snap.touched)
862
+ if _is_metrics(snap) and app_left is not None:
863
+ notes.append(
864
+ f"Secret {snap.name} was left as it is too, so its METRICS_TOKEN matches "
865
+ f"{app_left.name}'s (this deploy had changed {keys})."
866
+ )
867
+ continue
868
+ restored = False
869
+ try:
870
+ current = read_live_secret(snap.name, snap.target)
871
+ if (
872
+ current.exists == snap.before.exists
873
+ and all(current.raw.get(k) == snap.before.raw.get(k) for k in snap.applied)
874
+ and all(k in current.raw for k in snap.removed)
875
+ ):
876
+ continue # the apply never landed: nothing to put back
877
+ if not current.exists or any(
878
+ current.values.get(k) != v for k, v in snap.applied.items()
879
+ ):
880
+ notes.append(
881
+ f"Secret {snap.name} was changed by someone else after this deploy applied "
882
+ f"it, so it was left as it is (this deploy had changed {keys})."
883
+ )
884
+ elif not snap.before.exists:
885
+ _kube.kubectl(
886
+ ["delete", "secret", snap.name, "--ignore-not-found"],
887
+ snap.target,
888
+ console=console,
889
+ )
890
+ notes.append(f"Secret {snap.name}, which this deploy created, was deleted.")
891
+ restored = True
892
+ else:
893
+ result = _kube.run_cmd(
894
+ _apply_cmd(SecretPlan(snap.name, snap.target, {})),
895
+ input_text=_restore_manifest(snap, current),
896
+ check=False,
897
+ console=console,
898
+ )
899
+ if result.returncode != 0 and "has been modified" in (result.stderr or ""):
900
+ # The resourceVersion precondition: changed between the check and the apply.
901
+ notes.append(
902
+ f"Secret {snap.name} was changed by someone else while this deploy "
903
+ f"restored it, so it was left as it is (this deploy had changed {keys})."
904
+ )
905
+ elif result.returncode != 0:
906
+ raise _kube.ToolFailed(
907
+ (result.stderr or result.stdout or "").strip()
908
+ or f"kubectl apply exited {result.returncode}"
909
+ )
910
+ else:
911
+ notes.append(
912
+ f"Secret {snap.name} was restored to its values from before this deploy "
913
+ f"({keys})."
914
+ )
915
+ restored = True
916
+ except _kube.DeployError as e:
917
+ first = next((line for line in str(e).splitlines() if line.strip()), "kubectl failed")
918
+ readers = (
919
+ "Prometheus sends at its next scrape"
920
+ if _is_metrics(snap)
921
+ else "running pods read at their next restart"
922
+ )
923
+ notes.append(
924
+ f"Secret {snap.name} could NOT be restored ({first.strip()}): it still holds "
925
+ f"this deploy's values for {keys}, which {readers}. Re-apply the previous "
926
+ f"values with `graph-agents-cli secrets apply --env {snap.env or '<env>'} "
927
+ "--env-file <previous env file>`."
928
+ )
929
+ if not restored and not _is_metrics(snap):
930
+ app_left = snap
931
+ return notes
932
+
933
+
934
+ def describe_unrestored(snaps: list[Snapshot], env: str, why: str) -> list[str]:
935
+ """Lines for Secrets this run changed and left in place (the release was not restored)."""
936
+ return [
937
+ f"Secret {snap.name} keeps this deploy's values for {', '.join(snap.touched)} ({why}); "
938
+ f"{_readers(snap)}. To go back, re-apply the previous values: "
939
+ f"`graph-agents-cli secrets apply --env {env} --env-file <previous env file>`."
940
+ for snap in snaps
941
+ if snap.touched
942
+ ]
943
+
944
+
945
+ def provisioning_procedure(*, project: str, env: str, owner: str, mode: str) -> str:
946
+ """Text printed when deploy refuses to touch Secrets in a CD mode."""
947
+ who = owner or "the platform operator named in secrets.owner"
948
+ return (
949
+ f"In {mode} mode `deploy` never touches Secrets. {who} provisions the Secret "
950
+ f"once per environment from a workstation with cluster access:\n"
951
+ f" graph-agents-cli secrets apply --env {env} ({_env_file_note(env)})\n"
952
+ f" (or `kubectl create secret generic {project}-app ...`). Rotate with `secrets apply` "
953
+ f"followed by `graph-agents-cli deploy --env {env} --restart`."
954
+ )