diff --git a/charts/github-sts/README.md b/charts/github-sts/README.md index 432b9f5..452d301 100644 --- a/charts/github-sts/README.md +++ b/charts/github-sts/README.md @@ -11,44 +11,39 @@ A Kubernetes Helm chart for deploying the GitHub Security Token Service (STS). ### Quick Start -1. **Using an existing secret (recommended):** +1. **Create a Kubernetes secret with your GitHub App private key:** -First, create the secret: ```bash -kubectl create secret generic github-sts-credentials \ - --from-literal=github-app-id="YOUR_GITHUB_APP_ID" \ +kubectl create secret generic my-github-app-credentials \ --from-file=github-app-private-key=/path/to/private_key.pem ``` -Then install with the existing secret: -```bash -helm install github-sts . \ - --set github.existingSecret="github-sts-credentials" -``` - -2. **With `--set-file` for the private key:** +2. **Install with the app configured:** ```bash helm install github-sts . \ - --set github.appId="YOUR_GITHUB_APP_ID" \ - --set-file github.appPrivateKey=/path/to/private_key.pem + --set github.apps.default.appId="YOUR_GITHUB_APP_ID" \ + --set github.apps.default.existingSecret="my-github-app-credentials" ``` -> **Note:** Avoid passing multi-line PEM keys via `--set` on the command line. -> Use `--set-file`, an existing secret, or a values file instead to prevent -> shell history exposure and YAML escaping issues. - -3. **Using Vault:** +3. **Multiple apps:** -If you have Vault integration configured: ```bash -export GITHUB_APP_ID=$(vault kv get -field=github_app_id homelab/github-action/octo-sts) +kubectl create secret generic app1-credentials \ + --from-file=github-app-private-key=/path/to/app1_key.pem +kubectl create secret generic app2-credentials \ + --from-file=github-app-private-key=/path/to/app2_key.pem helm install github-sts . \ - --set github.appId="$GITHUB_APP_ID" \ - --set-file github.appPrivateKey=/path/to/private_key.pem + --set github.apps.app1.appId="111" \ + --set github.apps.app1.existingSecret="app1-credentials" \ + --set github.apps.app2.appId="222" \ + --set github.apps.app2.existingSecret="app2-credentials" ``` +> **Note:** Each app's private key must be stored in an existing Kubernetes Secret. +> The app name is used in policy paths: `{policy.basePath}/{appName}/{identity}.sts.yaml` + ## Configuration Key configuration options (see `values.yaml` for all options): @@ -63,10 +58,7 @@ Key configuration options (see `values.yaml` for all options): | `autoscaling.enabled` | `false` | Enable HPA | | `ingress.enabled` | `false` | Enable Ingress (traditional API) | | `httproute.enabled` | `false` | Enable HTTPRoute (Gateway API) | -| `github.appId` | `""` | GitHub App ID (required) | -| `github.appPrivateKey` | `""` | GitHub App Private Key (required) | -| `github.existingSecret` | `""` | Use an existing secret for credentials | -| `github.appName` | `"default"` | GitHub App name for env-configured app | +| `github.apps` | `{}` | Map of GitHub App configs (appId, existingSecret, secretPrivateKeyKey) | ## Ingress & Routing @@ -74,7 +66,8 @@ Key configuration options (see `values.yaml` for all options): ```bash helm install github-sts . \ - --set github.existingSecret="github-sts-credentials" \ + --set github.apps.default.appId="YOUR_APP_ID" \ + --set github.apps.default.existingSecret="my-github-app-credentials" \ --set ingress.enabled=true \ --set ingress.className="nginx" \ --set ingress.hosts[0].host="github-sts.example.com" @@ -86,7 +79,8 @@ Requires Gateway API CRDs. HTTPRoute is more powerful and flexible than Ingress. ```bash helm install github-sts . \ - --set github.existingSecret="github-sts-credentials" \ + --set github.apps.default.appId="YOUR_APP_ID" \ + --set github.apps.default.existingSecret="my-github-app-credentials" \ --set httproute.enabled=true \ --set httproute.parentRefs[0].name="my-gateway" \ --set httproute.hostnames[0]="github-sts.example.com" @@ -96,7 +90,8 @@ helm install github-sts . \ ```bash helm upgrade github-sts . \ - --set github.existingSecret="github-sts-credentials" + --set github.apps.default.appId="YOUR_APP_ID" \ + --set github.apps.default.existingSecret="my-github-app-credentials" ``` ## Uninstall diff --git a/charts/github-sts/ci/full-values.yaml b/charts/github-sts/ci/full-values.yaml new file mode 100644 index 0000000..26fdb15 --- /dev/null +++ b/charts/github-sts/ci/full-values.yaml @@ -0,0 +1,104 @@ +# CI test values: full configuration with all features enabled +replicaCount: 2 + +image: + registry: "ghcr.io" + repository: "AlexandreODelisle/github-sts" + tag: "v1.0.0" + +serviceAccount: + create: true + annotations: + eks.amazonaws.com/role-arn: "arn:aws:iam::role/github-sts" + +service: + type: ClusterIP + port: 8080 + targetPort: 8080 + +ingress: + enabled: true + className: "nginx" + annotations: + cert-manager.io/cluster-issuer: "letsencrypt-prod" + hosts: + - host: github-sts.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: github-sts-tls + hosts: + - github-sts.example.com + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 5 + targetCPUUtilizationPercentage: 75 + +resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +logging: + level: "INFO" + accessLevel: "INFO" + suppressHealthLogs: true + audit: + fileEnabled: true + filePath: "/var/log/github-sts/audit.json" + fileMaxBytes: 10485760 + fileBackupCount: 5 + +policy: + backend: "github" + basePath: ".github/sts" + cacheTtlSeconds: 120 + +oidc: + allowedIssuers: + - "https://token.actions.githubusercontent.com" + - "https://accounts.google.com" + +jti: + backend: "memory" + ttlSeconds: 3600 + +metrics: + enabled: true + prefix: "pygithubsts" + rateLimitPoll: + enabled: true + intervalSeconds: 60 + reachabilityProbe: + enabled: true + intervalSeconds: 30 + +serviceMonitor: + enabled: true + interval: 30s + scrapeTimeout: 10s + path: /metrics + honorLabels: true + +github: + apps: + default: + appId: 12345 + existingSecret: "github-app-default-credentials" + deploy-bot: + appId: 67890 + existingSecret: "deploy-bot-credentials" + +extraEnv: + - name: LOG_FORMAT + value: "json" + +podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" diff --git a/charts/github-sts/ci/multi-app-values.yaml b/charts/github-sts/ci/multi-app-values.yaml new file mode 100644 index 0000000..36a90fe --- /dev/null +++ b/charts/github-sts/ci/multi-app-values.yaml @@ -0,0 +1,13 @@ +# CI test values: multiple GitHub Apps deployment +github: + apps: + deploy-bot: + appId: 11111 + existingSecret: "deploy-bot-credentials" + release-bot: + appId: 22222 + existingSecret: "release-bot-credentials" + ci-bot: + appId: 33333 + existingSecret: "ci-bot-credentials" + secretPrivateKeyKey: "private-key.pem" diff --git a/charts/github-sts/ci/no-apps-values.yaml b/charts/github-sts/ci/no-apps-values.yaml new file mode 100644 index 0000000..1c80421 --- /dev/null +++ b/charts/github-sts/ci/no-apps-values.yaml @@ -0,0 +1,3 @@ +# CI test values: no apps configured (lint-only, should show WARNING in NOTES) +github: + apps: {} diff --git a/charts/github-sts/ci/single-app-values.yaml b/charts/github-sts/ci/single-app-values.yaml new file mode 100644 index 0000000..b7097f3 --- /dev/null +++ b/charts/github-sts/ci/single-app-values.yaml @@ -0,0 +1,6 @@ +# CI test values: single GitHub App deployment +github: + apps: + default: + appId: 12345 + existingSecret: "github-app-default-credentials" diff --git a/charts/github-sts/templates/NOTES.txt b/charts/github-sts/templates/NOTES.txt index eb68765..f9aed92 100644 --- a/charts/github-sts/templates/NOTES.txt +++ b/charts/github-sts/templates/NOTES.txt @@ -35,10 +35,16 @@ Access the application via port-forward: {{- end }} -{{- if and (not .Values.github.appId) (not .Values.github.existingSecret) }} - -WARNING: No GitHub App credentials configured! - Set github.appId and github.appPrivateKey, or provide github.existingSecret. +{{- if not .Values.github.apps }} + +WARNING: No GitHub Apps configured! + Configure at least one app under github.apps with appId and existingSecret. + Example: + github: + apps: + default: + appId: "12345" + existingSecret: "my-github-app-credentials" {{- end }} {{- if .Values.serviceMonitor.enabled }} diff --git a/charts/github-sts/templates/_helpers.tpl b/charts/github-sts/templates/_helpers.tpl index bdf07ec..50c92fc 100644 --- a/charts/github-sts/templates/_helpers.tpl +++ b/charts/github-sts/templates/_helpers.tpl @@ -77,21 +77,10 @@ Return the proper image name. {{- end }} {{/* -Return true if GitHub credentials are configured (either via existingSecret or inline values). +Return true if at least one GitHub App is configured. */}} -{{- define "github-sts.hasCredentials" -}} -{{- if or .Values.github.existingSecret (and .Values.github.appId .Values.github.appPrivateKey) -}} +{{- define "github-sts.hasApps" -}} +{{- if .Values.github.apps -}} true {{- end -}} {{- end }} - -{{/* -Return the name of the credentials secret. -*/}} -{{- define "github-sts.credentialsSecretName" -}} -{{- if .Values.github.existingSecret -}} -{{- .Values.github.existingSecret }} -{{- else -}} -{{- include "github-sts.fullname" . }}-credentials -{{- end -}} -{{- end }} diff --git a/charts/github-sts/templates/configmap.yaml b/charts/github-sts/templates/configmap.yaml index b79724c..3a3f810 100644 --- a/charts/github-sts/templates/configmap.yaml +++ b/charts/github-sts/templates/configmap.yaml @@ -53,3 +53,12 @@ data: rate_limit_poll_interval_seconds: {{ .Values.metrics.rateLimitPoll.intervalSeconds }} reachability_probe_enabled: {{ .Values.metrics.reachabilityProbe.enabled }} reachability_probe_interval_seconds: {{ .Values.metrics.reachabilityProbe.intervalSeconds }} + + {{- if .Values.github.apps }} + apps: + {{- range $appName, $appConfig := .Values.github.apps }} + {{ $appName }}: + app_id: {{ $appConfig.appId | int }} + private_key_path: {{ printf "/etc/github-sts/apps/%s/%s" $appName ($appConfig.secretPrivateKeyKey | default "github-app-private-key") | quote }} + {{- end }} + {{- end }} diff --git a/charts/github-sts/templates/deployment.yaml b/charts/github-sts/templates/deployment.yaml index 7605a20..6fb570c 100644 --- a/charts/github-sts/templates/deployment.yaml +++ b/charts/github-sts/templates/deployment.yaml @@ -15,7 +15,6 @@ spec: metadata: annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} @@ -45,20 +44,6 @@ spec: env: - name: PYGITHUBSTS_CONFIG_PATH value: "/etc/github-sts/config.yaml" - - name: PYGITHUBSTS_GITHUB_APP_NAME - value: {{ .Values.github.appName | quote }} - {{- if include "github-sts.hasCredentials" . }} - - name: PYGITHUBSTS_GITHUB_APP_ID - valueFrom: - secretKeyRef: - name: {{ include "github-sts.credentialsSecretName" . }} - key: {{ .Values.github.secretAppIdKey | default "github-app-id" }} - - name: PYGITHUBSTS_GITHUB_APP_PRIVATE_KEY - valueFrom: - secretKeyRef: - name: {{ include "github-sts.credentialsSecretName" . }} - key: {{ .Values.github.secretPrivateKeyKey | default "github-app-private-key" }} - {{- end }} {{- with .Values.extraEnv }} {{- toYaml . | nindent 12 }} {{- end }} @@ -99,6 +84,11 @@ spec: {{- with .Values.extraVolumeMounts }} {{- toYaml . | nindent 12 }} {{- end }} + {{- range $appName, $appConfig := .Values.github.apps }} + - name: github-app-{{ $appName }}-key + mountPath: /etc/github-sts/apps/{{ $appName }} + readOnly: true + {{- end }} volumes: - name: tmp emptyDir: {} @@ -113,6 +103,14 @@ spec: {{- with .Values.extraVolumes }} {{- toYaml . | nindent 8 }} {{- end }} + {{- range $appName, $appConfig := .Values.github.apps }} + - name: github-app-{{ $appName }}-key + secret: + secretName: {{ $appConfig.existingSecret }} + items: + - key: {{ $appConfig.secretPrivateKeyKey | default "github-app-private-key" }} + path: {{ $appConfig.secretPrivateKeyKey | default "github-app-private-key" }} + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/github-sts/templates/secret.yaml b/charts/github-sts/templates/secret.yaml index 37b8a94..b679cf1 100644 --- a/charts/github-sts/templates/secret.yaml +++ b/charts/github-sts/templates/secret.yaml @@ -1,13 +1,2 @@ -{{- if and (not .Values.github.existingSecret) .Values.github.appId .Values.github.appPrivateKey }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "github-sts.credentialsSecretName" . }} - labels: - {{- include "github-sts.labels" . | nindent 4 }} -type: Opaque -stringData: - github-app-id: {{ .Values.github.appId | quote }} - github-app-private-key: | - {{- .Values.github.appPrivateKey | nindent 4 }} -{{- end }} +{{/* Secret is no longer created by the chart. */}} +{{/* All GitHub App credentials must be provided via existingSecret in github.apps entries. */}} diff --git a/charts/github-sts/values.yaml b/charts/github-sts/values.yaml index 0b9faa5..9eed4ca 100644 --- a/charts/github-sts/values.yaml +++ b/charts/github-sts/values.yaml @@ -228,26 +228,26 @@ podMonitor: # -- Honor labels honorLabels: false -# -- GitHub App credentials (required) -# Provide credentials via existingSecret or inline appId + appPrivateKey. -# If neither is set, the chart renders without credential env vars (useful for linting). +# -- GitHub App configuration (required) +# Each entry under apps is a GitHub App identified by name. +# The app name is used in policy paths: {policy.basePath}/{appName}/{identity}.sts.yaml +# Each app's private key must be stored in an existing Kubernetes Secret. +# For a single-app deployment, define one entry (e.g. "default"). github: - # -- GitHub App ID - appId: "" - # -- GitHub App private key (PEM format, multiline) - # appPrivateKey: | - # -----BEGIN RSA PRIVATE KEY----- - # - # -----END RSA PRIVATE KEY----- - appPrivateKey: "" - # -- GitHub App name - appName: "default" - # -- Use an existing secret instead of creating one - existingSecret: "" - # -- Key in existing secret for app ID - secretAppIdKey: "github-app-id" - # -- Key in existing secret for private key - secretPrivateKeyKey: "github-app-private-key" + # -- GitHub Apps map. At least one app must be configured. + # apps: + # default: + # # -- GitHub App ID (required) + # appId: "12345" + # # -- Name of an existing Secret containing the private key (required) + # existingSecret: "my-github-app-credentials" + # # -- Key in the secret that holds the private key PEM (default: "github-app-private-key") + # secretPrivateKeyKey: "github-app-private-key" + # + # another-app: + # appId: "67890" + # existingSecret: "another-app-github-credentials" + apps: {} # -- Additional pod annotations podAnnotations: {} diff --git a/src/github_sts/config.py b/src/github_sts/config.py index a1bd2c9..c585440 100644 --- a/src/github_sts/config.py +++ b/src/github_sts/config.py @@ -102,7 +102,7 @@ class AppConfig(BaseModel): private_key_path: str | None = None # Path to PEM file @model_validator(mode="after") - def resolve_private_key(self) -> AppConfig: + def resolve_private_key(self) -> "AppConfig": # noqa: UP037 """Load private key from file if private_key_path is given.""" if self.private_key and not self.private_key.startswith("-----BEGIN"): # Treat as file path for backward compat diff --git a/src/github_sts/github_app.py b/src/github_sts/github_app.py index cb8d9ea..b4c7872 100644 --- a/src/github_sts/github_app.py +++ b/src/github_sts/github_app.py @@ -84,14 +84,14 @@ async def _get_installation_id(self, scope: str, caller: str = "") -> int: installation_id = resp.json()["id"] _installation_id_cache[id_cache_key] = installation_id metrics.GITHUB_API_CALLS.labels( - endpoint="get_installation", result="ok" + app=self._app_name, endpoint="get_installation", result="ok" ).inc() return installation_id except httpx.HTTPError: continue metrics.GITHUB_API_CALLS.labels( - endpoint="get_installation", result="not_found" + app=self._app_name, endpoint="get_installation", result="not_found" ).inc() raise ValueError( f"GitHub App {self._app_name!r} not installed for scope {scope!r}" @@ -152,9 +152,11 @@ async def get_installation_token( _installation_token_cache[cache_key] = (token, expires_epoch) perm_str = ",".join(f"{k}:{v}" for k, v in (permissions or {}).items()) - metrics.GITHUB_TOKEN_ISSUED.labels(scope=scope, permissions=perm_str).inc() + metrics.GITHUB_TOKEN_ISSUED.labels( + app=self._app_name, scope=scope, permissions=perm_str + ).inc() metrics.GITHUB_API_CALLS.labels( - endpoint="create_installation_token", result="ok" + app=self._app_name, endpoint="create_installation_token", result="ok" ).inc() # SECURITY: Never log token values — only metadata (app, scope, permissions) diff --git a/src/github_sts/metrics.py b/src/github_sts/metrics.py index 79e0a3c..cc87e6e 100644 --- a/src/github_sts/metrics.py +++ b/src/github_sts/metrics.py @@ -73,30 +73,32 @@ POLICY_LOADS_TOTAL = Counter( "pygithubsts_policy_loads_total", "Total policy file load attempts", - ["backend", "result"], # backend: github | database + ["app", "backend", "result"], # backend: github | database ) POLICY_CACHE_HITS = Counter( "pygithubsts_policy_cache_hits_total", "Policy cache hits", + ["app"], ) POLICY_CACHE_MISSES = Counter( "pygithubsts_policy_cache_misses_total", "Policy cache misses", + ["app"], ) # ── GitHub App metrics ──────────────────────────────────────────────────────── GITHUB_API_CALLS = Counter( "pygithubsts_github_api_calls_total", "Total GitHub API calls", - ["endpoint", "result"], + ["app", "endpoint", "result"], ) GITHUB_TOKEN_ISSUED = Counter( "pygithubsts_github_tokens_issued_total", "GitHub installation tokens issued", - ["scope", "permissions"], + ["app", "scope", "permissions"], ) # ── GitHub API rate limit metrics ───────────────────────────────────────────── diff --git a/src/github_sts/policy_loader.py b/src/github_sts/policy_loader.py index 5ffc823..2b54477 100644 --- a/src/github_sts/policy_loader.py +++ b/src/github_sts/policy_loader.py @@ -73,16 +73,20 @@ async def load( """Load and return a TrustPolicy, or None if not found.""" ... - def _parse(self, raw_yaml: str, source: str, backend: str) -> TrustPolicy | None: + def _parse( + self, raw_yaml: str, source: str, backend: str, app_name: str + ) -> TrustPolicy | None: try: data = yaml.safe_load(raw_yaml) policy = TrustPolicy(**data) - metrics.POLICY_LOADS_TOTAL.labels(backend=backend, result="ok").inc() + metrics.POLICY_LOADS_TOTAL.labels( + app=app_name, backend=backend, result="ok" + ).inc() return policy except (yaml.YAMLError, ValidationError, TypeError) as exc: logger.warning("Failed to parse policy from %s: %s", source, exc) metrics.POLICY_LOADS_TOTAL.labels( - backend=backend, result="parse_error" + app=app_name, backend=backend, result="parse_error" ).inc() return None @@ -116,10 +120,10 @@ async def load( if ttl > 0: cached = await _get_cached(cache_key) if cached is not None: - metrics.POLICY_CACHE_HITS.inc() + metrics.POLICY_CACHE_HITS.labels(app=app_name).inc() _, policy = cached return policy - metrics.POLICY_CACHE_MISSES.inc() + metrics.POLICY_CACHE_MISSES.labels(app=app_name).inc() policy = await self._fetch_from_github(scope, app_name, identity) @@ -148,11 +152,11 @@ async def _fetch_from_github( ) if resp.status_code == 404: metrics.POLICY_LOADS_TOTAL.labels( - backend="github", result="not_found" + app=app_name, backend="github", result="not_found" ).inc() return None resp.raise_for_status() - return self._parse(resp.text, url, "github") + return self._parse(resp.text, url, "github", app_name) except httpx.HTTPError as exc: logger.error( "GitHub API error fetching policy %s/%s/%s: %s", @@ -162,7 +166,7 @@ async def _fetch_from_github( exc, ) metrics.POLICY_LOADS_TOTAL.labels( - backend="github", result="http_error" + app=app_name, backend="github", result="http_error" ).inc() return None @@ -199,10 +203,10 @@ async def load( if ttl > 0: cached = await _get_cached(cache_key) if cached is not None: - metrics.POLICY_CACHE_HITS.inc() + metrics.POLICY_CACHE_HITS.labels(app=app_name).inc() _, policy = cached return policy - metrics.POLICY_CACHE_MISSES.inc() + metrics.POLICY_CACHE_MISSES.labels(app=app_name).inc() policy = await self._query(scope, app_name, identity) @@ -217,7 +221,7 @@ async def _query( if self._pool is None: logger.error("DatabasePolicyLoader: no db pool configured") metrics.POLICY_LOADS_TOTAL.labels( - backend="database", result="no_pool" + app=app_name, backend="database", result="no_pool" ).inc() return None @@ -231,11 +235,11 @@ async def _query( ) if row is None: metrics.POLICY_LOADS_TOTAL.labels( - backend="database", result="not_found" + app=app_name, backend="database", result="not_found" ).inc() return None return self._parse( - row["policy"], f"db:{scope}/{app_name}/{identity}", "database" + row["policy"], f"db:{scope}/{app_name}/{identity}", "database", app_name ) except Exception as exc: logger.error( @@ -246,7 +250,7 @@ async def _query( exc, ) metrics.POLICY_LOADS_TOTAL.labels( - backend="database", result="db_error" + app=app_name, backend="database", result="db_error" ).inc() return None diff --git a/src/github_sts/rate_limit.py b/src/github_sts/rate_limit.py index 55dab14..9a8b3fb 100644 --- a/src/github_sts/rate_limit.py +++ b/src/github_sts/rate_limit.py @@ -402,7 +402,9 @@ async def _poll_with_token( app=app_name, resource=resource_name ).set(pct) - metrics.GITHUB_API_CALLS.labels(endpoint="get_rate_limit", result="ok").inc() + metrics.GITHUB_API_CALLS.labels( + app=app_name, endpoint="get_rate_limit", result="ok" + ).inc() logger.debug( "Rate limit poll complete: app=%s installation=%s", diff --git a/tests/test_multi_app.py b/tests/test_multi_app.py new file mode 100644 index 0000000..d3519b7 --- /dev/null +++ b/tests/test_multi_app.py @@ -0,0 +1,374 @@ +""" +Tests for multi-app GitHub App support. + +Validates that metrics include the `app` label for all app-specific operations, +enabling proper per-app observability in multi-app deployments. + +Run with: pytest tests/test_multi_app.py +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from github_sts import metrics +from github_sts.policy_loader import ( + DatabasePolicyLoader, + GitHubPolicyLoader, + _policy_cache, +) + +VALID_POLICY_YAML = """ +issuer: https://token.actions.githubusercontent.com +subject: repo:org/repo:ref:refs/heads/main +permissions: + contents: read +""" + + +def _make_github_response( + status_code: int = 200, + text: str = VALID_POLICY_YAML, + json_data: dict | None = None, +) -> httpx.Response: + """Create a mock httpx.Response for GitHub API calls.""" + if json_data is not None: + return httpx.Response( + status_code=status_code, + json=json_data, + request=httpx.Request("GET", "https://api.github.com/test"), + ) + return httpx.Response( + status_code=status_code, + text=text, + request=httpx.Request("GET", "https://api.github.com/test"), + ) + + +class TestPolicyLoaderMetricsAppLabel: + """Verify that policy loader metrics include the `app` label.""" + + async def test_github_loader_cache_miss_includes_app_label(self): + """Intention: verify POLICY_CACHE_MISSES carries the app label on a cache miss. + + What is being tested: + - `GitHubPolicyLoader.load()` records a cache miss with app= + when no cached policy is present. + + Expected output: + - POLICY_CACHE_MISSES metric incremented with app="test-app". + """ + _policy_cache.clear() + + mock_provider = AsyncMock() + mock_provider.get_installation_token = AsyncMock(return_value="fake-token") + + loader = GitHubPolicyLoader(mock_provider) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client.get = AsyncMock( + return_value=_make_github_response( + status_code=200, text=VALID_POLICY_YAML + ) + ) + + before = metrics.POLICY_CACHE_MISSES.labels(app="test-app")._value.get() + await loader.load("org/repo", "test-app", "ci") + after = metrics.POLICY_CACHE_MISSES.labels(app="test-app")._value.get() + + assert after == before + 1, "Expected one cache miss for app=test-app" + + async def test_github_loader_cache_hit_includes_app_label(self): + """Intention: verify POLICY_CACHE_HITS carries the app label on a cache hit. + + What is being tested: + - `GitHubPolicyLoader.load()` records a cache hit with app= + when a valid policy is already cached. + + Expected output: + - POLICY_CACHE_HITS metric incremented with app="cached-app". + """ + _policy_cache.clear() + + mock_provider = AsyncMock() + mock_provider.get_installation_token = AsyncMock(return_value="fake-token") + + loader = GitHubPolicyLoader(mock_provider) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client.get = AsyncMock( + return_value=_make_github_response( + status_code=200, text=VALID_POLICY_YAML + ) + ) + # First load populates the cache + await loader.load("org/repo", "cached-app", "ci") + + # Second load should hit cache + before = metrics.POLICY_CACHE_HITS.labels(app="cached-app")._value.get() + await loader.load("org/repo", "cached-app", "ci") + after = metrics.POLICY_CACHE_HITS.labels(app="cached-app")._value.get() + + assert after == before + 1, "Expected one cache hit for app=cached-app" + + async def test_github_loader_success_includes_app_label(self): + """Intention: verify POLICY_LOADS_TOTAL carries the app label on success. + + What is being tested: + - `GitHubPolicyLoader.load()` records a successful load with app=. + + Expected output: + - POLICY_LOADS_TOTAL metric incremented with app="load-app", result="ok". + """ + _policy_cache.clear() + + mock_provider = AsyncMock() + mock_provider.get_installation_token = AsyncMock(return_value="fake-token") + + loader = GitHubPolicyLoader(mock_provider) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client.get = AsyncMock( + return_value=_make_github_response( + status_code=200, text=VALID_POLICY_YAML + ) + ) + + before = metrics.POLICY_LOADS_TOTAL.labels( + app="load-app", backend="github", result="ok" + )._value.get() + await loader.load("org/repo", "load-app", "ci") + after = metrics.POLICY_LOADS_TOTAL.labels( + app="load-app", backend="github", result="ok" + )._value.get() + + assert after == before + 1, "Expected one successful load for app=load-app" + + async def test_github_loader_not_found_includes_app_label(self): + """Intention: verify POLICY_LOADS_TOTAL carries app label on 404. + + What is being tested: + - `GitHubPolicyLoader.load()` records a not_found result with app= + when GitHub returns 404. + + Expected output: + - POLICY_LOADS_TOTAL metric incremented with app="missing-app", result="not_found". + """ + _policy_cache.clear() + + mock_provider = AsyncMock() + mock_provider.get_installation_token = AsyncMock(return_value="fake-token") + + loader = GitHubPolicyLoader(mock_provider) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client.get = AsyncMock( + return_value=_make_github_response(status_code=404, text="not found") + ) + + before = metrics.POLICY_LOADS_TOTAL.labels( + app="missing-app", backend="github", result="not_found" + )._value.get() + result = await loader.load("org/repo", "missing-app", "ci") + after = metrics.POLICY_LOADS_TOTAL.labels( + app="missing-app", backend="github", result="not_found" + )._value.get() + + assert result is None + assert after == before + 1, "Expected one not_found for app=missing-app" + + async def test_database_loader_cache_miss_includes_app_label(self): + """Intention: verify DatabasePolicyLoader cache miss includes app label. + + What is being tested: + - `DatabasePolicyLoader.load()` records cache misses with app=. + + Expected output: + - POLICY_CACHE_MISSES metric incremented with app="db-app". + """ + _policy_cache.clear() + + mock_pool = AsyncMock() + mock_pool.fetchrow = AsyncMock(return_value=None) + + loader = DatabasePolicyLoader(db_pool=mock_pool) + + before = metrics.POLICY_CACHE_MISSES.labels(app="db-app")._value.get() + await loader.load("org/repo", "db-app", "ci") + after = metrics.POLICY_CACHE_MISSES.labels(app="db-app")._value.get() + + assert after == before + 1, "Expected one cache miss for db-app" + + async def test_database_loader_not_found_includes_app_label(self): + """Intention: verify DatabasePolicyLoader not_found metric includes app label. + + What is being tested: + - `DatabasePolicyLoader.load()` records not_found with app= + when the database returns no matching row. + + Expected output: + - POLICY_LOADS_TOTAL metric incremented with app="db-not-found", result="not_found". + """ + _policy_cache.clear() + + mock_pool = AsyncMock() + mock_pool.fetchrow = AsyncMock(return_value=None) + + loader = DatabasePolicyLoader(db_pool=mock_pool) + + before = metrics.POLICY_LOADS_TOTAL.labels( + app="db-not-found", backend="database", result="not_found" + )._value.get() + result = await loader.load("org/repo", "db-not-found", "ci") + after = metrics.POLICY_LOADS_TOTAL.labels( + app="db-not-found", backend="database", result="not_found" + )._value.get() + + assert result is None + assert after == before + 1, "Expected one not_found for db-not-found app" + + async def test_different_apps_have_separate_metric_counts(self): + """Intention: verify that metrics for different apps are independently tracked. + + What is being tested: + - Loading policies for two different apps results in separate metric time series, + so each app can be monitored independently. + + Expected output: + - POLICY_CACHE_MISSES for app="app-alpha" and app="app-beta" are each incremented + once, independently of each other. + """ + _policy_cache.clear() + + mock_provider = AsyncMock() + mock_provider.get_installation_token = AsyncMock(return_value="fake-token") + + loader = GitHubPolicyLoader(mock_provider) + + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client.get = AsyncMock( + return_value=_make_github_response( + status_code=200, text=VALID_POLICY_YAML + ) + ) + + before_alpha = metrics.POLICY_CACHE_MISSES.labels( + app="app-alpha" + )._value.get() + before_beta = metrics.POLICY_CACHE_MISSES.labels( + app="app-beta" + )._value.get() + + await loader.load("org/repo", "app-alpha", "ci") + await loader.load("org/repo", "app-beta", "ci") + + after_alpha = metrics.POLICY_CACHE_MISSES.labels( + app="app-alpha" + )._value.get() + after_beta = metrics.POLICY_CACHE_MISSES.labels(app="app-beta")._value.get() + + assert after_alpha == before_alpha + 1, "app-alpha should have 1 cache miss" + assert after_beta == before_beta + 1, "app-beta should have 1 cache miss" + + +class TestGitHubAppTokenProviderMetricsAppLabel: + """Verify that GitHubAppTokenProvider metrics include the `app` label.""" + + @pytest.fixture + def mock_app_config(self): + config = MagicMock() + config.app_id = 12345 + config.private_key = ( + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----" + ) + return config + + async def test_get_installation_token_api_call_includes_app_label( + self, mock_app_config + ): + """Intention: verify GITHUB_API_CALLS for token creation includes app label. + + What is being tested: + - `GitHubAppTokenProvider.get_installation_token()` increments GITHUB_API_CALLS + with the correct app label. + + Expected output: + - GITHUB_API_CALLS with app="token-app", endpoint="create_installation_token", + result="ok" is incremented. + - GITHUB_TOKEN_ISSUED with app="token-app" is incremented. + """ + from github_sts.github_app import ( + GitHubAppTokenProvider, + _installation_id_cache, + _installation_token_cache, + ) + + # Pre-populate installation ID cache to skip that API call + _installation_id_cache["token-app:org/repo"] = 999 + _installation_token_cache.clear() + + provider = GitHubAppTokenProvider("token-app", mock_app_config) + + mock_token_response = { + "token": "ghs_fake_token_12345", + "expires_at": "2099-01-01T00:00:00Z", + } + + with patch("jwt.encode", return_value="fake.jwt.token"): + with patch("httpx.AsyncClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client_cls.return_value.__aenter__.return_value = mock_client + mock_client.post = AsyncMock( + return_value=httpx.Response( + status_code=201, + json=mock_token_response, + request=httpx.Request( + "POST", + "https://api.github.com/app/installations/999/access_tokens", + ), + ) + ) + + before_api = metrics.GITHUB_API_CALLS.labels( + app="token-app", + endpoint="create_installation_token", + result="ok", + )._value.get() + before_issued = metrics.GITHUB_TOKEN_ISSUED.labels( + app="token-app", + scope="org/repo", + permissions="contents:read", + )._value.get() + + await provider.get_installation_token( + "org/repo", {"contents": "read"}, "test-caller" + ) + + after_api = metrics.GITHUB_API_CALLS.labels( + app="token-app", + endpoint="create_installation_token", + result="ok", + )._value.get() + after_issued = metrics.GITHUB_TOKEN_ISSUED.labels( + app="token-app", + scope="org/repo", + permissions="contents:read", + )._value.get() + + assert after_api == before_api + 1, ( + "Expected GITHUB_API_CALLS to be incremented" + ) + assert after_issued == before_issued + 1, ( + "Expected GITHUB_TOKEN_ISSUED to be incremented" + )