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,1169 @@
|
|
|
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
|
+
"""Read-only prerequisite checks for ``infra check``: cluster, GitHub gates, disconnected profile.
|
|
15
|
+
|
|
16
|
+
Every check reports; nothing here creates or changes cluster or GitHub state.
|
|
17
|
+
Cluster checks shell out to ``kubectl`` with ``check=False`` so a missing
|
|
18
|
+
prerequisite is a row in the table, never an exception.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import binascii
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from dataclasses import asdict, dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from graph_agents_cli._defaults import REGISTRY_FIX_COMMAND, REGISTRY_FIX_EFFECT
|
|
34
|
+
from graph_agents_cli.deploy import _image, _kube, _modes, _preflight, gitops, local_load
|
|
35
|
+
from graph_agents_cli.deploy._config import DeploySettings
|
|
36
|
+
from graph_agents_cli.deploy._kube import Target, ToolFailed
|
|
37
|
+
from graph_agents_cli.deploy._values import load_chart_values
|
|
38
|
+
from graph_agents_cli.secrets import _apply as secrets_apply
|
|
39
|
+
from graph_agents_cli.secrets import _required
|
|
40
|
+
|
|
41
|
+
MIN_KUBERNETES = (1, 28)
|
|
42
|
+
DISCONNECTED = "disconnected"
|
|
43
|
+
PROFILES = (DISCONNECTED,)
|
|
44
|
+
|
|
45
|
+
OK = "ok"
|
|
46
|
+
MISSING = "missing"
|
|
47
|
+
WARN = "warn"
|
|
48
|
+
SKIP = "skip"
|
|
49
|
+
INFO = "info"
|
|
50
|
+
|
|
51
|
+
HOSTED_REGISTRY_HOSTS = ("ghcr.io", "docker.io", "index.docker.io", "gcr.io", "quay.io")
|
|
52
|
+
HOSTED_REGISTRY_SUFFIXES = (".amazonaws.com", ".pkg.dev", ".azurecr.io", ".gcr.io")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Check:
|
|
57
|
+
name: str
|
|
58
|
+
status: str
|
|
59
|
+
required: bool
|
|
60
|
+
detail: str = ""
|
|
61
|
+
hint: str = ""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def failed(self) -> bool:
|
|
65
|
+
return self.required and self.status == MISSING
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict[str, Any]:
|
|
68
|
+
return asdict(self)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Report:
|
|
73
|
+
env: str | None
|
|
74
|
+
profile: str | None
|
|
75
|
+
mode: str | None
|
|
76
|
+
checks: list[Check] = field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def ok(self) -> bool:
|
|
80
|
+
return not any(c.failed for c in self.checks)
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"env": self.env,
|
|
85
|
+
"profile": self.profile,
|
|
86
|
+
"mode": self.mode,
|
|
87
|
+
"ok": self.ok,
|
|
88
|
+
"checks": [c.to_dict() for c in self.checks],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _kubectl(args: list[str], target: Target | None, *, namespaced: bool = False) -> Any:
|
|
93
|
+
"""kubectl with a parsed JSON result: ``(returncode, data|None, stderr)``; never raises."""
|
|
94
|
+
try:
|
|
95
|
+
result = _kube.kubectl(
|
|
96
|
+
[*args, "-o", "json"], target, namespaced=namespaced, check=False, quiet=True
|
|
97
|
+
)
|
|
98
|
+
except ToolFailed as e:
|
|
99
|
+
return 1, None, str(e)
|
|
100
|
+
data = None
|
|
101
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(result.stdout)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
data = None
|
|
106
|
+
return result.returncode, data, (result.stderr or "").strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _names(listing: Any) -> list[str]:
|
|
110
|
+
if not isinstance(listing, dict):
|
|
111
|
+
return []
|
|
112
|
+
return [
|
|
113
|
+
str(item.get("metadata", {}).get("name", ""))
|
|
114
|
+
for item in listing.get("items", [])
|
|
115
|
+
if isinstance(item, dict)
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _minor(version: str) -> int:
|
|
120
|
+
digits = re.match(r"\d+", version or "")
|
|
121
|
+
return int(digits.group()) if digits else 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _enabled(values: dict[str, Any], *path: str, default: bool = False) -> bool:
|
|
125
|
+
node: Any = values
|
|
126
|
+
for key in path:
|
|
127
|
+
if not isinstance(node, dict):
|
|
128
|
+
return default
|
|
129
|
+
node = node.get(key)
|
|
130
|
+
if node is None:
|
|
131
|
+
return default
|
|
132
|
+
return bool(node)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get(values: dict[str, Any], *path: str, default: Any = None) -> Any:
|
|
136
|
+
node: Any = values
|
|
137
|
+
for key in path:
|
|
138
|
+
if not isinstance(node, dict):
|
|
139
|
+
return default
|
|
140
|
+
node = node.get(key)
|
|
141
|
+
return default if node is None else node
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- checks
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_tools(settings: DeploySettings, mode: str) -> list[Check]:
|
|
148
|
+
checks: list[Check] = []
|
|
149
|
+
needs = {
|
|
150
|
+
"kubectl": True,
|
|
151
|
+
"helm": mode != _modes.ARGOCD,
|
|
152
|
+
"docker": mode in _modes.DIRECT_MODES or (mode == _modes.HELM_PUSH),
|
|
153
|
+
"gh": settings.cd != "skip",
|
|
154
|
+
}
|
|
155
|
+
hints = {
|
|
156
|
+
"kubectl": "https://kubernetes.io/docs/tasks/tools/",
|
|
157
|
+
"helm": "https://helm.sh/docs/intro/install/",
|
|
158
|
+
"docker": "install Docker (or a docker-compatible CLI) to build images locally",
|
|
159
|
+
"gh": "https://cli.github.com/",
|
|
160
|
+
}
|
|
161
|
+
for tool, required in needs.items():
|
|
162
|
+
present = _kube.tool_available(tool)
|
|
163
|
+
checks.append(
|
|
164
|
+
Check(
|
|
165
|
+
f"tool: {tool}",
|
|
166
|
+
OK if present else MISSING,
|
|
167
|
+
required,
|
|
168
|
+
"found" if present else "not on PATH",
|
|
169
|
+
"" if present else hints[tool],
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
if settings.cd == _modes.ARGOCD:
|
|
173
|
+
present = _kube.tool_available("argocd")
|
|
174
|
+
checks.append(
|
|
175
|
+
Check(
|
|
176
|
+
"tool: argocd",
|
|
177
|
+
OK if present else INFO,
|
|
178
|
+
False,
|
|
179
|
+
"found" if present else "not on PATH; `deploy --status` falls back to kubectl",
|
|
180
|
+
"" if present else "https://argo-cd.readthedocs.io/en/stable/cli_installation/",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return checks
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def check_cluster(
|
|
187
|
+
settings: DeploySettings, env: str, target: Target, values: dict[str, Any]
|
|
188
|
+
) -> list[Check]:
|
|
189
|
+
checks: list[Check] = []
|
|
190
|
+
rc, version, err = _kubectl(["version"], target)
|
|
191
|
+
server = version.get("serverVersion") if isinstance(version, dict) else None
|
|
192
|
+
if rc != 0 or not isinstance(server, dict):
|
|
193
|
+
checks.append(
|
|
194
|
+
Check(
|
|
195
|
+
"cluster reachable",
|
|
196
|
+
MISSING,
|
|
197
|
+
True,
|
|
198
|
+
err.splitlines()[0]
|
|
199
|
+
if err
|
|
200
|
+
else f"kubectl could not reach context {target.context or '(current)'}",
|
|
201
|
+
"check the kube context recorded in environments.<env>.context and your kubeconfig",
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
return checks
|
|
205
|
+
git_version = str(server.get("gitVersion", ""))
|
|
206
|
+
checks.append(
|
|
207
|
+
Check(
|
|
208
|
+
"cluster reachable",
|
|
209
|
+
OK,
|
|
210
|
+
True,
|
|
211
|
+
f"context {target.context or '(current)'}, server {git_version}",
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
major, minor = _minor(str(server.get("major", "0"))), _minor(str(server.get("minor", "0")))
|
|
215
|
+
new_enough = (major, minor) >= MIN_KUBERNETES
|
|
216
|
+
checks.append(
|
|
217
|
+
Check(
|
|
218
|
+
f"kubernetes >= {MIN_KUBERNETES[0]}.{MIN_KUBERNETES[1]}",
|
|
219
|
+
OK if new_enough else MISSING,
|
|
220
|
+
True,
|
|
221
|
+
f"server {major}.{minor}",
|
|
222
|
+
""
|
|
223
|
+
if new_enough
|
|
224
|
+
else "upgrade the cluster; the chart uses gateway.networking.k8s.io/v1",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if _enabled(values, "gateway", "enabled", default=True):
|
|
229
|
+
checks += _gateway_checks(target, values)
|
|
230
|
+
else:
|
|
231
|
+
# Like cert-manager and metrics-server below: nothing to install, no hint.
|
|
232
|
+
checks.append(Check("gateway api", SKIP, False, "not needed: gateway.enabled is false"))
|
|
233
|
+
|
|
234
|
+
ingress_on = _enabled(values, "ingress", "enabled")
|
|
235
|
+
rc, listing, _ = _kubectl(["get", "ingressclass"], target)
|
|
236
|
+
ingress_classes = _names(listing) if rc == 0 else []
|
|
237
|
+
ingress_wanted = str(_get(values, "ingress", "className", default="") or "")
|
|
238
|
+
detail = ", ".join(ingress_classes) if ingress_classes else "no IngressClass in the cluster"
|
|
239
|
+
status = OK if ingress_classes else (MISSING if ingress_on else INFO)
|
|
240
|
+
if ingress_wanted and ingress_on:
|
|
241
|
+
status = OK if ingress_wanted in ingress_classes else MISSING
|
|
242
|
+
detail = f"{ingress_wanted} {'found' if status == OK else 'not found'}; available: {', '.join(ingress_classes) or 'none'}"
|
|
243
|
+
checks.append(
|
|
244
|
+
Check(
|
|
245
|
+
"ingress classes",
|
|
246
|
+
status,
|
|
247
|
+
ingress_on,
|
|
248
|
+
detail,
|
|
249
|
+
""
|
|
250
|
+
if status == OK
|
|
251
|
+
else "select a maintained ingress controller and set ingress.className",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if _enabled(values, "tls", "certManager", "enabled"):
|
|
256
|
+
rc, _data, _ = _kubectl(["get", "crd", "certificates.cert-manager.io"], target)
|
|
257
|
+
checks.append(
|
|
258
|
+
Check(
|
|
259
|
+
"cert-manager",
|
|
260
|
+
OK if rc == 0 else MISSING,
|
|
261
|
+
True,
|
|
262
|
+
"certificates.cert-manager.io present" if rc == 0 else "CRD absent",
|
|
263
|
+
""
|
|
264
|
+
if rc == 0
|
|
265
|
+
else "install cert-manager (https://cert-manager.io/docs/installation/) or set tls.certManager.enabled=false",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
checks.append(
|
|
270
|
+
Check("cert-manager", SKIP, False, "not needed: tls.certManager.enabled is false")
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if _enabled(values, "hpa", "enabled"):
|
|
274
|
+
rc, api, _ = _kubectl(["get", "apiservice", "v1beta1.metrics.k8s.io"], target)
|
|
275
|
+
available = False
|
|
276
|
+
if rc == 0 and isinstance(api, dict):
|
|
277
|
+
for cond in api.get("status", {}).get("conditions", []):
|
|
278
|
+
if cond.get("type") == "Available" and str(cond.get("status")).lower() == "true":
|
|
279
|
+
available = True
|
|
280
|
+
checks.append(
|
|
281
|
+
Check(
|
|
282
|
+
"metrics-server",
|
|
283
|
+
OK if available else MISSING,
|
|
284
|
+
True,
|
|
285
|
+
"metrics API available" if available else "v1beta1.metrics.k8s.io unavailable",
|
|
286
|
+
""
|
|
287
|
+
if available
|
|
288
|
+
else "install metrics-server (https://github.com/kubernetes-sigs/metrics-server) or set hpa.enabled=false",
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
else:
|
|
292
|
+
checks.append(Check("metrics-server", SKIP, False, "not needed: hpa.enabled is false"))
|
|
293
|
+
|
|
294
|
+
if settings.cd == _modes.ARGOCD:
|
|
295
|
+
rc_ns, _ns, _ = _kubectl(["get", "namespace", "argocd"], target)
|
|
296
|
+
rc_crd, _crd, _ = _kubectl(["get", "crd", "applications.argoproj.io"], target)
|
|
297
|
+
ok = rc_ns == 0 and rc_crd == 0
|
|
298
|
+
checks.append(
|
|
299
|
+
Check(
|
|
300
|
+
"argo cd",
|
|
301
|
+
OK if ok else MISSING,
|
|
302
|
+
True,
|
|
303
|
+
"namespace argocd and applications.argoproj.io present"
|
|
304
|
+
if ok
|
|
305
|
+
else f"namespace argocd {'ok' if rc_ns == 0 else 'missing'}, Application CRD {'ok' if rc_crd == 0 else 'missing'}",
|
|
306
|
+
""
|
|
307
|
+
if ok
|
|
308
|
+
else "install Argo CD (https://argo-cd.readthedocs.io/en/stable/getting_started/)",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
else:
|
|
312
|
+
checks.append(Check("argo cd", SKIP, False, f"not needed: cd is {settings.cd}"))
|
|
313
|
+
|
|
314
|
+
rc, _ns, _ = _kubectl(["get", "namespace", target.namespace], target)
|
|
315
|
+
checks.append(
|
|
316
|
+
Check(
|
|
317
|
+
f"namespace {target.namespace}",
|
|
318
|
+
OK if rc == 0 else WARN,
|
|
319
|
+
False,
|
|
320
|
+
"exists"
|
|
321
|
+
if rc == 0
|
|
322
|
+
else "absent (`deploy` and `secrets apply` create it; Argo CD with CreateNamespace)",
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
pull_secrets: list[str] = []
|
|
327
|
+
for item in _get(values, "imagePullSecrets", default=[]) or []:
|
|
328
|
+
if isinstance(item, dict) and item.get("name"):
|
|
329
|
+
pull_secrets.append(str(item["name"]))
|
|
330
|
+
elif isinstance(item, str):
|
|
331
|
+
pull_secrets.append(item)
|
|
332
|
+
if pull_secrets:
|
|
333
|
+
for name in pull_secrets:
|
|
334
|
+
rc, _s, _ = _kubectl(["get", "secret", name], target, namespaced=True)
|
|
335
|
+
checks.append(
|
|
336
|
+
Check(
|
|
337
|
+
f"image pull secret {name}",
|
|
338
|
+
OK if rc == 0 else MISSING,
|
|
339
|
+
True,
|
|
340
|
+
f"present in {target.namespace}"
|
|
341
|
+
if rc == 0
|
|
342
|
+
else f"absent from {target.namespace}",
|
|
343
|
+
""
|
|
344
|
+
if rc == 0
|
|
345
|
+
else f"kubectl create secret docker-registry {name} -n {target.namespace} ... (operator prerequisite)",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
else:
|
|
349
|
+
checks.append(Check("image pull secrets", SKIP, False, "none referenced in values"))
|
|
350
|
+
|
|
351
|
+
rc, secret, _ = _kubectl(["get", "secret", settings.secret_name], target, namespaced=True)
|
|
352
|
+
cd_mode = settings.cd != "skip"
|
|
353
|
+
required = _required.required_keys(settings, values)
|
|
354
|
+
present: set[str] = set()
|
|
355
|
+
if isinstance(secret, dict):
|
|
356
|
+
present = set(secret.get("data") or {}) | set(secret.get("stringData") or {})
|
|
357
|
+
missing = [k for k in required if k not in present]
|
|
358
|
+
provision = secrets_apply.provision_hint(env)
|
|
359
|
+
if rc != 0:
|
|
360
|
+
status, detail, hint = MISSING if cd_mode else WARN, "absent", provision
|
|
361
|
+
elif missing and isinstance(secret, dict):
|
|
362
|
+
status = MISSING if cd_mode else WARN
|
|
363
|
+
detail = f"present, missing required key(s): {', '.join(missing)}"
|
|
364
|
+
hint = provision
|
|
365
|
+
else:
|
|
366
|
+
status, detail, hint = (
|
|
367
|
+
OK,
|
|
368
|
+
"present" + (f" with the required key(s) {', '.join(required)}" if required else ""),
|
|
369
|
+
"",
|
|
370
|
+
)
|
|
371
|
+
# In cd skip, `deploy` applies the Secret from the env file itself, then
|
|
372
|
+
# refuses before helm when a required key is still missing.
|
|
373
|
+
checks.append(Check(f"app secret {settings.secret_name}", status, cd_mode, detail, hint))
|
|
374
|
+
checks += _jwt_checks(settings, env, values, present if rc == 0 else set())
|
|
375
|
+
checks += _database_tls_checks(settings, env, values, secret if rc == 0 else None)
|
|
376
|
+
checks += _metrics_token_checks(settings, env, target, values)
|
|
377
|
+
return checks
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _database_tls_checks(
|
|
381
|
+
settings: DeploySettings, env: str, values: dict[str, Any], secret: Any
|
|
382
|
+
) -> list[Check]:
|
|
383
|
+
"""Outside dev: whether the external database's DSN requires TLS (read, never printed)."""
|
|
384
|
+
if _modes.is_dev_env(env) or not isinstance(secret, dict):
|
|
385
|
+
return []
|
|
386
|
+
checks: list[Check] = []
|
|
387
|
+
data = secret.get("data") or {}
|
|
388
|
+
for key in _preflight.dsn_keys(settings, values):
|
|
389
|
+
raw = data.get(key)
|
|
390
|
+
if not raw:
|
|
391
|
+
continue
|
|
392
|
+
try:
|
|
393
|
+
dsn = base64.b64decode(str(raw)).decode("utf-8", errors="replace")
|
|
394
|
+
except (binascii.Error, ValueError):
|
|
395
|
+
continue
|
|
396
|
+
insecure = _preflight.dsn_without_tls(values, dsn)
|
|
397
|
+
checks.append(
|
|
398
|
+
Check(
|
|
399
|
+
f"database tls ({key})",
|
|
400
|
+
WARN if insecure else OK,
|
|
401
|
+
False,
|
|
402
|
+
"no sslmode=require/verify-ca/verify-full"
|
|
403
|
+
if insecure
|
|
404
|
+
else f"sslmode={_preflight.sslmode(dsn) or 'from PGSSLMODE'}",
|
|
405
|
+
"append ?sslmode=verify-full&sslrootcert=<CA file mounted into the pod> (or "
|
|
406
|
+
"sslrootcert=system) to the DSN, and connect as a least-privileged role"
|
|
407
|
+
if insecure
|
|
408
|
+
else "",
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
return checks
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _gateway_checks(target: Target, values: dict[str, Any]) -> list[Check]:
|
|
415
|
+
"""Gateway API CRDs, a GatewayClass and the parentRef (only called while the gateway is on)."""
|
|
416
|
+
checks: list[Check] = []
|
|
417
|
+
gateway_on = True
|
|
418
|
+
rc, _data, _ = _kubectl(["get", "crd", "httproutes.gateway.networking.k8s.io"], target)
|
|
419
|
+
have_gateway_crd = rc == 0
|
|
420
|
+
checks.append(
|
|
421
|
+
Check(
|
|
422
|
+
"gateway api crds",
|
|
423
|
+
OK if have_gateway_crd else MISSING,
|
|
424
|
+
gateway_on,
|
|
425
|
+
"httproutes.gateway.networking.k8s.io present"
|
|
426
|
+
if have_gateway_crd
|
|
427
|
+
else "HTTPRoute CRD absent",
|
|
428
|
+
""
|
|
429
|
+
if have_gateway_crd
|
|
430
|
+
else "install the Gateway API CRDs (https://gateway-api.sigs.k8s.io/guides/) or set ingress.enabled=true",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
class_names: list[str] = []
|
|
434
|
+
if have_gateway_crd:
|
|
435
|
+
rc, listing, _ = _kubectl(["get", "gatewayclass"], target)
|
|
436
|
+
class_names = _names(listing) if rc == 0 else []
|
|
437
|
+
wanted = str(_get(values, "gateway", "className", default="") or "")
|
|
438
|
+
if wanted:
|
|
439
|
+
found = wanted in class_names
|
|
440
|
+
checks.append(
|
|
441
|
+
Check(
|
|
442
|
+
"gateway class",
|
|
443
|
+
OK if found else MISSING,
|
|
444
|
+
gateway_on,
|
|
445
|
+
f"{wanted} {'found' if found else 'not found'}; available: {', '.join(class_names) or 'none'}",
|
|
446
|
+
""
|
|
447
|
+
if found
|
|
448
|
+
else "set gateway.className to one of the GatewayClasses in the cluster",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
else:
|
|
452
|
+
checks.append(
|
|
453
|
+
Check(
|
|
454
|
+
"gateway classes",
|
|
455
|
+
OK if class_names else MISSING,
|
|
456
|
+
gateway_on,
|
|
457
|
+
", ".join(class_names) if class_names else "no GatewayClass in the cluster",
|
|
458
|
+
""
|
|
459
|
+
if class_names
|
|
460
|
+
else "install a Gateway API implementation (Envoy Gateway, Cilium, Istio, Traefik, NGINX Gateway Fabric, Kong) and set gateway.className",
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
# The chart's HTTPRoute marks gateway.parentRef.name as `required`, and `deploy`
|
|
464
|
+
# refuses (exit 3) before any tool runs when it is blank; report it here so the
|
|
465
|
+
# operator sees it before the first deploy.
|
|
466
|
+
parent_name = str(_get(values, "gateway", "parentRef", "name", default="") or "").strip()
|
|
467
|
+
checks.append(
|
|
468
|
+
Check(
|
|
469
|
+
"gateway parentRef",
|
|
470
|
+
OK if parent_name else MISSING,
|
|
471
|
+
gateway_on,
|
|
472
|
+
f"gateway.parentRef.name = {parent_name}"
|
|
473
|
+
if parent_name
|
|
474
|
+
else "gateway.parentRef.name is blank",
|
|
475
|
+
""
|
|
476
|
+
if parent_name
|
|
477
|
+
else "set gateway.parentRef.name to the Gateway to attach to in values-<env>.yaml (or set gateway.enabled: false / ingress.enabled: true)",
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
return checks
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _jwt_checks(
|
|
484
|
+
settings: DeploySettings, env: str, values: dict[str, Any], secret_keys: set[str]
|
|
485
|
+
) -> list[Check]:
|
|
486
|
+
"""The ``jwt`` policy's verification settings (a key source; issuer and audience outside dev)."""
|
|
487
|
+
if _preflight.effective_auth_policy(settings, values) != "jwt":
|
|
488
|
+
return []
|
|
489
|
+
findings = _preflight.jwt_findings(settings, env, values, secret_keys)
|
|
490
|
+
name = "auth: jwt settings"
|
|
491
|
+
if not findings:
|
|
492
|
+
return [Check(name, OK, findings.strict, "verification key (and issuer and audience) set")]
|
|
493
|
+
hint = f"set them in {findings.where}"
|
|
494
|
+
if findings.strict:
|
|
495
|
+
detail = "; ".join(findings.problems) + " (the pods refuse to start)"
|
|
496
|
+
return [Check(name, MISSING, True, detail, hint)]
|
|
497
|
+
detail = "; ".join(findings.problems) + " (the pods answer every request with 503)"
|
|
498
|
+
return [Check(name, WARN, False, detail, hint)]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _metrics_token_checks(
|
|
502
|
+
settings: DeploySettings, env: str, target: Target, values: dict[str, Any]
|
|
503
|
+
) -> list[Check]:
|
|
504
|
+
"""The ServiceMonitor's token Secret, when the ServiceMonitor sends one."""
|
|
505
|
+
monitor = _get(values, "metrics", "serviceMonitor", default={}) or {}
|
|
506
|
+
bearer = monitor.get("bearerToken") if isinstance(monitor, dict) else None
|
|
507
|
+
if not (
|
|
508
|
+
_enabled(values, "metrics", "serviceMonitor", "enabled")
|
|
509
|
+
and isinstance(bearer, dict)
|
|
510
|
+
and bearer.get("enabled")
|
|
511
|
+
):
|
|
512
|
+
return []
|
|
513
|
+
name = str(bearer.get("secretName") or settings.metrics_secret_name)
|
|
514
|
+
key = str(bearer.get("key") or secrets_apply.METRICS_TOKEN_KEY)
|
|
515
|
+
rc, secret, _ = _kubectl(["get", "secret", name], target, namespaced=True)
|
|
516
|
+
present = set((secret or {}).get("data") or {}) if isinstance(secret, dict) else set()
|
|
517
|
+
ok = rc == 0 and key in present
|
|
518
|
+
hint = ""
|
|
519
|
+
if not ok:
|
|
520
|
+
hint = (
|
|
521
|
+
f"add {secrets_apply.METRICS_TOKEN_KEY} to secrets.keys and the env file, then "
|
|
522
|
+
f"{secrets_apply.provision_hint(env)} (it writes {settings.metrics_secret_name})"
|
|
523
|
+
if name == settings.metrics_secret_name
|
|
524
|
+
else f"create Secret {name} with the key {key} in {target.namespace}"
|
|
525
|
+
)
|
|
526
|
+
return [
|
|
527
|
+
Check(
|
|
528
|
+
f"metrics token secret {name}",
|
|
529
|
+
OK if ok else WARN,
|
|
530
|
+
False,
|
|
531
|
+
f"present with {key}"
|
|
532
|
+
if ok
|
|
533
|
+
else (f"absent from {target.namespace}" if rc != 0 else f"has no key {key}")
|
|
534
|
+
+ ": the ServiceMonitor's scrapes get 401",
|
|
535
|
+
hint,
|
|
536
|
+
)
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _gated_apis(policy_path: Path) -> list[str]:
|
|
541
|
+
"""``name (approvers)`` of each API whose valid policy has an approval block.
|
|
542
|
+
|
|
543
|
+
With a list of approval rules, the approvers of every rule, each once.
|
|
544
|
+
"""
|
|
545
|
+
from graph_agents_cli._api_policy import (
|
|
546
|
+
ApiPolicyFileError,
|
|
547
|
+
approval_rules,
|
|
548
|
+
load_policy_document,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if not policy_path.is_file():
|
|
552
|
+
return []
|
|
553
|
+
try:
|
|
554
|
+
document = load_policy_document(policy_path)
|
|
555
|
+
except ApiPolicyFileError:
|
|
556
|
+
return [] # `lint` reports an invalid policy
|
|
557
|
+
gated = []
|
|
558
|
+
for name, api in document["apis"].items():
|
|
559
|
+
rules = approval_rules(api) if isinstance(api, dict) else []
|
|
560
|
+
if rules:
|
|
561
|
+
approvers = dict.fromkeys(str(a) for rule in rules for a in rule["approvers"])
|
|
562
|
+
gated.append(f"{name} ({', '.join(approvers)})")
|
|
563
|
+
return gated
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def check_approvals(
|
|
567
|
+
settings: DeploySettings, values: dict[str, Any], project_root: Path | None = None
|
|
568
|
+
) -> list[Check]:
|
|
569
|
+
"""Where pending approvals are kept, when api-policy.yaml gates any call.
|
|
570
|
+
|
|
571
|
+
A gated call pauses its run until an approver decides; the paused run
|
|
572
|
+
lives in the checkpointer and the approval in the ``approvals`` table of
|
|
573
|
+
the app database (``agent_approvals`` in ``DATABASE_URI`` under
|
|
574
|
+
langgraph-server). With the in-memory checkpointer both are lost when the
|
|
575
|
+
pod restarts, and another replica cannot resume them.
|
|
576
|
+
"""
|
|
577
|
+
from graph_agents_cli._api_policy import POLICY_FILENAME
|
|
578
|
+
|
|
579
|
+
gated = _gated_apis((project_root or Path.cwd()) / POLICY_FILENAME)
|
|
580
|
+
if not gated:
|
|
581
|
+
return []
|
|
582
|
+
name = "approval gates"
|
|
583
|
+
apis = f"api-policy.yaml gates calls of {'; '.join(gated)}"
|
|
584
|
+
memory = (
|
|
585
|
+
settings.runtime != "langgraph-server"
|
|
586
|
+
and not _enabled(values, "postgresql", "enabled")
|
|
587
|
+
and str(_preflight.chart_env(values).get("CHECKPOINTER") or "postgres").strip().lower()
|
|
588
|
+
== "memory"
|
|
589
|
+
)
|
|
590
|
+
if memory:
|
|
591
|
+
return [
|
|
592
|
+
Check(
|
|
593
|
+
name,
|
|
594
|
+
WARN,
|
|
595
|
+
False,
|
|
596
|
+
f"{apis}; with CHECKPOINTER=memory a paused run and its pending approval live "
|
|
597
|
+
"in one pod's memory: a restart loses them and another replica cannot resume "
|
|
598
|
+
"them",
|
|
599
|
+
"set CHECKPOINTER=postgres: the paused runs and the approvals table then live "
|
|
600
|
+
"in the app database",
|
|
601
|
+
)
|
|
602
|
+
]
|
|
603
|
+
server = settings.runtime == "langgraph-server"
|
|
604
|
+
where = "DATABASE_URI" if server else "POSTGRES_DSN"
|
|
605
|
+
table = "agent_approvals" if server else "approvals"
|
|
606
|
+
return [
|
|
607
|
+
Check(
|
|
608
|
+
name,
|
|
609
|
+
INFO,
|
|
610
|
+
False,
|
|
611
|
+
f"{apis}; pending and decided approvals are kept in the {table} table of the "
|
|
612
|
+
f"app database ({where}, beside the checkpoints): back it up with them, and keep "
|
|
613
|
+
"its retention in line with RETENTION_DAYS",
|
|
614
|
+
)
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _codeowners_placeholders() -> list[str]:
|
|
619
|
+
path = Path(".github") / "CODEOWNERS"
|
|
620
|
+
if not path.is_file():
|
|
621
|
+
return []
|
|
622
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
623
|
+
return [
|
|
624
|
+
line.strip()
|
|
625
|
+
for line in lines
|
|
626
|
+
if line.strip() and not line.lstrip().startswith("#") and _image.has_placeholder(line)
|
|
627
|
+
]
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _argocd_placeholders() -> list[str]:
|
|
631
|
+
"""``deployment/argocd/*.yaml`` files whose ``repoURL`` still holds the placeholder."""
|
|
632
|
+
import yaml
|
|
633
|
+
|
|
634
|
+
found: list[str] = []
|
|
635
|
+
for path in sorted((Path("deployment") / "argocd").glob("*.yaml")):
|
|
636
|
+
try:
|
|
637
|
+
doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
638
|
+
except (OSError, yaml.YAMLError):
|
|
639
|
+
continue
|
|
640
|
+
spec = doc.get("spec") if isinstance(doc, dict) else None
|
|
641
|
+
if not isinstance(spec, dict):
|
|
642
|
+
continue
|
|
643
|
+
sources = [spec.get("source"), *(spec.get("sources") or [])]
|
|
644
|
+
urls = [str(s.get("repoURL") or "") for s in sources if isinstance(s, dict)]
|
|
645
|
+
if any(_image.has_placeholder(url) for url in urls):
|
|
646
|
+
found.append(str(path))
|
|
647
|
+
return found
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def check_placeholders(
|
|
651
|
+
settings: DeploySettings, values: dict[str, Any], env: str | None = None
|
|
652
|
+
) -> list[Check]:
|
|
653
|
+
"""Scaffold placeholders (``CHANGE-ME``) that break a build, a rollout or the production gate.
|
|
654
|
+
|
|
655
|
+
A placeholder in the chart env is what ``deploy`` refuses outside dev and
|
|
656
|
+
warns about in dev (the pods would call it); ``env`` selects which.
|
|
657
|
+
"""
|
|
658
|
+
checks: list[Check] = []
|
|
659
|
+
registry = settings.registry
|
|
660
|
+
problem = (
|
|
661
|
+
_image.reference_problem(
|
|
662
|
+
f"{registry.rstrip('/')}/{settings.project_name}", None, registry=registry
|
|
663
|
+
)
|
|
664
|
+
if registry
|
|
665
|
+
else "create_params.registry is empty"
|
|
666
|
+
)
|
|
667
|
+
checks.append(
|
|
668
|
+
Check(
|
|
669
|
+
"placeholder: registry",
|
|
670
|
+
MISSING if problem else OK,
|
|
671
|
+
True,
|
|
672
|
+
registry if not problem else problem.splitlines()[0],
|
|
673
|
+
""
|
|
674
|
+
if not problem
|
|
675
|
+
else f"run `{REGISTRY_FIX_COMMAND}` ({REGISTRY_FIX_EFFECT}), or set "
|
|
676
|
+
"create_params.registry in graph-agents-cli-manifest.yaml (e.g. ghcr.io/<org>)",
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
repository = str(_get(values, "image", "repository", default="") or "")
|
|
680
|
+
argocd = settings.cd == _modes.ARGOCD
|
|
681
|
+
chart_placeholder = _image.has_placeholder(repository)
|
|
682
|
+
checks.append(
|
|
683
|
+
Check(
|
|
684
|
+
"placeholder: chart image.repository",
|
|
685
|
+
(MISSING if argocd else WARN) if chart_placeholder else OK,
|
|
686
|
+
argocd,
|
|
687
|
+
repository or "(unset)",
|
|
688
|
+
""
|
|
689
|
+
if not chart_placeholder
|
|
690
|
+
else f"run `{REGISTRY_FIX_COMMAND}`, or set image.repository in the chart's "
|
|
691
|
+
"values.yaml (Argo CD renders it as is; `deploy` overrides it with --set)",
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
env_keys = _preflight.env_placeholders(values)
|
|
695
|
+
env_required = not (env and _modes.is_dev_env(env))
|
|
696
|
+
checks.append(
|
|
697
|
+
Check(
|
|
698
|
+
"placeholder: chart env",
|
|
699
|
+
(MISSING if env_required else WARN) if env_keys else OK,
|
|
700
|
+
env_required,
|
|
701
|
+
f"CHANGE-ME in env.{', env.'.join(env_keys)}" if env_keys else "none",
|
|
702
|
+
""
|
|
703
|
+
if not env_keys
|
|
704
|
+
else "replace the placeholder URLs in values.yaml / values-<env>.yaml",
|
|
705
|
+
)
|
|
706
|
+
)
|
|
707
|
+
owners = _codeowners_placeholders()
|
|
708
|
+
gated = settings.cd != "skip"
|
|
709
|
+
checks.append(
|
|
710
|
+
Check(
|
|
711
|
+
"placeholder: CODEOWNERS",
|
|
712
|
+
(MISSING if gated else WARN) if owners else OK,
|
|
713
|
+
gated,
|
|
714
|
+
f"{len(owners)} rule(s) name a CHANGE-ME owner" if owners else "no placeholder owner",
|
|
715
|
+
""
|
|
716
|
+
if not owners
|
|
717
|
+
else "name the team or users who approve production changes in .github/CODEOWNERS "
|
|
718
|
+
"(GitHub ignores unknown owners, so the code-owner review gate would require nobody)",
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
if argocd:
|
|
722
|
+
apps = _argocd_placeholders()
|
|
723
|
+
checks.append(
|
|
724
|
+
Check(
|
|
725
|
+
"placeholder: argocd repoURL",
|
|
726
|
+
MISSING if apps else OK,
|
|
727
|
+
True,
|
|
728
|
+
f"CHANGE-ME repoURL in {', '.join(apps)}" if apps else "set",
|
|
729
|
+
"" if not apps else "set spec.source.repoURL to this repository's git URL",
|
|
730
|
+
)
|
|
731
|
+
)
|
|
732
|
+
return checks
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _github_token_available() -> bool:
|
|
736
|
+
if gitops.github_token():
|
|
737
|
+
return True
|
|
738
|
+
if not _kube.tool_available("gh"):
|
|
739
|
+
return False
|
|
740
|
+
try:
|
|
741
|
+
result = _kube.run_cmd(["gh", "auth", "status"], check=False, quiet=True)
|
|
742
|
+
except ToolFailed:
|
|
743
|
+
return False
|
|
744
|
+
return result.returncode == 0
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _gh_api(path: str) -> tuple[int, Any]:
|
|
748
|
+
rc, data, _err = _gh_api_detail(path)
|
|
749
|
+
return rc, data
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _gh_api_detail(path: str) -> tuple[int, Any, str]:
|
|
753
|
+
"""``gh api <path>`` (GET, read-only): ``(returncode, parsed JSON or None, stderr)``."""
|
|
754
|
+
try:
|
|
755
|
+
result = _kube.run_cmd(["gh", "api", path], check=False, quiet=True)
|
|
756
|
+
except ToolFailed as e:
|
|
757
|
+
return 1, None, str(e)
|
|
758
|
+
if result.returncode != 0:
|
|
759
|
+
return result.returncode, None, (result.stderr or "").strip()
|
|
760
|
+
try:
|
|
761
|
+
return 0, json.loads(result.stdout or "null"), ""
|
|
762
|
+
except json.JSONDecodeError:
|
|
763
|
+
return 0, None, ""
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# `gh api` reports an HTTP error as e.g. "gh: Not Found (HTTP 404)".
|
|
767
|
+
_HTTP_STATUS = re.compile(r"\(HTTP (\d{3})\)")
|
|
768
|
+
|
|
769
|
+
FOUND = "found"
|
|
770
|
+
ABSENT = "absent"
|
|
771
|
+
DENIED = "denied"
|
|
772
|
+
UNKNOWN = "unknown"
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _gh_lookup(path: str) -> tuple[str, Any, str]:
|
|
776
|
+
"""Classify a read: FOUND, ABSENT (HTTP 404), DENIED (401/403) or UNKNOWN (network, ...)."""
|
|
777
|
+
rc, data, err = _gh_api_detail(path)
|
|
778
|
+
if rc == 0:
|
|
779
|
+
return FOUND, data, ""
|
|
780
|
+
match = _HTTP_STATUS.search(err)
|
|
781
|
+
code = int(match.group(1)) if match else 0
|
|
782
|
+
if code == 404:
|
|
783
|
+
return ABSENT, None, err
|
|
784
|
+
if code in (401, 403):
|
|
785
|
+
return DENIED, None, err
|
|
786
|
+
return UNKNOWN, None, err
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _first_line(text: str) -> str:
|
|
790
|
+
return next((line.strip() for line in text.splitlines() if line.strip()), "")
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# GitHub environments the helm-push workflows run in (staging.yaml, promote-to-prod.yaml)
|
|
794
|
+
# and the environment secret holding the cluster credentials there.
|
|
795
|
+
HELM_PUSH_ENVIRONMENTS = ("staging", "production")
|
|
796
|
+
KUBECONFIG_SECRET = "DEPLOY_KUBECONFIG"
|
|
797
|
+
REPO_KUBECONFIG_NAMES = ("KUBECONFIG", "DEPLOY_KUBECONFIG")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def check_kubeconfig_secrets(repo: str) -> list[Check]:
|
|
801
|
+
"""helm-push: the kubeconfig must be an environment secret, never a repository one.
|
|
802
|
+
|
|
803
|
+
Only jobs that pass the environment's protection rules (required reviewers
|
|
804
|
+
for production, main only) can read an environment secret. A repository or
|
|
805
|
+
organization secret with the same name is readable by every workflow job of
|
|
806
|
+
the repository, and ``secrets.DEPLOY_KUBECONFIG`` falls back to it whenever
|
|
807
|
+
the environment secret is missing, which bypasses the production gate.
|
|
808
|
+
Only secret names are read (GitHub never returns values).
|
|
809
|
+
"""
|
|
810
|
+
checks: list[Check] = []
|
|
811
|
+
for environment in HELM_PUSH_ENVIRONMENTS:
|
|
812
|
+
name = f"github secret: {KUBECONFIG_SECRET} ({environment})"
|
|
813
|
+
state, _data, err = _gh_lookup(
|
|
814
|
+
f"{repo}/environments/{environment}/secrets/{KUBECONFIG_SECRET}"
|
|
815
|
+
)
|
|
816
|
+
if state == FOUND:
|
|
817
|
+
checks.append(Check(name, OK, False, f"set in the {environment} environment"))
|
|
818
|
+
elif state == ABSENT:
|
|
819
|
+
checks.append(
|
|
820
|
+
Check(
|
|
821
|
+
name,
|
|
822
|
+
WARN,
|
|
823
|
+
False,
|
|
824
|
+
f"not set in the {environment} environment (or the environment does not "
|
|
825
|
+
"exist); the helm-push deploy job then stops at `Configure kubeconfig`, "
|
|
826
|
+
"or uses a repository or organization secret of that name",
|
|
827
|
+
f"gh secret set {KUBECONFIG_SECRET} --env {environment} < <kubeconfig of the "
|
|
828
|
+
f"{environment} cluster> (an environment secret, never a repository secret)",
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
elif state == DENIED:
|
|
832
|
+
checks.append(
|
|
833
|
+
Check(
|
|
834
|
+
name,
|
|
835
|
+
INFO,
|
|
836
|
+
False,
|
|
837
|
+
"not checked: listing environment secrets needs admin access (or the "
|
|
838
|
+
"secrets read permission) on the repository",
|
|
839
|
+
)
|
|
840
|
+
)
|
|
841
|
+
else:
|
|
842
|
+
checks.append(
|
|
843
|
+
Check(name, INFO, False, f"not checked: {_first_line(err) or 'gh api failed'}")
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
found: list[str] = []
|
|
847
|
+
unknown: list[str] = []
|
|
848
|
+
for secret in REPO_KUBECONFIG_NAMES:
|
|
849
|
+
state, _data, err = _gh_lookup(f"{repo}/actions/secrets/{secret}")
|
|
850
|
+
if state == FOUND:
|
|
851
|
+
found.append(f"repository secret {secret}")
|
|
852
|
+
elif state == DENIED:
|
|
853
|
+
unknown.append("reading repository secrets needs admin access on the repository")
|
|
854
|
+
elif state != ABSENT:
|
|
855
|
+
unknown.append(_first_line(err) or "gh api failed")
|
|
856
|
+
# Organization secrets shared with the repository resolve the same way (best effort).
|
|
857
|
+
state, listing, _err = _gh_lookup(f"{repo}/actions/organization-secrets?per_page=100")
|
|
858
|
+
org_checked = state == FOUND and isinstance(listing, dict)
|
|
859
|
+
if org_checked:
|
|
860
|
+
for item in listing.get("secrets") or []:
|
|
861
|
+
if isinstance(item, dict) and item.get("name") in REPO_KUBECONFIG_NAMES:
|
|
862
|
+
found.append(f"organization secret {item['name']}")
|
|
863
|
+
name = "github secret: repository-level kubeconfig"
|
|
864
|
+
if found:
|
|
865
|
+
checks.append(
|
|
866
|
+
Check(
|
|
867
|
+
name,
|
|
868
|
+
WARN,
|
|
869
|
+
False,
|
|
870
|
+
f"{', '.join(found)} exist(s): every workflow job of the repository can read "
|
|
871
|
+
f"it, and secrets.{KUBECONFIG_SECRET} falls back to it when an environment "
|
|
872
|
+
"secret is missing (bypassing the production gate)",
|
|
873
|
+
"delete it (`gh secret delete <name>`, or remove the organization secret's "
|
|
874
|
+
f"access to this repository) and keep the kubeconfig only as the "
|
|
875
|
+
f"{KUBECONFIG_SECRET} secret of the staging and production environments",
|
|
876
|
+
)
|
|
877
|
+
)
|
|
878
|
+
elif unknown:
|
|
879
|
+
checks.append(Check(name, INFO, False, f"not checked: {unknown[0]}"))
|
|
880
|
+
else:
|
|
881
|
+
scope = "repository or organization" if org_checked else "repository"
|
|
882
|
+
checks.append(
|
|
883
|
+
Check(
|
|
884
|
+
name,
|
|
885
|
+
OK,
|
|
886
|
+
False,
|
|
887
|
+
f"no {scope} secret named {' or '.join(REPO_KUBECONFIG_NAMES)}",
|
|
888
|
+
)
|
|
889
|
+
)
|
|
890
|
+
return checks
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def check_github(settings: DeploySettings) -> list[Check]:
|
|
894
|
+
"""Report (never enforce) the production environment and branch protection settings."""
|
|
895
|
+
if settings.cd == "skip":
|
|
896
|
+
return [Check("github protection", SKIP, False, "not needed: cd is skip")]
|
|
897
|
+
if not _github_token_available():
|
|
898
|
+
return [
|
|
899
|
+
Check(
|
|
900
|
+
"github protection",
|
|
901
|
+
INFO,
|
|
902
|
+
False,
|
|
903
|
+
"skipped: no GITHUB_TOKEN and gh is not authenticated",
|
|
904
|
+
)
|
|
905
|
+
]
|
|
906
|
+
if not _kube.tool_available("gh"):
|
|
907
|
+
return [Check("github protection", INFO, False, "skipped: gh CLI not installed")]
|
|
908
|
+
try:
|
|
909
|
+
remote = gitops.parse_remote(gitops.origin_url())
|
|
910
|
+
except Exception as e:
|
|
911
|
+
return [Check("github protection", INFO, False, f"skipped: {e}")]
|
|
912
|
+
checks: list[Check] = []
|
|
913
|
+
repo = f"repos/{remote.owner}/{remote.repo}"
|
|
914
|
+
|
|
915
|
+
rc, prod, err = _gh_api_detail(f"{repo}/environments/production")
|
|
916
|
+
if rc != 0 and not _HTTP_STATUS.search(err):
|
|
917
|
+
# No HTTP answer at all (offline, DNS, proxy): nothing below can be checked.
|
|
918
|
+
return [
|
|
919
|
+
Check(
|
|
920
|
+
"github protection",
|
|
921
|
+
INFO,
|
|
922
|
+
False,
|
|
923
|
+
f"skipped: the GitHub API did not answer ({_first_line(err) or 'gh api failed'})",
|
|
924
|
+
)
|
|
925
|
+
]
|
|
926
|
+
if rc != 0 or not isinstance(prod, dict):
|
|
927
|
+
checks.append(
|
|
928
|
+
Check(
|
|
929
|
+
"github env: production",
|
|
930
|
+
WARN,
|
|
931
|
+
False,
|
|
932
|
+
"environment not found",
|
|
933
|
+
"create the `production` environment with required reviewers, prevent self-review, branches restricted to main",
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
else:
|
|
937
|
+
rules = prod.get("protection_rules") or []
|
|
938
|
+
reviewers = next((r for r in rules if r.get("type") == "required_reviewers"), None)
|
|
939
|
+
has_reviewers = bool(reviewers and reviewers.get("reviewers"))
|
|
940
|
+
prevent_self = bool(reviewers and reviewers.get("prevent_self_review"))
|
|
941
|
+
policy = prod.get("deployment_branch_policy") or {}
|
|
942
|
+
restricted = bool(policy.get("protected_branches") or policy.get("custom_branch_policies"))
|
|
943
|
+
detail = (
|
|
944
|
+
f"reviewers: {'yes' if has_reviewers else 'no'}, prevent self-review: {'yes' if prevent_self else 'no'}, "
|
|
945
|
+
f"branches restricted: {'yes' if restricted else 'no'}"
|
|
946
|
+
)
|
|
947
|
+
ok = has_reviewers and prevent_self and restricted
|
|
948
|
+
checks.append(
|
|
949
|
+
Check(
|
|
950
|
+
"github env: production",
|
|
951
|
+
OK if ok else WARN,
|
|
952
|
+
False,
|
|
953
|
+
detail,
|
|
954
|
+
""
|
|
955
|
+
if ok
|
|
956
|
+
else "at least one required reviewer, prevent self-review, deployment branches restricted to main",
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
rc, staging = _gh_api(f"{repo}/environments/staging")
|
|
961
|
+
if rc != 0 or not isinstance(staging, dict):
|
|
962
|
+
checks.append(
|
|
963
|
+
Check(
|
|
964
|
+
"github env: staging",
|
|
965
|
+
WARN,
|
|
966
|
+
False,
|
|
967
|
+
"environment not found",
|
|
968
|
+
"create the `staging` environment with deployment branches restricted to main",
|
|
969
|
+
)
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
policy = staging.get("deployment_branch_policy") or {}
|
|
973
|
+
restricted = bool(policy.get("protected_branches") or policy.get("custom_branch_policies"))
|
|
974
|
+
checks.append(
|
|
975
|
+
Check(
|
|
976
|
+
"github env: staging",
|
|
977
|
+
OK if restricted else WARN,
|
|
978
|
+
False,
|
|
979
|
+
f"branches restricted: {'yes' if restricted else 'no'}",
|
|
980
|
+
"" if restricted else "restrict deployment branches to main",
|
|
981
|
+
)
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
rc, prot = _gh_api(f"{repo}/branches/main/protection")
|
|
985
|
+
if rc != 0 or not isinstance(prot, dict):
|
|
986
|
+
checks.append(
|
|
987
|
+
Check(
|
|
988
|
+
"github branch protection: main",
|
|
989
|
+
WARN,
|
|
990
|
+
False,
|
|
991
|
+
"no branch protection on main",
|
|
992
|
+
"require pull requests, code-owner review, dismiss stale approvals, pr_checks as a required status check",
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
else:
|
|
996
|
+
reviews = prot.get("required_pull_request_reviews") or {}
|
|
997
|
+
contexts = (prot.get("required_status_checks") or {}).get("contexts") or []
|
|
998
|
+
checks_list = [
|
|
999
|
+
c.get("context")
|
|
1000
|
+
for c in (prot.get("required_status_checks") or {}).get("checks") or []
|
|
1001
|
+
if isinstance(c, dict)
|
|
1002
|
+
]
|
|
1003
|
+
has_pr_checks = any("pr_checks" in str(c) for c in [*contexts, *checks_list])
|
|
1004
|
+
code_owners = bool(reviews.get("require_code_owner_reviews"))
|
|
1005
|
+
dismiss = bool(reviews.get("dismiss_stale_reviews"))
|
|
1006
|
+
ok = bool(reviews) and code_owners and dismiss and has_pr_checks
|
|
1007
|
+
detail = (
|
|
1008
|
+
f"pull requests: {'yes' if reviews else 'no'}, code-owner review: {'yes' if code_owners else 'no'}, "
|
|
1009
|
+
f"dismiss stale: {'yes' if dismiss else 'no'}, pr_checks required: {'yes' if has_pr_checks else 'no'}"
|
|
1010
|
+
)
|
|
1011
|
+
checks.append(
|
|
1012
|
+
Check(
|
|
1013
|
+
"github branch protection: main",
|
|
1014
|
+
OK if ok else WARN,
|
|
1015
|
+
False,
|
|
1016
|
+
detail,
|
|
1017
|
+
"" if ok else "the branch protection settings the production gate relies on",
|
|
1018
|
+
)
|
|
1019
|
+
)
|
|
1020
|
+
if settings.cd == _modes.HELM_PUSH:
|
|
1021
|
+
checks += check_kubeconfig_secrets(repo)
|
|
1022
|
+
return checks
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _hosted_registry(registry: str) -> bool:
|
|
1026
|
+
host = registry.split("/")[0].lower()
|
|
1027
|
+
return host in HOSTED_REGISTRY_HOSTS or host.endswith(HOSTED_REGISTRY_SUFFIXES)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def check_disconnected(settings: DeploySettings, values: dict[str, Any]) -> list[Check]:
|
|
1031
|
+
"""Disconnected profile: fail on every hosted dependency."""
|
|
1032
|
+
checks: list[Check] = []
|
|
1033
|
+
provider_ok = settings.model_provider == "openai-compatible"
|
|
1034
|
+
checks.append(
|
|
1035
|
+
Check(
|
|
1036
|
+
"disconnected: model provider",
|
|
1037
|
+
OK if provider_ok else MISSING,
|
|
1038
|
+
True,
|
|
1039
|
+
settings.model_provider,
|
|
1040
|
+
""
|
|
1041
|
+
if provider_ok
|
|
1042
|
+
else "use MODEL_PROVIDER=openai-compatible with OPENAI_BASE_URL pointing at an on-network server",
|
|
1043
|
+
)
|
|
1044
|
+
)
|
|
1045
|
+
base_url = str(_get(values, "env", "OPENAI_BASE_URL", default="") or "")
|
|
1046
|
+
checks.append(
|
|
1047
|
+
Check(
|
|
1048
|
+
"disconnected: OPENAI_BASE_URL",
|
|
1049
|
+
OK if base_url else MISSING,
|
|
1050
|
+
True,
|
|
1051
|
+
base_url or "not set in chart env",
|
|
1052
|
+
""
|
|
1053
|
+
if base_url
|
|
1054
|
+
else "set env.OPENAI_BASE_URL in values-<env>.yaml to the in-cluster model server",
|
|
1055
|
+
)
|
|
1056
|
+
)
|
|
1057
|
+
runtime_ok = settings.runtime == "fastapi"
|
|
1058
|
+
checks.append(
|
|
1059
|
+
Check(
|
|
1060
|
+
"disconnected: runtime",
|
|
1061
|
+
OK if runtime_ok else MISSING,
|
|
1062
|
+
True,
|
|
1063
|
+
settings.runtime,
|
|
1064
|
+
""
|
|
1065
|
+
if runtime_ok
|
|
1066
|
+
else "langgraph-server needs a LangSmith license check (a hosted call); use runtime fastapi",
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
hosted = bool(settings.registry) and _hosted_registry(settings.registry)
|
|
1070
|
+
checks.append(
|
|
1071
|
+
Check(
|
|
1072
|
+
"disconnected: registry",
|
|
1073
|
+
MISSING if hosted else OK,
|
|
1074
|
+
True,
|
|
1075
|
+
settings.registry or "(none)",
|
|
1076
|
+
""
|
|
1077
|
+
if not hosted
|
|
1078
|
+
else "mirror images into an on-network registry (Harbor, registry:2) and set create_params.registry",
|
|
1079
|
+
)
|
|
1080
|
+
)
|
|
1081
|
+
tracing_on = _enabled(values, "tracing", "enabled")
|
|
1082
|
+
otlp = str(_get(values, "tracing", "otlpEndpoint", default="") or "")
|
|
1083
|
+
langsmith = tracing_on and not otlp
|
|
1084
|
+
checks.append(
|
|
1085
|
+
Check(
|
|
1086
|
+
"disconnected: tracing",
|
|
1087
|
+
MISSING if langsmith else OK,
|
|
1088
|
+
True,
|
|
1089
|
+
"LangSmith (hosted)" if langsmith else ("OTLP " + otlp if tracing_on else "off"),
|
|
1090
|
+
""
|
|
1091
|
+
if not langsmith
|
|
1092
|
+
else "set tracing.otlpEndpoint to an in-cluster collector or disable tracing",
|
|
1093
|
+
)
|
|
1094
|
+
)
|
|
1095
|
+
if settings.cd == "skip":
|
|
1096
|
+
checks.append(
|
|
1097
|
+
Check(
|
|
1098
|
+
"disconnected: ci/cd",
|
|
1099
|
+
OK,
|
|
1100
|
+
True,
|
|
1101
|
+
"cd is skip: lifecycle covered by direct-mode deploy",
|
|
1102
|
+
)
|
|
1103
|
+
)
|
|
1104
|
+
elif gitops.enterprise_hosts():
|
|
1105
|
+
checks.append(
|
|
1106
|
+
Check(
|
|
1107
|
+
"disconnected: ci/cd",
|
|
1108
|
+
WARN,
|
|
1109
|
+
False,
|
|
1110
|
+
f"cd is {settings.cd} on GitHub Enterprise Server {', '.join(sorted(gitops.enterprise_hosts()))} (CD needs GitHub Actions on that server)",
|
|
1111
|
+
)
|
|
1112
|
+
)
|
|
1113
|
+
else:
|
|
1114
|
+
checks.append(
|
|
1115
|
+
Check(
|
|
1116
|
+
"disconnected: ci/cd",
|
|
1117
|
+
MISSING,
|
|
1118
|
+
True,
|
|
1119
|
+
f"cd is {settings.cd} on GitHub-hosted CI",
|
|
1120
|
+
"GitHub-hosted Actions are outside the profile; use cd: skip or an on-network GitHub Enterprise Server (set GH_HOST)",
|
|
1121
|
+
)
|
|
1122
|
+
)
|
|
1123
|
+
update_check = os.environ.get("GRAPH_AGENTS_CLI_NO_UPDATE_CHECK") == "1"
|
|
1124
|
+
checks.append(
|
|
1125
|
+
Check(
|
|
1126
|
+
"disconnected: update check",
|
|
1127
|
+
OK if update_check else WARN,
|
|
1128
|
+
False,
|
|
1129
|
+
"GRAPH_AGENTS_CLI_NO_UPDATE_CHECK=1"
|
|
1130
|
+
if update_check
|
|
1131
|
+
else "GRAPH_AGENTS_CLI_NO_UPDATE_CHECK is not set",
|
|
1132
|
+
"" if update_check else "export GRAPH_AGENTS_CLI_NO_UPDATE_CHECK=1",
|
|
1133
|
+
)
|
|
1134
|
+
)
|
|
1135
|
+
return checks
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def run_checks(
|
|
1139
|
+
settings: DeploySettings,
|
|
1140
|
+
env: str | None,
|
|
1141
|
+
profile: str | None = None,
|
|
1142
|
+
*,
|
|
1143
|
+
context_resolver: Callable[[DeploySettings, str], str | None] = _modes.resolve_context,
|
|
1144
|
+
) -> Report:
|
|
1145
|
+
target: Target | None = None
|
|
1146
|
+
mode: str | None = None
|
|
1147
|
+
values: dict[str, Any] = load_chart_values(settings.chart_dir, env)
|
|
1148
|
+
if env:
|
|
1149
|
+
base = settings.target(env)
|
|
1150
|
+
target = Target(context=context_resolver(settings, env), namespace=base.namespace)
|
|
1151
|
+
local = None
|
|
1152
|
+
if settings.cd == "skip":
|
|
1153
|
+
# The same cluster-based detection `deploy` uses, not the context name alone.
|
|
1154
|
+
local = local_load.detect(target.context)[0] is not None
|
|
1155
|
+
mode = _modes.derive_mode(settings.cd, target.context, local=local)
|
|
1156
|
+
else:
|
|
1157
|
+
mode = _modes.derive_mode(settings.cd, None)
|
|
1158
|
+
report = Report(env=env, profile=profile, mode=mode)
|
|
1159
|
+
report.checks += check_tools(settings, mode)
|
|
1160
|
+
if target is not None:
|
|
1161
|
+
report.checks += check_cluster(settings, env or "", target, values)
|
|
1162
|
+
else:
|
|
1163
|
+
report.checks.append(Check("cluster", SKIP, False, "pass --env to check the cluster"))
|
|
1164
|
+
report.checks += check_placeholders(settings, values, env)
|
|
1165
|
+
report.checks += check_approvals(settings, values)
|
|
1166
|
+
report.checks += check_github(settings)
|
|
1167
|
+
if profile == DISCONNECTED:
|
|
1168
|
+
report.checks += check_disconnected(settings, values)
|
|
1169
|
+
return report
|