From 0f1ec78490d615d2b17684bd8eee60b7960f8202 Mon Sep 17 00:00:00 2001 From: hhhhsc Date: Wed, 14 Oct 2026 11:27:10 +0800 Subject: [PATCH 1/3] fix(k8s): inject complete env files into backend containers --- deploy/k8s/deploy.sh | 147 +------ deploy/k8s/env.sh | 69 +++ deploy/k8s/helm/nexent/README.md | 8 + .../nexent-common/templates/configmap.yaml | 1 + .../nexent-common/templates/env-secrets.yaml | 17 + .../nexent/charts/nexent-common/values.yaml | 6 + .../nexent-config/templates/deployment.yaml | 5 + .../templates/deployment.yaml | 3 + .../nexent-mcp/templates/deployment.yaml | 3 + .../templates/deployment.yaml | 3 + .../nexent-runtime/templates/deployment.yaml | 5 + .../nexent-web/templates/deployment.yaml | 5 + deploy/k8s/helm/nexent/templates/_helpers.tpl | 26 ++ deploy/tests/test_common.sh | 9 +- test-e2e/cases/K8S-ENV-D5-001/case.yaml | 82 ++++ test-e2e/cases/K8S-ENV-D5-001/execution.yaml | 17 + test-e2e/cases/K8S-ENV-D5-001/test.py | 271 ++++++++++++ test-e2e/changes/bugs/K8S-ENV-INJECTION.yaml | 35 ++ .../changes/bugs/K8S-ENV-INJECTION/design.md | 195 +++++++++ test-e2e/features/F-173/cases.md | 2 +- test-e2e/features/F-173/feature.yaml | 2 + test/backend/deploy/__init__.py | 0 test/backend/deploy/test_k8s_env_injection.py | 397 ++++++++++++++++++ 23 files changed, 1180 insertions(+), 128 deletions(-) create mode 100644 deploy/k8s/env.sh create mode 100644 deploy/k8s/helm/nexent/charts/nexent-common/templates/env-secrets.yaml create mode 100644 test-e2e/cases/K8S-ENV-D5-001/case.yaml create mode 100644 test-e2e/cases/K8S-ENV-D5-001/execution.yaml create mode 100644 test-e2e/cases/K8S-ENV-D5-001/test.py create mode 100644 test-e2e/changes/bugs/K8S-ENV-INJECTION.yaml create mode 100644 test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md create mode 100644 test/backend/deploy/__init__.py create mode 100644 test/backend/deploy/test_k8s_env_injection.py diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh index a7bdf922a9..48488c108d 100755 --- a/deploy/k8s/deploy.sh +++ b/deploy/k8s/deploy.sh @@ -52,6 +52,9 @@ if [ -f "$VERSION_HELPER" ]; then source "$VERSION_HELPER" fi +# shellcheck source=env.sh +source "$SCRIPT_DIR/env.sh" + # Global variables for deployment options IS_MAINLAND="" APP_VERSION="" @@ -452,136 +455,15 @@ render_k8s_runtime_config_values() { render_yaml_literal_file "$(basename "$file")" "$file" 6 8 done < <(find "$SUPABASE_SQL_DIR" -maxdepth 1 -type f -name '*.sql' -print | sort -V) echo " config:" + printf ' timezone: %s\n' "$(yaml_quote "${TZ:-Asia/Shanghai}")" + # Web does not load the backend env Secret; keep its Northbound proxy URL. echo " services:" - printf ' configUrl: %s\n' "$(yaml_quote "$(env_or_default CONFIG_SERVICE_URL "http://nexent-config:5010")")" - printf ' elasticsearchService: %s\n' "$(yaml_quote "$(env_or_default ELASTICSEARCH_SERVICE "http://nexent-config:5010/api")")" - printf ' runtimeUrl: %s\n' "$(yaml_quote "$(env_or_default RUNTIME_SERVICE_URL "http://nexent-runtime:5014")")" - printf ' mcpServer: %s\n' "$(yaml_quote "$(env_or_default NEXENT_MCP_SERVER "http://nexent-mcp:5011")")" - printf ' mcpManagementServer: %s\n' "$(yaml_quote "$(env_or_default MCP_MANAGEMENT_API "http://nexent-mcp:5015")")" - printf ' dataProcessService: %s\n' "$(yaml_quote "$(env_or_default DATA_PROCESS_SERVICE "http://nexent-data-process:5012/api")")" printf ' northboundServer: %s\n' "$(yaml_quote "$(env_or_default NORTHBOUND_API_SERVER "http://nexent-northbound:5013/api")")" - printf ' northboundExternalUrl: %s\n' "$(yaml_quote "$(env_or_default NORTHBOUND_EXTERNAL_URL "")")" - echo " postgres:" - printf ' host: %s\n' "$(yaml_quote "$(env_or_default POSTGRES_HOST "nexent-postgresql")")" - printf ' user: %s\n' "$(yaml_quote "$(env_or_default POSTGRES_USER "root")")" - printf ' db: %s\n' "$(yaml_quote "$(env_or_default POSTGRES_DB "nexent")")" - printf ' port: %s\n' "$(yaml_quote "$(env_or_default POSTGRES_PORT "5432")")" + # These inputs also supply defaults for runtime state and worker concurrency. echo " redis:" printf ' url: %s\n' "$(yaml_quote "$(env_or_default REDIS_URL "redis://nexent-redis:6379/0")")" - printf ' backendUrl: %s\n' "$(yaml_quote "$(env_or_default REDIS_BACKEND_URL "redis://nexent-redis:6379/1")")" - printf ' port: %s\n' "$(yaml_quote "$(env_or_default REDIS_PORT "6379")")" - echo " minio:" - printf ' endpoint: %s\n' "$(yaml_quote "$(env_or_default MINIO_ENDPOINT "http://nexent-minio:9000")")" - printf ' region: %s\n' "$(yaml_quote "$(env_or_default MINIO_REGION "cn-north-1")")" - printf ' defaultBucket: %s\n' "$(yaml_quote "$(env_or_default MINIO_DEFAULT_BUCKET "nexent")")" - echo " elasticsearch:" - printf ' host: %s\n' "$(yaml_quote "$(env_or_default ELASTICSEARCH_HOST "http://nexent-elasticsearch:9200")")" - printf ' javaOpts: %s\n' "$(yaml_quote "$(env_or_default ES_JAVA_OPTS "-Xms2g -Xmx2g")")" - printf ' diskWatermarkLow: %s\n' "$(yaml_quote "$(env_or_default ES_DISK_WATERMARK_LOW "85%")")" - printf ' diskWatermarkHigh: %s\n' "$(yaml_quote "$(env_or_default ES_DISK_WATERMARK_HIGH "90%")")" - printf ' diskWatermarkFloodStage: %s\n' "$(yaml_quote "$(env_or_default ES_DISK_WATERMARK_FLOOD_STAGE "95%")")" - printf ' skipProxy: %s\n' "$(yaml_quote "$(env_or_default skip_proxy "true")")" - printf ' umask: %s\n' "$(yaml_quote "$(env_or_default UMASK "0022")")" - printf ' skillsPath: %s\n' "$(yaml_quote "$(env_or_default SKILLS_PATH "/mnt/nexent-data/skills")")" - printf ' logDir: %s\n' "$(yaml_quote "$(env_or_default LOG_DIR "/mnt/nexent-data/logs")")" - echo " skill:" - printf ' maxSkillsPerTenant: %s\n' "$(yaml_quote "$(env_or_default MAX_SKILLS_PER_TENANT "1000")")" - printf ' maxUploadSizeMb: %s\n' "$(yaml_quote "$(env_or_default MAX_SKILL_UPLOAD_SIZE_MB "10")")" - echo " evaluationSet:" - printf ' maxFileSizeMb: %s\n' "$(yaml_quote "$(env_or_default MAX_EVALUATION_SET_FILE_SIZE_MB "20")")" - echo " modelEngine:" - printf ' enabled: %s\n' "$(yaml_quote "$(env_or_default MODEL_ENGINE_ENABLED "false")")" - echo " voiceService:" - printf ' appid: %s\n' "$(yaml_quote "$(env_or_default APPID "app_id")")" - printf ' token: %s\n' "$(yaml_quote "$(env_or_default TOKEN "token")")" - printf ' cluster: %s\n' "$(yaml_quote "$(env_or_default CLUSTER "volcano_tts")")" - printf ' voiceType: %s\n' "$(yaml_quote "$(env_or_default VOICE_TYPE "zh_male_jieshuonansheng_mars_bigtts")")" - printf ' speedRatio: %s\n' "$(yaml_quote "$(env_or_default SPEED_RATIO "1.3")")" - echo " modelPath:" - printf ' clipModelPath: %s\n' "$(yaml_quote "$(env_or_default CLIP_MODEL_PATH "/opt/models/clip-vit-base-patch32")")" - printf ' nltkData: %s\n' "$(yaml_quote "$(env_or_default NLTK_DATA "/opt/models/nltk_data")")" - printf ' tableTransformerModelPath: %s\n' "$(yaml_quote "$(env_or_default TABLE_TRANSFORMER_MODEL_PATH "/opt/models/table-transformer-structure-recognition")")" - printf ' unstructuredDefaultModelInitializeParamsJsonPath: %s\n' "$(yaml_quote "$(env_or_default UNSTRUCTURED_DEFAULT_MODEL_INITIALIZE_PARAMS_JSON_PATH "/opt/models/yolox")")" - echo " terminal:" - printf ' sshPrivateKeyPath: %s\n' "$(yaml_quote "$(env_or_default SSH_PRIVATE_KEY_PATH "/path/to/openssh-server/ssh-keys/openssh_server_key")")" - echo " supabase:" - printf ' dashboardUsername: %s\n' "$(yaml_quote "$(env_or_default DASHBOARD_USERNAME "supabase")")" - printf ' dashboardPassword: %s\n' "$(yaml_quote "$(env_or_default DASHBOARD_PASSWORD "Huawei123")")" - printf ' siteUrl: %s\n' "$(yaml_quote "$(env_or_default SITE_URL "http://localhost:3011")")" - printf ' supabaseUrl: %s\n' "$(yaml_quote "$(env_or_default SUPABASE_URL "http://nexent-supabase-kong:8000")")" - printf ' apiExternalUrl: %s\n' "$(yaml_quote "$(env_or_default API_EXTERNAL_URL "http://nexent-supabase-kong:8000")")" - printf ' disableSignup: %s\n' "$(yaml_quote "$(env_or_default DISABLE_SIGNUP "false")")" - printf ' jwtExpiry: %s\n' "$(yaml_quote "$(env_or_default JWT_EXPIRY "3600")")" - printf ' debugJwtExpireSeconds: %s\n' "$(yaml_quote "$(env_or_default DEBUG_JWT_EXPIRE_SECONDS "0")")" - printf ' enableEmailSignup: %s\n' "$(yaml_quote "$(env_or_default ENABLE_EMAIL_SIGNUP "true")")" - printf ' enableEmailAutoconfirm: %s\n' "$(yaml_quote "$(env_or_default ENABLE_EMAIL_AUTOCONFIRM "true")")" - printf ' enableAnonymousUsers: %s\n' "$(yaml_quote "$(env_or_default ENABLE_ANONYMOUS_USERS "false")")" - printf ' enablePhoneSignup: %s\n' "$(yaml_quote "$(env_or_default ENABLE_PHONE_SIGNUP "false")")" - printf ' enablePhoneAutoconfirm: %s\n' "$(yaml_quote "$(env_or_default ENABLE_PHONE_AUTOCONFIRM "false")")" - printf ' inviteCode: %s\n' "$(yaml_quote "$(env_or_default INVITE_CODE "nexent2025")")" - printf ' mailerUrlpathsConfirmation: %s\n' "$(yaml_quote "$(env_or_default MAILER_URLPATHS_CONFIRMATION "/auth/v1/verify")")" - printf ' mailerUrlpathsInvite: %s\n' "$(yaml_quote "$(env_or_default MAILER_URLPATHS_INVITE "/auth/v1/verify")")" - printf ' mailerUrlpathsRecovery: %s\n' "$(yaml_quote "$(env_or_default MAILER_URLPATHS_RECOVERY "/auth/v1/verify")")" - printf ' mailerUrlpathsEmailChange: %s\n' "$(yaml_quote "$(env_or_default MAILER_URLPATHS_EMAIL_CHANGE "/auth/v1/verify")")" - printf ' postgresHost: %s\n' "$(yaml_quote "$(env_or_default SUPABASE_POSTGRES_HOST "nexent-supabase-db")")" - printf ' postgresDb: %s\n' "$(yaml_quote "$(env_or_default SUPABASE_POSTGRES_DB "supabase")")" - printf ' postgresPort: %s\n' "$(yaml_quote "$(env_or_default SUPABASE_POSTGRES_PORT "5436")")" - printf ' additionalRedirectUrls: %s\n' "$(yaml_quote "$(env_or_default ADDITIONAL_REDIRECT_URLS "")")" echo " dataProcess:" - printf ' flowerPort: %s\n' "$(yaml_quote "$(env_or_default FLOWER_PORT "5555")")" - printf ' disableCeleryFlower: %s\n' "$(yaml_quote "$(env_or_default DISABLE_CELERY_FLOWER "true")")" - printf ' dockerEnvironment: %s\n' "$(yaml_quote "$(env_or_default DOCKER_ENVIRONMENT "false")")" - printf ' enableUploadImage: %s\n' "$(yaml_quote "$(env_or_default ENABLE_UPLOAD_IMAGE "false")")" - printf ' celeryWorkerPrefetchMultiplier: %s\n' "$(yaml_quote "$(env_or_default CELERY_WORKER_PREFETCH_MULTIPLIER "1")")" - printf ' celeryTaskTimeLimit: %s\n' "$(yaml_quote "$(env_or_default CELERY_TASK_TIME_LIMIT "3600")")" - printf ' elasticsearchRequestTimeout: %s\n' "$(yaml_quote "$(env_or_default ELASTICSEARCH_REQUEST_TIMEOUT "30")")" - printf ' queues: %s\n' "$(yaml_quote "$(env_or_default QUEUES "process_q,parse_q,forward_q,forward_part_q,forward_aggregate_q")")" printf ' partProcessorCount: %s\n' "$(yaml_quote "$(env_or_default DP_PART_PROCESSOR_COUNT "3")")" - printf ' fileSplitSizeMb: %s\n' "$(yaml_quote "$(env_or_default DP_FILE_SPLIT_SIZE_MB "5")")" - printf ' parseMaxProcesses: %s\n' "$(yaml_quote "$(env_or_default DP_PARSE_MAX_PROCESSES "3")")" - printf ' parseMinProcesses: %s\n' "$(yaml_quote "$(env_or_default DP_PARSE_MIN_PROCESSES "1")")" - printf ' parseThreadsPerProcess: %s\n' "$(yaml_quote "$(env_or_default DP_PARSE_THREADS_PER_PROCESS "2")")" - printf ' parseMaxTasksPerChild: %s\n' "$(yaml_quote "$(env_or_default DP_PARSE_MAX_TASKS_PER_CHILD "1000")")" - printf ' preloadModels: %s\n' "$(yaml_quote "$(env_or_default DP_PRELOAD_MODELS "unstructured_default")")" - printf ' parserStartupTimeout: %s\n' "$(yaml_quote "$(env_or_default DP_PARSER_STARTUP_TIMEOUT_S "300")")" - printf ' workerName: %s\n' "$(yaml_quote "$(env_or_default WORKER_NAME "")")" - echo " oauth:" - printf ' githubClientId: %s\n' "$(yaml_quote "$(env_or_default GITHUB_OAUTH_CLIENT_ID "")")" - printf ' githubClientSecret: %s\n' "$(yaml_quote "$(env_or_default GITHUB_OAUTH_CLIENT_SECRET "")")" - printf ' enableWechat: %s\n' "$(yaml_quote "$(env_or_default ENABLE_WECHAT_OAUTH "false")")" - printf ' wechatClientId: %s\n' "$(yaml_quote "$(env_or_default WECHAT_OAUTH_APP_ID "")")" - printf ' wechatClientSecret: %s\n' "$(yaml_quote "$(env_or_default WECHAT_OAUTH_APP_SECRET "")")" - printf ' gdeUrl: %s\n' "$(yaml_quote "$(env_or_default GDE_URL "")")" - printf ' gdeClientId: %s\n' "$(yaml_quote "$(env_or_default GDE_OAUTH_CLIENT_ID "")")" - printf ' gdeClientSecret: %s\n' "$(yaml_quote "$(env_or_default GDE_OAUTH_CLIENT_SECRET "")")" - printf ' sslVerify: %s\n' "$(yaml_quote "$(env_or_default OAUTH_SSL_VERIFY "true")")" - printf ' caBundle: %s\n' "$(yaml_quote "$(env_or_default OAUTH_CA_BUNDLE "")")" - printf ' callbackBaseUrl: %s\n' "$(yaml_quote "$(env_or_default OAUTH_CALLBACK_BASE_URL "http://localhost:30000")")" - printf ' loginMode: %s\n' "$(yaml_quote "$(env_or_default OAUTH_LOGIN_MODE "button")")" - echo " cas:" - printf ' enabled: %s\n' "$(yaml_quote "$(env_or_default CAS_ENABLED "false")")" - printf ' serverUrl: %s\n' "$(yaml_quote "$(env_or_default CAS_SERVER_URL "")")" - printf ' validatePath: %s\n' "$(yaml_quote "$(env_or_default CAS_VALIDATE_PATH "/p3/serviceValidate")")" - printf ' callbackBaseUrl: %s\n' "$(yaml_quote "$(env_or_default CAS_CALLBACK_BASE_URL "http://localhost:30000")")" - printf ' loginMode: %s\n' "$(yaml_quote "$(env_or_default CAS_LOGIN_MODE "disabled")")" - printf ' userAttribute: %s\n' "$(yaml_quote "$(env_or_default CAS_USER_ATTRIBUTE "")")" - printf ' emailAttribute: %s\n' "$(yaml_quote "$(env_or_default CAS_EMAIL_ATTRIBUTE "email")")" - printf ' roleAttribute: %s\n' "$(yaml_quote "$(env_or_default CAS_ROLE_ATTRIBUTE "role")")" - printf ' defaultRole: %s\n' "$(yaml_quote "$(env_or_default CAS_DEFAULT_ROLE "USER")")" - printf ' tenantAttribute: %s\n' "$(yaml_quote "$(env_or_default CAS_TENANT_ATTRIBUTE "tenant_id")")" - printf ' defaultTenantId: %s\n' "$(yaml_quote "$(env_or_default CAS_DEFAULT_TENANT_ID "tenant_id")")" - printf ' roleMapJson: %s\n' "$(yaml_quote "$(env_or_default CAS_ROLE_MAP_JSON "")")" - printf ' sessionMaxAgeSeconds: %s\n' "$(yaml_quote "$(env_or_default CAS_SESSION_MAX_AGE_SECONDS "3600")")" - printf ' localSessionMaxAgeSeconds: %s\n' "$(yaml_quote "$(env_or_default LOCAL_SESSION_MAX_AGE_SECONDS "3600")")" - printf ' heartbeatUrl: %s\n' "$(yaml_quote "$(env_or_default CAS_HEARTBEAT_URL "")")" - printf ' heartbeatIntervalSeconds: %s\n' "$(yaml_quote "$(env_or_default CAS_HEARTBEAT_INTERVAL_SECONDS "300")")" - printf ' heartbeatCookieName: %s\n' "$(yaml_quote "$(env_or_default CAS_HEARTBEAT_COOKIE_NAME "")")" - printf ' renewBeforeSeconds: %s\n' "$(yaml_quote "$(env_or_default CAS_RENEW_BEFORE_SECONDS "300")")" - printf ' renewTimeoutSeconds: %s\n' "$(yaml_quote "$(env_or_default CAS_RENEW_TIMEOUT_SECONDS "10")")" - printf ' syntheticEmailDomain: %s\n' "$(yaml_quote "$(env_or_default CAS_SYNTHETIC_EMAIL_DOMAIN "cas.local")")" - printf ' logoutUrl: %s\n' "$(yaml_quote "$(env_or_default CAS_LOGOUT_URL "")")" - printf ' sslVerify: %s\n' "$(yaml_quote "$(env_or_default CAS_SSL_VERIFY "true")")" - printf ' caBundle: %s\n' "$(yaml_quote "$(env_or_default CAS_CA_BUNDLE "")")" echo "nexent-web:" echo " config:" printf ' fileUploadSizeLimit: %s\n' "$(yaml_quote "$(env_or_default FILE_UPLOAD_SIZE_LIMIT "10")")" @@ -1308,10 +1190,20 @@ render_runtime_secret_values() { local sql_checksum local supabase_postgres_password local supabase_secret_checksum + local root_env_data + local monitoring_env_data=" {}" + local monitoring_env_file + + root_env_data="$(k8s_env_file_data "$ROOT_ENV_FILE" 6)" || return 1 + monitoring_env_file="$(deployment_monitoring_env_file)" + if [ -f "$monitoring_env_file" ]; then + monitoring_env_data="$(k8s_env_file_data "$monitoring_env_file" 6)" || return 1 + fi supabase_postgres_password="$(env_or_default SUPABASE_POSTGRES_PASSWORD "Huawei123")" gotrue_db_url="$(env_or_default GOTRUE_DB_DATABASE_URL "postgres://supabase_auth_admin:${supabase_postgres_password}@$(env_or_default SUPABASE_POSTGRES_HOST "nexent-supabase-db"):$(env_or_default SUPABASE_POSTGRES_PORT "5436")/$(env_or_default SUPABASE_POSTGRES_DB "supabase")?search_path=auth&sslmode=disable")" env_checksum="$(deployment_env_values_checksum)" + env_checksum="$(deployment_sha256_string "${env_checksum}|root=${root_env_data}|monitoring=${monitoring_env_data}|sandboxMode=${DEPLOYMENT_SANDBOX_MODE:-lightweight}|mcp=${NEXENT_MCP_DOCKER_IMAGE:-}|sandbox=${NEXENT_SANDBOX_IMAGE:-}")" sql_checksum="$(sql_files_checksum)" supabase_secret_checksum="$(deployment_sha256_string "jwt=${JWT_SECRET:-}|secretKeyBase=${SECRET_KEY_BASE:-}|vault=${VAULT_ENC_KEY:-}|anon=${SUPABASE_ANON_KEY:-}|service=${SUPABASE_SERVICE_ROLE_KEY:-}|postgres=${supabase_postgres_password}|gotrue=${gotrue_db_url}")" @@ -1323,6 +1215,11 @@ render_runtime_secret_values() { printf ' supabaseSecret: %s\n' "$(yaml_quote "$supabase_secret_checksum")" deployment_render_image_rollout_checksums echo "nexent-common:" + echo " envFiles:" + echo " rootData:" + printf '%s\n' "$root_env_data" + echo " monitoringData:" + printf '%s\n' "$monitoring_env_data" echo " secrets:" printf ' elasticsearchApiKey: %s\n' "$(yaml_quote "$(env_or_default ELASTICSEARCH_API_KEY "")")" printf ' postgresPassword: %s\n' "$(yaml_quote "$(env_or_default NEXENT_POSTGRES_PASSWORD "nexent@4321")")" @@ -1344,7 +1241,7 @@ render_runtime_secret_values() { printf ' postgresPassword: %s\n' "$(yaml_quote "$supabase_postgres_password")" printf ' gotrueDbUrl: %s\n' "$(yaml_quote "$gotrue_db_url")" fi - } > "$GENERATED_SECRETS_VALUES" + } | k8s_write_private_values "$GENERATED_SECRETS_VALUES" } render_infrastructure_secret_values() { diff --git a/deploy/k8s/env.sh b/deploy/k8s/env.sh new file mode 100644 index 0000000000..c1774147a3 --- /dev/null +++ b/deploy/k8s/env.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Render trusted shell-compatible env files without dumping the host environment. + +k8s_env_file_data() { + local nexent_env_input="$1" + local nexent_env_indent="${2:-0}" + local nexent_env_helper + local nexent_env_data + nexent_env_helper="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/env.sh" + + if ! nexent_env_data="$(bash --noprofile --norc -e -o pipefail "$nexent_env_helper" --load \ + "$nexent_env_input" "$nexent_env_indent" 2>/dev/null)"; then + printf 'Error: failed to load environment file: %s\n' "$nexent_env_input" >&2 + return 1 + fi + printf '%s\n' "$nexent_env_data" +} + +k8s_load_env_file_data() { + local nexent_env_input="$1" + local nexent_env_indent="$2" + local nexent_env_keys=() + local nexent_env_key nexent_env_encoded nexent_env_padding + + [ -f "$nexent_env_input" ] && [ -r "$nexent_env_input" ] || return 1 + [[ "$nexent_env_indent" =~ ^[0-9]+$ ]] || return 1 + + # DEBUG observes complete Bash assignment commands, including multiline + # values. A line scanner would mistake assignment-looking value text for keys. + set -a + set -T + trap 'if [[ "$BASH_COMMAND" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)= ]]; then nexent_env_keys+=("${BASH_REMATCH[2]}"); fi' DEBUG + # shellcheck source=/dev/null + source "$nexent_env_input" >/dev/null 2>&1 + trap - DEBUG + set +T + set +a + + nexent_env_padding="$(printf '%*s' "$nexent_env_indent" '')" + if [ "${#nexent_env_keys[@]}" -eq 0 ]; then + printf '%s{}\n' "$nexent_env_padding" + return 0 + fi + while IFS= read -r nexent_env_key; do + [ "${!nexent_env_key+x}" = x ] || continue + nexent_env_encoded="$(printf '%s' "${!nexent_env_key}" | base64 | tr -d '\r\n')" + printf '%s%s: "%s"\n' "$nexent_env_padding" "$nexent_env_key" "$nexent_env_encoded" + done < <(printf '%s\n' "${nexent_env_keys[@]}" | LC_ALL=C sort -u) +} + +k8s_write_private_values() ( + local nexent_values_output="$1" + local nexent_values_temporary + umask 077 + nexent_values_temporary="$(mktemp "${nexent_values_output}.XXXXXX")" || return 1 + trap 'rm -f "$nexent_values_temporary"' EXIT + cat > "$nexent_values_temporary" || return 1 + chmod 600 "$nexent_values_temporary" || return 1 + mv -f "$nexent_values_temporary" "$nexent_values_output" +) + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -e + if [ "${1:-}" != --load ] || [ "$#" -ne 3 ]; then + echo 'Usage: env.sh --load ENV_FILE INDENT' >&2 + exit 1 + fi + k8s_load_env_file_data "$2" "$3" +fi diff --git a/deploy/k8s/helm/nexent/README.md b/deploy/k8s/helm/nexent/README.md index 63ea758d75..7f5c74cbff 100644 --- a/deploy/k8s/helm/nexent/README.md +++ b/deploy/k8s/helm/nexent/README.md @@ -94,6 +94,14 @@ bash uninstall.sh k8s delete-all --keep-local-data K8s deployments read runtime configuration from `deploy/env/.env`, the same file used by Docker. Before every deployment, existing values, comments, ordering, and old-only variables are preserved while assignments newly introduced by the current `deploy/env/.env.example` are appended. If `.env` is missing, the deploy script first reuses legacy `docker/.env`, then falls back to the current template. A readable template is required before deployment starts. Do not edit generated Helm values by hand; they are recreated from the merged `deploy/env/.env` and deployment options. +Config, Runtime, MCP, Northbound, and Data Process load every variable declared in `.env` through the backend-only `nexent-env` Secret, including newly added variables without a Helm mapping. Config and Runtime additionally load `deploy/env/monitoring.env` through `nexent-monitoring-env`. These scopes match the production Docker Compose configuration. Web and infrastructure do not reference either complete env Secret; they keep their service-specific environment configuration. + +Environment precedence, from lowest to highest, is the existing ConfigMap, `.env`, `monitoring.env` where applicable, finalized deployment credentials, and explicit container environment settings. Kubernetes mode, namespace, SQL startup/checksum, selected MCP/sandbox images, and sandbox execution level remain deployment-controlled. Env files use the existing trusted Bash-compatible syntax: empty strings, quoting, JSON, substitutions, and multiline strings are preserved. Only variables assigned in the file are emitted; unrelated exported host variables are excluded. + +Application Secret values are rendered after credentials are finalized and written atomically with mode `0600`. The chart exposes `nexent-common.envFiles.rootData` and `monitoringData` as Base64-encoded string mappings; both default to `{}` so direct Helm rendering works without generated env files. Editing `.env` takes effect on the next deployment, when the environment checksum replaces backend Pods. Fresh Secret maps also remove deleted variables. Generated values contain sensitive configuration and must stay outside Git and shared logs. + +Ordinary backend configuration is injected through these file Secrets instead of being copied field by field into generated common values. The common ConfigMap and Chart values remain the fallback for omitted keys and direct Helm use. The runtime renderer retains timezone and Web's Northbound URL/upload limit, plus Redis URL and part-processor count because they also supply the runtime-state Redis fallback and derived worker concurrency. Deployment overrides, finalized credentials and infrastructure configuration keep their dedicated rendering paths. + When `--persistence-mode local` is used, Nexent renders static PVs with `hostPath` and `DirectoryOrCreate`; node affinity is not required. Shared workspace data uses `/var/lib/nexent`, shared skills use `/var/lib/nexent-data/skills`, Web project configuration uses `/var/lib/nexent-data/project-config`, and service data uses `/var/lib/nexent-data/nexent-*` by default. Config, Runtime, and Northbound use `/health/live` startup probes, while Web probes `/`, before their liveness and readiness probes become active. Each service waits 30 seconds before its first startup check; the following probe budget is five minutes (`periodSeconds: 5`, `failureThreshold: 60`) so cold starts and Python imports do not trigger a premature liveness restart. Operators can override each delay independently, for example with `--set nexent-runtime.probes.startup.initialDelaySeconds=60`. diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml index 823b3cf913..aab1a64128 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/configmap.yaml @@ -7,6 +7,7 @@ metadata: annotations: "helm.sh/hook-weight": "-3" data: + TZ: {{ .Values.config.timezone | quote }} # Deployment version DEPLOYMENT_VERSION: {{ .Values.global.deploymentVersion | quote }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/templates/env-secrets.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/templates/env-secrets.yaml new file mode 100644 index 0000000000..5ea1033d78 --- /dev/null +++ b/deploy/k8s/helm/nexent/charts/nexent-common/templates/env-secrets.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Secret +metadata: + name: nexent-env + namespace: {{ .Values.global.namespace }} +type: Opaque +data: +{{ toYaml .Values.envFiles.rootData | indent 2 }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: nexent-monitoring-env + namespace: {{ .Values.global.namespace }} +type: Opaque +data: +{{ toYaml .Values.envFiles.monitoringData | indent 2 }} diff --git a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml index 808f26b32d..7f7ab93391 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-common/values.yaml @@ -21,8 +21,14 @@ sqlFiles: migrations: {} supabase: {} +# Base64-encoded file-declared values; full env files are backend-only Secrets. +envFiles: + rootData: {} + monitoringData: {} + # ConfigMap data - this will be used by nexent-config ConfigMap config: + timezone: "Asia/Shanghai" # Service URLs (internal) services: configUrl: "http://nexent-config:5010" diff --git a/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml index e11cf593fc..9f42473b6c 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-config/templates/deployment.yaml @@ -51,9 +51,14 @@ spec: envFrom: - configMapRef: name: nexent-config + - secretRef: + name: nexent-env + - secretRef: + name: nexent-monitoring-env - secretRef: name: nexent-secrets env: +{{ include "nexent.backendPlatformEnv" . | indent 12 }} - name: NEXENT_SQL_STARTUP_MODE value: "migrate" - name: DEPLOYMENT_VERSION diff --git a/deploy/k8s/helm/nexent/charts/nexent-data-process/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-data-process/templates/deployment.yaml index 5079734a00..a52756df26 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-data-process/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-data-process/templates/deployment.yaml @@ -51,9 +51,12 @@ spec: envFrom: - configMapRef: name: nexent-config + - secretRef: + name: nexent-env - secretRef: name: nexent-secrets env: +{{ include "nexent.backendPlatformEnv" . | indent 12 }} - name: NEXENT_SQL_STARTUP_MODE value: "off" - name: DEPLOYMENT_VERSION diff --git a/deploy/k8s/helm/nexent/charts/nexent-mcp/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-mcp/templates/deployment.yaml index b2e2a41173..15fc770b61 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-mcp/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-mcp/templates/deployment.yaml @@ -42,9 +42,12 @@ spec: envFrom: - configMapRef: name: nexent-config + - secretRef: + name: nexent-env - secretRef: name: nexent-secrets env: +{{ include "nexent.backendPlatformEnv" . | indent 12 }} - name: NEXENT_SQL_STARTUP_MODE value: "wait" - name: DEPLOYMENT_VERSION diff --git a/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml index c876952595..34ee4ae602 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-northbound/templates/deployment.yaml @@ -49,9 +49,12 @@ spec: envFrom: - configMapRef: name: nexent-config + - secretRef: + name: nexent-env - secretRef: name: nexent-secrets env: +{{ include "nexent.backendPlatformEnv" . | indent 12 }} - name: NEXENT_SQL_STARTUP_MODE value: "wait" - name: DEPLOYMENT_VERSION diff --git a/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml index 0de9ea2751..124ac4ed23 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-runtime/templates/deployment.yaml @@ -51,9 +51,14 @@ spec: envFrom: - configMapRef: name: nexent-config + - secretRef: + name: nexent-env + - secretRef: + name: nexent-monitoring-env - secretRef: name: nexent-secrets env: +{{ include "nexent.backendPlatformEnv" . | indent 12 }} - name: NEXENT_SQL_STARTUP_MODE value: "wait" - name: DEPLOYMENT_VERSION diff --git a/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml b/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml index 84e64f3e20..16ca5445e2 100644 --- a/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml +++ b/deploy/k8s/helm/nexent/charts/nexent-web/templates/deployment.yaml @@ -36,6 +36,11 @@ spec: - configMapRef: name: nexent-config env: + - name: TZ + valueFrom: + configMapKeyRef: + name: nexent-config + key: TZ - name: HOSTNAME value: "0.0.0.0" - name: HTTP_BACKEND diff --git a/deploy/k8s/helm/nexent/templates/_helpers.tpl b/deploy/k8s/helm/nexent/templates/_helpers.tpl index e6ddf2eae6..5df345412d 100644 --- a/deploy/k8s/helm/nexent/templates/_helpers.tpl +++ b/deploy/k8s/helm/nexent/templates/_helpers.tpl @@ -20,3 +20,29 @@ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- define "nexent.podLabels" -}} app: {{ include "nexent.fullname" . }} {{- end }} + +{{- define "nexent.backendPlatformEnv" -}} +- name: TZ + valueFrom: + configMapKeyRef: + name: nexent-config + key: TZ +- name: IS_DEPLOYED_BY_KUBERNETES + value: "true" +- name: KUBERNETES_NAMESPACE + value: {{ .Values.global.namespace | quote }} +- name: NEXENT_SQL_FILES_CHECKSUM + value: {{ dig "rolloutChecksums" "sql" "" .Values.global | quote }} +- name: NEXENT_MCP_DOCKER_IMAGE + valueFrom: + configMapKeyRef: + name: nexent-config + key: NEXENT_MCP_DOCKER_IMAGE +- name: NEXENT_SANDBOX_DOCKER_IMAGE + valueFrom: + configMapKeyRef: + name: nexent-config + key: NEXENT_SANDBOX_DOCKER_IMAGE +- name: NEXENT_SANDBOX_DEFAULT_LEVEL + value: {{ ternary "local" "docker" (eq (default "lightweight" .Values.global.sandboxMode) "disabled") | quote }} +{{- end }} diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index 31c87b8954..682fc3358d 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -7,6 +7,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../common/common.sh" # shellcheck source=/dev/null source "$SCRIPT_DIR/../common/version.sh" +# shellcheck source=/dev/null +source "$SCRIPT_DIR/../k8s/env.sh" TMP_DIR="${TMPDIR:-/tmp}/nexent-deployment-test-$$" mkdir -p "$TMP_DIR" @@ -417,10 +419,13 @@ assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-data-process/templates/depl assert_not_contains "$(cat "$K8S_CHART_DIR/charts/nexent-data-process/templates/deployment.yaml")" "checksum/nexent-backend:" "data-process deployment should not keep removed backend rollout annotation" assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-common/templates/configmap.yaml")" "CAS_DEFAULT_TENANT_ID" "common configmap should expose the CAS default tenant" assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-common/values.yaml")" "defaultTenantId: \"tenant_id\"" "common values should preserve the CAS default tenant" -assert_contains "$(cat "$SCRIPT_DIR/../k8s/deploy.sh")" "CAS_DEFAULT_TENANT_ID" "k8s deploy should render the CAS default tenant from environment configuration" assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-common/templates/configmap.yaml")" "CAS_DEFAULT_ROLE" "common configmap should expose the CAS default role" assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-common/values.yaml")" "defaultRole: \"USER\"" "common values should preserve the CAS default role" -assert_contains "$(cat "$SCRIPT_DIR/../k8s/deploy.sh")" "CAS_DEFAULT_ROLE" "k8s deploy should render the CAS default role from environment configuration" +CAS_ENV_FILE="$TMP_DIR/cas.env" +printf 'CAS_DEFAULT_TENANT_ID=synthetic-tenant\nCAS_DEFAULT_ROLE=ADMIN\n' > "$CAS_ENV_FILE" +CAS_ENV_DATA="$(k8s_env_file_data "$CAS_ENV_FILE")" +assert_contains "$CAS_ENV_DATA" "CAS_DEFAULT_TENANT_ID: \"$(printf '%s' synthetic-tenant | base64 | tr -d '\r\n')\"" "k8s env Secret should preserve the configured CAS default tenant" +assert_contains "$CAS_ENV_DATA" "CAS_DEFAULT_ROLE: \"$(printf '%s' ADMIN | base64 | tr -d '\r\n')\"" "k8s env Secret should preserve the configured CAS default role" assert_contains "$(cat "$SCRIPT_DIR/../env/.env.example")" "CAS_DEFAULT_ROLE=USER" "docker environment example should preserve the CAS default role" assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-common/templates/configmap.yaml")" "CAS_HEARTBEAT_URL" "common configmap should expose the CAS heartbeat URL" assert_contains "$(cat "$K8S_CHART_DIR/charts/nexent-common/templates/configmap.yaml")" "CAS_HEARTBEAT_INTERVAL_SECONDS" "common configmap should expose the CAS heartbeat interval" diff --git a/test-e2e/cases/K8S-ENV-D5-001/case.yaml b/test-e2e/cases/K8S-ENV-D5-001/case.yaml new file mode 100644 index 0000000000..28e7b80671 --- /dev/null +++ b/test-e2e/cases/K8S-ENV-D5-001/case.yaml @@ -0,0 +1,82 @@ +schema_version: '1.0' +stage: D5 +module: V5-BASELINE +case: + case_id: K8S-ENV-D5-001 + feature_id: F-173 + business_rule_ids: [F-173-RULE-02] + title: K8s backend env file injection, precedence, replacement and cleanup + type: DEPLOYMENT + priority: P1 + status: active + automation: automated + objective: Prove that actual Kubernetes containers receive complete synthetic env file configuration at the production Compose service boundary and receive new/changed/deleted values after replacement. + preconditions: + - Bash, Helm and kubectl are installed; the machine-local test home and result directory are available. + - D5_ENABLE_MUTATING_DEPLOYMENT=true and D5_ISOLATED_ENVIRONMENT=true explicitly authorize mutations on an isolated test cluster. + - NEXENT_D5_K8S_CONTEXT explicitly selects that isolated cluster; no current-context fallback is permitted. + - NEXENT_D5_K8S_PROBE_IMAGE identifies an available image with python3 and a shell; it contains no product credentials. + - The test identity may create and delete a new run-scoped namespace and its Secrets, ConfigMaps, Deployments and Pods. + test_data: + source: Synthetic temporary root and monitoring env files; never read deploy/env/.env. + variants: Unknown added key, empty string, multiline/JSON value, root/monitoring collision, credential collision, platform collision, CAS override, Redis/worker derived defaults, modified key and removed key. + steps: + - order: 1 + action: Check all mutation/isolation/context/image prerequisites before any kubectl command; render synthetic env payloads through the production helper and render the production Helm chart for a unique namespace. + expected: Missing prerequisites are BLOCKED without cluster access; valid payloads contain all declared synthetic keys and no inherited host-only key. + - order: 2 + action: Assert Secret references for the five backend Deployment templates and their absence in Web/infrastructure. Create the new namespace and apply only synthetic Secrets/ConfigMap plus five probe Deployments using the rendered backend env/envFrom configuration. + expected: Only the five backend probes load root env, and only Config/Runtime load monitoring env; no existing release is touched. + - order: 3 + action: Read probe environment snapshots and compare root/monitoring/final-credential/explicit-env collisions, empty values, unknown keys, derived defaults and platform overrides across all five backend probes. + expected: Every selected env value matches the declared precedence and service scope; platform settings and finalized synthetic credentials win; runtime-state Redis and worker concurrency follow the configured input values. + - order: 4 + action: Change a synthetic value, add a key, delete a key and rerender fresh Secret payloads and the deployment environment checksum; replace the env Secrets and update the probe templates, then await rollout and reread all snapshots. + expected: All five new containers receive the modified and added values, the removed key is absent, and the environment checksum differs. + - order: 5 + action: Delete the run-scoped namespace in a finally block and confirm its removal; check that the repository local env and the selected pre-existing namespaces/releases were not modified. + expected: All run-owned resources are cleaned after success or failure; cleanup failure is reported rather than swallowed. + expected_results: + - All file-declared synthetic root keys reach the five backend containers with preserved string values and without host-only key leakage. + - Monitoring scope and effective precedence match production Compose; explicit Kubernetes settings and final credentials win; Web/infrastructure do not reference the new Secrets. + - Redeployment changes and adds values, removes deleted keys and changes the Pod environment checksum. + - Missing prerequisites block before cluster access; all run-owned resources are cleaned and existing configuration/releases are preserved. + forbidden_side_effects: + - Reading or modifying the real deployment .env, dumping arbitrary container environments or printing credential values in evidence. + - Accessing an implicit current context or creating/updating/deleting resources outside the newly created run namespace. + - Swallowing failed rollout or cleanup, or treating a missing test cluster as a pass. + acceptance: + - id: AC-001 + description: Complete file-declared string configuration reaches actual backend containers without host-only leakage. + steps: [1, 3] + expected_results: [1] + forbidden_side_effects: [1] + - id: AC-002 + description: Service-specific monitoring and credentials/platform precedence are respected. + steps: [2, 3] + expected_results: [2] + forbidden_side_effects: [2] + - id: AC-003 + description: Replacement updates, adds and deletes configuration in newly started containers. + steps: [4] + expected_results: [3] + - id: AC-004 + description: Prerequisite gating and cleanup preserve unrelated resources and report missing or failed runtime proof. + steps: [1, 5] + expected_results: [4] + forbidden_side_effects: [1, 2, 3] + risk_type: DEPLOYMENT + risk: Omitted or stale environment variables silently disable business features, incorrect precedence breaks Kubernetes configuration, and overly broad Secret references disclose backend credentials to unrelated services. + condition: + - Five probe Deployments use env/envFrom from the real Helm backend templates on an explicitly selected isolated Kubernetes cluster. + - Two successive synthetic configurations contain collisions and added/changed/deleted keys. + metrics: + - Correct environment snapshots across five services, including exact empty/multiline string preservation. + - Number of stale removed keys, unintended Secret references and unrelated modified resources. + - Successful rollout and confirmed namespace cleanup. + thresholds: + - All five backend snapshots satisfy every declared comparison; stale keys, unintended references and unrelated modifications are zero. + - Both rollouts finish within 180 seconds and namespace cleanup within 120 seconds. + recovery_expectations: + - A failed assertion or rollout still deletes only this run's namespace; any cleanup failure remains a failed result. + - An unavailable isolated cluster remains a dependency blocker; no mutation of an existing deployment is attempted. diff --git a/test-e2e/cases/K8S-ENV-D5-001/execution.yaml b/test-e2e/cases/K8S-ENV-D5-001/execution.yaml new file mode 100644 index 0000000000..5d8957c5fb --- /dev/null +++ b/test-e2e/cases/K8S-ENV-D5-001/execution.yaml @@ -0,0 +1,17 @@ +schema_version: '1.0' +case_id: K8S-ENV-D5-001 +implementations: + - framework: pytest + file: test.py + selector: K8S-ENV-D5-001 +acceptance_bindings: + AC-001: [test_k8s_env_files_runtime] + AC-002: [test_k8s_env_files_runtime] + AC-003: [test_k8s_env_files_runtime] + AC-004: [test_k8s_env_files_runtime] +preparation: + families: [] + anchors: false + special_assets: false + d4_groups: [] +notes: Requires explicit D5 mutation/isolation gates, NEXENT_D5_K8S_CONTEXT and NEXENT_D5_K8S_PROBE_IMAGE. Missing prerequisites raise a dependency blocker before any cluster command; never use the implicit current context. diff --git a/test-e2e/cases/K8S-ENV-D5-001/test.py b/test-e2e/cases/K8S-ENV-D5-001/test.py new file mode 100644 index 0000000000..e5f5303ba4 --- /dev/null +++ b/test-e2e/cases/K8S-ENV-D5-001/test.py @@ -0,0 +1,271 @@ +"""Real Kubernetes env injection on an explicitly selected isolated test cluster.""" + +import base64 +import copy +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from uuid import uuid4 + +import pytest +import yaml + +from d5.assets import bash_path +from shared.asset_registry import AssetDependencyError +from shared.config import repo_root + + +CASE_ID = "K8S-ENV-D5-001" +BACKENDS = ("config", "runtime", "mcp", "northbound", "data-process") + + +def prerequisite(name, value): + if not value: + raise AssetDependencyError("k8s_env_injection", name, consumer_case_id=CASE_ID, + detail="Explicit isolated-cluster prerequisite is missing; no cluster access attempted") + return value + + +def runtime_configuration(): + for name in ("D5_ENABLE_MUTATING_DEPLOYMENT", "D5_ISOLATED_ENVIRONMENT"): + prerequisite(name, os.environ.get(name, "").lower() in {"1", "true", "yes"}) + context = prerequisite("context", os.environ.get("NEXENT_D5_K8S_CONTEXT", "").strip()) + image = prerequisite("probe_image", os.environ.get("NEXENT_D5_K8S_PROBE_IMAGE", "").strip()) + commands = {name: prerequisite(name, shutil.which(name)) for name in ("bash", "helm", "kubectl")} + return context, image, commands + + +def fixture_configuration(updated=False): + expected = { + "INJECTION_SHARED": "root", "INJECTION_VALUE": "new" if updated else "old", + "INJECTION_UNKNOWN_FEATURE": "enabled", "INJECTION_EMPTY": "", + "INJECTION_JSON": '{"enabled":false,"value":"a=b # c"}', + "INJECTION_MULTILINE": "first\nASSIGNMENT_LOOKING=value\nlast\n", + "HITL_ENABLED": "true", "RUNTIME_AGENT_THREAD_MAX_WORKERS": "17", + "CAS_DEFAULT_TENANT_ID": "synthetic-tenant", "CAS_DEFAULT_ROLE": "ADMIN", + "REDIS_URL": "redis://synthetic:6379/4", "DP_PART_PROCESSOR_COUNT": "7", + "MINIO_ACCESS_KEY": "root-credential", "IS_DEPLOYED_BY_KUBERNETES": "false", + "KUBERNETES_NAMESPACE": "wrong", "DEPLOYMENT_VERSION": "wrong", + "NEXENT_SQL_STARTUP_MODE": "wrong", "NEXENT_SQL_FILES_CHECKSUM": "wrong", + "NEXENT_MCP_DOCKER_IMAGE": "wrong", "NEXENT_SANDBOX_DOCKER_IMAGE": "wrong", + "NEXENT_SANDBOX_DEFAULT_LEVEL": "wrong", + } + if updated: + expected["INJECTION_ADDED"] = "added" + else: + expected["INJECTION_REMOVED"] = "removed" + content = "\n".join(key + "='" + value + "'" for key, value in expected.items()) + "\n" + monitoring = {"INJECTION_SHARED": "monitoring", "INJECTION_MONITOR_ONLY": "yes", + "MINIO_ACCESS_KEY": "monitor-credential"} + monitoring_content = "\n".join(key + "='" + value + "'" for key, value in monitoring.items()) + "\n" + return content, expected, monitoring_content, monitoring + + +def run_local(command, *, timeout=30): + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) + assert result.returncode == 0, "Synthetic configuration rendering failed; no live env file was used" + return result.stdout + + +def render(repo, directory, commands, namespace, updated=False): + root_content, root_expected, monitoring_content, monitoring_expected = fixture_configuration(updated) + root = directory / ".env" + root.write_text(root_content, encoding="utf-8") + (directory / "monitoring.env").write_text(monitoring_content, encoding="utf-8") + output = directory / "generated.yaml" + runtime = directory / "runtime.yaml" + script = r''' +set -e +source "$1" --help >/dev/null +source "$2" +ROOT_ENV_FILE="$2" +DEPLOYMENT_ROOT_ENV="$2" +GENERATED_SECRETS_VALUES="$3" +DEPLOYMENT_COMPONENTS="application,data-process" +DEPLOYMENT_SANDBOX_MODE=lightweight +MINIO_ACCESS_KEY=final-access +MINIO_SECRET_KEY=final-secret +NEXENT_POSTGRES_PASSWORD=final-postgres +NEXENT_MCP_DOCKER_IMAGE=synthetic/mcp:chosen +NEXENT_SANDBOX_IMAGE=synthetic/sandbox:chosen +sql_files_checksum() { printf 'synthetic-sql-checksum'; } +deployment_render_image_rollout_checksums() { printf ' backendImage: "synthetic-probe"\n'; } +render_k8s_runtime_config_values "$4" +render_runtime_secret_values +''' + run_local([commands["bash"], "-c", script, "k8s-env-case", + bash_path(repo / "deploy/k8s/deploy.sh"), bash_path(root), bash_path(output), bash_path(runtime)]) + assert output.stat().st_mode & 0o777 == 0o600, "Secret values permissions must be private" + values = yaml.safe_load(output.read_text(encoding="utf-8")) + for field, expected in (("rootData", root_expected), ("monitoringData", monitoring_expected)): + data = values["nexent-common"]["envFiles"][field] + decoded = {key: base64.b64decode(value).decode() for key, value in data.items()} + assert decoded == expected, "File-declared synthetic values were omitted or altered" + values["global"].update(namespace=namespace, deploymentVersion="speed", sandboxMode="lightweight") + values["nexent-common"]["images"] = { + "mcp": {"repository": "synthetic/mcp", "tag": "chosen"}, + "sandbox": {"repository": "synthetic/sandbox", "tag": "chosen"}, + } + output.write_text(yaml.safe_dump(values), encoding="utf-8") + chart = repo / "deploy/k8s/helm/nexent" + documents = list(yaml.safe_load_all(run_local([ + commands["helm"], "template", "env-probe", str(chart), "-f", str(runtime), "-f", str(output), + ]))) + infrastructure = list(yaml.safe_load_all(run_local([ + commands["helm"], "template", "env-probe", str(chart.parent / "nexent-infrastructure"), + ]))) + assert_service_scope(documents, infrastructure) + return documents, values["global"]["rolloutChecksums"]["env"], root_expected, monitoring_expected + + +def assert_service_scope(documents, infrastructure): + found = set() + for document in documents + infrastructure: + if not document or document["kind"] != "Deployment": + continue + name = document["metadata"]["name"].removeprefix("nexent-") + for container in document["spec"]["template"]["spec"]["containers"]: + references = [item.get("secretRef", {}).get("name") for item in container.get("envFrom", [])] + if name in BACKENDS: + expected = [None, "nexent-env"] + if name in ("config", "runtime"): + expected.append("nexent-monitoring-env") + assert references == expected + ["nexent-secrets"], "Incorrect backend envFrom precedence" + found.add(name) + else: + assert "nexent-env" not in references and "nexent-monitoring-env" not in references, "Unexpected complete env Secret reference" + assert found == set(BACKENDS), "All five backend templates must be rendered" + + +def probe_resources(documents, image): + resources = [] + for document in documents: + if not document: + continue + name = document["metadata"]["name"] + if document["kind"] in ("Secret", "ConfigMap") and name in ( + "nexent-config", "nexent-env", "nexent-monitoring-env", "nexent-secrets", + ): + resources.append(document) + elif document["kind"] == "Deployment" and name.removeprefix("nexent-") in BACKENDS: + template = document["spec"]["template"] + original = template["spec"]["containers"][0] + probe = {"apiVersion": "apps/v1", "kind": "Deployment", "metadata": document["metadata"], "spec": { + "replicas": 1, "strategy": {"type": "Recreate"}, "selector": document["spec"]["selector"], + "template": {"metadata": copy.deepcopy(template["metadata"]), "spec": {"containers": [{ + "name": name, "image": image, + "command": ["python3", "-c", "import time; time.sleep(3600)"], + "envFrom": copy.deepcopy(original["envFrom"]), "env": copy.deepcopy(original["env"]), + "resources": {"requests": {"cpu": "10m", "memory": "16Mi"}, "limits": {"memory": "64Mi"}}, + "securityContext": {"allowPrivilegeEscalation": False, "runAsUser": 1000, + "capabilities": {"drop": ["ALL"]}}, + }]}}, + }} + resources.append(probe) + return resources + + +class IsolatedCluster: + """Restrict every test operation to an explicit context and run-owned namespace.""" + + def __init__(self, command, context, namespace): + self.command = command + self.context = context + self.namespace = namespace + self.created = False + + def call(self, arguments, *, payload=None, timeout=30): + result = subprocess.run( + [self.command, "--context", self.context, *arguments], + input=payload, capture_output=True, text=True, timeout=timeout, check=False, + ) + assert result.returncode == 0, "Isolated Kubernetes command failed: " + " ".join(arguments[:3]) + return result.stdout + + def create(self): + # Create fails on collisions. Only a successful create grants cleanup ownership. + self.call(["create", "namespace", self.namespace]) + self.created = True + + def apply(self, resources): + assert self.created, "Cannot apply before creating the run-owned namespace" + for resource in resources: + assert resource["metadata"]["namespace"] == self.namespace + assert resource["kind"] in {"Secret", "ConfigMap", "Deployment"} + self.call(["-n", self.namespace, "apply", "-f", "-"], payload=yaml.safe_dump_all(resources)) + + def wait(self): + for name in BACKENDS: + self.call(["-n", self.namespace, "rollout", "status", "deployment/nexent-" + name, + "--timeout=180s"], timeout=190) + + def snapshot(self, name, keys): + # Inspect selected synthetic keys only; never dump an entire environment. + script = "import json,os; print(json.dumps({k:os.environ.get(k) for k in " + repr(sorted(keys)) + "}))" + output = self.call(["-n", self.namespace, "exec", "deployment/nexent-" + name, + "--", "python3", "-c", script]) + return json.loads(output) + + def cleanup(self): + if self.created: + self.call(["delete", "namespace", self.namespace, "--wait=true", "--timeout=120s"], timeout=130) + remaining = self.call(["get", "namespace", self.namespace, "--ignore-not-found", "-o", "name"]) + assert not remaining.strip(), "Run-owned namespace was not cleaned" + + +def assert_snapshots(cluster, root_expected, monitoring_expected, namespace, updated): + for name in BACKENDS: + expected = dict(root_expected) + if name in ("config", "runtime"): + expected.update(monitoring_expected) + expected.update({ + "MINIO_ACCESS_KEY": "final-access", "IS_DEPLOYED_BY_KUBERNETES": "true", + "KUBERNETES_NAMESPACE": namespace, "DEPLOYMENT_VERSION": "speed", + "NEXENT_SQL_STARTUP_MODE": {"config": "migrate", "data-process": "off"}.get(name, "wait"), + "NEXENT_SQL_FILES_CHECKSUM": "synthetic-sql-checksum", + "NEXENT_MCP_DOCKER_IMAGE": "synthetic/mcp:chosen", + "NEXENT_SANDBOX_DOCKER_IMAGE": "synthetic/sandbox:chosen", + "NEXENT_SANDBOX_DEFAULT_LEVEL": "docker", "HOST_ONLY_SENTINEL": None, + "RUNTIME_STATE_REDIS_URL": "redis://synthetic:6379/4", "WORKER_CONCURRENCY": "8", + }) + if name not in ("config", "runtime"): + expected["INJECTION_MONITOR_ONLY"] = None + if updated: + expected["INJECTION_REMOVED"] = None + snapshot = cluster.snapshot(name, expected) + for key, value in expected.items(): + assert snapshot.get(key) == value, "Unexpected synthetic env value for " + name + "/" + key + + +@pytest.mark.stage("D5") +@pytest.mark.case_id(CASE_ID) +@pytest.mark.deploy +@pytest.mark.p1 +def test_k8s_env_files_runtime(monkeypatch): + context, image, commands = runtime_configuration() + repo = repo_root() + local_env = repo / "deploy/env/.env" + before = (local_env.stat().st_mtime_ns, local_env.stat().st_size) if local_env.exists() else None + namespace = "nexent-env-test-" + uuid4().hex[:16] + cluster = IsolatedCluster(commands["kubectl"], context, namespace) + monkeypatch.setenv("HOST_ONLY_SENTINEL", "must-not-leak") + try: + with tempfile.TemporaryDirectory(prefix="nexent-k8s-env-") as temporary: + directory = Path(temporary) + documents, first_checksum, root_expected, monitoring_expected = render(repo, directory, commands, namespace) + cluster.create() + cluster.apply(probe_resources(documents, image)) + cluster.wait() + assert_snapshots(cluster, root_expected, monitoring_expected, namespace, False) + documents, second_checksum, root_expected, monitoring_expected = render(repo, directory, commands, namespace, True) + assert first_checksum != second_checksum, "Effective env updates must change the rollout checksum" + cluster.apply(probe_resources(documents, image)) + cluster.wait() + assert_snapshots(cluster, root_expected, monitoring_expected, namespace, True) + finally: + cluster.cleanup() + after = (local_env.stat().st_mtime_ns, local_env.stat().st_size) if local_env.exists() else None + assert before == after, "The real deployment env file was modified" diff --git a/test-e2e/changes/bugs/K8S-ENV-INJECTION.yaml b/test-e2e/changes/bugs/K8S-ENV-INJECTION.yaml new file mode 100644 index 0000000000..74da0059e2 --- /dev/null +++ b/test-e2e/changes/bugs/K8S-ENV-INJECTION.yaml @@ -0,0 +1,35 @@ +schema_version: '1.0' +change: + change_id: K8S-ENV-INJECTION + change_type: bugfix + bug_id: K8S-ENV-INJECTION + title: Inject complete deployment env files into Kubernetes backend containers + description: Replace the fixed-list-only injection boundary with backend-only env file Secrets while preserving production Compose service scope and explicit deployment overrides. + observed_behavior: Docker Compose loads the entire deployment .env for five backend services, whereas Kubernetes maps a fixed subset. New business configuration can therefore be silently omitted. + expected_behavior: All file-declared variables reach the five backend services; only Config and Runtime load monitoring.env. Final credentials and explicit Kubernetes parameters win; a subsequent deployment updates and removes variables without exposing the new Secrets to Web or infrastructure. + escaped_stage: unknown + coverage_gap: + type: missing_boundary + description: Existing deploy/tests/test_common.sh asserts selected mappings and rollout checksums, and DEP-AUTO-7177AC18CCE799DD proves sandbox option dispatch. Neither proves complete env file injection through Kubernetes envFrom or removal after an update. + feature_contract_changed: true + affected_features: + existing: [] + added: [] + modified: [F-173] + retired: [] + affected_cases: + existing: [DEP-AUTO-7177AC18CCE799DD] + added: [K8S-ENV-D5-001] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Preserve Bash-loaded string values, empty values, quoting, JSON, multiline strings and last duplicate assignments; emit only file-declared keys. + - Reject unreadable or invalid env files without publishing partial values or sensitive diagnostics; generated application Secret values are mode 0600. + - Verify default Helm compatibility, backend-only Secret references, platform overrides, update/removal and deterministic rollout checksums using synthetic configuration. + - Remove redundant backend runtime-value copies while retaining Web-specific mappings, Redis runtime-state fallback, derived worker concurrency, final credentials and direct Helm defaults. + reason: The new renderer and manifest composition have isolated contracts suitable for traditional UT. Real Kubernetes env resolution and replacement need separate D5 evidence; no additional D1-D4 boundary is introduced. + test_selectors: + - test/backend/deploy/test_k8s_env_injection.py + notes: 'User explicitly waived the external nexent-doc SPEC requirement for this change. Repository-local design and evidence: test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md. Do not change the local deployment .env, existing clusters, SQL, HTTP interfaces or CI. The D5 case requires an explicitly selected isolated test cluster and mutation gates; missing prerequisites remain BLOCKED.' diff --git a/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md b/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md new file mode 100644 index 0000000000..e1caa5f9bb --- /dev/null +++ b/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md @@ -0,0 +1,195 @@ +# Design — Kubernetes deployment environment injection + +## Context and functional boundary + +Change: `K8S-ENV-INJECTION`; owner: `F-173`, rule `F-173-RULE-02`. +The user approved a repository-local design instead of the unavailable external SPEC repository. +The current branch is `dev/k8s-env`; initial unrelated state is the untracked `nexent-test-suite/` directory. + +`deploy/docker/compose/docker-compose.prod.yml` loads `.env` for Config, Runtime, +MCP, Northbound and Data Process; Config and Runtime additionally load `monitoring.env`. +`deploy/k8s/deploy.sh` currently renders a fixed set of common ConfigMap/Secret fields. +Inspection on 2026-10-14 found 91 local `.env` keys absent from those shared templates, +including HITL, runtime thread limits and AIDP. Some absent keys have dedicated injection paths. +This establishes a configuration-transfer defect, without claiming live cluster behavior. + +The capability consumes the existing deployment env files and finalized deployment options, +produces Helm values and Secret resources, and starts backend containers with their effective +configuration. Existing credential generation, split infrastructure/application releases, +service contracts, defaults and deployment selection remain in place. + +## Final design + +- Observe assignments executed by Bash while sourcing each trusted env file in a separate + fail-fast shell. Emit only declared variable names, with their final values encoded as + Base64. This preserves actual Bash quoting, substitutions and multiline values without + dumping inherited host variables or mistaking assignment-looking multiline content for keys. +- Add `nexent-common.envFiles.rootData` and `monitoringData`, defaulting to empty mappings. + Render `nexent-env` and `nexent-monitoring-env` even when empty for direct Helm compatibility. +- Backend envFrom order is common ConfigMap, root env Secret, monitoring Secret where + applicable, then finalized credentials Secret. Explicit env has highest priority. +- Preserve Kubernetes mode, namespace, SQL startup mode/checksum, selected MCP/sandbox images + and sandbox execution level as explicit platform settings. Do not give Web or infrastructure + the new Secret references. Keep Web's dedicated configuration injection. +- Render application Secret values after credential setup. Prepare file payloads before + writing, write atomically with mode 0600, and propagate failures without printing env values. + The rollout checksum includes the deterministic effective file payloads and platform options. + Rendering fresh maps removes deleted keys; the next deployment recreates backend containers. +- Missing optional monitoring input produces an empty map; required root input errors stop + application Secret rendering. The existing .env initialization/merge process is unchanged. +- Follow-up cleanup removes the fixed per-variable backend copy in the runtime values + renderer. Common Chart defaults remain available for direct Helm use. Keep only timezone, + Web's Northbound URL, Redis URL as the runtime-state fallback input, and the part-processor + count as the worker-concurrency input, plus Web's dedicated upload limit. Final credentials, + infrastructure settings, SQL content and explicit container overrides are retained. + +## Current fix acceptance + +| AC | Observable requirement | Proof | +| --- | --- | --- | +| AC-001 | All file-declared keys, including new/unknown and empty keys, preserve loaded values; host-only keys are excluded. Invalid input publishes no partial payload or secrets in errors. | Traditional UT; runtime portion of K8S-ENV-D5-001 | +| AC-002 | Five backend services reference root env; only Config/Runtime reference monitoring; effective precedence preserves finalized credentials and explicit platform values. Web/infrastructure never reference the new Secrets. | Helm UT; K8S-ENV-D5-001 | +| AC-003 | A redeployment changes modified values, admits new keys, removes deleted keys, and changes the Pod environment checksum. Equivalent ordering/comments preserve the checksum. | Traditional UT; K8S-ENV-D5-001 | +| AC-004 | Direct Helm defaults render valid empty env Secrets. Generated application Secret values are atomic and mode 0600; failure preserves the previous file. | Traditional UT | +| AC-005 | Real test resources are created only in a new run-scoped namespace on an explicitly selected isolated cluster and are cleaned after success/failure; no local real .env or existing release is mutated. | K8S-ENV-D5-001 | +| AC-006 | Ordinary backend variables use the complete env Secret without fixed runtime-value copies. Web configuration, derived defaults, finalized credentials and direct Helm defaults retain their behavior. | Traditional UT; reuse K8S-ENV-D5-001 for the backend injection boundary | + +## Traditional UT + D1-D5 responsibilities + +| Feature / rule / AC | Traditional UT | D1 | D2 | D3 | D4 | D5 | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| F-173-RULE-02 / AC-001,003,004 | Add renderer and Helm composition assertions | N/A | N/A | N/A | N/A | K8S-ENV-D5-001 | UT proves isolated generation; D5 proves actual Kubernetes environment injection/update. | +| F-173-RULE-02 / AC-002,005 | Add static composition/precedence assertions | N/A | N/A | N/A | N/A | K8S-ENV-D5-001 | No changed HTTP, browser or model boundary. | +| Retained sandbox option selection | Existing deploy/tests/test_common.sh | N/A | N/A | N/A | N/A | DEP-AUTO-7177AC18CCE799DD | Unchanged formal regression, separate from the new runtime case. | +| F-173-RULE-02 / AC-006 | Strengthen the existing renderer/composition UT and CAS shell regression | N/A | N/A | N/A | N/A | Reuse K8S-ENV-D5-001 | No new proof boundary or Case is required for removal of redundant mapping. | + +Existing tests were inspected: `deploy/tests/test_common.sh` checks selected CAS mappings, +rollout annotations and checksum normalization; `test/backend/consts/test_runtime_thread_config.py` +checks application constants but does not prove deployment injection; the sandbox D5 script +checks option dispatch and persistence. None asserts complete envFrom injection or live deletion. +No duplicate D1 is added. Other deployment feature history remains outside this fix. + +Before cleanup, the runtime renderer duplicates backend file keys into the common ConfigMap, +while the complete root Secret already overrides those keys. The existing Web UT checks only +timezone/upload limit, and the six Helm precedence variants do not check derived defaults or +use the runtime values renderer. Extend those assertions with representative CAS/OAuth, +model-path and Data Process values, custom Web Northbound URL, Redis fallback, worker count, +empty values and deleted-key fallback. The old CAS shell regression checks the presence of +fixed rendering strings; replace that implementation-dependent check with actual Secret data. + +## Test implementation and execution limits + +Use synthetic env files and temporary output directories for UT and Helm rendering. +The D5 primary script belongs beside its Case, uses the production renderer and rendered +backend env configuration, and runs small probe Deployments to inspect env resolution without +starting business services or requiring model providers. Probe images are explicit test assets. +Use existing D5 mutation/isolation gates plus an explicit test context; never fall back to the +operator's current context. Missing prerequisites raise a dependency blocker before any cluster +command. Do not execute the live D5 against the current machine's existing cluster in this task. + +## Acceptance traceability + +| AC | Code areas | Traditional UT | Formal case | Evidence | Result | +| --- | --- | --- | --- | --- | --- | +| AC-001 | `deploy/k8s/env.sh`; `render_runtime_secret_values` | `test/backend/deploy/test_k8s_env_injection.py`: value preservation, empty files, unknown keys, host exclusion, loading/encoding errors | K8S-ENV-D5-001 / AC-001 | Local UT PASS; live runtime not executed | LOCAL PASS; RUNTIME BLOCKED | +| AC-002 | Common env Secrets; five backend Deployments; platform env helper | Same UT selector: defaults, service scope, Web mapping, six deployment/sandbox combinations and precedence | K8S-ENV-D5-001 / AC-002 | Helm assertions and lint PASS; omission mutation detected | LOCAL PASS; RUNTIME BLOCKED | +| AC-003 | Fresh Secret maps; environment rollout checksum | Same UT selector: added/changed/deleted keys, order/comment normalization, sandbox mode changes, Pod annotations | K8S-ENV-D5-001 / AC-003 | Local generation and render PASS; actual replacement not executed | LOCAL PASS; RUNTIME BLOCKED | +| AC-004 | Atomic private writer; Helm defaults | Same UT selector: 0600 permissions, no leftover temporary files, previous values preserved on failure, empty Secret maps | N/A: isolated file/render contract | UT and default Helm rendering PASS | PASS | +| AC-005 | Case-local prerequisite gates, explicit context, run-owned namespace and cleanup | N/A: runtime risk boundary | K8S-ENV-D5-001 / AC-004 | Runner stops at the disabled mutation gate before any kubectl command; live cleanup unverified | BLOCKED | +| AC-006 | Reduced runtime values renderer and retained common Chart defaults | Existing UT selector: representative backend overrides, missing-key fallback, Web proxy URL, Redis/worker derivation and zero/empty inputs | Reuse K8S-ENV-D5-001 | 24 UT PASS; shell and local D5 composition PASS; live runtime blocked | LOCAL PASS; RUNTIME BLOCKED | + +UT, shell regression, manifest rendering, asset validation and actual cluster execution results +must remain distinct. A passing render is not a passing Kubernetes runtime acceptance result. + +## Delivery evidence — 2026-10-14 + +Traditional UT command: + +```bash +backend/.venv/bin/python -m pytest test/backend/deploy/test_k8s_env_injection.py -q +``` + +Initial result: **20 passed**. The tests use synthetic files and exercise the production renderer and +Helm templates. As a regression sensitivity check, removing the `nexent-env` references in a +temporary Chart copy makes the precedence/composition assertion fail. This is a local proof, +not Kubernetes runtime execution. + +`helm lint deploy/k8s/helm/nexent`, Bash syntax checks for both changed shell files, and +`git diff --check` passed. `deploy/tests/test_common.sh` and +`deploy/tests/test_build_offline_package.sh` passed in an isolated deployment copy, with +mock Docker/image packaging operations and synthetic deployment files. Both unchanged +workflow inputs required by the offline regression were copied as read-only fixtures; no +repository workflow was edited. + +Strict formal design and implementation validation passed using the current change and Case +selectors. Generated Feature/registry/Excel views were regenerated; they are not execution +evidence. Final implementation drift validation uses: + +```bash +backend/.venv/bin/python test-e2e/infra/tools/validate_test_assets.py \ + --phase implementation --require-ut-change K8S-ENV-INJECTION \ + --require-acceptance-case K8S-ENV-D5-001 +``` + +Formal execution used the machine-local test home +`/private/tmp/nexent-k8s-env-tests-_cs5_iy0` with mutation disabled: + +```bash +D5_ENABLE_MUTATING_DEPLOYMENT=false backend/.venv/bin/python \ + test-e2e/infra/tools/run_cases.py \ + DEP-AUTO-7177AC18CCE799DD K8S-ENV-D5-001 \ + --test-home /private/tmp/nexent-k8s-env-tests-_cs5_iy0 +``` + +| Case | Script execution | Acceptance status | Local evidence, relative to test home | +| --- | --- | --- | --- | +| DEP-AUTO-7177AC18CCE799DD | PASS | UNREVIEWED: historical Case has no acceptance bindings | `runs/repository-local/20261014T025348490977Z-DEP-AUTO-7177AC18CCE799DD/status.json` | +| K8S-ENV-D5-001 | BLOCKED_BY_DEPENDENCY | Not passed: runner records FAIL for unexecuted bound obligations and UNREVIEWED semantic completeness | `runs/repository-local/20261014T025349909489Z-K8S-ENV-D5-001/status.json`, `checkpoints/results.jsonl`, `acceptance-execution.json` | + +The new Case stops at `D5_ENABLE_MUTATING_DEPLOYMENT=false`. An authorized isolated cluster, +explicit context and probe image were not provided for this task, so no cluster command ran. +Collection and the Case's synthetic render/probe-resource construction were checked locally: +two fixture variants, five probe Deployments, three Secrets, service scope and differing +rollout checksums passed. Actual container values, live updates and resource cleanup remain +required runtime proof, not an exemption or a completed acceptance claim. + +The first legacy common-script regression in the original checkout appended template defaults +to the local `.env` as an existing help-command side effect. The exact original bytes were +restored, and later shell regressions ran only in the isolated copy. Final `.env` SHA-256 +matches the initial value: +`5978f5bba93f1ec8147177f9c391e589e51758509acbef0870d450cb3f1f72b2`. +No existing cluster deployment, merged SQL, business interface or CI configuration was changed. + +## Cleanup follow-up evidence — 2026-10-14 + +The user authorized cleanup of redundant fixed rendering. The application runtime values +renderer now keeps four common inputs (timezone, Web Northbound URL, Redis URL and processor +count) and the Web upload limit. Backend CAS/OAuth, voice, model paths, storage/service +connections, limits and Data Process overrides are supplied by the complete file Secret. +Common Chart defaults, SQL rendering, credentials, platform parameters and infrastructure +rendering remain available through their existing paths. + +The same traditional UT command now reports **24 passed**. Added assertions check that +ordinary backend fields are absent from generated common runtime values and remain effective +through Secret injection, removed fields recover Chart defaults, custom Web proxy/upload/TZ +settings survive, Redis fallback and worker concurrency follow their inputs (including zero +and empty strings), and direct Helm overrides still work. Existing input-error, private-write, +precedence and deployment/sandbox tests continue passing. + +The updated `deploy/tests/test_common.sh` passes in the isolated deployment copy. Its CAS +regression now checks actual generic Secret payloads instead of requiring a fixed mapping +string in the deployment script. Helm lint, Bash syntax and diff checks pass. The D5 Case +now renders runtime values as well as Secret values and checks CAS overrides plus derived +Redis/worker values. Its two local fixture/probe-composition variants pass without cluster +commands; this remains local evidence only. + +The affected formal selection was rerun with the same mutation-disabled command: + +| Case | Script execution | Acceptance status | Local evidence, relative to the same test home | +| --- | --- | --- | --- | +| DEP-AUTO-7177AC18CCE799DD | PASS | UNREVIEWED historical acceptance | `runs/repository-local/20261014T031959790881Z-DEP-AUTO-7177AC18CCE799DD/status.json` | +| K8S-ENV-D5-001 | BLOCKED_BY_DEPENDENCY | Live acceptance not passed; mutation gate disabled before any cluster command | `runs/repository-local/20261014T032001105423Z-K8S-ENV-D5-001/status.json` | + +Strict design and implementation validation with regeneration passed for the updated +contracts and script. The isolated-cluster runtime blocker remains; cleanup does not change +the task's deployment authorization or the `.env` preservation requirement. diff --git a/test-e2e/features/F-173/cases.md b/test-e2e/features/F-173/cases.md index 7e0e11a420..572d499e55 100644 --- a/test-e2e/features/F-173/cases.md +++ b/test-e2e/features/F-173/cases.md @@ -2,4 +2,4 @@ 此页由结构化用例生成,不手工编辑。 -当前测试基线未保留关联用例。 +- D5 [K8S-ENV-D5-001](../../cases/K8S-ENV-D5-001/case.yaml) · active diff --git a/test-e2e/features/F-173/feature.yaml b/test-e2e/features/F-173/feature.yaml index 2ab0c6e580..8482b4fcca 100644 --- a/test-e2e/features/F-173/feature.yaml +++ b/test-e2e/features/F-173/feature.yaml @@ -10,6 +10,8 @@ feature: business_rules: - rule_id: F-173-RULE-01 description: Docker/K8s/Helm/离线部署与卸载:编排、镜像、持久卷、备份恢复、沙箱模式与离线资产 + - rule_id: F-173-RULE-02 + description: K8s 的五个后端服务通过独立 Secret 加载完整部署 .env;仅 Config、Runtime 叠加 monitoring.env。文件配置覆盖 Chart 默认值,最终凭据和容器显式参数优先;更新部署后新增、修改及删除的变量均生效,Web 和基础设施不引用完整 env Secret。 required_test_stages: [] source: notes: Migrated from V5 01_代码功能清单 without changing the functional description. diff --git a/test/backend/deploy/__init__.py b/test/backend/deploy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/backend/deploy/test_k8s_env_injection.py b/test/backend/deploy/test_k8s_env_injection.py new file mode 100644 index 0000000000..f630335d8c --- /dev/null +++ b/test/backend/deploy/test_k8s_env_injection.py @@ -0,0 +1,397 @@ +"""Isolated contracts for Kubernetes env file rendering and manifest composition.""" + +import base64 +import os +from pathlib import Path +import shutil +import subprocess + +import pytest +import yaml + + +REPO = Path(__file__).resolve().parents[3] +HELPER = REPO / "deploy/k8s/env.sh" +DEPLOY = REPO / "deploy/k8s/deploy.sh" +CHART = REPO / "deploy/k8s/helm/nexent" +BACKENDS = ("config", "runtime", "mcp", "northbound", "data-process") + + +def render_data(path, extra_environment=None): + environment = os.environ.copy() + environment.update(extra_environment or {}) + return subprocess.run( + ["bash", "-c", 'source "$1"; k8s_env_file_data "$2"', "env-test", str(HELPER), str(path)], + env=environment, capture_output=True, text=True, check=False, + ) + + +def decoded_data(output): + return {key: base64.b64decode(value).decode() for key, value in (yaml.safe_load(output) or {}).items()} + + +def render_values(root, output, extra_environment=None): + environment = os.environ.copy() + environment.update(extra_environment or {}) + script = r''' +set -e +source "$1" --help >/dev/null +ROOT_ENV_FILE="$2" +DEPLOYMENT_ROOT_ENV="$2" +GENERATED_SECRETS_VALUES="$3" +DEPLOYMENT_COMPONENTS="application,data-process" +DEPLOYMENT_SANDBOX_MODE="${TEST_SANDBOX_MODE:-lightweight}" +MINIO_ACCESS_KEY="final-access" +MINIO_SECRET_KEY="final-secret" +NEXENT_POSTGRES_PASSWORD="final-postgres" +NEXENT_MCP_DOCKER_IMAGE="synthetic/mcp:chosen" +NEXENT_SANDBOX_IMAGE="synthetic/sandbox:chosen" +sql_files_checksum() { printf 'synthetic-sql-checksum'; } +deployment_render_image_rollout_checksums() { printf ' backendImage: "synthetic-image-checksum"\n'; } +render_runtime_secret_values +''' + return subprocess.run( + ["bash", "-c", script, "values-test", str(DEPLOY), str(root), str(output)], + env=environment, capture_output=True, text=True, check=False, + ) + + +def helm_documents(values=None, infrastructure=False): + helm = shutil.which("helm") + assert helm, "Helm is required for deployment manifest verification" + chart = CHART.parent / "nexent-infrastructure" if infrastructure else CHART + command = [helm, "template", "env-test", str(chart)] + if values: + for path in values if isinstance(values, (list, tuple)) else [values]: + command += ["-f", str(path)] + result = subprocess.run(command, capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + return [document for document in yaml.safe_load_all(result.stdout) if document] + + +def render_runtime_values(root, output): + script = 'set -e; source "$1" --help >/dev/null; source "$2"; render_k8s_runtime_config_values "$3"' + return subprocess.run( + ["bash", "-c", script, "runtime-test", str(DEPLOY), str(root), str(output)], + capture_output=True, text=True, check=False, + ) + + +def container_environment(documents, deployment): + configmaps = {d["metadata"]["name"]: d.get("data", {}) for d in documents if d["kind"] == "ConfigMap"} + secrets = {d["metadata"]["name"]: decoded_data(yaml.safe_dump(d.get("data", {}))) + for d in documents if d["kind"] == "Secret"} + environment = {} + container = deployment["spec"]["template"]["spec"]["containers"][0] + for source in container.get("envFrom", []): + if "configMapRef" in source: + environment.update(configmaps[source["configMapRef"]["name"]]) + else: + environment.update(secrets[source["secretRef"]["name"]]) + for variable in container.get("env", []): + if "value" in variable: + environment[variable["name"]] = variable["value"] + else: + reference = variable["valueFrom"]["configMapKeyRef"] + environment[variable["name"]] = configmaps[reference["name"]][reference["key"]] + return container, environment + + +def test_env_values_preserve_bash_strings_and_only_declared_names(tmp_path): + root = tmp_path / ".env" + root.write_text(''' +# Synthetic values only. +PLAIN=hello +EMPTY= +BOOLEAN=false +NUMBER=00123 +export EXPORTED="some value" +JSON='{"key":"a=b # c","enabled":false}' +LITERAL='$literal "quoted" \\path # =' +MULTILINE="first +ASSIGNMENT_LOOKING=value +last +" +DUPLICATE=old +DUPLICATE=new +EXPANDED="${PLAIN}-suffix" +SUBPROCESS_EXPANDED=$(printenv PLAIN) +UNKNOWN_NEW_FEATURE=enabled +''') + result = render_data(root, {"HOST_ONLY_SENTINEL": "must-not-leak", "EXPORTED": "host-value"}) + assert result.returncode == 0, result.stderr + assert decoded_data(result.stdout) == { + "PLAIN": "hello", "EMPTY": "", "BOOLEAN": "false", "NUMBER": "00123", + "EXPORTED": "some value", "JSON": '{"key":"a=b # c","enabled":false}', + "LITERAL": '$literal "quoted" \\path # =', + "MULTILINE": "first\nASSIGNMENT_LOOKING=value\nlast\n", + "DUPLICATE": "new", "EXPANDED": "hello-suffix", "SUBPROCESS_EXPANDED": "hello", + "UNKNOWN_NEW_FEATURE": "enabled", + } + + +@pytest.mark.parametrize("content", ["", "# Comments only\n\n"]) +def test_empty_file_renders_empty_mapping(tmp_path, content): + path = tmp_path / ".env" + path.write_text(content) + result = render_data(path) + assert result.returncode == 0 + assert yaml.safe_load(result.stdout) == {} + + +@pytest.mark.parametrize("content", [ + 'VALID=synthetic-secret\nINVALID="unterminated\n', + 'VALID=synthetic-secret\nfalse\nAFTER=unused\n', + 'VALID=synthetic-secret\ncommand_that_does_not_exist\nAFTER=unused\n', +]) +def test_invalid_file_fails_without_partial_data_or_sensitive_diagnostics(tmp_path, content): + path = tmp_path / ".env" + path.write_text(content) + result = render_data(path) + assert result.returncode != 0 + assert result.stdout == "" + assert "failed to load environment file" in result.stderr + assert "synthetic-secret" not in result.stderr + + +def test_missing_file_fails(tmp_path): + result = render_data(tmp_path / "missing.env") + assert result.returncode != 0 + assert result.stdout == "" + + +def test_encoder_failure_does_not_publish_partial_values(tmp_path): + root = tmp_path / ".env" + root.write_text("FIRST=synthetic\nSECOND=value\n") + commands = tmp_path / "bin" + commands.mkdir() + encoder = commands / "base64" + encoder.write_text("#!/bin/sh\nexit 1\n") + encoder.chmod(0o755) + result = render_data(root, {"PATH": str(commands) + os.pathsep + os.environ["PATH"]}) + assert result.returncode != 0 + assert result.stdout == "" + + +def test_private_values_preserve_file_scope_and_final_credentials(tmp_path): + root = tmp_path / ".env" + root.write_text("SHARED=root\nMINIO_ACCESS_KEY=original\nHITL_ENABLED=true\nEMPTY=\n") + (tmp_path / "monitoring.env").write_text("SHARED=monitoring\nMONITOR_ONLY=yes\n") + output = tmp_path / "generated.yaml" + output.write_text("old: data\n") + output.chmod(0o644) + result = render_values(root, output, {"HOST_ONLY_SENTINEL": "must-not-leak", "SHARED": "host"}) + assert result.returncode == 0, result.stderr + assert output.stat().st_mode & 0o777 == 0o600 + values = yaml.safe_load(output.read_text()) + env_files = values["nexent-common"]["envFiles"] + assert decoded_data(yaml.safe_dump(env_files["rootData"])) == { + "SHARED": "root", "MINIO_ACCESS_KEY": "original", "HITL_ENABLED": "true", "EMPTY": "", + } + assert decoded_data(yaml.safe_dump(env_files["monitoringData"])) == { + "SHARED": "monitoring", "MONITOR_ONLY": "yes", + } + assert values["nexent-common"]["secrets"]["minio"]["accessKey"] == "final-access" + assert list(tmp_path.glob("generated.yaml.*")) == [] + + +@pytest.mark.parametrize("invalid_input", ["root", "monitoring"]) +def test_values_failure_preserves_previous_file(tmp_path, invalid_input): + root = tmp_path / ".env" + root.write_text("VALID=synthetic\n") + invalid = root if invalid_input == "root" else tmp_path / "monitoring.env" + invalid.write_text('SENSITIVE=synthetic-secret\nBROKEN="unterminated\n') + output = tmp_path / "generated.yaml" + output.write_text("previous: intact\n") + result = render_values(root, output) + assert result.returncode != 0 + assert output.read_text() == "previous: intact\n" + assert "synthetic-secret" not in result.stdout + result.stderr + + +def test_fresh_values_remove_keys_and_track_effective_changes(tmp_path): + root = tmp_path / ".env" + root.write_text("REMOVED=old\nCHANGED=old\nEMPTY=\n") + output = tmp_path / "generated.yaml" + assert render_values(root, output).returncode == 0 + first = yaml.safe_load(output.read_text()) + root.write_text("EMPTY=\nNEW=added\nCHANGED=new\n") + assert render_values(root, output).returncode == 0 + second = yaml.safe_load(output.read_text()) + assert decoded_data(yaml.safe_dump(second["nexent-common"]["envFiles"]["rootData"])) == { + "EMPTY": "", "NEW": "added", "CHANGED": "new", + } + assert second["nexent-common"]["envFiles"]["monitoringData"] == {} + assert first["global"]["rolloutChecksums"]["env"] != second["global"]["rolloutChecksums"]["env"] + root.write_text("# Reorder only\nCHANGED=new\n\nEMPTY=\nNEW=added\n") + assert render_values(root, output).returncode == 0 + third = yaml.safe_load(output.read_text()) + assert second["global"]["rolloutChecksums"]["env"] == third["global"]["rolloutChecksums"]["env"] + assert render_values(root, output, {"TEST_SANDBOX_MODE": "disabled"}).returncode == 0 + fourth = yaml.safe_load(output.read_text()) + assert third["global"]["rolloutChecksums"]["env"] != fourth["global"]["rolloutChecksums"]["env"] + + +def test_default_helm_has_empty_env_secrets_and_service_specific_references(): + documents = helm_documents() + secrets = {d["metadata"]["name"]: d for d in documents if d["kind"] == "Secret"} + assert secrets["nexent-env"]["data"] == {} + assert secrets["nexent-monitoring-env"]["data"] == {} + for deployment in [d for d in documents if d["kind"] == "Deployment"]: + name = deployment["metadata"]["name"].removeprefix("nexent-") + references = [v.get("secretRef", {}).get("name") + for v in deployment["spec"]["template"]["spec"]["containers"][0].get("envFrom", [])] + if name in BACKENDS: + expected = [None, "nexent-env"] + if name in ("config", "runtime"): + expected.append("nexent-monitoring-env") + assert references == expected + ["nexent-secrets"] + else: + assert "nexent-env" not in references + assert "nexent-monitoring-env" not in references + for document in helm_documents(infrastructure=True): + if document["kind"] == "Deployment": + for container in document["spec"]["template"]["spec"]["containers"]: + assert all(source.get("secretRef", {}).get("name") not in + ("nexent-env", "nexent-monitoring-env") for source in container.get("envFrom", [])) + + +def test_runtime_mapping_preserves_web_specific_configuration(tmp_path): + root = tmp_path / ".env" + root.write_text("TZ=Etc/UTC\nFILE_UPLOAD_SIZE_LIMIT=23\nNORTHBOUND_API_SERVER=http://synthetic-northbound:5013/api\n") + output = tmp_path / "runtime.yaml" + result = render_runtime_values(root, output) + assert result.returncode == 0, result.stderr + documents = helm_documents(output) + deployments = [d for d in documents if d["kind"] == "Deployment" and + d["metadata"]["name"].removeprefix("nexent-") in (*BACKENDS, "web")] + for deployment in deployments: + _, effective = container_environment(documents, deployment) + assert effective["TZ"] == "Etc/UTC" + if deployment["metadata"]["name"] == "nexent-web": + assert effective["FILE_UPLOAD_SIZE_LIMIT"] == "23" + assert effective["NORTHBOUND_API_SERVER"] == "http://synthetic-northbound:5013/api" + + +def test_backend_configuration_uses_secret_without_fixed_runtime_copies(tmp_path): + root = tmp_path / ".env" + expected = { + "CAS_DEFAULT_TENANT_ID": "synthetic-tenant", "CAS_DEFAULT_ROLE": "ADMIN", + "CAS_HEARTBEAT_INTERVAL_SECONDS": "71", "GITHUB_OAUTH_CLIENT_SECRET": "synthetic-oauth", + "TOKEN": "synthetic-voice", "DP_PARSE_THREADS_PER_PROCESS": "5", + "CLIP_MODEL_PATH": "/synthetic/model", "SKILLS_PATH": "/synthetic/skills", + "RUNTIME_AGENT_THREAD_MAX_WORKERS": "17", "UNKNOWN_NEW_FEATURE": "enabled", + "CAS_ROLE_MAP_JSON": '{"team":"ADMIN"}', "CAS_SERVER_URL": "", + } + root.write_text("\n".join(key + "='" + value + "'" for key, value in expected.items()) + "\n") + runtime = tmp_path / "runtime.yaml" + secrets = tmp_path / "secrets.yaml" + assert render_runtime_values(root, runtime).returncode == 0 + assert render_values(root, secrets).returncode == 0 + common = yaml.safe_load(runtime.read_text())["nexent-common"]["config"] + assert common == { + "timezone": "Asia/Shanghai", "services": {"northboundServer": "http://nexent-northbound:5013/api"}, + "redis": {"url": "redis://nexent-redis:6379/0"}, "dataProcess": {"partProcessorCount": "3"}, + } + documents = helm_documents([runtime, secrets]) + configmap = next(d for d in documents if d["kind"] == "ConfigMap" and d["metadata"]["name"] == "nexent-config") + assert configmap["data"]["CAS_DEFAULT_TENANT_ID"] == "tenant_id" + assert configmap["data"]["GITHUB_OAUTH_CLIENT_SECRET"] == "" + for deployment in [d for d in documents if d["kind"] == "Deployment" and + d["metadata"]["name"].removeprefix("nexent-") in BACKENDS]: + _, effective = container_environment(documents, deployment) + for key, value in expected.items(): + assert effective[key] == value + assert effective["MINIO_ACCESS_KEY"] == "final-access" + + # Removing an override restores the Chart fallback through a fresh Secret map. + root.write_text("UNKNOWN_NEW_FEATURE=enabled\n") + assert render_runtime_values(root, runtime).returncode == 0 + assert render_values(root, secrets).returncode == 0 + documents = helm_documents([runtime, secrets]) + deployment = next(d for d in documents if d["kind"] == "Deployment" and d["metadata"]["name"] == "nexent-config") + _, effective = container_environment(documents, deployment) + assert effective["CAS_DEFAULT_TENANT_ID"] == "tenant_id" + assert effective["GITHUB_OAUTH_CLIENT_SECRET"] == "" + assert effective["CLIP_MODEL_PATH"] == "/opt/models/clip-vit-base-patch32" + + +@pytest.mark.parametrize("count,redis_url", [("7", "redis://synthetic:6379/4"), ("0", "")]) +def test_cleanup_retains_derived_defaults_and_empty_inputs(tmp_path, count, redis_url): + root = tmp_path / ".env" + root.write_text("DP_PART_PROCESSOR_COUNT=" + count + "\nREDIS_URL='" + redis_url + "'\n") + runtime = tmp_path / "runtime.yaml" + secrets = tmp_path / "secrets.yaml" + assert render_runtime_values(root, runtime).returncode == 0 + assert render_values(root, secrets).returncode == 0 + documents = helm_documents([runtime, secrets]) + deployment = next(d for d in documents if d["kind"] == "Deployment" and d["metadata"]["name"] == "nexent-data-process") + _, effective = container_environment(documents, deployment) + assert effective["WORKER_CONCURRENCY"] == str(int(count) + 1) + assert effective["RUNTIME_STATE_REDIS_URL"] == redis_url + + +def test_direct_helm_configuration_retains_defaults_and_custom_overrides(tmp_path): + output = tmp_path / "direct-helm.yaml" + output.write_text(yaml.safe_dump({"nexent-common": {"config": { + "cas": {"defaultTenantId": "helm-tenant"}, "redis": {"url": "redis://helm:6379/3"}, + "dataProcess": {"partProcessorCount": "6"}, + }}})) + documents = helm_documents(output) + deployment = next(d for d in documents if d["kind"] == "Deployment" and d["metadata"]["name"] == "nexent-config") + _, effective = container_environment(documents, deployment) + assert effective["CAS_DEFAULT_TENANT_ID"] == "helm-tenant" + assert effective["RUNTIME_STATE_REDIS_URL"] == "redis://helm:6379/3" + assert effective["WORKER_CONCURRENCY"] == "7" + assert effective["CAS_DEFAULT_ROLE"] == "USER" + assert effective["NORTHBOUND_API_SERVER"] == "http://nexent-northbound:5013/api" + + +@pytest.mark.parametrize("deployment_version", ["speed", "full"]) +@pytest.mark.parametrize("sandbox_mode,level", [("lightweight", "docker"), ("disabled", "local"), ("full", "docker")]) +def test_helm_composition_preserves_precedence_and_platform_settings(tmp_path, deployment_version, sandbox_mode, level): + root = tmp_path / ".env" + root.write_text("""SHARED=root +MINIO_ACCESS_KEY=original +EMPTY= +UNKNOWN_NEW_FEATURE=enabled +IS_DEPLOYED_BY_KUBERNETES=false +KUBERNETES_NAMESPACE=wrong +DEPLOYMENT_VERSION=wrong +NEXENT_SQL_STARTUP_MODE=wrong +NEXENT_SQL_FILES_CHECKSUM=wrong +NEXENT_MCP_DOCKER_IMAGE=wrong +NEXENT_SANDBOX_DOCKER_IMAGE=wrong +NEXENT_SANDBOX_DEFAULT_LEVEL=wrong +""") + (tmp_path / "monitoring.env").write_text("SHARED=monitoring\nMONITOR_ONLY=yes\nMINIO_ACCESS_KEY=monitor-credential\n") + output = tmp_path / "generated.yaml" + result = render_values(root, output) + assert result.returncode == 0, result.stderr + values = yaml.safe_load(output.read_text()) + values["global"].update(namespace="synthetic-namespace", deploymentVersion=deployment_version, sandboxMode=sandbox_mode) + output.write_text(yaml.safe_dump(values)) + documents = helm_documents(output) + deployments = [d for d in documents if d["kind"] == "Deployment" and + d["metadata"]["name"].removeprefix("nexent-") in BACKENDS] + assert len(deployments) == 5 + for deployment in deployments: + name = deployment["metadata"]["name"].removeprefix("nexent-") + container, effective = container_environment(documents, deployment) + assert effective["SHARED"] == ("monitoring" if name in ("config", "runtime") else "root") + assert ("MONITOR_ONLY" in effective) == (name in ("config", "runtime")) + assert effective["MINIO_ACCESS_KEY"] == "final-access" + assert effective["EMPTY"] == "" + assert effective["UNKNOWN_NEW_FEATURE"] == "enabled" + assert effective["IS_DEPLOYED_BY_KUBERNETES"] == "true" + assert effective["KUBERNETES_NAMESPACE"] == "synthetic-namespace" + assert effective["DEPLOYMENT_VERSION"] == deployment_version + assert effective["NEXENT_SQL_STARTUP_MODE"] == {"config": "migrate", "data-process": "off"}.get(name, "wait") + assert effective["NEXENT_SQL_FILES_CHECKSUM"] == "synthetic-sql-checksum" + assert effective["NEXENT_MCP_DOCKER_IMAGE"] == "nexent/nexent-mcp:latest" + assert effective["NEXENT_SANDBOX_DOCKER_IMAGE"] == "nexent/nexent-sandbox:latest" + assert effective["NEXENT_SANDBOX_DEFAULT_LEVEL"] == level + assert effective["TZ"] == "Asia/Shanghai" + names = [v["name"] for v in container["env"]] + assert len(names) == len(set(names)) + assert deployment["spec"]["template"]["metadata"]["annotations"]["checksum/nexent-env"] == values["global"]["rolloutChecksums"]["env"] From 98914acd075b834927811984688763d4d0b02a92 Mon Sep 17 00:00:00 2001 From: hhhhsc <1710496817@qq.com> Date: Wed, 14 Oct 2026 17:00:51 +0800 Subject: [PATCH 2/3] fix(deploy): refresh version defaults and include runtime utilities --- .../dockerfiles/data-process/Dockerfile | 4 + deploy/images/dockerfiles/docs/Dockerfile | 2 + deploy/images/dockerfiles/main/Dockerfile | 4 + deploy/images/dockerfiles/mcp/Dockerfile | 10 +- .../dockerfiles/sandbox-full/Dockerfile | 7 +- deploy/images/dockerfiles/sandbox/Dockerfile | 7 +- deploy/images/dockerfiles/terminal/Dockerfile | 5 +- deploy/images/dockerfiles/web/Dockerfile | 4 +- deploy/k8s/deploy.sh | 12 +- test-e2e/cases/DEP-IMAGE-BASE-TOOLS/case.yaml | 58 ++++++++++ .../changes/bugs/K8S-ENV-INJECTION/design.md | 74 ++++++++++++ .../changes/bugs/K8S-VERSION-DEFAULT.yaml | 35 ++++++ .../requirements/IMAGE-BASE-TOOLS.yaml | 23 ++++ test-e2e/features/F-173/cases.md | 1 + test-e2e/features/F-173/feature.yaml | 2 + .../deploy/test_k8s_version_selection.py | 109 ++++++++++++++++++ 16 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 test-e2e/cases/DEP-IMAGE-BASE-TOOLS/case.yaml create mode 100644 test-e2e/changes/bugs/K8S-VERSION-DEFAULT.yaml create mode 100644 test-e2e/changes/requirements/IMAGE-BASE-TOOLS.yaml create mode 100644 test/backend/deploy/test_k8s_version_selection.py diff --git a/deploy/images/dockerfiles/data-process/Dockerfile b/deploy/images/dockerfiles/data-process/Dockerfile index 5fd62e3ab2..16da00ef5a 100644 --- a/deploy/images/dockerfiles/data-process/Dockerfile +++ b/deploy/images/dockerfiles/data-process/Dockerfile @@ -28,6 +28,10 @@ RUN --mount=type=cache,id=nexent-data-process-apt-cache-${TARGETARCH},target=/va apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --fix-missing \ curl \ + vim \ + procps \ + wget \ + less \ tzdata \ postgresql-client \ libmagic1 \ diff --git a/deploy/images/dockerfiles/docs/Dockerfile b/deploy/images/dockerfiles/docs/Dockerfile index 9e5b15d082..9ea740d14e 100644 --- a/deploy/images/dockerfiles/docs/Dockerfile +++ b/deploy/images/dockerfiles/docs/Dockerfile @@ -32,6 +32,8 @@ RUN if [ "$APK_MIRROR" = "tsinghua" ]; then \ ' }' \ '}' > /etc/nginx/conf.d/default.conf +RUN apk add --no-cache curl vim procps-ng wget less + COPY --from=builder /app/docs/.vitepress/dist /usr/share/nginx/html EXPOSE 4173 diff --git a/deploy/images/dockerfiles/main/Dockerfile b/deploy/images/dockerfiles/main/Dockerfile index 8ac4947a29..f0156abd7f 100644 --- a/deploy/images/dockerfiles/main/Dockerfile +++ b/deploy/images/dockerfiles/main/Dockerfile @@ -27,6 +27,10 @@ RUN --mount=type=cache,id=nexent-main-apt-cache-${TARGETARCH},target=/var/cache/ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ curl \ + vim \ + procps \ + wget \ + less \ tzdata \ postgresql-client \ fontconfig \ diff --git a/deploy/images/dockerfiles/mcp/Dockerfile b/deploy/images/dockerfiles/mcp/Dockerfile index b99aa51097..3e466e0e1a 100644 --- a/deploy/images/dockerfiles/mcp/Dockerfile +++ b/deploy/images/dockerfiles/mcp/Dockerfile @@ -24,7 +24,15 @@ RUN --mount=type=cache,id=nexent-mcp-apt-cache-${TARGETARCH},target=/var/cache/a done; \ fi && \ apt-get update && \ - apt-get install -y --no-install-recommends curl ca-certificates gnupg xz-utils + apt-get install -y --no-install-recommends \ + curl \ + vim \ + procps \ + wget \ + less \ + ca-certificates \ + gnupg \ + xz-utils # Install Node.js 20 from official binaries (pin exact version to avoid repo issues) ARG NODE_VERSION=20.17.0 diff --git a/deploy/images/dockerfiles/sandbox-full/Dockerfile b/deploy/images/dockerfiles/sandbox-full/Dockerfile index 76802f0870..13fa5e31d1 100644 --- a/deploy/images/dockerfiles/sandbox-full/Dockerfile +++ b/deploy/images/dockerfiles/sandbox-full/Dockerfile @@ -155,8 +155,7 @@ LABEL org.opencontainers.image.title="Nexent Sandbox Full" \ USER root RUN umask 0022 -# Minimal apt surface: curl is required by some tools to fetch the -# /files endpoints; libgomp1 is needed by numpy/scipy wheels. +# Install runtime utilities and document-processing dependencies. RUN --mount=type=cache,id=nexent-sandbox-apt-cache-${TARGETARCH},target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=nexent-sandbox-apt-lists-${TARGETARCH},target=/var/lib/apt/lists,sharing=locked \ rm -f /etc/apt/apt.conf.d/docker-clean && \ @@ -174,6 +173,10 @@ RUN --mount=type=cache,id=nexent-sandbox-apt-cache-${TARGETARCH},target=/var/cac apt-get update && \ apt-get install -y --no-install-recommends \ curl \ + vim \ + procps \ + wget \ + less \ fontconfig \ fonts-dejavu-core \ fonts-liberation \ diff --git a/deploy/images/dockerfiles/sandbox/Dockerfile b/deploy/images/dockerfiles/sandbox/Dockerfile index eca351f6d1..275c428e3a 100644 --- a/deploy/images/dockerfiles/sandbox/Dockerfile +++ b/deploy/images/dockerfiles/sandbox/Dockerfile @@ -77,8 +77,7 @@ LABEL org.opencontainers.image.title="Nexent Sandbox" \ USER root RUN umask 0022 -# Minimal apt surface: curl is required by some tools to fetch the -# /files endpoints; libgomp1 is needed by numpy/scipy wheels. +# Install runtime utilities and libgomp1 for numpy/scipy wheels. RUN --mount=type=cache,id=nexent-sandbox-apt-cache-${TARGETARCH},target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=nexent-sandbox-apt-lists-${TARGETARCH},target=/var/lib/apt/lists,sharing=locked \ rm -f /etc/apt/apt.conf.d/docker-clean && \ @@ -96,6 +95,10 @@ RUN --mount=type=cache,id=nexent-sandbox-apt-cache-${TARGETARCH},target=/var/cac apt-get update && \ apt-get install -y --no-install-recommends \ curl \ + vim \ + procps \ + wget \ + less \ libgomp1 \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean diff --git a/deploy/images/dockerfiles/terminal/Dockerfile b/deploy/images/dockerfiles/terminal/Dockerfile index 220ef70b4d..70f87fc1ae 100644 --- a/deploy/images/dockerfiles/terminal/Dockerfile +++ b/deploy/images/dockerfiles/terminal/Dockerfile @@ -19,12 +19,15 @@ RUN --mount=type=cache,id=nexent-terminal-apt-cache-${TARGETARCH},target=/var/ca openssh-server \ curl \ wget \ + vim \ + procps \ + less \ git \ python3 \ python3-pip \ python3-venv && \ if [ "$TERMINAL_VARIANT" = "conda" ]; then \ - apt-get install -y --no-install-recommends vim build-essential; \ + apt-get install -y --no-install-recommends build-essential; \ fi # Configure SSH - enable root login + enable password authentication. diff --git a/deploy/images/dockerfiles/web/Dockerfile b/deploy/images/dockerfiles/web/Dockerfile index 471eba3862..92bb44f718 100644 --- a/deploy/images/dockerfiles/web/Dockerfile +++ b/deploy/images/dockerfiles/web/Dockerfile @@ -66,13 +66,13 @@ RUN if [ "$APK_MIRROR" = "tsinghua" ]; then \ echo "https://mirrors.tuna.tsinghua.edu.cn/alpine/latest-stable/community" >> /etc/apk/repositories; \ fi -# Update package index, upgrade busybox first, then install curl +# Update package index, upgrade busybox first, then install runtime utilities # This avoids trigger script issues in cross-platform builds with QEMU emulation RUN --mount=type=cache,id=nexent-web-apk-${TARGETARCH},target=/var/cache/apk,sharing=locked \ mkdir -p /var/cache/apk && \ apk update && \ (apk upgrade busybox || true) && \ - apk add --no-scripts curl vim tzdata + apk add --no-scripts curl vim procps-ng wget less tzdata WORKDIR /opt/frontend-dist diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh index 48488c108d..2c83e88577 100755 --- a/deploy/k8s/deploy.sh +++ b/deploy/k8s/deploy.sh @@ -471,7 +471,7 @@ render_k8s_runtime_config_values() { } > "$output_file" } -# Get APP_VERSION from backend/consts/const.py +# Read the current repository VERSION through the shared resolver. get_app_version() { if declare -F deployment_read_version >/dev/null 2>&1; then deployment_read_version "" @@ -496,7 +496,6 @@ persist_deploy_options() { deployment_persist_local_config "$DEPLOY_OPTIONS_FILE" { printf 'k8s:\n' - printf ' appVersion: %s\n' "$(yaml_quote "$APP_VERSION")" printf ' isMainland: %s\n' "$(yaml_quote "$IS_MAINLAND_SAVED")" printf ' deploymentVersion: %s\n' "$(yaml_quote "$VERSION_CHOICE_SAVED")" printf ' persistenceMode: %s\n' "$(yaml_quote "$PERSISTENCE_MODE")" @@ -524,7 +523,8 @@ apply_loaded_deploy_option() { local value="$2" case "$key" in APP_VERSION|appVersion) - [ -n "${K8S_APP_VERSION_EXPLICIT:-}" ] || APP_VERSION="$value" + # Saved release versions must not pin subsequent deployments. + return 0 ;; IS_MAINLAND|isMainland) [ -n "${K8S_IS_MAINLAND_EXPLICIT:-}" ] || IS_MAINLAND="$value" @@ -629,7 +629,7 @@ update_values_yaml() { fi if [ -z "$APP_VERSION" ]; then - echo "Failed to determine APP_VERSION from const.py, using 'latest'" + echo "Failed to determine APP_VERSION from VERSION, using 'latest'" APP_VERSION="latest" fi echo "Using APP_VERSION: $APP_VERSION" @@ -1515,7 +1515,7 @@ print_usage() { echo " --image-source SOURCE general、mainland 或 local-latest" echo " --image-registry-prefix P 镜像仓库前缀,例如 registry.example.com/nexent" echo " --is-mainland Y|N 兼容旧参数,映射为 mainland/general 镜像源" - echo " --version VERSION 指定应用版本(未设置时自动从 const.py 检测)" + echo " --version VERSION 指定应用版本(未设置时读取当前仓库 VERSION)" echo " --defaults 复用保存配置或内置默认值并跳过交互界面" echo " --deployment-version VER 兼容旧部署版本:speed 或 full" echo " --persistence-mode MODE local、dynamic 或 existing" @@ -1549,7 +1549,7 @@ print_usage() { echo " --image-source SOURCE general, mainland, or local-latest" echo " --image-registry-prefix P Image registry prefix, e.g. registry.example.com/nexent" echo " --is-mainland Y|N Legacy alias for image source mainland/general" - echo " --version VERSION Specify app version (auto-detected from const.py if not set)" + echo " --version VERSION Specify app version (defaults to the current repository VERSION)" echo " --defaults Use saved config or built-in defaults and skip TUI" echo " --deployment-version VER Legacy deployment version: speed or full" echo " --persistence-mode MODE local, dynamic, or existing" diff --git a/test-e2e/cases/DEP-IMAGE-BASE-TOOLS/case.yaml b/test-e2e/cases/DEP-IMAGE-BASE-TOOLS/case.yaml new file mode 100644 index 0000000000..b5e68abec5 --- /dev/null +++ b/test-e2e/cases/DEP-IMAGE-BASE-TOOLS/case.yaml @@ -0,0 +1,58 @@ +schema_version: '1.0' +stage: D5 +module: V5-BASELINE +case: + case_id: DEP-IMAGE-BASE-TOOLS + feature_id: F-173 + requirement_ids: [IMAGE-BASE-TOOLS] + business_rule_ids: [F-173-RULE-03] + title: Runtime utility installation layers and sandbox user access + type: DEPLOYMENT + priority: P2 + status: manual + automation: manual + objective: Verify actual OS package installation and command execution without building or starting the complete application stack. + preconditions: + - Docker is available, and the declared base images and their package repositories are reachable. + - Use a temporary directory outside Git and uniquely named disposable containers or probe images. + test_data: + base_images: [python:3.11-slim, ubuntu:24.04, node:22-alpine, nginx:1.27-alpine] + apt_packages: [vim, procps, curl, wget, less] + apk_packages: [vim, procps-ng, curl, wget, less] + runtime_images: [main, web, docs, data-process, mcp, terminal, sandbox, sandbox-full] + variants: [data-process-cpu, data-process-gpu, terminal-slim, terminal-conda] + commands: [vim, ps, top, curl, wget, less] + steps: + - order: 1 + action: Inspect each runtime Dockerfile, including inherited base stages. Confirm all six commands are installed in a stage inherited by the final image; terminal tools must be outside the conda conditional and data-process tools outside the CPU/GPU conditional. + expected: All eight runtime paths and four variants retain the utility packages; sandbox final USER remains sandbox. + - order: 2 + action: In disposable containers based on the four declared base images, use the corresponding apt/apk utility packages and existing installation flags. Preserve each base image's package repositories and record its image ID and OS release. + expected: All four real package installations exit zero; the Alpine installations include procps-ng rather than relying on BusyBox ps/top. + - order: 3 + action: Check command -v for vim, ps, top, curl, wget and less. Run vim/ps/curl/wget/less version checks, ps -ef and top -b -n 1. Use vim in noninteractive mode to write a temporary file, then read it through less and compare its contents. + expected: All six commands execute successfully, ps supports -ef, top produces a process snapshot, and vim/less preserve the expected file contents. + - order: 4 + action: Repeat the command checks in the Debian probe as UID/GID 1000 without elevated privileges, matching the sandbox runtime user. Save sanitized evidence outside Git and remove disposable containers/probe images. + expected: Sandbox-equivalent ordinary users execute every command; the existing stack and repository runtime configuration are unchanged. + expected_results: + - All runtime inheritance paths and variants include the utility installation layer. + - All four base-system installations and six command checks pass. + - Sandbox-equivalent non-root command checks pass. + - Evidence explicitly covers installation layers, not full application-image builds or application startup. + forbidden_side_effects: + - Do not publish images, restart existing containers, redeploy Kubernetes or rewrite local deployment configuration. + - Do not log application secrets or leave disposable test containers/images behind. + risk_type: DEPLOYMENT + risk: Tools installed only in a builder or optional variant are absent when troubleshooting runtime containers. + condition: + - Verify the final runtime inheritance graph and real package installation on Debian, Ubuntu and both Alpine bases. + - Verify unprivileged execution for the Debian-based sandbox runtime. + metrics: + - Number of runtime Dockerfiles with inherited utility packages. + - Number of successful base-system installation probes and non-root command probes. + thresholds: + - Eight runtime Dockerfiles and all four variants have the common tools. + - Four base-system probes and one non-root probe pass every declared command check. + recovery_expectations: + - Disposable containers and probe images are removed, with local evidence retained outside Git. diff --git a/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md b/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md index e1caa5f9bb..7418e2ac8d 100644 --- a/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md +++ b/test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md @@ -1,5 +1,79 @@ # Design — Kubernetes deployment environment injection +## K8S deployment version selection — K8S-VERSION-DEFAULT + +This follow-up reuses the existing deployment configuration design path and +the owning `F-173` capability. Inspection on 2026-10-14 covered the unavailable +external `nexent-doc` directory, the repository-local deployment design, +`deploy/common/version.sh`, the K8s options loader and actual existing UT/formal +assertions. The user observed `app_version=v2.5.0` and requires the current +repository `VERSION`, `v2.7.0`, to be displayed by default. + +The coherent configuration boundary reads the current version and command-line +arguments, reloads deployment/storage preferences, then supplies the resolved +version to deployment output and shared image selection. Application versions +are not durable machine preferences: absent an explicit `--version` or +`--app-version`, the repository `VERSION` is authoritative. Explicit arguments +still select a requested release or `latest`. Saved storage mode, StorageClass, +registry and component choices retain their existing behavior. Malformed or +unrecognized saved version values must never be executed or used as a default. + +The defect is in `apply_loaded_deploy_option`: restoring nested `k8s.appVersion` +or legacy `APP_VERSION` populates the variable before the current-version +fallback. Ignore these historical version keys and stop writing `k8s.appVersion` +in `persist_deploy_options`. Preserve the existing version resolver and explicit +CLI parsing. Refresh help text that incorrectly identifies `const.py` as the +default source. No cluster resource or local credential/preference file needs +to be rewritten for this code correction. + +| AC | Observable requirement | Traditional UT | Formal case | Result | +| --- | --- | --- | --- | --- | +| VERSION-AC-001 | Nested YAML and legacy shell saved v2.5.0 cannot override current VERSION; other storage preferences still reload. | `test_saved_version_does_not_override_current_repository_version` | N/A: local option rule | PASS | +| VERSION-AC-002 | Explicit --version and --app-version remain authoritative, including latest; missing/empty options use current VERSION. | `test_explicit_version_wins_over_saved_and_repository_versions`; `test_missing_or_empty_saved_options_use_current_repository_version` | N/A: local option rule | PASS | +| VERSION-AC-003 | Persisted options retain deployment/storage choices and omit application versions. | `test_persisted_options_do_not_pin_an_application_version` | DEP-AUTO-7177AC18CCE799DD, unchanged adjacent regression | UT PASS; formal script PASS, historical acceptance completeness UNREVIEWED | + +| Feature / rule / AC | Traditional UT | D1 | D2 | D3 | D4 | D5 | Notes | +| --- | --- | --- | --- | --- | --- | --- | --- | +| F-173 / VERSION-AC-001,002 | Add version precedence and option-loader assertions | N/A | N/A | N/A | N/A | N/A | This unit defect does not change deployment resources or introduce another proof boundary. | +| Retained saved configuration / VERSION-AC-003 | Add K8s persistence assertions; existing common-script version assertions inspected | N/A | N/A | N/A | N/A | DEP-AUTO-7177AC18CCE799DD | Reuse unchanged sandbox option/persistence regression; it does not establish version acceptance. | + +The existing shared-shell tests ignore top-level `appVersion`, but neither those +assertions nor the sandbox formal script invokes the K8s nested/legacy version +loader. New traditional UT supplies the missing regression without creating a +duplicate formal Case. Keep UT evidence separate from the unchanged D5 result. +Only current version-selection acceptance and its adjacent persistence +regression are selected; the original live env-injection acceptance below is +unaffected. Image rebuilds and Supabase expiry configuration are separate issues. + +Verification on 2026-10-14: + +- Before the product fix, `backend/.venv/bin/python -m pytest + test/backend/deploy/test_k8s_version_selection.py -q` reported **3 failed, + 6 passed**. The nested YAML and legacy shell regressions both returned + `v2.5.0` instead of the current `v2.7.0`; serialization retained `appVersion`. +- After the fix, the deployment UT selection reported **33 passed**, including + all nine new version variants and 24 existing env-injection tests. The first + broader attempt encountered 13 environment failures: the Windows-mounted + temporary directory could not enforce mode 0600 and Snap's launcher could not + start Helm. The successful run used the same configured backend interpreter, + an exact temporary Linux copy of tracked deployment/test sources plus the new + test, and `/snap/helm/current/helm` directly. Local secrets were excluded. +- A read-only invocation of the actual `apply_deployment_common_config` with the + existing saved `k8s.appVersion=v2.5.0` resolved **APP_VERSION=v2.7.0**. +- Strict design validation with `--generate --require-ut-change + K8S-VERSION-DEFAULT` and implementation validation with + `--require-ut-change K8S-VERSION-DEFAULT` both passed. Generated views are + unchanged, and Bash syntax / Git whitespace checks passed. +- The unchanged formal `DEP-AUTO-7177AC18CCE799DD` script passed through + `test-e2e/infra/tools/run_cases.py` using a dedicated, case-specific temporary + Python runtime. Evidence is under + `/tmp/nexent-k8s-version-tests/runs/repository-local/20261014T074307536399Z-DEP-AUTO-7177AC18CCE799DD`. + This is adjacent sandbox/configuration regression execution; its historical + acceptance completeness remains UNREVIEWED and it does not prove the new + version rule. The traditional UT above proves that rule. + +No existing cluster, local `.env`, saved `deploy.options`, SQL or CI was changed. + ## Context and functional boundary Change: `K8S-ENV-INJECTION`; owner: `F-173`, rule `F-173-RULE-02`. diff --git a/test-e2e/changes/bugs/K8S-VERSION-DEFAULT.yaml b/test-e2e/changes/bugs/K8S-VERSION-DEFAULT.yaml new file mode 100644 index 0000000000..c79301b144 --- /dev/null +++ b/test-e2e/changes/bugs/K8S-VERSION-DEFAULT.yaml @@ -0,0 +1,35 @@ +schema_version: '1.0' +change: + change_id: K8S-VERSION-DEFAULT + change_type: bugfix + bug_id: K8S-VERSION-DEFAULT + title: Use the current repository version when restoring Kubernetes deployment preferences + description: Ignore saved application versions in both legacy shell and nested Kubernetes options and stop persisting a version that can become stale. + observed_behavior: A saved k8s.appVersion or legacy APP_VERSION of v2.5.0 overrides the current VERSION v2.7.0 before the deployment configuration and status output are resolved. + expected_behavior: Without an explicit version argument, the deployment uses the current repository VERSION. Explicit --version and --app-version arguments remain authoritative; other saved deployment preferences still reload. + escaped_stage: unknown + coverage_gap: + type: missing_assertion + description: deploy/tests/test_common.sh proves that a top-level appVersion cannot override the shared current version, but no test exercises the K8s-specific nested or legacy option loader. Add traditional UT for that unit contract; the unchanged sandbox D5 Case provides adjacent configuration and persistence regression coverage. + feature_contract_changed: false + affected_features: + existing: [F-173, V5-DEP-AUTO-7177AC18CCE799DD] + added: [] + modified: [] + retired: [] + affected_cases: + existing: [DEP-AUTO-7177AC18CCE799DD] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Ignore obsolete nested YAML and legacy shell application versions while retaining saved storage preferences. + - Preserve explicit --version and --app-version choices, including the latest image tag. + - Use the current repository version with missing or empty saved options. + - Persist other deployment options without saving an application version. + reason: Version precedence and preference serialization are local shell configuration contracts. Existing formal sandbox coverage is reused for adjacent persistence behavior; this fix changes no Pod, API, browser or provider contract and requires no additional formal Case. + test_selectors: + - test/backend/deploy/test_k8s_version_selection.py + notes: 'Design and acceptance evidence reuse the existing deployment configuration baseline at test-e2e/changes/bugs/K8S-ENV-INJECTION/design.md, section K8S deployment version selection. The external nexent-doc directory is unavailable; preserve this existing design path. The live .env and deploy.options are diagnostic inputs only; do not rewrite them or redeploy the current cluster. Image freshness and the separately observed Supabase JWT expiry mismatch remain outside this version-selection fix.' diff --git a/test-e2e/changes/requirements/IMAGE-BASE-TOOLS.yaml b/test-e2e/changes/requirements/IMAGE-BASE-TOOLS.yaml new file mode 100644 index 0000000000..e5f3ab41a4 --- /dev/null +++ b/test-e2e/changes/requirements/IMAGE-BASE-TOOLS.yaml @@ -0,0 +1,23 @@ +schema_version: '1.0' +change: + change_id: IMAGE-BASE-TOOLS + change_type: requirement + requirement_id: IMAGE-BASE-TOOLS + title: Include basic troubleshooting utilities in every repository-built runtime image + description: Extend existing apt/apk package installation layers to provide vim, ps, top, curl, wget and less; move terminal vim into the shared slim/conda package list. + affected_features: + existing: [] + added: [] + modified: [F-173] + retired: [] + affected_cases: + existing: [] + added: [DEP-IMAGE-BASE-TOOLS] + modified: [] + retired: [] + traditional_ut: + decision: exempt + behaviors: + - No Python, frontend or shell unit behavior changes; only runtime OS package manifests change. + reason: This package-manifest-only change does not modify Python, frontend or shell unit behavior. Existing deploy/tests/test_images_build.sh checks build dispatch, arguments and variant selection separately; real package installation and command execution belong to the manual D5 boundary, not package-name assertions in Python UT. + notes: 'Approved implementation plan: update eight runtime Dockerfiles using vim/procps/curl/wget/less on Debian/Ubuntu and vim/procps-ng/curl/wget/less on Alpine. Reuse existing mirrors, caches and cleanup; preserve final sandbox users and Docker 18.09 compatibility. Validate installation layers on the four declared base images and inspect all runtime inheritance paths. Full application-image rebuilding, publishing and deployment are outside this change. Keep local evidence outside Git. D1-D4 are not applicable because no component, API, service or UI behavior changes. The sibling nexent-doc repository is unavailable; the user-approved plan supplies current-change acceptance, while canonical SPEC documentation remains unavailable.' diff --git a/test-e2e/features/F-173/cases.md b/test-e2e/features/F-173/cases.md index 572d499e55..9c4d018c0f 100644 --- a/test-e2e/features/F-173/cases.md +++ b/test-e2e/features/F-173/cases.md @@ -2,4 +2,5 @@ 此页由结构化用例生成,不手工编辑。 +- D5 [DEP-IMAGE-BASE-TOOLS](../../cases/DEP-IMAGE-BASE-TOOLS/case.yaml) · manual - D5 [K8S-ENV-D5-001](../../cases/K8S-ENV-D5-001/case.yaml) · active diff --git a/test-e2e/features/F-173/feature.yaml b/test-e2e/features/F-173/feature.yaml index 8482b4fcca..6451e61b5c 100644 --- a/test-e2e/features/F-173/feature.yaml +++ b/test-e2e/features/F-173/feature.yaml @@ -12,6 +12,8 @@ feature: description: Docker/K8s/Helm/离线部署与卸载:编排、镜像、持久卷、备份恢复、沙箱模式与离线资产 - rule_id: F-173-RULE-02 description: K8s 的五个后端服务通过独立 Secret 加载完整部署 .env;仅 Config、Runtime 叠加 monitoring.env。文件配置覆盖 Chart 默认值,最终凭据和容器显式参数优先;更新部署后新增、修改及删除的变量均生效,Web 和基础设施不引用完整 env Secret。 + - rule_id: F-173-RULE-03 + description: All eight repository-built runtime images provide vim, ps, top, curl, wget and less, including data-process CPU/GPU and terminal slim/conda variants. Sandbox users can execute these tools without root privileges. required_test_stages: [] source: notes: Migrated from V5 01_代码功能清单 without changing the functional description. diff --git a/test/backend/deploy/test_k8s_version_selection.py b/test/backend/deploy/test_k8s_version_selection.py new file mode 100644 index 0000000000..53423d079b --- /dev/null +++ b/test/backend/deploy/test_k8s_version_selection.py @@ -0,0 +1,109 @@ +"""Regression contracts for current-version selection in Kubernetes deployment.""" + +import os +from pathlib import Path +import subprocess + +import pytest +import yaml + + +REPO = Path(__file__).resolve().parents[3] +DEPLOY = REPO / "deploy/k8s/deploy.sh" +CURRENT_VERSION = (REPO / "VERSION").read_text().strip() + + +def resolve_options(options, *arguments): + environment = os.environ.copy() + environment.update(DEPLOYMENT_LANG="en", NEXENT_DEPLOY_CONFIG_MODE="defaults") + script = r''' +set -e +source "$1" --help "${@:3}" >/dev/null +DEPLOY_OPTIONS_FILE="$2" +ORIGINAL_ARGS=(--defaults --local-config "$2" "${@:3}") +apply_deployment_common_config >/dev/null +printf 'app_version: %s\nshared_version: %s\npersistence_mode: %s\nstorage_class: %s\n' \ + "$APP_VERSION" "$DEPLOYMENT_APP_VERSION" "$PERSISTENCE_MODE" "$STORAGE_CLASS_NAME" +''' + result = subprocess.run( + ["bash", "-c", script, "k8s-version-test", str(DEPLOY), str(options), *arguments], + env=environment, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return yaml.safe_load(result.stdout) + + +@pytest.mark.parametrize("saved_format", ["yaml", "legacy"]) +def test_saved_version_does_not_override_current_repository_version(tmp_path, saved_format): + options = tmp_path / "deploy.options" + if saved_format == "yaml": + options.write_text('k8s:\n appVersion: "v2.5.0"\n persistenceMode: "dynamic"\n' + ' storageClassName: "synthetic-storage"\n') + else: + options.write_text('APP_VERSION="v2.5.0"\nPERSISTENCE_MODE="dynamic"\n' + 'STORAGE_CLASS_NAME="synthetic-storage"\n') + + actual = resolve_options(options) + + assert actual["app_version"] == CURRENT_VERSION + assert actual["shared_version"] == CURRENT_VERSION + assert actual["persistence_mode"] == "dynamic" + assert actual["storage_class"] == "synthetic-storage" + + +@pytest.mark.parametrize("argument", ["--version", "--app-version"]) +@pytest.mark.parametrize("version", ["v9.8.7", "latest"]) +def test_explicit_version_wins_over_saved_and_repository_versions(tmp_path, argument, version): + options = tmp_path / "deploy.options" + options.write_text('k8s:\n appVersion: "v2.5.0"\n') + + actual = resolve_options(options, argument, version) + + assert actual["app_version"] == version + assert actual["shared_version"] == version + + +@pytest.mark.parametrize("exists", [False, True]) +def test_missing_or_empty_saved_options_use_current_repository_version(tmp_path, exists): + options = tmp_path / "deploy.options" + if exists: + options.write_text("") + + actual = resolve_options(options) + + assert actual["app_version"] == CURRENT_VERSION + assert actual["shared_version"] == CURRENT_VERSION + + +def test_persisted_options_do_not_pin_an_application_version(tmp_path): + options = tmp_path / "deploy.options" + script = r''' +set -e +source "$1" --help >/dev/null +DEPLOY_OPTIONS_FILE="$2" +APP_VERSION="v2.5.0" +DEPLOYMENT_COMPONENTS="infrastructure,application" +DEPLOYMENT_PORT_POLICY="development" +DEPLOYMENT_IMAGE_SOURCE="general" +DEPLOYMENT_SANDBOX_MODE="lightweight" +DEPLOYMENT_MONITORING_PROVIDER="phoenix" +DEPLOYMENT_HTTPS_MODE="disabled" +IS_MAINLAND_SAVED="N" +VERSION_CHOICE_SAVED="speed" +persist_deploy_options +''' + result = subprocess.run( + ["bash", "-c", script, "k8s-persist-test", str(DEPLOY), str(options)], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + persisted = yaml.safe_load(options.read_text()) + assert "appVersion" not in persisted + assert "appVersion" not in persisted["k8s"] + assert persisted["k8s"]["persistenceMode"] == "local" + assert persisted["components"] == ["infrastructure", "application"] From 10d3d1cf4ee09384a1d29a1fdd5a75f6f2181091 Mon Sep 17 00:00:00 2001 From: hhhhsc <1710496817@qq.com> Date: Thu, 15 Oct 2026 09:32:51 +0800 Subject: [PATCH 3/3] chore: add missing copyright headers --- deploy/k8s/deploy.sh | 2 ++ deploy/k8s/env.sh | 2 ++ deploy/tests/test_common.sh | 1 + test-e2e/cases/K8S-ENV-D5-001/test.py | 2 ++ test/backend/deploy/__init__.py | 1 + test/backend/deploy/test_k8s_env_injection.py | 2 ++ test/backend/deploy/test_k8s_version_selection.py | 2 ++ 7 files changed, 12 insertions(+) diff --git a/deploy/k8s/deploy.sh b/deploy/k8s/deploy.sh index 2c83e88577..c5fb5084b4 100755 --- a/deploy/k8s/deploy.sh +++ b/deploy/k8s/deploy.sh @@ -1,4 +1,6 @@ #!/bin/bash +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + # Helm Deployment Script for Nexent # Usage: ./deploy.sh [apply] [options] # diff --git a/deploy/k8s/env.sh b/deploy/k8s/env.sh index c1774147a3..984b2fb94f 100644 --- a/deploy/k8s/env.sh +++ b/deploy/k8s/env.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + # Render trusted shell-compatible env files without dumping the host environment. k8s_env_file_data() { diff --git a/deploy/tests/test_common.sh b/deploy/tests/test_common.sh index 682fc3358d..83f61f514c 100755 --- a/deploy/tests/test_common.sh +++ b/deploy/tests/test_common.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. set -euo pipefail diff --git a/test-e2e/cases/K8S-ENV-D5-001/test.py b/test-e2e/cases/K8S-ENV-D5-001/test.py index e5f5303ba4..de28c6acf1 100644 --- a/test-e2e/cases/K8S-ENV-D5-001/test.py +++ b/test-e2e/cases/K8S-ENV-D5-001/test.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Real Kubernetes env injection on an explicitly selected isolated test cluster.""" import base64 diff --git a/test/backend/deploy/__init__.py b/test/backend/deploy/__init__.py index e69de29bb2..9124792bbe 100644 --- a/test/backend/deploy/__init__.py +++ b/test/backend/deploy/__init__.py @@ -0,0 +1 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. diff --git a/test/backend/deploy/test_k8s_env_injection.py b/test/backend/deploy/test_k8s_env_injection.py index f630335d8c..47579f7745 100644 --- a/test/backend/deploy/test_k8s_env_injection.py +++ b/test/backend/deploy/test_k8s_env_injection.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Isolated contracts for Kubernetes env file rendering and manifest composition.""" import base64 diff --git a/test/backend/deploy/test_k8s_version_selection.py b/test/backend/deploy/test_k8s_version_selection.py index 53423d079b..f5195be46c 100644 --- a/test/backend/deploy/test_k8s_version_selection.py +++ b/test/backend/deploy/test_k8s_version_selection.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + """Regression contracts for current-version selection in Kubernetes deployment.""" import os