From 5c210f603a316f928f17a90ce9bc192e2fd280f7 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 23 Jul 2026 14:46:39 +0100 Subject: [PATCH 1/3] feat: Implement native Gatekeeper configuration support --- .../compose/templates/auto-gatekeepers.yaml | 72 ++++++++------- .../helm/templates/auto-gatekeepers.yaml | 91 ++++++++++--------- deployment-configuration/value-template.yaml | 3 + docs/accounts.md | 22 +++++ docs/model/GatekeeperConf.md | 3 +- libraries/models/api/openapi.yaml | 9 ++ .../models/gatekeeper_conf.py | 7 +- libraries/models/docs/GatekeeperConf.md | 3 +- libraries/models/test/test_deserialize.py | 17 +++- .../tests/test_dockercompose.py | 68 ++++++++++++++ tools/deployment-cli-tools/tests/test_helm.py | 60 ++++++++++++ 11 files changed, 270 insertions(+), 85 deletions(-) diff --git a/deployment-configuration/compose/templates/auto-gatekeepers.yaml b/deployment-configuration/compose/templates/auto-gatekeepers.yaml index 4b78ec865..3e1fba236 100644 --- a/deployment-configuration/compose/templates/auto-gatekeepers.yaml +++ b/deployment-configuration/compose/templates/auto-gatekeepers.yaml @@ -42,41 +42,51 @@ {{- define "securedservice.deploy.resources" }} {{- $tls := not (not .root.Values.tls) }} +{{- $scheme := ternary "https" "http" $tls }} +{{- $readTimeout := .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }} +{{- $sendTimeout := .app.harness.proxy.timeout.send | default .root.Values.proxy.timeout.send | default 180 }} +{{- $keepaliveTimeout := .app.harness.proxy.timeout.keepalive | default .root.Values.proxy.timeout.keepalive | default 180 }} +{{- $generatedGatekeeperConfiguration := dict + "verbose" .root.Values.debug + "discovery-url" (printf "%s://%s.%s/realms/%s" $scheme .root.Values.apps.accounts.harness.subdomain .root.Values.domain .root.Values.namespace) + "client-id" .root.Values.apps.accounts.webclient.id + "client-secret" .root.Values.apps.accounts.webclient.secret + "secure-cookie" $tls + "forbidden-page" "/templates/access-denied.html.tmpl" + "enable-default-deny" (eq (.app.harness.secured | toString) "true") + "listen" "0.0.0.0:8080" + "enable-refresh-tokens" true + "server-write-timeout" (printf "%vs" $sendTimeout) + "upstream-timeout" (printf "%vs" $readTimeout) + "upstream-response-header-timeout" (printf "%vs" $readTimeout) + "upstream-expect-continue-timeout" (printf "%vs" $readTimeout) + "server-read-timeout" (printf "%vs" $readTimeout) + "upstream-keepalive-timeout" (printf "%vs" $keepaliveTimeout) + "http-only-cookie" false + "tls-cert" "" + "tls-private-key" "" + "redirection-url" (printf "%s://%s.%s" $scheme .app.harness.subdomain .root.Values.domain) + "encryption-key" "AgXa7xRcoClDEU0ZDSH4X0XhL5Qy2Z2j" + "upstream-url" (printf "http://%s.%s:%v" .app.harness.service.name (.app.namespace | default .root.Release.Namespace) (.app.harness.service.port | default 80)) +}} +{{- if and .app.harness.secured .app.harness.uri_role_mapping }} +{{- $_ := set $generatedGatekeeperConfiguration "resources" .app.harness.uri_role_mapping }} +{{- end }} +{{- if or .root.Values.local (not $tls) }} +{{- $_ := set $generatedGatekeeperConfiguration "skip-openid-provider-tls-verify" true }} +{{- $_ := set $generatedGatekeeperConfiguration "skip-upstream-tls-verify" true }} +{{- end }} +{{- $globalGatekeeperConfiguration := deepCopy (.root.Values.proxy.gatekeeper.configuration | default dict) }} +{{- $applicationGatekeeperConfiguration := deepCopy (.app.harness.proxy.gatekeeper.configuration | default dict) }} +{{- $gatekeeperConfiguration := mergeOverwrite $generatedGatekeeperConfiguration $globalGatekeeperConfiguration $applicationGatekeeperConfiguration }} +{{- if and (not $tls) (eq ((get $gatekeeperConfiguration "same-site-cookie") | toString) "None") }} +{{- $_ := set $gatekeeperConfiguration "same-site-cookie" "Lax" }} +{{- end }} cloudharness-metadata: path: resources/generated/{{ .app.harness.service.name }}-gk/proxy.yml data: |- - verbose: {{ .root.Values.debug }} - discovery-url: {{ ternary "https" "http" $tls}}://{{ .root.Values.apps.accounts.harness.subdomain }}.{{ .root.Values.domain }}/realms/{{ .root.Values.namespace }} - client-id: {{ .root.Values.apps.accounts.webclient.id | quote }} - client-secret: {{ .root.Values.apps.accounts.webclient.secret }} - secure-cookie: {{ $tls }} - forbidden-page: /templates/access-denied.html.tmpl - enable-default-deny: {{ eq (.app.harness.secured | toString) "true" }} - listen: 0.0.0.0:8080 - enable-refresh-tokens: true - server-write-timeout: {{ .app.harness.proxy.timeout.send | default .root.Values.proxy.timeout.send | default 180 }}s - upstream-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - upstream-response-header-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - upstream-expect-continue-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - server-read-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - upstream-keepalive-timeout: {{ .app.harness.proxy.timeout.keepalive | default .root.Values.proxy.timeout.keepalive | default 180 }}s - http-only-cookie: false - tls-cert: - tls-private-key: - redirection-url: {{ ternary "https" "http" $tls }}://{{ .app.harness.subdomain }}.{{ .root.Values.domain }} - encryption-key: AgXa7xRcoClDEU0ZDSH4X0XhL5Qy2Z2j - upstream-url: http://{{ .app.harness.service.name }}.{{ .app.namespace | default .root.Release.Namespace }}:{{ .app.harness.service.port | default 80}} - {{ if .app.harness.secured }} - {{ with .app.harness.uri_role_mapping }} - resources: - {{. | toYaml | nindent 4 }} - {{- end }} - {{- end }} - {{ if or .root.Values.local (not $tls) }} - skip-openid-provider-tls-verify: true - skip-upstream-tls-verify: true - {{- end }} +{{ $gatekeeperConfiguration | toYaml | nindent 4 }} --- cloudharness-metadata: path: resources/generated/{{ .app.harness.service.name }}-gk/cacert.crt diff --git a/deployment-configuration/helm/templates/auto-gatekeepers.yaml b/deployment-configuration/helm/templates/auto-gatekeepers.yaml index 3031d526c..6f4494848 100644 --- a/deployment-configuration/helm/templates/auto-gatekeepers.yaml +++ b/deployment-configuration/helm/templates/auto-gatekeepers.yaml @@ -11,6 +11,49 @@ {{- define "deploy_utils.securedservice" }} {{- $tls := not (not .root.Values.tls) }} {{- $noWildcards := include "check_no_wildcard_uri" (dict "uri_role_mapping" .app.harness.uri_role_mapping) }} +{{- $scheme := ternary "https" "http" $tls }} +{{- $readTimeout := .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }} +{{- $sendTimeout := .app.harness.proxy.timeout.send | default .root.Values.proxy.timeout.send | default 180 }} +{{- $keepaliveTimeout := .app.harness.proxy.timeout.keepalive | default .root.Values.proxy.timeout.keepalive | default 180 }} +{{- $generatedGatekeeperConfiguration := dict + "verbose" .root.Values.debug + "discovery-url" (printf "%s://%s.%s/realms/%s" $scheme .root.Values.apps.accounts.harness.subdomain .root.Values.domain .root.Values.namespace) + "client-id" .root.Values.apps.accounts.webclient.id + "client-secret" .root.Values.apps.accounts.webclient.secret + "secure-cookie" $tls + "forbidden-page" "/templates/access-denied.html.tmpl" + "enable-default-deny" (eq $noWildcards "true") + "listen" "0.0.0.0:8080" + "enable-encrypted-token" false + "enable-refresh-tokens" true + "server-write-timeout" (printf "%vs" $sendTimeout) + "upstream-timeout" (printf "%vs" $readTimeout) + "upstream-response-header-timeout" (printf "%vs" $readTimeout) + "upstream-expect-continue-timeout" (printf "%vs" $readTimeout) + "server-read-timeout" (printf "%vs" $readTimeout) + "upstream-keepalive-timeout" (printf "%vs" $keepaliveTimeout) + "http-only-cookie" false + "tls-cert" "" + "tls-private-key" "" + "redirection-url" (printf "%s://%s.%s" $scheme .subdomain .root.Values.domain) + "upstream-url" (printf "http://%s.%s:%v" .app.harness.service.name (.app.namespace | default .root.Release.Namespace) (.app.harness.service.port | default 80)) + "cors-origins" (list (printf "*.%s" .root.Values.domain)) + "cors-methods" (list "GET" "POST" "PUT" "DELETE" "PATCH") + "cors-headers" (list "Authorization" "Content-Type" "Origin") +}} +{{- if and .app.harness.secured .app.harness.uri_role_mapping }} +{{- $_ := set $generatedGatekeeperConfiguration "resources" .app.harness.uri_role_mapping }} +{{- end }} +{{- if or .root.Values.local (not $tls) }} +{{- $_ := set $generatedGatekeeperConfiguration "skip-openid-provider-tls-verify" true }} +{{- $_ := set $generatedGatekeeperConfiguration "skip-upstream-tls-verify" true }} +{{- end }} +{{- $globalGatekeeperConfiguration := deepCopy (.root.Values.proxy.gatekeeper.configuration | default dict) }} +{{- $applicationGatekeeperConfiguration := deepCopy (.app.harness.proxy.gatekeeper.configuration | default dict) }} +{{- $gatekeeperConfiguration := mergeOverwrite $generatedGatekeeperConfiguration $globalGatekeeperConfiguration $applicationGatekeeperConfiguration }} +{{- if and (not $tls) (eq ((get $gatekeeperConfiguration "same-site-cookie") | toString) "None") }} +{{- $_ := set $gatekeeperConfiguration "same-site-cookie" "Lax" }} +{{- end }} apiVersion: v1 kind: ConfigMap metadata: @@ -19,49 +62,7 @@ metadata: app: "{{ .subdomain }}-gk" data: proxy.yml: |- - verbose: {{ .root.Values.debug }} - discovery-url: {{ ternary "https" "http" $tls}}://{{ .root.Values.apps.accounts.harness.subdomain }}.{{ .root.Values.domain }}/realms/{{ .root.Values.namespace }} - client-id: {{ .root.Values.apps.accounts.webclient.id | quote }} - client-secret: {{ .root.Values.apps.accounts.webclient.secret }} - secure-cookie: {{ $tls }} - forbidden-page: /templates/access-denied.html.tmpl - enable-default-deny: {{ $noWildcards }} - listen: 0.0.0.0:8080 - enable-encrypted-token: false - enable-refresh-tokens: true - server-write-timeout: {{ .app.harness.proxy.timeout.send | default .root.Values.proxy.timeout.send | default 180 }}s - upstream-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - upstream-response-header-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - upstream-expect-continue-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - server-read-timeout: {{ .app.harness.proxy.timeout.read | default .root.Values.proxy.timeout.read | default 180 }}s - upstream-keepalive-timeout: {{ .app.harness.proxy.timeout.keepalive | default .root.Values.proxy.timeout.keepalive | default 180 }}s - http-only-cookie: false - tls-cert: - tls-private-key: - redirection-url: {{ ternary "https" "http" $tls }}://{{ .subdomain }}.{{ .root.Values.domain }} - upstream-url: http://{{ .app.harness.service.name }}.{{ .app.namespace | default .root.Release.Namespace }}:{{ .app.harness.service.port | default 80}} - {{ if .app.harness.secured }} - {{ with .app.harness.uri_role_mapping }} - resources: - {{. | toYaml | nindent 4 }} - {{- end }} - {{- end }} - {{ if or .root.Values.local (not $tls) }} - skip-openid-provider-tls-verify: true - skip-upstream-tls-verify: true - {{- end }} - cors-origins: - - {{ (printf "*.%s" .root.Values.domain) | quote }} - cors-methods: - - GET - - POST - - PUT - - DELETE - - PATCH - cors-headers: - - Authorization - - Content-Type - - Origin +{{ $gatekeeperConfiguration | toYaml | nindent 4 }} cacert.crt: {{ .files.Get "resources/certs/cacert.crt" | quote }} access-denied.html.tmpl: |- @@ -173,7 +174,7 @@ spec: template: metadata: annotations: - checksum/config: {{ .app.harness.uri_role_mapping | toString | sha256sum }} + checksum/config: {{ $gatekeeperConfiguration | toJson | sha256sum }} labels: app: "{{ .subdomain }}-gk" spec: @@ -243,4 +244,4 @@ spec: {{- end }} {{- end }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/deployment-configuration/value-template.yaml b/deployment-configuration/value-template.yaml index e5e8aa136..74fd46460 100644 --- a/deployment-configuration/value-template.yaml +++ b/deployment-configuration/value-template.yaml @@ -181,6 +181,9 @@ harness: image: "quay.io/gogatekeeper/gatekeeper:4.6.0" # -- Default number of gatekeeper replicas replicas: 1 + # -- Native Gatekeeper proxy.yml settings. Kebab-case keys follow the + # Gatekeeper configuration reference and override global/generated values. + configuration: {} resources: requests: memory: "32Mi" diff --git a/docs/accounts.md b/docs/accounts.md index d566d521a..35706956f 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -80,6 +80,28 @@ harness: # -- Maximum size of payload in MB max: ``` + +Native [Gatekeeper configuration](https://gogatekeeper.github.io/gatekeeper/configuration/) +can also be supplied with its `proxy.yml` kebab-case keys. CloudHarness merges +generated settings first, then the global map, and finally the application map: + +```yaml +harness: + proxy: + gatekeeper: + configuration: + same-site-cookie: "None" + enable-pkce: true + no-redirects: false + cors-exposed-headers: + - X-Request-ID +``` + +Application values are the last writer, including for settings generated by +CloudHarness. Global defaults use `proxy.gatekeeper.configuration`. When TLS is +disabled, CloudHarness renders `same-site-cookie: Lax` even if `None` was +requested because browsers require `SameSite=None` cookies to be secure. + ### Secure an enpoint with OpenAPI In every api endpoint that you want to secure, add the bearerAuth security as in the example: diff --git a/docs/model/GatekeeperConf.md b/docs/model/GatekeeperConf.md index 268f65b34..dfc33fbe8 100644 --- a/docs/model/GatekeeperConf.md +++ b/docs/model/GatekeeperConf.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes **replicas** | **int** | | [optional] **resources** | [**DeploymentResourcesConf**](DeploymentResourcesConf.md) | | [optional] **secret** | **str** | | [optional] +**configuration** | **Dict[str, Any]** | Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults. | [optional] ## Example @@ -29,5 +30,3 @@ gatekeeper_conf_dict = gatekeeper_conf_instance.to_dict() gatekeeper_conf_from_dict = GatekeeperConf.from_dict(gatekeeper_conf_dict) ``` [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/libraries/models/api/openapi.yaml b/libraries/models/api/openapi.yaml index 3c9abf03a..b566680f0 100644 --- a/libraries/models/api/openapi.yaml +++ b/libraries/models/api/openapi.yaml @@ -875,9 +875,18 @@ components: secret: description: '' type: string + configuration: + description: >- + Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from + the Gatekeeper configuration reference. Application values override global + values and CloudHarness-generated defaults. + type: object + additionalProperties: true example: image: 'quay.io/gogatekeeper/gatekeeper:2.14.3' replicas: 5 + configuration: + same-site-cookie: Lax GatewayConfig: description: '' type: object diff --git a/libraries/models/cloudharness_model/models/gatekeeper_conf.py b/libraries/models/cloudharness_model/models/gatekeeper_conf.py index e83212f59..1f10fc60d 100644 --- a/libraries/models/cloudharness_model/models/gatekeeper_conf.py +++ b/libraries/models/cloudharness_model/models/gatekeeper_conf.py @@ -35,8 +35,9 @@ class GatekeeperConf(CloudHarnessBaseModel): replicas: Optional[StrictInt] = None resources: Optional[DeploymentResourcesConf] = None secret: Optional[StrictStr] = None + configuration: Optional[Dict[str, Any]] = Field(default=None, description="Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults.") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["image", "replicas", "resources", "secret"] + __properties: ClassVar[List[str]] = ["image", "replicas", "resources", "secret", "configuration"] def to_dict(self) -> Dict[str, Any]: """Return the dictionary representation of the model using alias. @@ -81,7 +82,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "image": obj.get("image"), "replicas": obj.get("replicas"), "resources": DeploymentResourcesConf.from_dict(obj["resources"]) if obj.get("resources") is not None else None, - "secret": obj.get("secret") + "secret": obj.get("secret"), + "configuration": obj.get("configuration") }) # store additional fields in additional_properties for _key in obj.keys(): @@ -90,4 +92,3 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return _obj - diff --git a/libraries/models/docs/GatekeeperConf.md b/libraries/models/docs/GatekeeperConf.md index 268f65b34..dfc33fbe8 100644 --- a/libraries/models/docs/GatekeeperConf.md +++ b/libraries/models/docs/GatekeeperConf.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes **replicas** | **int** | | [optional] **resources** | [**DeploymentResourcesConf**](DeploymentResourcesConf.md) | | [optional] **secret** | **str** | | [optional] +**configuration** | **Dict[str, Any]** | Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults. | [optional] ## Example @@ -29,5 +30,3 @@ gatekeeper_conf_dict = gatekeeper_conf_instance.to_dict() gatekeeper_conf_from_dict = GatekeeperConf.from_dict(gatekeeper_conf_dict) ``` [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/libraries/models/test/test_deserialize.py b/libraries/models/test/test_deserialize.py index e7c97f1a0..247610452 100644 --- a/libraries/models/test/test_deserialize.py +++ b/libraries/models/test/test_deserialize.py @@ -1,8 +1,7 @@ -import pytest from os.path import join, dirname as dn, realpath import oyaml as yaml -from cloudharness_model import HarnessMainConfig, ApplicationConfig, User, ApplicationHarnessConfig, CDCEvent, ApplicationTestConfig, DatabaseConfig +from cloudharness_model import HarnessMainConfig, ApplicationConfig, User, ApplicationHarnessConfig, CDCEvent, ApplicationTestConfig, DatabaseConfig, GatekeeperConf HERE = dn(realpath(__file__)) @@ -58,6 +57,20 @@ def test_database_config_parameters_round_trip(): } +def test_gatekeeper_native_configuration_round_trip(): + native_configuration = { + "same-site-cookie": "None", + "enable-pkce": True, + "max-token-size": 65536, + "cors-exposed-headers": ["X-Request-ID", "X-Trace-ID"], + } + + config = GatekeeperConf.from_dict({"configuration": native_configuration}) + + assert config.configuration == native_configuration + assert config.to_dict()["configuration"] == native_configuration + + def test_robustness(): d = {'aliases': [], 'database': {'auto': True, 'mongo': {'image': 'mongo:5', 'ports': [{'name': 'http', 'port': 27017}]}, 'name': 'keycloak-postgres', 'neo4j': {'dbms_security_auth_enabled': 'false', 'image': 'neo4j:4.1.9', 'memory': {'heap': {'initial': '64M', 'max': '128M'}, 'pagecache': {'size': '64M'}, 'size': '256M'}, 'ports': [{'name': 'http', 'port': 7474}, {'name': 'bolt', 'port': 7687}]}, 'pass': 'password', 'postgres': {'image': 'postgres:10.4', 'initialdb': 'auth_db', 'ports': [{'name': 'http', 'port': 5432}]}, 'resources': {'limits': {'cpu': '1000m', 'memory': '2Gi'}, 'requests': {'cpu': '100m', 'memory': '512Mi'}}, 'size': '2Gi', 'type': 'postgres', 'user': 'user'}, 'dependencies': {'build': [], 'hard': [], 'soft': []}, 'deployment': {'auto': True, 'image': 'osb/accounts:3e02a15477b4696ed554e08cedf4109c67908cbe6b03331072b5b73e83b4fc2b', 'name': 'accounts', 'port': 8080, 'replicas': 1, 'resources': {'limits': {'cpu': '500m', 'memory': '1024Mi'}, 'requests': {'cpu': '10m', 'memory': '512Mi'}}}, 'domain': None, 'env': [{'name': 'KEYCLOAK_IMPORT', 'value': '/tmp/realm.json'}, {'name': 'KEYCLOAK_USER', 'value': 'admin'}, {'name': 'KEYCLOAK_PASSWORD', 'value': 'metacell'}, {'name': 'PROXY_ADDRESS_FORWARDING', 'value': 'true'}, {'name': 'DB_VENDOR', 'value': 'POSTGRES'}, {'name': 'DB_ADDR', 'value': 'keycloak-postgres'}, {'name': 'DB_DATABASE', 'value': 'auth_db'}, {'name': 'DB_USER', 'value': 'user'}, {'name': 'DB_PASSWORD', 'value': 'password'}, {'name': 'JAVA_OPTS', 'value': '-server -Xms64m -Xmx896m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true -Djboss.modules.system.pkgs=org.jboss.byteman -Djava.awt.headless=true --add-exports=java.base/sun.nio.ch=ALL-UNNAMED --add-exports=jdk.unsupported/sun.misc=ALL-UNNAMED --add-exports=jdk.unsupported/sun.reflect=ALL-UNNAMED'}], 'name': 'accounts', 'readinessProbe': {'path': '/realms/master'}, 'resources': [{'dst': '/tmp/realm.json', 'name': 'realm-config', 'src': 'realm.json'}], 'secrets': {}, 'secured': False, 'service': {'auto': True, 'name': 'accounts', 'port': 8080}, 'subdomain': 'accounts', 'uri_role_mapping': [{'roles': ['administrator'], 'uri': '/*'}], 'use_services': []} diff --git a/tools/deployment-cli-tools/tests/test_dockercompose.py b/tools/deployment-cli-tools/tests/test_dockercompose.py index b3e11607c..993147297 100644 --- a/tools/deployment-cli-tools/tests/test_dockercompose.py +++ b/tools/deployment-cli-tools/tests/test_dockercompose.py @@ -3,6 +3,8 @@ from ch_cli_tools.preprocessing import preprocess_build_overrides, generate_hash_based_image_tags import pytest import shutil +import subprocess +import yaml HERE = os.path.dirname(os.path.realpath(__file__)) RESOURCES = os.path.join(HERE, 'resources') @@ -94,6 +96,72 @@ def test_collect_compose_values(tmp_path): assert 'cloudharness-base-debian' not in values[KEY_TASK_IMAGES] +def test_compose_gatekeeper_native_configuration_rendering(tmp_path): + out_folder = tmp_path / 'test_compose_gatekeeper_native_configuration' + create_docker_compose_configuration( + [CLOUDHARNESS_ROOT, RESOURCES], + output_path=out_folder, + include=['samples'], + domain="my.local", + local=False, + tls=True, + ) + + compose_path = out_folder / COMPOSE_PATH + values_path = compose_path / 'values.yaml' + with open(values_path, 'r') as values_file: + values = yaml.safe_load(values_file) + + values['apps']['samples']['harness']['proxy']['gatekeeper']['configuration'] = { + 'same-site-cookie': 'None', + 'enable-pkce': False, + 'http-only-cookie': True, + 'cors-exposed-headers': ['X-Request-ID', 'X-Trace-ID'], + } + values['proxy']['gatekeeper']['configuration'] = { + 'same-site-cookie': 'Strict', + 'enable-pkce': True, + 'max-token-size': 65536, + } + + def render_proxy_config(): + with open(values_path, 'w') as values_file: + yaml.safe_dump(values, values_file) + completed = subprocess.run( + ['helm', 'template', str(compose_path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + for document in yaml.safe_load_all(completed.stdout): + metadata = (document or {}).get('cloudharness-metadata', {}) + if metadata.get('path') == 'resources/generated/samples-gk/proxy.yml': + return yaml.safe_load(document['data']) + raise AssertionError('Could not find the samples Gatekeeper proxy configuration') + + tls_config = render_proxy_config() + assert tls_config['secure-cookie'] is True + assert tls_config['same-site-cookie'] == 'None' + assert tls_config['enable-pkce'] is False + assert tls_config['http-only-cookie'] is True + assert tls_config['max-token-size'] == 65536 + assert tls_config['cors-exposed-headers'] == ['X-Request-ID', 'X-Trace-ID'] + + values['tls'] = False + non_tls_config = render_proxy_config() + assert non_tls_config['secure-cookie'] is False + assert non_tls_config['same-site-cookie'] == 'Lax' + assert non_tls_config['enable-pkce'] is False + assert non_tls_config['max-token-size'] == 65536 + + values['tls'] = True + values['apps']['samples']['harness']['proxy']['gatekeeper']['configuration'].pop('same-site-cookie') + inherited_config = render_proxy_config() + assert inherited_config['same-site-cookie'] == 'Strict' + assert inherited_config['enable-pkce'] is False + + def test_compose_app_depends_on_task_only(tmp_path): out_folder = tmp_path / 'test_compose_app_depends_on_task_only' diff --git a/tools/deployment-cli-tools/tests/test_helm.py b/tools/deployment-cli-tools/tests/test_helm.py index 57126bc24..5833f90e9 100644 --- a/tools/deployment-cli-tools/tests/test_helm.py +++ b/tools/deployment-cli-tools/tests/test_helm.py @@ -365,6 +365,66 @@ def test_cnpg_postgres_parameters_render_only_when_set(tmp_path): assert 'postgresql' not in cluster['spec'] +def test_gatekeeper_native_configuration_rendering_and_checksum(tmp_path): + out_folder = tmp_path / 'test_gatekeeper_native_configuration' + create_helm_chart([CLOUDHARNESS_ROOT, RESOURCES], output_path=out_folder, domain="my.local", + local=False, tls=True, include=["myapp", "accounts"], exclude=["legacy"]) + + helm_path = out_folder / HELM_CHART_PATH + shutil.rmtree(helm_path / 'charts') + values_path = helm_path / 'values.yaml' + with open(values_path, 'r') as values_file: + values = yaml.safe_load(values_file) + + app_harness = values['apps']['myapp']['harness'] + app_harness['secured'] = True + app_harness['proxy']['gatekeeper']['configuration'] = { + 'same-site-cookie': 'None', + 'enable-pkce': False, + 'http-only-cookie': True, + 'cors-exposed-headers': ['X-Request-ID', 'X-Trace-ID'], + } + values['proxy']['gatekeeper']['configuration'] = { + 'same-site-cookie': 'Strict', + 'enable-pkce': True, + 'max-token-size': 65536, + } + + def render_gatekeeper(): + with open(values_path, 'w') as values_file: + yaml.safe_dump(values, values_file) + manifests = render_helm_chart(helm_path) + config = find_manifest(manifests, 'ConfigMap', 'mysubdomain-gk') + deployment = find_manifest(manifests, 'Deployment', 'mysubdomain-gk') + return ( + yaml.safe_load(config['data']['proxy.yml']), + deployment['spec']['template']['metadata']['annotations']['checksum/config'], + ) + + tls_config, tls_checksum = render_gatekeeper() + assert tls_config['secure-cookie'] is True + assert tls_config['same-site-cookie'] == 'None' + assert tls_config['enable-pkce'] is False + assert tls_config['http-only-cookie'] is True + assert tls_config['max-token-size'] == 65536 + assert tls_config['cors-exposed-headers'] == ['X-Request-ID', 'X-Trace-ID'] + + values['tls'] = False + non_tls_config, non_tls_checksum = render_gatekeeper() + assert non_tls_config['secure-cookie'] is False + assert non_tls_config['same-site-cookie'] == 'Lax' + assert non_tls_config['enable-pkce'] is False + assert non_tls_config['max-token-size'] == 65536 + assert non_tls_checksum != tls_checksum + + values['tls'] = True + app_harness['proxy']['gatekeeper']['configuration'].pop('same-site-cookie') + inherited_config, inherited_checksum = render_gatekeeper() + assert inherited_config['same-site-cookie'] == 'Strict' + assert inherited_config['enable-pkce'] is False + assert inherited_checksum != tls_checksum + + def test_clear_all_dbconfig_if_nodb(tmp_path): out_folder = tmp_path / 'test_clear_all_dbconfig_if_nodb' From 094f8e6c50458ca7d12366e2d5603fa6c83313a9 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 23 Jul 2026 15:21:10 +0100 Subject: [PATCH 2/3] chore: Drop python 3.9 from tox matrices --- libraries/cloudharness-common/tox.ini | 2 +- libraries/cloudharness-utils/tox.ini | 3 +-- tools/deployment-cli-tools/tox.ini | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/libraries/cloudharness-common/tox.ini b/libraries/cloudharness-common/tox.ini index 6c0c94f4c..74f6cbe51 100644 --- a/libraries/cloudharness-common/tox.ini +++ b/libraries/cloudharness-common/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{9,12} +envlist = py312 [testenv] deps=-r{toxinidir}/test-requirements.txt diff --git a/libraries/cloudharness-utils/tox.ini b/libraries/cloudharness-utils/tox.ini index 3bed59ad0..ff59b00cc 100644 --- a/libraries/cloudharness-utils/tox.ini +++ b/libraries/cloudharness-utils/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{9,12} +envlist = py312 skipsdist = False [pytest] @@ -15,4 +15,3 @@ commands = python -c "print('cloudharness-utils: No tests to run - utility library only')" python -c "import cloudharness_utils; print('Import successful')" - diff --git a/tools/deployment-cli-tools/tox.ini b/tools/deployment-cli-tools/tox.ini index 191920c2e..fc32cd1a3 100644 --- a/tools/deployment-cli-tools/tox.ini +++ b/tools/deployment-cli-tools/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py3{9,12} +envlist = py312 [pytest] addopts = --ignore-glob=**/application-templates/** @@ -12,4 +12,3 @@ commands= coverage run --source ch_cli_tools -m pytest -v coverage report -m - From 9187a5e46ca1a895f7e31ff6b31923f4a2ad9dfc Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Thu, 23 Jul 2026 15:37:12 +0100 Subject: [PATCH 3/3] fix: Safely handle optional values in helm templates --- deployment-configuration/helm/templates/auto-database.yaml | 5 +++-- .../helm/templates/auto-deployments.yaml | 6 ++++-- .../helm/templates/auto-network-policies.yaml | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/deployment-configuration/helm/templates/auto-database.yaml b/deployment-configuration/helm/templates/auto-database.yaml index 681b9e735..bc18f9c28 100644 --- a/deployment-configuration/helm/templates/auto-database.yaml +++ b/deployment-configuration/helm/templates/auto-database.yaml @@ -14,7 +14,8 @@ spec: --- {{- end }} {{- define "deploy_utils.database" }} -{{- if and (eq .app.harness.database.type "postgres") .app.harness.database.postgres.operator }} +{{- $postgres := .app.harness.database.postgres | default dict }} +{{- if and (eq .app.harness.database.type "postgres") $postgres.operator }} {{- include "deploy_utils.database.postgres.operator" . }} {{- else }} --- @@ -132,4 +133,4 @@ spec: {{- end }} {{- end }} {{- end }} - {{- end }} \ No newline at end of file + {{- end }} diff --git a/deployment-configuration/helm/templates/auto-deployments.yaml b/deployment-configuration/helm/templates/auto-deployments.yaml index 443fe9bb7..7b1a79e79 100644 --- a/deployment-configuration/helm/templates/auto-deployments.yaml +++ b/deployment-configuration/helm/templates/auto-deployments.yaml @@ -11,7 +11,8 @@ metadata: {{- end }} {{- include "deploy_utils.labels" .root | indent 4 }} spec: - {{- if and .app.harness.deployment.volume (or (not (hasKey .app.harness.deployment.volume "usenfs")) (not .app.harness.deployment.volume.usenfs)) }} + {{- if .app.harness.deployment.volume }} + {{- if or (not (hasKey .app.harness.deployment.volume "usenfs")) (not .app.harness.deployment.volume.usenfs) }} # A ReadWriteOnce volume attaches to a single node and the pod is pinned to the # volume's node via podAffinity (see below). Recreate terminates the old pod # before starting the new one, avoiding an unschedulable pod when the node can't @@ -19,6 +20,7 @@ spec: strategy: type: Recreate {{- end }} + {{- end }} replicas: {{ .app.harness.deployment.replicas | default 1 }} selector: matchLabels: @@ -204,4 +206,4 @@ data: {{- end }} {{- end }} {{- end }} - {{- end }} \ No newline at end of file + {{- end }} diff --git a/deployment-configuration/helm/templates/auto-network-policies.yaml b/deployment-configuration/helm/templates/auto-network-policies.yaml index 63f5dc4c3..920e5069f 100644 --- a/deployment-configuration/helm/templates/auto-network-policies.yaml +++ b/deployment-configuration/helm/templates/auto-network-policies.yaml @@ -64,6 +64,7 @@ spec: {{- end }} {{- define "deploy_utils.database_network_policy" }} +{{- $postgres := .app.harness.database.postgres | default dict }} {{- $hasNetwork := hasKey .app.harness.database "network" }} {{- $allowIngress := and $hasNetwork .app.harness.database.network.ingress }} {{- $allowEgress := and $hasNetwork .app.harness.database.network.egress }} @@ -117,7 +118,7 @@ spec: protocol: UDP - port: 53 protocol: TCP - {{- if and (eq .app.harness.database.type "postgres") .app.harness.database.postgres.operator }} + {{- if and (eq .app.harness.database.type "postgres") $postgres.operator }} # Allow CNPG pods to reach the Kubernetes API server {{- $apiCidrs := list }} {{- $kubeSvc := (lookup "v1" "Service" "default" "kubernetes") }}