diff --git a/Chart.yaml b/Chart.yaml index 56c854a..a343df5 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -12,7 +12,7 @@ description: A Helm chart for deploying HeLx to Kubernetes. type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 4.5.9 +version: 4.5.10 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. diff --git a/README.md b/README.md index c451cbb..9ca30da 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Helm chart for deploying HeLx to Kubernetes. -![Version: 4.5.9](https://img.shields.io/badge/Version-4.5.9-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.6.5](https://img.shields.io/badge/AppVersion-3.6.5-informational?style=flat-square) +![Version: 4.5.10](https://img.shields.io/badge/Version-4.5.10-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.6.5](https://img.shields.io/badge/AppVersion-3.6.5-informational?style=flat-square) HeLx puts the most advanced analytical scientific models at investigator’s finger tips using equally advanced cloud native, container orchestrated, distributed computing systems. HeLx can be applied in many domains. Its ability to empower researchers to leverage advanced analytical tools without installation or other infrastructure concerns has broad reaching benefits. diff --git a/charts/resty/Chart.yaml b/charts/resty/Chart.yaml index 9fc3c9c..4c807d8 100644 --- a/charts/resty/Chart.yaml +++ b/charts/resty/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v1 description: A Helm chart for Kubernetes name: resty -version: "1.0.5" +version: "1.0.6" appVersion: "2.0.1" diff --git a/charts/resty/README.md b/charts/resty/README.md index 1bcd39b..2d1bf2f 100644 --- a/charts/resty/README.md +++ b/charts/resty/README.md @@ -1,6 +1,6 @@ # resty -![Version: 1.0.5](https://img.shields.io/badge/Version-1.0.5-informational?style=flat-square) ![AppVersion: 2.0.1](https://img.shields.io/badge/AppVersion-2.0.1-informational?style=flat-square) +![Version: 1.0.6](https://img.shields.io/badge/Version-1.0.6-informational?style=flat-square) ![AppVersion: 2.0.1](https://img.shields.io/badge/AppVersion-2.0.1-informational?style=flat-square) A Helm chart for Kubernetes @@ -11,6 +11,11 @@ A Helm chart for Kubernetes | DEV_PHASE.dev | bool | `false` | Set the DEV_PHASE.dev True, if Appstore/Tycho running locally. Else, set it to False | | airflow.authenticate | bool | `true` | | | artifactCache | object | `{"authenticate":false,"enabled":false,"port":8080,"serviceName":"artifact-cache"}` | Optional /artifact route to an in-cluster artifact-cache service (replaces the ambassador Mapping for /artifact). Enable in envs that serve the helx-apps registry/specs from an artifact cache (e.g. air-gapped OpenShift). Off by default. Set authenticate=true to require the auth_request gate on /artifact. | +| authCache | object | `{"enabled":true,"negativeTtl":2,"sessionCookieName":"sessionid","size":"10m","ttl":30}` | Caches appstore's authorization + routing decision for /private requests in memory, keyed by session cookie and app/user/guid, so loading an app costs one appstore call per ttl instead of one per request. | +| authCache.negativeTtl | int | `2` | Seconds a failed decision (403/404/5xx) is reused, so a burst of requests behind a failing check doesn't each re-ask appstore. 0 disables. | +| authCache.sessionCookieName | string | `"sessionid"` | appstore's Django session cookie (SESSION_COOKIE_NAME). | +| authCache.size | string | `"10m"` | Shared memory for cached decisions; least-recently-used entries are evicted when full. | +| authCache.ttl | int | `30` | Seconds a successful decision is reused. Bounds how long access outlives a logout or an app shutdown. Keep it well below appstore's DJANGO_SESSION_IDLE_TIMEOUT (default 300): only cache misses reach appstore and refresh a session's idle timer. | | basicAuth | object | `{"enabled":false,"password":"defaultPassword","username":"defaultUser"}` | Creates a basicAuth scheme preventing un-authenticated access to the whole site. | | basicAuth.password | string | `"defaultPassword"` | Password, make sure to override. | | basicAuth.username | string | `"defaultUser"` | Username , make sure to override. | @@ -38,7 +43,7 @@ A Helm chart for Kubernetes | ingress.ingressClassName | string | `nil` | Set to use a specific ingress class other than the default. | | ingress.tls.enabled | bool | `true` | Values inserted into the TLS block come from SSL.nginxTLSSecret and service.serverName for backward compatibility | | nameOverride | string | `""` | | -| replicaCount | int | `1` | | +| replicaCount | int | `1` | Number of resty pods. Each pod keeps its own authCache. | | resources.limits.cpu | string | `"100m"` | | | resources.limits.memory | string | `"128Mi"` | | | resources.requests.cpu | string | `"50m"` | | diff --git a/charts/resty/templates/nginx-default-configmap.yaml b/charts/resty/templates/nginx-default-configmap.yaml index bbec5f9..49a5799 100644 --- a/charts/resty/templates/nginx-default-configmap.yaml +++ b/charts/resty/templates/nginx-default-configmap.yaml @@ -25,6 +25,12 @@ data: # Override with resty.dnsResolver if your cluster DNS differs. resolver {{ .Values.dnsResolver | default "kube-dns.kube-system.svc.cluster.local" }} valid=10s ipv6=off; + {{- if .Values.authCache.enabled }} + + lua_shared_dict private_route_cache {{ .Values.authCache.size }}; + lua_shared_dict private_route_locks 1m; + {{- end }} + {{- $appsNamespace := .Values.global.apps_namespace | default .Release.Namespace }} {{- $dnsSuffix := .Values.global.cluster_dns_suffix | default "svc.cluster.local" }} {{- /* Use FQDNs: any proxy_pass containing a variable (e.g. $request_uri) @@ -107,9 +113,8 @@ data: # backend. appstore is authoritative: it verifies the user OWNS this # system (a check Ambassador never did -- it set bypass_auth: true) and # returns the backend Service host, its real port, and the per-app - # rewrite rule. We then proxy over cluster DNS. This subrequest fires - # at the same frequency Ambassador's auth_request already did, so it - # adds no round-trip versus the old flow. + # rewrite rule. We then proxy over cluster DNS. With authCache enabled, + # each decision is reused for authCache.ttl seconds per session + system. ##################################################### location ~ ^/private/(?[^/]+)/(?[^/]+)/(?[^/]+)(?/.*)?$ { client_max_body_size 2000M; @@ -124,26 +129,96 @@ data: # dev: appstore/tycho run locally, no in-cluster resolution. {{- else }} access_by_lua_block { - -- Ask appstore to authorize + resolve this /private URL. Cookies - -- are inherited by the subrequest; pass the original path along. - local res = ngx.location.capture( - "/appstore-resolve", - { args = { uri = ngx.var.request_uri } } - ) - if res.status ~= 200 then - -- 403 (not owner), 404 (not ready), 5xx (resolve failed) - ngx.exit(res.status) + local function resolve() + -- Ask appstore to authorize + resolve this /private URL. Cookies + -- are inherited by the subrequest. Pass $uri, not $request_uri: + -- the backend Service is built from the location captures, which + -- come from the normalized $uri, so appstore must authorize that + -- same path. A raw "/../" path would otherwise be authorized as + -- one system and proxied to another. + local res = ngx.location.capture( + "/appstore-resolve", + { args = { uri = ngx.var.uri } } + ) + if res.status ~= 200 then + -- 403 (not owner), 404 (not ready), 5xx (resolve failed) + return { status = res.status } + end + local h = res.header + if not h["X-Backend-Port"] then + ngx.log(ngx.ERR, "private resolve: missing backend port") + return { status = 502 } + end + return { + status = 200, + port = h["X-Backend-Port"], + rewrite = h["X-Rewrite"] or "", + prefix = h["X-Prefix"] or "", + remote_user = h["REMOTE_USER"] or "", + access_token = h["ACCESS_TOKEN"] or "", + } + end + + {{- if .Values.authCache.enabled }} + + -- Django reads the last session cookie, nginx's $cookie_* the first. + -- Only cache when there is exactly one, so the cache key is the + -- session appstore actually authenticated. + local function session_id() + local cookies = ngx.var.http_cookie + if not cookies then return nil end + local sid + for pair in cookies:gmatch("[^;]+") do + local name, value = pair:match("^%s*(.-)%s*=%s*(.-)%s*$") + if name == {{ .Values.authCache.sessionCookieName | quote }} then + if sid then return nil end + sid = value + end + end + if sid ~= "" then return sid end end - local h = res.header - local port = h["X-Backend-Port"] - local rewrite = h["X-Rewrite"] or "" - local prefix = h["X-Prefix"] or "" + local route + local sid = session_id() + if sid then + local cjson = require "cjson.safe" + local cache = ngx.shared.private_route_cache + local key = ngx.md5(sid) .. "/" .. ngx.var.app .. "/" .. ngx.var.user .. "/" .. ngx.var.guid + local function lookup() + local v = cache:get(key) + return v and cjson.decode(v) + end - if not port then - ngx.log(ngx.ERR, "private resolve: missing backend port") - ngx.exit(502) + route = lookup() + if not route then + -- Concurrent misses on one key share a single appstore call. + local lock = require("resty.lock"):new("private_route_locks", { timeout = 30, exptime = 35 }) + local locked = lock and lock:lock(key) + route = lookup() + if not route then + route = resolve() + local ttl = route.status == 200 and {{ .Values.authCache.ttl | float64 }} or {{ .Values.authCache.negativeTtl | float64 }} + if ttl > 0 then + cache:set(key, cjson.encode(route), ttl) + end + end + if locked then lock:unlock() end + end + else + route = resolve() end + {{- else }} + + local route = resolve() + {{- end }} + + if route.status ~= 200 then + ngx.exit(route.status) + end + + local port = route.port + local rewrite = route.rewrite + local prefix = route.prefix -- The backend Service name is deterministic from the URL path: -- {app}-{guid} (Tycho System.name = f"{app_id}-{identifier}"). @@ -183,8 +258,8 @@ data: end -- Inject identity downstream, as Ambassador's REMOTE_USER header did. - ngx.req.set_header("REMOTE_USER", h["REMOTE_USER"] or "") - ngx.req.set_header("Authorization", h["ACCESS_TOKEN"] or "") + ngx.req.set_header("REMOTE_USER", route.remote_user) + ngx.req.set_header("Authorization", route.access_token) } {{- end }} diff --git a/charts/resty/templates/nginx-deploy.yaml b/charts/resty/templates/nginx-deploy.yaml index 11f95ae..76932aa 100644 --- a/charts/resty/templates/nginx-deploy.yaml +++ b/charts/resty/templates/nginx-deploy.yaml @@ -3,7 +3,7 @@ kind: Deployment metadata: name: {{ include "resty.fullname" . }} spec: - replicas: 1 + replicas: {{ .Values.replicaCount }} selector: matchLabels: {{- include "resty.selectorLabels" . | nindent 6 }} diff --git a/charts/resty/values.yaml b/charts/resty/values.yaml index cd6b058..261efe4 100644 --- a/charts/resty/values.yaml +++ b/charts/resty/values.yaml @@ -2,6 +2,7 @@ # This is a YAML-formatted file. # Declare variables to be passed into your templates. +# -- Number of resty pods. Each pod keeps its own authCache. replicaCount: 1 image: @@ -84,6 +85,25 @@ external_http_host: false airflow: authenticate: true +# -- Caches appstore's authorization + routing decision for /private requests +# in memory, keyed by session cookie and app/user/guid, so loading an app costs +# one appstore call per ttl instead of one per request. +authCache: + enabled: true + # -- Seconds a successful decision is reused. Bounds how long access outlives + # a logout or an app shutdown. Keep it well below appstore's + # DJANGO_SESSION_IDLE_TIMEOUT (default 300): only cache misses reach appstore + # and refresh a session's idle timer. + ttl: 30 + # -- Seconds a failed decision (403/404/5xx) is reused, so a burst of + # requests behind a failing check doesn't each re-ask appstore. 0 disables. + negativeTtl: 2 + # -- Shared memory for cached decisions; least-recently-used entries are + # evicted when full. + size: 10m + # -- appstore's Django session cookie (SESSION_COOKIE_NAME). + sessionCookieName: sessionid + workerConnections: 1024 stubStatus: enabled: false