diff --git a/CHANGELOG.md b/CHANGELOG.md index fb8f2bc1..fb7861d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Public and private scopes now register DNS records in their correct Route53 hosted zone when using `DNS_TYPE=external_dns`, preventing cross-zone record leakage - Add configurable main HTTP port for k8s scopes (default 8080) and HTTP support for additional ports - Improve **wait deployment active** failure logging: consolidate repeated `Unhealthy` probe events per pod into a single human-readable line, emit a progress heartbeat every 10% of timeout, and surface a targeted suggested fix based on the probe failure mode (port not open / HTTP non-2xx / probe timeout) +- Add configurable memory and CPU limits, independent from requests, for k8s scope containers - Improve **k8s/diagnose** evidence: every check now emits structured evidence following a documented schema (`summary`, `severity`, `affected`, `details`, `suggested_actions`), failure findings embed the relevant pod log slice (current or previous depending on the failure mode), and a new **Application Logs** category surfaces the user-owned `application` container's log tail directly in the UI ## [1.11.0] - 2026-04-16 diff --git a/k8s/deployment/build_context b/k8s/deployment/build_context index 1f357deb..31880dc7 100755 --- a/k8s/deployment/build_context +++ b/k8s/deployment/build_context @@ -23,6 +23,20 @@ MIN_REPLICAS=$(echo "$MIN_REPLICAS" | awk '{printf "%d", ($1 == int($1) ? $1 : i DEPLOYMENT_STATUS=$(echo "$CONTEXT" | jq -r ".deployment.status") +# Fill in *_limit capability fields with the corresponding request value when +# the limit is missing or explicitly null, then clamp any limit below its +# request up to the request value. The schema rejects limit < request at save +# time; this is defense-in-depth so the script can never produce an invalid +# resources block, regardless of how the context was built. +normalize_capability_limits() { + echo "$1" | jq ' + .scope.capabilities.cpu_millicores_limit //= .scope.capabilities.cpu_millicores + | .scope.capabilities.ram_memory_limit //= .scope.capabilities.ram_memory + | .scope.capabilities.cpu_millicores_limit = ([.scope.capabilities.cpu_millicores, .scope.capabilities.cpu_millicores_limit] | max) + | .scope.capabilities.ram_memory_limit = ([.scope.capabilities.ram_memory, .scope.capabilities.ram_memory_limit] | max) + ' +} + validate_status() { local action="$1" local status="$2" @@ -313,6 +327,8 @@ CONTEXT=$(echo "$CONTEXT" | jq \ main_http_port: ($main_http_port | tonumber) }') +CONTEXT=$(normalize_capability_limits "$CONTEXT") + DEPLOYMENT_ID=$(echo "$CONTEXT" | jq -r '.deployment.id') OUTPUT_DIR="$SERVICE_PATH/output/$SCOPE_ID-$DEPLOYMENT_ID" diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index 3552c483..44f58d77 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -312,8 +312,8 @@ spec: {{ end }} resources: limits: - cpu: {{ .scope.capabilities.cpu_millicores }}m - memory: {{ .scope.capabilities.ram_memory }}Mi + cpu: {{ .scope.capabilities.cpu_millicores_limit }}m + memory: {{ .scope.capabilities.ram_memory_limit }}Mi requests: cpu: {{ .scope.capabilities.cpu_millicores }}m memory: {{ .scope.capabilities.ram_memory }}Mi diff --git a/k8s/deployment/tests/build_context.bats b/k8s/deployment/tests/build_context.bats index 690a8ab4..72d9d020 100644 --- a/k8s/deployment/tests/build_context.bats +++ b/k8s/deployment/tests/build_context.bats @@ -946,3 +946,95 @@ set_additional_ports() { assert_equal "$(echo "$CONTEXT" | jq -c '.scope.capabilities.additional_ports')" "[]" } + +# ============================================================================= +# Capability limits normalization +# These tests source the real deployment/build_context and assert on the +# resulting CONTEXT, exercising the full pipeline. Limits default to their +# corresponding request value when missing or explicitly null; explicit values +# pass through. +# ============================================================================= + +# Patches CONTEXT.scope.capabilities with the given JSON fragment (merged into +# the existing capabilities object). +set_capabilities() { + CONTEXT=$(echo "$CONTEXT" | jq --argjson v "$1" '.scope.capabilities = (.scope.capabilities + $v)') +} + +@test "capability limits: cpu limit defaults to cpu_millicores when absent" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"ram_memory":1024,"ram_memory_limit":2048}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "500" +} + +@test "capability limits: ram limit defaults to ram_memory when absent" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"cpu_millicores_limit":1000,"ram_memory":1024}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "1024" +} + +@test "capability limits: both limits default to their requests when both absent" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"ram_memory":1024}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "500" + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "1024" +} + +@test "capability limits: explicit null limits fall back to their requests" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"cpu_millicores_limit":null,"ram_memory":1024,"ram_memory_limit":null}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "500" + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "1024" +} + +@test "capability limits: explicit non-null limits pass through unchanged" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"cpu_millicores_limit":2000,"ram_memory":1024,"ram_memory_limit":4096}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "2000" + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "4096" +} + +@test "capability limits: cpu limit below request is clamped up to request" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"cpu_millicores_limit":100,"ram_memory":1024,"ram_memory_limit":2048}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "500" + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "2048" +} + +@test "capability limits: ram limit below request is clamped up to request" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"cpu_millicores_limit":1000,"ram_memory":1024,"ram_memory_limit":64}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "1000" + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "1024" +} + +@test "capability limits: both limits below their requests are clamped up" { + setup_full_build_context + set_capabilities '{"cpu_millicores":500,"cpu_millicores_limit":100,"ram_memory":1024,"ram_memory_limit":64}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.cpu_millicores_limit')" "500" + assert_equal "$(echo "$CONTEXT" | jq -r '.scope.capabilities.ram_memory_limit')" "1024" +} diff --git a/k8s/specs/service-spec.json.tpl b/k8s/specs/service-spec.json.tpl index 656e641d..f2cd6507 100644 --- a/k8s/specs/service-spec.json.tpl +++ b/k8s/specs/service-spec.json.tpl @@ -5,11 +5,13 @@ "type":"object", "required":[ "ram_memory", + "ram_memory_limit", "visibility", "autoscaling", "health_check", "scaling_type", "cpu_millicores", + "cpu_millicores_limit", "fixed_instances", "scheduled_stop", "additional_ports", @@ -44,12 +46,22 @@ "elements":[ { "type":"Category", - "label":"Processor", + "label":"Resources", "elements":[ { "type":"Control", "label":"CPU Millicores", "scope":"#/properties/cpu_millicores" + }, + { + "type":"Control", + "label":"CPU Millicores Limit", + "scope":"#/properties/cpu_millicores_limit" + }, + { + "type":"Control", + "label":"RAM Memory Limit", + "scope":"#/properties/ram_memory_limit" } ] }, @@ -356,6 +368,27 @@ "default":128, "description":"Amount of RAM memory to allocate to the container (in MB)" }, + "ram_memory_limit":{ + "type":["integer","null"], + "oneOf":[ + {"const":null, "title":"Same as request"}, + {"const":64, "title":"64 MB"}, + {"const":128, "title":"128 MB"}, + {"const":256, "title":"256 MB"}, + {"const":512, "title":"512 MB"}, + {"const":1024, "title":"1 GB"}, + {"const":2048, "title":"2 GB"}, + {"const":4096, "title":"4 GB"}, + {"const":8192, "title":"8 GB"}, + {"const":16384, "title":"16 GB"} + ], + "title":"RAM Memory Limit", + "default":null, + "minimum":{ + "$data":"1/ram_memory" + }, + "description":"Maximum memory the container can use (in MB). Pick 'Same as request' to leave it equal to the request value." + }, "visibility":{ "type":"string", "oneOf":[ @@ -490,6 +523,24 @@ "minimum":100, "description":"Amount of CPU to allocate (in millicores, 1000m = 1 CPU core)" }, + "cpu_millicores_limit":{ + "type":["integer","null"], + "oneOf":[ + {"const":null, "title":"Same as request"}, + {"const":100, "title":"100 m"}, + {"const":250, "title":"250 m"}, + {"const":500, "title":"500 m"}, + {"const":1000, "title":"1000 m"}, + {"const":2000, "title":"2000 m"}, + {"const":4000, "title":"4000 m"} + ], + "title":"CPU Millicores Limit", + "default":null, + "minimum":{ + "$data":"1/cpu_millicores" + }, + "description":"Maximum CPU the container can use (in millicores). Pick 'Same as request' to leave it equal to the request value." + }, "scheduled_stop":{ "type":"object", "title":"Scheduled Stop",