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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|