infra(salt): manage k3s unit file with maxPods=250 #333

Merged
forgejo_admin merged 8 commits from 332-salt-k3s-maxpods into main 2026-05-09 16:34:24 +00:00
Contributor

Summary

  • Converts k3s Salt state from verify-only to fully managed systemd unit file
  • Adds --kubelet-arg=max-pods=250 via pillar-driven Jinja2 template
  • Raises pod ceiling from default 110 to 250 (node was hitting limit at 125 pods)

Changes

  • salt/states/k3s/init.sls: Converted from file.exists + service.running to file.managed + cmd.wait + watch-triggered restart
  • salt/states/k3s/k3s.service.j2: New Jinja2 template for the systemd unit, iterates over pillar args
  • salt/pillar/k3s.sls: New pillar with server_args and kubelet_args lists
  • salt/pillar/top.sls: Include k3s pillar for archbox

Test Plan

  • salt-call pillar.get k3s returns correct values
  • salt-call state.apply k3s test=True passes (4 succeeded, 0 failed)
  • Node reports 250 allocatable pods after live fix
  • westside-admin-dev pod scheduled successfully after limit raised
  • No regressions in other Salt states

Review Checklist

  • Passed automated review-fix loop
  • No secrets committed
  • No unnecessary file changes
  • Commit messages are descriptive
  • forgejo_admin/pal-e-platform #332 — the Forgejo issue this PR implements
  • project-pal-e-platform — platform infrastructure

Closes #332

## Summary - Converts k3s Salt state from verify-only to fully managed systemd unit file - Adds `--kubelet-arg=max-pods=250` via pillar-driven Jinja2 template - Raises pod ceiling from default 110 to 250 (node was hitting limit at 125 pods) ## Changes - `salt/states/k3s/init.sls`: Converted from `file.exists` + `service.running` to `file.managed` + `cmd.wait` + watch-triggered restart - `salt/states/k3s/k3s.service.j2`: New Jinja2 template for the systemd unit, iterates over pillar args - `salt/pillar/k3s.sls`: New pillar with `server_args` and `kubelet_args` lists - `salt/pillar/top.sls`: Include k3s pillar for archbox ## Test Plan - [x] `salt-call pillar.get k3s` returns correct values - [x] `salt-call state.apply k3s test=True` passes (4 succeeded, 0 failed) - [x] Node reports 250 allocatable pods after live fix - [x] westside-admin-dev pod scheduled successfully after limit raised - [ ] No regressions in other Salt states ## Review Checklist - [x] Passed automated review-fix loop - [x] No secrets committed - [x] No unnecessary file changes - [x] Commit messages are descriptive ## Related Notes - `forgejo_admin/pal-e-platform #332` — the Forgejo issue this PR implements - `project-pal-e-platform` — platform infrastructure Closes #332
observability: reduce alert noise + add payment pipeline signals
All checks were successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
432e24ef2e
Addresses the 31-firing-alerts fatigue identified during the Apr 13
checkout outage by removing expected-down blackbox targets, silencing
kube-state-metrics default duplicates, and adding revenue-critical
Stripe webhook alerts + a basketball-api golden signals dashboard.

Changes:
- Remove blackbox targets westside-dev, pal-e-app, mac-agent (all
  expected-down; generated critical EndpointDown alerts indistinguishable
  from real outages). Mac-agent uptime still covered by MacAgentDown.
- Downgrade EndpointDown critical -> warning, raise "for" 2m -> 5m to
  suppress flaps.
- Disable kube-prometheus-stack defaultRules.kubeStateMetrics and
  kubernetesApps (duplicate namespace noise). Our PodRestartStorm,
  OOMKilled, TargetDown rules provide the real signal.
- Add alertmanager inhibit rule so critical alerts suppress their
  warning counterparts on the same alertname+namespace.
- New PrometheusRule payment-pipeline-alerts with:
    - WebhookErrorRate (warning, 5m) on increase(webhook_errors_total[5m])
    - WebhookStale (warning, 10m) when checkout.session.completed
      timestamp is >30min stale during business hours (9am-9pm MST weekdays)
- New basketball-api-golden-signals dashboard wired via ConfigMap,
  following the pal-e-app-golden-signals pattern. Uses only metrics
  basketball-api currently exposes (basketball_api_up, webhook_*).

tofu validate passes. tofu plan -lock=false shows only the expected
resource additions/updates under module.monitoring plus pre-existing
drift from previously-merged PRs (database/ops).

Closes #290

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix(observability): WebhookStale day-of-week boundary + rollover cushion
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
6501a2d2a1
Per QA review on PR #329.

1. Day-of-week off-by-one across UTC midnight. The previous filter applied
   one day_of_week() check to BOTH halves of business hours, but those
   halves fall on different UTC days:
     16:00-23:59 UTC = today    (Mon-Fri = days 1-5)
     00:00-04:00 UTC = tomorrow (Tue-Sat = days 2-6)

   Old:        (hour()>=16 or hour()<4) and (day_of_week() in 1..5)
   Friday bug: 8-9pm MST = Sat UTC, day=6 fails <6 -> silently no alert
   Sunday bug: 8-9pm MST = Mon UTC, day=1 passes  -> false alert

   New:        ((hour()>=16 and day_of_week() in 1..5)
                or (hour()<4 and day_of_week() in 2..6))

2. for: 10m -> 60m. The 16:00 UTC rollover into business hours fires
   immediately because last-checkout staleness is naturally >30min from
   overnight. The 60-minute sustained-staleness check cushions normal
   morning gaps. Low-traffic-day design wrinkle (no first checkout yet)
   is tracked as a follow-up ticket.

tofu fmt + tofu validate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(observability): clarify WebhookStale annotation + reference #330
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
c38aa1dc60
Two non-blocking nits from QA approval on PR #329:

1. Annotation said "30+ minutes" but `for: 60m` means the staleness
   condition has to be sustained for 60min before firing. Reword the
   summary and description to make both thresholds explicit.

2. Comment now references issue #330 by number for the deferred
   low-traffic-morning design fix, replacing the bare "tracked separately."

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New SvelteKit dev-overlay app modeled on pal-e-dictionary. Public Tailscale
funnel renders all boards from pal-e-docs as a TOC grouped by project, with
sub-boards (issue-scoped kanbans) nested under their parent project board.
7-column kanban view per board, mobile-first, read-only. Auth deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
infra(salt): manage k3s unit file with maxPods=250 (#332)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
4cd232afca
Converts k3s Salt state from verify-only to managed unit file.
Kubelet default of 110 pods was exhausted on single-node archbox
(125 pods, mostly Tailscale proxies). Pillar-driven args allow
future kubelet/server arg changes without editing the service file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author
Contributor

PR #333 Review

DOMAIN REVIEW

Tech stack: Salt (SaltStack states + pillar + Jinja2 templates), systemd unit files.

Salt state correctness (init.sls):

  • Requisite ordering is correct: k3s-unit-file requires k3s-binary, k3s-daemon-reload watches k3s-unit-file, k3s-service watches k3s-unit-file AND requires k3s-daemon-reload. This guarantees the sequence: binary exists -> unit file written -> daemon-reload -> service restart.
  • cmd.wait is the correct state for daemon-reload (only fires on watch trigger, not every highstate). Good.
  • service.running with watch on the unit file means Salt will restart k3s if the file changes. Combined with the require on cmd.wait, daemon-reload will always happen before the restart. Correct.

Pillar structure (k3s.sls + top.sls):

  • Clean separation. Only archbox gets the k3s pillar. Appropriate.
  • Default handling in the template uses pillar.get('k3s', {}).get('server_args', ['--disable=traefik']) -- safe fallback if pillar is missing entirely.

Jinja2 template safety (k3s.service.j2):

  • pillar.get('k3s', {}).get(...) is the correct defensive pattern. If k3s pillar is absent or the key is missing, defaults kick in. No KeyError risk.

BLOCKERS

1. Trailing backslash on last loop iteration produces invalid ExecStart (BLOCKER)

With the current pillar (server_args: ['--disable=traefik'], kubelet_args: ['max-pods=250']), the template renders:

ExecStart=/usr/local/bin/k3s \
    server \
    '--disable=traefik' \
    '--kubelet-arg=max-pods=250' \

The LAST argument in the last loop also gets a trailing \ (backslash), followed by an empty line. In systemd unit files, a trailing backslash is a line continuation. This means:

  • On some systemd versions, the trailing \ + empty line results in an empty string appended to argv, which k3s may reject or ignore silently.
  • On other versions, it could cause a parse error on unit load.

Fix: The last line of ExecStart must NOT have a trailing backslash. Common Jinja2 patterns to fix this:

{%- set all_args = [] %}
{%- for arg in pillar.get('k3s', {}).get('server_args', ['--disable=traefik']) %}
{%-   do all_args.append("'" ~ arg ~ "'") %}
{%- endfor %}
{%- for arg in pillar.get('k3s', {}).get('kubelet_args', []) %}
{%-   do all_args.append("'--kubelet-arg=" ~ arg ~ "'") %}
{%- endfor %}
ExecStart=/usr/local/bin/k3s \
    server \
{%- for a in all_args %}
    {{ a }}{{ ' \\' if not loop.last else '' }}
{%- endfor %}

Or use loop.last in the existing loop structure. The key is: the final rendered argument must NOT have a trailing backslash.

Severity note: The PR body says salt-call state.apply k3s test=True passed and the service is running post-fix. This suggests systemd on this host silently accepts the trailing backslash+newline. However, this is not portable behavior and violates systemd unit file spec. It is a correctness issue that should be fixed before merge.

NITS

  1. Unit file mode 0644 is fine for a systemd unit file. Some systems use 0644, some 0755. Since this is a service unit (not a binary), 0644 is standard. No issue.

  2. Single-quoting args in ExecStart ('--disable=traefik') is unusual for systemd. Systemd ExecStart does support quoting per systemd.exec(5), but arguments are typically unquoted unless they contain spaces. Not harmful, but unconventional. Minor style point.

  3. Empty kubelet_args default means if someone removes the kubelet_args key from pillar, the kubelet_args loop produces no output and only the trailing backslash from the server_args loop's last iteration remains dangling. Same root cause as the blocker, but a second scenario to consider.

  4. Template does not have a trailing newline control. Line 37 is blank. Depending on how the file is saved, there may or may not be a final newline. Salt's file.managed generally handles this correctly, so this is cosmetic.

SOP COMPLIANCE

  • Branch named after issue (332-salt-k3s-maxpods matches #332)
  • PR body follows template (Summary, Changes, Test Plan, Related present)
  • Related references project (project-pal-e-platform)
  • PR needs rebase off main -- currently branched off 290-payment-pipeline-observability, pulling in 3 unrelated files (pal-e-boards design spec, basketball-api dashboard JSON, monitoring terraform changes). The diff shows 811 additions / 29 deletions across 7 files, but only 4 files are in-scope. Mergeable is false.
  • No secrets committed
  • Scope creep -- unrelated files included due to branch ancestry (not the author's fault, but needs rebase)

PROCESS OBSERVATIONS

  • The PR was correctly scoped to a single issue (#332) with clear acceptance criteria.
  • Test plan includes real validation (salt-call test=True + live verification). Good DORA practice.
  • The branch ancestry issue (off 290-payment-pipeline-observability instead of main) means this PR cannot be merged cleanly until rebased. Forgejo reports mergeable: false.
  • Change failure risk is moderate: the trailing backslash issue could silently break k3s on a future systemd upgrade or on a different distro version, even though it works today.

VERDICT: NOT APPROVED

Reason: The trailing backslash on the last ExecStart argument line is a correctness bug in the Jinja2 template. While it appears to work on the current systemd version (based on test plan results), it produces technically invalid unit file syntax. Fix the template to omit the trailing backslash on the final argument, then rebase off main to remove unrelated diff noise.

## PR #333 Review ### DOMAIN REVIEW **Tech stack:** Salt (SaltStack states + pillar + Jinja2 templates), systemd unit files. **Salt state correctness (`init.sls`):** - Requisite ordering is correct: `k3s-unit-file` requires `k3s-binary`, `k3s-daemon-reload` watches `k3s-unit-file`, `k3s-service` watches `k3s-unit-file` AND requires `k3s-daemon-reload`. This guarantees the sequence: binary exists -> unit file written -> daemon-reload -> service restart. - `cmd.wait` is the correct state for daemon-reload (only fires on watch trigger, not every highstate). Good. - `service.running` with `watch` on the unit file means Salt will restart k3s if the file changes. Combined with the `require` on `cmd.wait`, daemon-reload will always happen before the restart. Correct. **Pillar structure (`k3s.sls` + `top.sls`):** - Clean separation. Only `archbox` gets the k3s pillar. Appropriate. - Default handling in the template uses `pillar.get('k3s', {}).get('server_args', ['--disable=traefik'])` -- safe fallback if pillar is missing entirely. **Jinja2 template safety (`k3s.service.j2`):** - `pillar.get('k3s', {}).get(...)` is the correct defensive pattern. If k3s pillar is absent or the key is missing, defaults kick in. No KeyError risk. ### BLOCKERS **1. Trailing backslash on last loop iteration produces invalid ExecStart (BLOCKER)** With the current pillar (`server_args: ['--disable=traefik']`, `kubelet_args: ['max-pods=250']`), the template renders: ``` ExecStart=/usr/local/bin/k3s \ server \ '--disable=traefik' \ '--kubelet-arg=max-pods=250' \ ``` The LAST argument in the last loop also gets a trailing ` \` (backslash), followed by an empty line. In systemd unit files, a trailing backslash is a line continuation. This means: - On some systemd versions, the trailing `\` + empty line results in an empty string appended to argv, which k3s may reject or ignore silently. - On other versions, it could cause a parse error on unit load. **Fix:** The last line of ExecStart must NOT have a trailing backslash. Common Jinja2 patterns to fix this: ```jinja {%- set all_args = [] %} {%- for arg in pillar.get('k3s', {}).get('server_args', ['--disable=traefik']) %} {%- do all_args.append("'" ~ arg ~ "'") %} {%- endfor %} {%- for arg in pillar.get('k3s', {}).get('kubelet_args', []) %} {%- do all_args.append("'--kubelet-arg=" ~ arg ~ "'") %} {%- endfor %} ExecStart=/usr/local/bin/k3s \ server \ {%- for a in all_args %} {{ a }}{{ ' \\' if not loop.last else '' }} {%- endfor %} ``` Or use `loop.last` in the existing loop structure. The key is: the final rendered argument must NOT have a trailing backslash. **Severity note:** The PR body says `salt-call state.apply k3s test=True` passed and the service is running post-fix. This suggests systemd on this host silently accepts the trailing backslash+newline. However, this is not portable behavior and violates systemd unit file spec. It is a correctness issue that should be fixed before merge. ### NITS 1. **Unit file mode 0644 is fine** for a systemd unit file. Some systems use 0644, some 0755. Since this is a service unit (not a binary), 0644 is standard. No issue. 2. **Single-quoting args in ExecStart** (`'--disable=traefik'`) is unusual for systemd. Systemd ExecStart does support quoting per `systemd.exec(5)`, but arguments are typically unquoted unless they contain spaces. Not harmful, but unconventional. Minor style point. 3. **Empty `kubelet_args` default** means if someone removes the `kubelet_args` key from pillar, the kubelet_args loop produces no output and only the trailing backslash from the `server_args` loop's last iteration remains dangling. Same root cause as the blocker, but a second scenario to consider. 4. **Template does not have a trailing newline control.** Line 37 is blank. Depending on how the file is saved, there may or may not be a final newline. Salt's `file.managed` generally handles this correctly, so this is cosmetic. ### SOP COMPLIANCE - [x] Branch named after issue (`332-salt-k3s-maxpods` matches #332) - [x] PR body follows template (Summary, Changes, Test Plan, Related present) - [x] Related references project (`project-pal-e-platform`) - [ ] **PR needs rebase off main** -- currently branched off `290-payment-pipeline-observability`, pulling in 3 unrelated files (pal-e-boards design spec, basketball-api dashboard JSON, monitoring terraform changes). The diff shows 811 additions / 29 deletions across 7 files, but only 4 files are in-scope. Mergeable is `false`. - [x] No secrets committed - [ ] **Scope creep** -- unrelated files included due to branch ancestry (not the author's fault, but needs rebase) ### PROCESS OBSERVATIONS - The PR was correctly scoped to a single issue (#332) with clear acceptance criteria. - Test plan includes real validation (salt-call test=True + live verification). Good DORA practice. - The branch ancestry issue (off `290-payment-pipeline-observability` instead of `main`) means this PR cannot be merged cleanly until rebased. Forgejo reports `mergeable: false`. - Change failure risk is moderate: the trailing backslash issue could silently break k3s on a future systemd upgrade or on a different distro version, even though it works today. ### VERDICT: NOT APPROVED **Reason:** The trailing backslash on the last ExecStart argument line is a correctness bug in the Jinja2 template. While it appears to work on the current systemd version (based on test plan results), it produces technically invalid unit file syntax. Fix the template to omit the trailing backslash on the final argument, then rebase off `main` to remove unrelated diff noise.
forgejo_admin force-pushed 332-salt-k3s-maxpods from 4cd232afca
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
to 9b4e7dbcb8
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed
2026-05-05 01:30:13 +00:00
Compare
Author
Contributor

PR #333 Re-Review

DOMAIN REVIEW

Tech stack: Salt (state + pillar) + Jinja2 systemd unit template.

Jinja2 loop.last logic (the previous blocker) -- VERIFIED CORRECT:

Both trailing-backslash conditions handle all cases properly:

  1. server_args loop (line 33): {% if not loop.last or kubelet_args %} \{% endif %}

    • Last server_arg WITH kubelet_args present: False or True = True -> backslash (continuation needed)
    • Last server_arg WITHOUT kubelet_args: False or False = False -> no backslash (final arg)
    • Non-last server_arg: True or * = True -> backslash (always continue)
  2. kubelet_args loop (line 37): {% if not loop.last %} \{% endif %}

    • Last kubelet_arg: no backslash (final arg in entire ExecStart)
    • Non-last kubelet_arg: backslash (continue)

Salt requisites -- CORRECT:

  • cmd.wait (daemon-reload) only fires when k3s-unit-file changes (watch trigger)
  • service.running watches k3s-unit-file (restart on change) and requires both k3s-binary and k3s-daemon-reload
  • This guarantees ordering: file change -> daemon-reload -> service restart
  • The require on cmd.wait ensures daemon-reload completes before the service state evaluates, even though cmd.wait is a no-op when nothing changes

Pillar structure: Clean separation of server_args vs kubelet_args. Default fallback in template (['--disable=traefik']) provides safety if pillar is missing.

BLOCKERS

None.

NITS

  1. Line 40 of the template has a trailing blank line after the final endfor. Cosmetic only -- systemd ignores it, but systemd-analyze verify may emit a warning on some versions. Non-blocking.

  2. The server_args default in the template (['--disable=traefik']) duplicates the pillar value. If someone removes it from pillar but expects the default to hold, it works -- but the intent could be documented with a comment in the template. Non-blocking.

SOP COMPLIANCE

  • Branch named after issue (332-salt-k3s-maxpods)
  • PR body follows template (Summary, Changes, Test Plan, Related)
  • Related references project slug (project-pal-e-platform)
  • No secrets committed
  • No unnecessary file changes (4 files, all scoped to k3s management)
  • Commit messages descriptive
  • Closes #332 in PR body

PROCESS OBSERVATIONS

  • Clean fix cycle: previous review found 2 issues, both resolved correctly on first pass.
  • Test plan includes live validation (salt-call state.apply k3s test=True passing, node reporting 250 allocatable pods). Strong evidence of correctness.
  • The change from verify-only to managed-unit is a sensible infrastructure maturation step.

VERDICT: APPROVED

## PR #333 Re-Review ### DOMAIN REVIEW **Tech stack**: Salt (state + pillar) + Jinja2 systemd unit template. **Jinja2 loop.last logic (the previous blocker) -- VERIFIED CORRECT:** Both trailing-backslash conditions handle all cases properly: 1. **server_args loop** (line 33): `{% if not loop.last or kubelet_args %} \{% endif %}` - Last server_arg WITH kubelet_args present: `False or True` = True -> backslash (continuation needed) - Last server_arg WITHOUT kubelet_args: `False or False` = False -> no backslash (final arg) - Non-last server_arg: `True or *` = True -> backslash (always continue) 2. **kubelet_args loop** (line 37): `{% if not loop.last %} \{% endif %}` - Last kubelet_arg: no backslash (final arg in entire ExecStart) - Non-last kubelet_arg: backslash (continue) **Salt requisites -- CORRECT:** - `cmd.wait` (daemon-reload) only fires when `k3s-unit-file` changes (watch trigger) - `service.running` watches `k3s-unit-file` (restart on change) and requires both `k3s-binary` and `k3s-daemon-reload` - This guarantees ordering: file change -> daemon-reload -> service restart - The `require` on `cmd.wait` ensures daemon-reload completes before the service state evaluates, even though `cmd.wait` is a no-op when nothing changes **Pillar structure**: Clean separation of server_args vs kubelet_args. Default fallback in template (`['--disable=traefik']`) provides safety if pillar is missing. ### BLOCKERS None. ### NITS 1. Line 40 of the template has a trailing blank line after the final `endfor`. Cosmetic only -- systemd ignores it, but `systemd-analyze verify` may emit a warning on some versions. Non-blocking. 2. The `server_args` default in the template (`['--disable=traefik']`) duplicates the pillar value. If someone removes it from pillar but expects the default to hold, it works -- but the intent could be documented with a comment in the template. Non-blocking. ### SOP COMPLIANCE - [x] Branch named after issue (`332-salt-k3s-maxpods`) - [x] PR body follows template (Summary, Changes, Test Plan, Related) - [x] Related references project slug (`project-pal-e-platform`) - [x] No secrets committed - [x] No unnecessary file changes (4 files, all scoped to k3s management) - [x] Commit messages descriptive - [x] Closes #332 in PR body ### PROCESS OBSERVATIONS - Clean fix cycle: previous review found 2 issues, both resolved correctly on first pass. - Test plan includes live validation (`salt-call state.apply k3s test=True` passing, node reporting 250 allocatable pods). Strong evidence of correctness. - The change from verify-only to managed-unit is a sensible infrastructure maturation step. ### VERDICT: APPROVED
fix(ci): replace backticks with printf hex in PR comment script
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed
97d8e2f2b7
Woodpecker's shellescape + busybox sh interaction causes triple
backticks in heredoc content to be parsed as command substitution
during trace echo. Uses printf '\x60' to generate fence markers
at runtime, avoiding the shell parsing issue entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Platform SSO initiative: all infrastructure services now authenticate
through the Keycloak platform realm with zero re-login from the admin
dashboard. Includes new pal-e-admin SvelteKit app deployed at
admin.tail5b443a.ts.net with Tailscale funnel.

Services wired:
- Forgejo: declarative gitea.oauth in Helm values (#335)
- Grafana: generic_oauth with auto_login + envFromSecrets (#337)
- Harbor: admin API via null_resource (#338)
- MinIO: Helm chart oidc section (#339)
- Woodpecker: FORGEJO_URL fixed to external + open registration

New admin module: namespace, deployment, service, harbor-creds,
auth secret, and funnel ingress.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add Forgejo session lifetime to prevent frequent logouts
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed
6624b48917
Sets SESSION_LIFE_TIME to 7 days (604800s) with COOKIE_SECURE, matching
the Keycloak platform realm max lifespan. Fixes frequent Forgejo logouts
caused by the default in-memory session expiry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Merge remote-tracking branch 'forgejo/main' into 332-salt-k3s-maxpods
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed
eef06b8919
# Conflicts:
#	terraform/modules/forgejo/main.tf
#	terraform/modules/harbor/main.tf
#	terraform/modules/storage/main.tf
style: fix tofu fmt alignment drift
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline failed
d1cc63ec0d
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ci: add missing OIDC and admin secrets to plan/apply steps
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
93beaa460b
Six TF variables added after the SSO wiring commit were missing from
the Woodpecker pipeline, causing plan to fail on PRs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author
Contributor

Tofu Plan Output (full)

module.networking.tailscale_acl.this: Refreshing state... [id=acl]
module.database.data.kubernetes_namespace_v1.basketball_api: Reading...
module.database.kubernetes_namespace_v1.postgres: Refreshing state... [id=postgres]
module.staging.kubernetes_namespace_v1.staging: Refreshing state... [id=staging]
module.networking.kubernetes_namespace_v1.tailscale: Refreshing state... [id=tailscale]
module.keycloak.kubernetes_namespace_v1.keycloak: Refreshing state... [id=keycloak]
module.monitoring.kubernetes_namespace_v1.monitoring: Refreshing state... [id=monitoring]
module.database.kubernetes_namespace_v1.cnpg_system: Refreshing state... [id=cnpg-system]
module.forgejo.kubernetes_namespace_v1.forgejo: Refreshing state... [id=forgejo]
module.database.data.kubernetes_namespace_v1.basketball_api: Read complete after 0s [id=basketball-api]
module.database.data.kubernetes_namespace_v1.pal_e_production: Reading...
module.database.data.kubernetes_namespace_v1.westside_admin: Reading...
module.database.data.kubernetes_namespace_v1.pal_e_production: Read complete after 0s [id=pal-e-app]
module.keycloak.kubernetes_config_map_v1.keycloak_westside_theme: Refreshing state... [id=keycloak/keycloak-westside-theme]
module.keycloak.kubernetes_persistent_volume_claim_v1.keycloak_data: Refreshing state... [id=keycloak/keycloak-data]
module.keycloak.kubernetes_service_v1.keycloak: Refreshing state... [id=keycloak/keycloak]
module.database.data.kubernetes_namespace_v1.westside_admin: Read complete after 0s [id=westside-admin]
module.keycloak.kubernetes_secret_v1.keycloak_admin: Refreshing state... [id=keycloak/keycloak-admin]
module.forgejo.kubernetes_secret_v1.forgejo_oidc: Refreshing state... [id=forgejo/forgejo-oidc]
module.monitoring.helm_release.loki_stack: Refreshing state... [id=loki-stack]
module.database.kubernetes_job_v1.admin_app_user_provision: Refreshing state... [id=basketball-api/admin-app-user-provision-c5662180]
module.monitoring.kubernetes_secret_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
module.monitoring.helm_release.kube_prometheus_stack: Refreshing state... [id=kube-prometheus-stack]
module.monitoring.kubernetes_service_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
module.monitoring.kubernetes_config_map_v1.uptime_dashboard: Refreshing state... [id=monitoring/uptime-dashboard]
module.database.helm_release.cnpg: Refreshing state... [id=cnpg]
module.database.kubernetes_secret_v1.paledocs_db_url: Refreshing state... [id=pal-e-app/paledocs-db-url]
module.networking.helm_release.tailscale_operator: Refreshing state... [id=tailscale-operator]
module.keycloak.kubernetes_deployment_v1.keycloak: Refreshing state... [id=keycloak/keycloak]
module.forgejo.helm_release.forgejo: Refreshing state... [id=forgejo]
kubernetes_manifest.netpol_basketball_api: Refreshing state...
kubernetes_manifest.netpol_keycloak: Refreshing state...
kubernetes_manifest.netpol_postgres: Refreshing state...
kubernetes_manifest.netpol_forgejo: Refreshing state...
kubernetes_manifest.netpol_monitoring: Refreshing state...
module.database.kubernetes_secret_v1.admin_app_db_url: Refreshing state... [id=basketball-api/admin-app-db-url]
module.database.kubernetes_secret_v1.admin_app_db_url_westside_admin: Refreshing state... [id=westside-admin/admin-app-db-url]
kubernetes_manifest.netpol_cnpg_system: Refreshing state...
kubernetes_manifest.netpol_staging: Refreshing state...
module.monitoring.helm_release.blackbox_exporter: Refreshing state... [id=blackbox-exporter]
module.monitoring.kubernetes_config_map_v1.basketball_api_dashboard: Refreshing state... [id=monitoring/basketball-api-dashboard]
module.monitoring.kubernetes_config_map_v1.playme2k_dashboard: Refreshing state... [id=monitoring/playme2k-dashboard]
module.monitoring.kubernetes_manifest.blackbox_alerts: Refreshing state...
module.monitoring.kubernetes_manifest.payment_pipeline_alerts: Refreshing state...
module.monitoring.kubernetes_manifest.embedding_alerts: Refreshing state...
module.monitoring.kubernetes_deployment_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
module.monitoring.kubernetes_config_map_v1.dora_dashboard: Refreshing state... [id=monitoring/dora-dashboard]
module.monitoring.kubernetes_config_map_v1.grafana_loki_datasource: Refreshing state... [id=monitoring/grafana-loki-datasource]
module.monitoring.kubernetes_config_map_v1.pal_e_production_dashboard: Refreshing state... [id=monitoring/pal-e-app-dashboard]
module.monitoring.kubernetes_config_map_v1.mac_agent_dashboard: Refreshing state... [id=monitoring/mac-agent-dashboard]
module.monitoring.kubernetes_manifest.gmail_oauth_expiry_alert: Refreshing state...
module.monitoring.kubernetes_manifest.dora_exporter_service_monitor: Refreshing state...
module.monitoring.kubernetes_manifest.embedding_worker_service_monitor: Refreshing state...
module.networking.kubernetes_ingress_v1.keycloak_funnel: Refreshing state... [id=keycloak/keycloak-funnel]
module.networking.kubernetes_ingress_v1.alertmanager_funnel: Refreshing state... [id=monitoring/alertmanager-funnel]
module.networking.kubernetes_ingress_v1.forgejo_funnel: Refreshing state... [id=forgejo/forgejo-funnel]
module.networking.kubernetes_ingress_v1.grafana_funnel: Refreshing state... [id=monitoring/grafana-funnel]
module.networking.kubernetes_manifest.tailscale_subnet_router: Refreshing state...
module.harbor.kubernetes_namespace_v1.harbor: Refreshing state... [id=harbor]
module.storage.kubernetes_namespace_v1.minio: Refreshing state... [id=minio]
module.storage.kubernetes_config_map_v1.minio_console_nginx: Refreshing state... [id=minio/minio-console-nginx]
module.storage.kubernetes_config_map_v1.minio_console_css: Refreshing state... [id=minio/minio-console-css]
module.harbor.kubernetes_config_map_v1.harbor_portal_nginx: Refreshing state... [id=harbor/harbor-portal-nginx]
module.storage.kubernetes_service_v1.minio_console_proxy: Refreshing state... [id=minio/minio-console-proxy]
module.storage.helm_release.minio: Refreshing state... [id=minio]
module.harbor.kubernetes_config_map_v1.harbor_portal_css: Refreshing state... [id=harbor/harbor-portal-css]
module.harbor.kubernetes_service_v1.harbor_portal_proxy: Refreshing state... [id=harbor/harbor-portal-proxy]
module.harbor.helm_release.harbor: Refreshing state... [id=harbor]
module.networking.kubernetes_ingress_v1.harbor_funnel: Refreshing state... [id=harbor/harbor-funnel]
module.networking.kubernetes_ingress_v1.minio_funnel: Refreshing state... [id=minio/minio-funnel]
kubernetes_manifest.netpol_harbor: Refreshing state...
module.networking.kubernetes_ingress_v1.minio_api_funnel: Refreshing state... [id=minio/minio-api-funnel]
kubernetes_manifest.netpol_minio: Refreshing state...
module.storage.minio_iam_policy.tf_backup: Refreshing state... [id=tf-backup]
module.storage.minio_iam_user.tf_backup: Refreshing state... [id=tf-backup]
module.storage.minio_iam_policy.cnpg_wal: Refreshing state... [id=cnpg-wal]
module.storage.minio_s3_bucket.tf_state_backups: Refreshing state... [id=tf-state-backups]
module.storage.minio_s3_bucket.postgres_wal: Refreshing state... [id=postgres-wal]
module.storage.minio_iam_user.cnpg: Refreshing state... [id=cnpg]
module.storage.minio_s3_bucket.assets: Refreshing state... [id=assets]
module.storage.minio_iam_user_policy_attachment.tf_backup: Refreshing state... [id=tf-backup-20260314163610110100000001]
module.storage.minio_iam_user_policy_attachment.cnpg: Refreshing state... [id=cnpg-20260302210642491000000001]
module.storage.minio_s3_bucket_policy.assets_public_read: Refreshing state... [id=assets]
module.database.kubernetes_secret_v1.cnpg_s3_creds: Refreshing state... [id=postgres/cnpg-s3-creds]
module.storage.kubernetes_deployment_v1.minio_console_proxy: Refreshing state... [id=minio/minio-console-proxy]
module.database.kubernetes_cron_job_v1.cnpg_backup_verify: Refreshing state... [id=postgres/cnpg-backup-verify]
module.ops.kubernetes_namespace_v1.ollama: Refreshing state... [id=ollama]
module.ops.kubernetes_service_account_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
module.ops.kubernetes_secret_v1.tf_backup_s3_creds: Refreshing state... [id=tofu-state/tf-backup-s3-creds]
module.ops.kubernetes_role_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
module.ops.kubernetes_service_v1.embedding_worker_metrics: Refreshing state... [id=pal-e-app/embedding-worker-metrics]
module.ops.helm_release.nvidia_device_plugin: Refreshing state... [id=nvidia-device-plugin]
module.ops.kubernetes_role_binding_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
kubernetes_manifest.netpol_ollama: Refreshing state...
module.ci.kubernetes_namespace_v1.woodpecker: Refreshing state... [id=woodpecker]
module.ops.kubernetes_cron_job_v1.tf_state_backup: Refreshing state... [id=tofu-state/tf-state-backup]
module.ci.kubernetes_secret_v1.woodpecker_cnpg_s3_creds: Refreshing state... [id=woodpecker/cnpg-s3-creds]
module.ci.kubernetes_secret_v1.woodpecker_db_credentials: Refreshing state... [id=woodpecker/woodpecker-db-credentials]
module.networking.kubernetes_ingress_v1.woodpecker_funnel: Refreshing state... [id=woodpecker/woodpecker-funnel]
kubernetes_manifest.netpol_woodpecker: Refreshing state...
module.ci.kubernetes_manifest.woodpecker_postgres: Refreshing state...
module.harbor.kubernetes_deployment_v1.harbor_portal_proxy: Refreshing state... [id=harbor/harbor-portal-proxy]
module.ci.helm_release.woodpecker: Refreshing state... [id=woodpecker]
module.ci.kubernetes_manifest.woodpecker_postgres_scheduled_backup: Refreshing state...
module.ci.kubernetes_manifest.woodpecker_postgres_podmonitor: Refreshing state...
module.ops.helm_release.ollama: Refreshing state... [id=ollama]

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
 <= read (data resources)

OpenTofu will perform the following actions:

  # kubernetes_manifest.netpol_basketball_api will be updated in-place
  ~ resource "kubernetes_manifest" "netpol_basketball_api" {
      ~ object   = {
          ~ spec       = {
              ~ ingress     = [
                    # (4 unchanged elements hidden)
                    {
                        from  = [
                            {
                                ipBlock           = {
                                    cidr   = null
                                    except = null
                                }
                                namespaceSelector = {
                                    matchExpressions = null
                                    matchLabels      = {
                                        "kubernetes.io/metadata.name" = "monitoring"
                                    }
                                }
                                podSelector       = {
                                    matchExpressions = null
                                    matchLabels      = null
                                }
                            },
                        ]
                        ports = null
                    },
                  - {
                      - from  = [
                          - {
                              - ipBlock           = {
                                  - cidr   = null
                                  - except = null
                                }
                              - namespaceSelector = {
                                  - matchExpressions = null
                                  - matchLabels      = {
                                      - "kubernetes.io/metadata.name" = "westside-admin"
                                    }
                                }
                              - podSelector       = {
                                  - matchExpressions = null
                                  - matchLabels      = null
                                }
                            },
                        ]
                      - ports = null
                    },
                ]
                # (3 unchanged attributes hidden)
            }
            # (3 unchanged attributes hidden)
        }
        # (1 unchanged attribute hidden)

        # (1 unchanged block hidden)
    }

  # kubernetes_manifest.netpol_postgres will be updated in-place
  ~ resource "kubernetes_manifest" "netpol_postgres" {
      ~ object   = {
          ~ spec       = {
              ~ ingress     = [
                    # (3 unchanged elements hidden)
                    {
                        from  = [
                            {
                                ipBlock           = {
                                    cidr   = null
                                    except = null
                                }
                                namespaceSelector = {
                                    matchExpressions = null
                                    matchLabels      = {
                                        "kubernetes.io/metadata.name" = "monitoring"
                                    }
                                }
                                podSelector       = {
                                    matchExpressions = null
                                    matchLabels      = null
                                }
                            },
                        ]
                        ports = null
                    },
                  - {
                      - from  = [
                          - {
                              - ipBlock           = {
                                  - cidr   = null
                                  - except = null
                                }
                              - namespaceSelector = {
                                  - matchExpressions = null
                                  - matchLabels      = {
                                      - "kubernetes.io/metadata.name" = "westside-ror"
                                    }
                                }
                              - podSelector       = {
                                  - matchExpressions = null
                                  - matchLabels      = null
                                }
                            },
                        ]
                      - ports = null
                    },
                  - {
                      - from  = [
                          - {
                              - ipBlock           = {
                                  - cidr   = null
                                  - except = null
                                }
                              - namespaceSelector = {
                                  - matchExpressions = null
                                  - matchLabels      = {
                                      - "kubernetes.io/metadata.name" = "pal-e-ror"
                                    }
                                }
                              - podSelector       = {
                                  - matchExpressions = null
                                  - matchLabels      = null
                                }
                            },
                        ]
                      - ports = null
                    },
                ]
                # (3 unchanged attributes hidden)
            }
            # (3 unchanged attributes hidden)
        }
        # (1 unchanged attribute hidden)

        # (1 unchanged block hidden)
    }

  # module.admin.kubernetes_deployment_v1.admin will be created
  + resource "kubernetes_deployment_v1" "admin" {
      + id               = (known after apply)
      + wait_for_rollout = true

      + metadata {
          + generation       = (known after apply)
          + labels           = {
              + "app" = "pal-e-admin"
            }
          + name             = "pal-e-admin"
          + namespace        = "pal-e-admin"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }

      + spec {
          + min_ready_seconds         = 0
          + paused                    = false
          + progress_deadline_seconds = 600
          + replicas                  = "1"
          + revision_history_limit    = 10

          + selector {
              + match_labels = {
                  + "app" = "pal-e-admin"
                }
            }

          + strategy (known after apply)

          + template {
              + metadata {
                  + generation       = (known after apply)
                  + labels           = {
                      + "app" = "pal-e-admin"
                    }
                  + name             = (known after apply)
                  + resource_version = (known after apply)
                  + uid              = (known after apply)
                }
              + spec {
                  + automount_service_account_token  = true
                  + dns_policy                       = "ClusterFirst"
                  + enable_service_links             = true
                  + host_ipc                         = false
                  + host_network                     = false
                  + host_pid                         = false
                  + hostname                         = (known after apply)
                  + node_name                        = (known after apply)
                  + restart_policy                   = "Always"
                  + scheduler_name                   = (known after apply)
                  + service_account_name             = (known after apply)
                  + share_process_namespace          = false
                  + termination_grace_period_seconds = 30

                  + container {
                      + image                      = "harbor.tail5b443a.ts.net/pal-e-admin/pal-e-admin:latest"
                      + image_pull_policy          = (known after apply)
                      + name                       = "admin"
                      + stdin                      = false
                      + stdin_once                 = false
                      + termination_message_path   = "/dev/termination-log"
                      + termination_message_policy = (known after apply)
                      + tty                        = false

                      + env_from {
                          + secret_ref {
                              + name = "admin-auth"
                            }
                        }

                      + port {
                          + container_port = 3000
                          + protocol       = "TCP"
                        }

                      + resources {
                          + limits   = {
                              + "memory" = "256Mi"
                            }
                          + requests = {
                              + "cpu"    = "50m"
                              + "memory" = "64Mi"
                            }
                        }
                    }

                  + image_pull_secrets {
                      + name = "harbor-creds"
                    }

                  + readiness_gate (known after apply)
                }
            }
        }
    }

  # module.admin.kubernetes_namespace_v1.admin will be created
  + resource "kubernetes_namespace_v1" "admin" {
      + id                               = (known after apply)
      + wait_for_default_service_account = false

      + metadata {
          + generation       = (known after apply)
          + labels           = {
              + "name" = "pal-e-admin"
            }
          + name             = "pal-e-admin"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }
    }

  # module.admin.kubernetes_secret_v1.admin_auth will be created
  + resource "kubernetes_secret_v1" "admin_auth" {
      + data                           = (sensitive value)
      + id                             = (known after apply)
      + type                           = "Opaque"
      + wait_for_service_account_token = true

      + metadata {
          + generation       = (known after apply)
          + name             = "admin-auth"
          + namespace        = "pal-e-admin"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }
    }

  # module.admin.kubernetes_secret_v1.harbor_creds will be created
  + resource "kubernetes_secret_v1" "harbor_creds" {
      + data                           = (sensitive value)
      + id                             = (known after apply)
      + type                           = "kubernetes.io/dockerconfigjson"
      + wait_for_service_account_token = true

      + metadata {
          + generation       = (known after apply)
          + name             = "harbor-creds"
          + namespace        = "pal-e-admin"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }
    }

  # module.admin.kubernetes_service_v1.admin will be created
  + resource "kubernetes_service_v1" "admin" {
      + id                     = (known after apply)
      + status                 = (known after apply)
      + wait_for_load_balancer = true

      + metadata {
          + generation       = (known after apply)
          + name             = "pal-e-admin"
          + namespace        = "pal-e-admin"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }

      + spec {
          + allocate_load_balancer_node_ports = true
          + cluster_ip                        = (known after apply)
          + cluster_ips                       = (known after apply)
          + external_traffic_policy           = (known after apply)
          + health_check_node_port            = (known after apply)
          + internal_traffic_policy           = (known after apply)
          + ip_families                       = (known after apply)
          + ip_family_policy                  = (known after apply)
          + publish_not_ready_addresses       = false
          + selector                          = {
              + "app" = "pal-e-admin"
            }
          + session_affinity                  = "None"
          + type                              = "ClusterIP"

          + port {
              + node_port   = (known after apply)
              + port        = 80
              + protocol    = "TCP"
              + target_port = "3000"
            }

          + session_affinity_config (known after apply)
        }
    }

  # module.ci.helm_release.woodpecker will be updated in-place
  ~ resource "helm_release" "woodpecker" {
        id                         = "woodpecker"
      ~ metadata                   = [
          - {
              - app_version    = "3.13.0"
              - chart          = "woodpecker"
              - first_deployed = 1773625582
              - last_deployed  = 1778034163
              - name           = "woodpecker"
              - namespace      = "woodpecker"
              - notes          = <<-EOT
                    1. Get the application URL by running these commands:
                      export POD_NAME=$(kubectl get pods --namespace woodpecker -l "app.kubernetes.io/name=server,app.kubernetes.io/instance=woodpecker" -o jsonpath="{.items[0].metadata.name}")
                      export CONTAINER_PORT=$(kubectl get pod --namespace woodpecker $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
                      echo "Visit http://127.0.0.1:8080 to use your application"
                      kubectl --namespace woodpecker port-forward $POD_NAME 8080:$CONTAINER_PORT
                EOT
              - revision       = 23
              - values         = jsonencode(
                    {
                      - agent  = {
                          - enabled      = true
                          - env          = {
                              - HARBOR_REGISTRY_INTERNAL             = "harbor.harbor.svc.cluster.local"
                              - WOODPECKER_AGENT_SECRET              = "(sensitive value)"
                              - WOODPECKER_BACKEND                   = "kubernetes"
                              - WOODPECKER_BACKEND_K8S_NAMESPACE     = "woodpecker"
                              - WOODPECKER_BACKEND_K8S_STORAGE_CLASS = "local-path"
                              - WOODPECKER_BACKEND_K8S_VOLUME_SIZE   = "1Gi"
                              - WOODPECKER_CONNECT_RETRY_COUNT       = "10"
                              - WOODPECKER_FILTER_LABELS             = "platform=linux"
                              - WOODPECKER_MAX_WORKFLOWS             = "4"
                            }
                          - replicaCount = 1
                          - resources    = {
                              - limits   = {
                                  - memory = "256Mi"
                                }
                              - requests = {
                                  - cpu    = "50m"
                                  - memory = "64Mi"
                                }
                            }
                        }
                      - server = {
                          - env              = {
                              - WOODPECKER_ADMIN               = "forgejo_admin"
                              - WOODPECKER_AGENT_SECRET        = "(sensitive value)"
                              - WOODPECKER_DATABASE_DATASOURCE = "postgres://woodpecker:kM3L4AhLNiuMhIY7tMQ@woodpecker-db-rw.woodpecker.svc.cluster.local:5432/woodpecker?sslmode=disable"
                              - WOODPECKER_DATABASE_DRIVER     = "postgres"
                              - WOODPECKER_ENCRYPTION_KEY      = "(sensitive value)"
                              - WOODPECKER_FORGEJO             = "true"
                              - WOODPECKER_FORGEJO_CLIENT      = "(sensitive value)"
                              - WOODPECKER_FORGEJO_SECRET      = "(sensitive value)"
                              - WOODPECKER_FORGEJO_URL         = "http://forgejo-http.forgejo.svc.cluster.local"
                              - WOODPECKER_HOST                = "https://woodpecker.tail5b443a.ts.net"
                            }
                          - persistentVolume = {
                              - enabled      = true
                              - size         = "5Gi"
                              - storageClass = "local-path"
                            }
                          - resources        = {
                              - limits   = {
                                  - memory = "512Mi"
                                }
                              - requests = {
                                  - cpu    = "50m"
                                  - memory = "128Mi"
                                }
                            }
                          - statefulSet      = {
                              - replicaCount = 1
                            }
                        }
                    }
                )
              - version        = "3.5.1"
            },
        ] -> (known after apply)
        name                       = "woodpecker"
      ~ values                     = [
          - (sensitive value),
          + (sensitive value),
        ]
        # (25 unchanged attributes hidden)

        # (5 unchanged blocks hidden)
    }

  # module.database.kubernetes_job_v1.admin_app_user_provision will be created
  + resource "kubernetes_job_v1" "admin_app_user_provision" {
      + id                  = (known after apply)
      + wait_for_completion = true

      + metadata {
          + generation       = (known after apply)
          + labels           = {
              + "app.kubernetes.io/managed-by" = "pal-e-platform-terraform"
              + "app.kubernetes.io/name"       = "admin-app-user-provision"
              + "arch"                         = "postgres"
              + "story"                        = "admin-row-crud"
            }
          + name             = (sensitive value)
          + namespace        = "basketball-api"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }

      + spec {
          + backoff_limit              = 4
          + completion_mode            = (known after apply)
          + completions                = 1
          + parallelism                = 1
          + ttl_seconds_after_finished = "3600"

          + selector (known after apply)

          + template {
              + metadata {
                  + generation       = (known after apply)
                  + labels           = {
                      + "app.kubernetes.io/name" = "admin-app-user-provision"
                    }
                  + name             = (known after apply)
                  + resource_version = (known after apply)
                  + uid              = (known after apply)
                }
              + spec {
                  + automount_service_account_token  = true
                  + dns_policy                       = "ClusterFirst"
                  + enable_service_links             = true
                  + host_ipc                         = false
                  + host_network                     = false
                  + host_pid                         = false
                  + hostname                         = (known after apply)
                  + node_name                        = (known after apply)
                  + restart_policy                   = "OnFailure"
                  + scheduler_name                   = (known after apply)
                  + service_account_name             = (known after apply)
                  + share_process_namespace          = false
                  + termination_grace_period_seconds = 30

                  + container {
                      + args                       = [
                          + <<-EOT
                                set -euo pipefail
                                
                                echo "==> Provisioning admin_app role on ${PGHOST}/${PGDATABASE}"
                                
                                # Idempotent: CREATE ROLE if missing, otherwise rotate password.
                                psql -v ON_ERROR_STOP=1 -v admin_pw="${ADMIN_APP_PASSWORD}" <<'SQL'
                                  -- Idempotent role creation/rotation. Conditional uses psql's \gset + \if
                                  -- (client-side) so we can use :'admin_pw' substitution. psql variable
                                  -- substitution does NOT work inside DO $$...$$ dollar-quoted blocks.
                                  SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'admin_app') AS role_exists \gset
                                  \if :role_exists
                                    ALTER ROLE admin_app WITH LOGIN PASSWORD :'admin_pw';
                                  \else
                                    CREATE ROLE admin_app WITH LOGIN PASSWORD :'admin_pw';
                                  \endif
                                
                                  -- DML-only grants on schema public. Idempotent (re-grant is a no-op).
                                  GRANT USAGE ON SCHEMA public TO admin_app;
                                  GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO admin_app;
                                  GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO admin_app;
                                
                                  -- Forward grants for tables/sequences created later by Drizzle migrations
                                  -- (which run as the basketball superuser).
                                  ALTER DEFAULT PRIVILEGES FOR ROLE basketball IN SCHEMA public
                                    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO admin_app;
                                  ALTER DEFAULT PRIVILEGES FOR ROLE basketball IN SCHEMA public
                                    GRANT USAGE ON SEQUENCES TO admin_app;
                                SQL
                                
                                echo "==> admin_app role provisioned successfully"
                            EOT,
                        ]
                      + command                    = [
                          + "/bin/sh",
                          + "-c",
                        ]
                      + image                      = "postgres:16-alpine"
                      + image_pull_policy          = (known after apply)
                      + name                       = "psql"
                      + stdin                      = false
                      + stdin_once                 = false
                      + termination_message_path   = "/dev/termination-log"
                      + termination_message_policy = (known after apply)
                      + tty                        = false

                      + env {
                          + name  = "PGHOST"
                          + value = "postgres.basketball-api.svc.cluster.local"
                        }
                      + env {
                          + name  = "PGPORT"
                          + value = "5432"
                        }
                      + env {
                          + name  = "PGUSER"
                          + value = "basketball"
                        }
                      + env {
                          + name  = "PGDATABASE"
                          + value = "basketball"
                        }
                      + env {
                          + name = "PGPASSWORD"

                          + value_from {
                              + secret_key_ref {
                                  + key  = "postgres-password"
                                  + name = "basketball-api-secrets"
                                }
                            }
                        }
                      + env {
                          + name  = "ADMIN_APP_PASSWORD"
                          + value = (sensitive value)
                        }

                      + resources {
                          + limits   = {
                              + "memory" = "128Mi"
                            }
                          + requests = {
                              + "cpu"    = "50m"
                              + "memory" = "64Mi"
                            }
                        }
                    }

                  + image_pull_secrets (known after apply)

                  + readiness_gate (known after apply)
                }
            }
        }

      + timeouts {
          + create = "5m"
          + update = "5m"
        }
    }

  # module.forgejo.helm_release.forgejo will be updated in-place
  ~ resource "helm_release" "forgejo" {
        id                         = "forgejo"
      ~ metadata                   = [
          - {
              - app_version    = "14.0.2"
              - chart          = "forgejo"
              - first_deployed = 1771564458
              - last_deployed  = 1778313401
              - name           = "forgejo"
              - namespace      = "forgejo"
              - notes          = <<-EOT
                    1. Get the application URL by running these commands:
                      echo "Visit http://127.0.0.1:80 to use your application"
                      kubectl --namespace forgejo port-forward svc/forgejo-http 80:80
                    2. Review these warnings:
                      - Forgejo uses 'memory' for caching which is not recommended for production use. See https://forgejo.org/docs/latest/admin/config-cheat-sheet/#cache-cache for available options.
                      - Forgejo uses 'leveldb' for queue actions which is not recommended for production use. See https://forgejo.org/docs/latest/admin/config-cheat-sheet/#queue-queue-and-queue for available options.
                      - Forgejo uses 'memory' for sessions which is not recommended for production use. See https://forgejo.org/docs/latest/admin/config-cheat-sheet/#session-session for available options.
                EOT
              - revision       = 12
              - values         = jsonencode(
                    {
                      - gitea       = {
                          - admin  = {
                              - email        = "admin@forgejo.local"
                              - password     = "(sensitive value)"
                              - passwordMode = "keepUpdated"
                              - username     = "forgejo_admin"
                            }
                          - config = {
                              - oauth2_client = {
                                  - ACCOUNT_LINKING          = "auto"
                                  - ENABLE_AUTO_REGISTRATION = true
                                  - USERNAME                 = "email"
                                }
                              - server        = {
                                  - DOMAIN     = "forgejo.tail5b443a.ts.net"
                                  - HTTP_ADDR  = "0.0.0.0"
                                  - ROOT_URL   = "https://forgejo.tail5b443a.ts.net/"
                                  - SSH_DOMAIN = "forgejo.tail5b443a.ts.net"
                                }
                              - session       = {
                                  - COOKIE_SECURE     = true
                                  - SESSION_LIFE_TIME = 604800
                                }
                              - ui            = {
                                  - DEFAULT_THEME = "forgejo-dark"
                                }
                              - webhook       = {
                                  - ALLOWED_HOST_LIST = "external,loopback"
                                }
                            }
                          - oauth  = [
                              - {
                                  - autoDiscoverUrl = "https://keycloak.tail5b443a.ts.net/realms/platform/.well-known/openid-configuration"
                                  - existingSecret  = "forgejo-oidc"
                                  - name            = "Keycloak"
                                  - provider        = "openidConnect"
                                  - scopes          = "openid profile email"
                                },
                            ]
                        }
                      - ingress     = {
                          - enabled = false
                        }
                      - persistence = {
                          - enabled      = true
                          - size         = "10Gi"
                          - storageClass = "local-path"
                        }
                      - resources   = {
                          - limits   = {
                              - memory = "2Gi"
                            }
                          - requests = {
                              - cpu    = "100m"
                              - memory = "512Mi"
                            }
                        }
                      - service     = {
                          - http = {
                              - port = 80
                              - type = "ClusterIP"
                            }
                          - ssh  = {
                              - port = 22
                              - type = "ClusterIP"
                            }
                        }
                    }
                )
              - version        = "16.2.0"
            },
        ] -> (known after apply)
        name                       = "forgejo"
      ~ values                     = [
          - <<-EOT
                "gitea":
                  "admin":
                    "email": "admin@forgejo.local"
                    "passwordMode": "keepUpdated"
                    "username": "forgejo_admin"
                  "config":
                    "oauth2_client":
                      "ACCOUNT_LINKING": "auto"
                      "ENABLE_AUTO_REGISTRATION": true
                      "USERNAME": "email"
                    "server":
                      "DOMAIN": "forgejo.tail5b443a.ts.net"
                      "HTTP_ADDR": "0.0.0.0"
                      "ROOT_URL": "https://forgejo.tail5b443a.ts.net/"
                      "SSH_DOMAIN": "forgejo.tail5b443a.ts.net"
                    "session":
                      "COOKIE_SECURE": true
                      "SESSION_LIFE_TIME": 604800
                    "ui":
                      "DEFAULT_THEME": "forgejo-dark"
                    "webhook":
                      "ALLOWED_HOST_LIST": "external,loopback"
                  "oauth":
                  - "autoDiscoverUrl": "https://keycloak.tail5b443a.ts.net/realms/platform/.well-known/openid-configuration"
                    "existingSecret": "forgejo-oidc"
                    "name": "Keycloak"
                    "provider": "openidConnect"
                    "scopes": "openid profile email"
                "ingress":
                  "enabled": false
                "persistence":
                  "enabled": true
                  "size": "10Gi"
                  "storageClass": "local-path"
                "resources":
                  "limits":
                    "memory": "2Gi"
                  "requests":
                    "cpu": "100m"
                    "memory": "512Mi"
                "service":
                  "http":
                    "port": 80
                    "type": "ClusterIP"
                  "ssh":
                    "port": 22
                    "type": "ClusterIP"
            EOT,
          + <<-EOT
                "extraContainerVolumeMounts":
                - "mountPath": "/data/gitea/public/css/custom.css"
                  "name": "custom-css"
                  "readOnly": true
                  "subPath": "custom.css"
                "extraVolumes":
                - "configMap":
                    "name": "forgejo-custom-css"
                  "name": "custom-css"
                "gitea":
                  "admin":
                    "email": "admin@forgejo.local"
                    "passwordMode": "keepUpdated"
                    "username": "forgejo_admin"
                  "config":
                    "oauth2_client":
                      "ACCOUNT_LINKING": "auto"
                      "ENABLE_AUTO_REGISTRATION": true
                      "USERNAME": "email"
                    "server":
                      "DOMAIN": "forgejo.tail5b443a.ts.net"
                      "HTTP_ADDR": "0.0.0.0"
                      "ROOT_URL": "https://forgejo.tail5b443a.ts.net/"
                      "SSH_DOMAIN": "forgejo.tail5b443a.ts.net"
                    "session":
                      "COOKIE_SECURE": true
                      "SESSION_LIFE_TIME": 604800
                    "ui":
                      "DEFAULT_THEME": "forgejo-dark"
                    "webhook":
                      "ALLOWED_HOST_LIST": "external,loopback"
                  "oauth":
                  - "autoDiscoverUrl": "https://keycloak.tail5b443a.ts.net/realms/platform/.well-known/openid-configuration"
                    "existingSecret": "forgejo-oidc"
                    "name": "Keycloak"
                    "provider": "openidConnect"
                    "scopes": "openid profile email"
                "ingress":
                  "enabled": false
                "persistence":
                  "enabled": true
                  "size": "10Gi"
                  "storageClass": "local-path"
                "resources":
                  "limits":
                    "memory": "2Gi"
                  "requests":
                    "cpu": "100m"
                    "memory": "512Mi"
                "service":
                  "http":
                    "port": 80
                    "type": "ClusterIP"
                  "ssh":
                    "port": 22
                    "type": "ClusterIP"
            EOT,
        ]
        # (26 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.forgejo.kubernetes_config_map_v1.forgejo_custom_css will be created
  + resource "kubernetes_config_map_v1" "forgejo_custom_css" {
      + data = {
          + "custom.css" = <<-EOT
                /*
                 * Forgejo mobile-responsive overrides
                 *
                 * Mobile-first: base styles target phones (≤599px).
                 * Desktop overrides via @media (min-width: 600px).
                 *
                 * Must not break the forgejo-dark theme.
                 * Only adds responsive behavior -- no desktop layout changes.
                 */
                
                /* ── Mobile base (phones, ≤599px) ── */
                
                /* Shrink top navbar so it doesn't eat screen real estate */
                .ui.secondary.pointing.menu .item {
                  padding: 0.5em 0.6em;
                  font-size: 0.85rem;
                }
                
                /* Make the hamburger menu button easier to tap */
                #navbar .item.icon {
                  min-width: 44px;
                  min-height: 44px;
                  display: flex;
                  align-items: center;
                  justify-content: center;
                }
                
                /* Full-width mobile nav dropdown */
                .ui.secondary.pointing.menu .ui.dropdown .menu {
                  min-width: 100vw;
                  left: 0;
                  right: 0;
                }
                
                /* Readable font on small screens */
                .repository .diff-file-body .code-diff td.lines-code {
                  font-size: 0.75rem;
                  word-break: break-all;
                  white-space: pre-wrap;
                }
                
                /* Prevent horizontal overflow on diff views */
                .diff-file-body {
                  overflow-x: auto;
                  -webkit-overflow-scrolling: touch;
                }
                
                /* Stack PR/issue header metadata vertically */
                .issue-content-right {
                  width: 100%;
                }
                
                /* Make comment boxes full-width */
                .timeline .comment .content {
                  max-width: 100%;
                }
                
                /* Repo file list: tighter rows on mobile */
                #repo-files-table td {
                  padding: 0.4em 0.5em;
                  font-size: 0.85rem;
                }
                
                /* Repo header: allow wrapping */
                .repo-header .repo-header-title {
                  flex-wrap: wrap;
                  gap: 0.25rem;
                }
                
                /* Dashboard cards: full-width stack */
                .dashboard .activity-card {
                  width: 100% !important;
                }
                
                /* ── Desktop restore (≥600px) ── */
                
                @media (min-width: 600px) {
                  .ui.secondary.pointing.menu .item {
                    padding: revert;
                    font-size: revert;
                  }
                
                  .repository .diff-file-body .code-diff td.lines-code {
                    font-size: revert;
                    word-break: normal;
                    white-space: pre;
                  }
                
                  #repo-files-table td {
                    padding: revert;
                    font-size: revert;
                  }
                }
            EOT
        }
      + id   = (known after apply)

      + metadata {
          + generation       = (known after apply)
          + name             = "forgejo-custom-css"
          + namespace        = "forgejo"
          + resource_version = (known after apply)
          + uid              = (known after apply)
        }
    }

  # module.harbor.null_resource.harbor_oidc will be created
  + resource "null_resource" "harbor_oidc" {
      + id       = (known after apply)
      + triggers = {
          + "oidc_endpoint"    = "https://keycloak.tail5b443a.ts.net/realms/platform"
          + "oidc_secret_hash" = (sensitive value)
        }
    }

  # module.monitoring.helm_release.kube_prometheus_stack will be updated in-place
  ~ resource "helm_release" "kube_prometheus_stack" {
        id                         = "kube-prometheus-stack"
      ~ metadata                   = [
          - {
              - app_version    = "v0.89.0"
              - chart          = "kube-prometheus-stack"
              - first_deployed = 1771560679
              - last_deployed  = 1778034131
              - name           = "kube-prometheus-stack"
              - namespace      = "monitoring"
              - notes          = <<-EOT
                    1. Get your 'admin' user password by running:
                    
                       kubectl get secret --namespace monitoring kube-prometheus-stack-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo
                    
                    
                    2. The Grafana server can be accessed via port 80 on the following DNS name from within your cluster:
                    
                       kube-prometheus-stack-grafana.monitoring.svc.cluster.local
                    
                       Get the Grafana URL to visit by running these commands in the same shell:
                         export POD_NAME=$(kubectl get pods --namespace monitoring -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=kube-prometheus-stack" -o jsonpath="{.items[0].metadata.name}")
                         kubectl --namespace monitoring port-forward $POD_NAME 3000
                    
                    3. Login with the password from step 1 and the username: admin
                    
                    kube-prometheus-stack has been installed. Check its status by running:
                      kubectl --namespace monitoring get pods -l "release=kube-prometheus-stack"
                    
                    Get Grafana 'admin' user password by running:
                    
                      kubectl --namespace monitoring get secrets kube-prometheus-stack-grafana -o jsonpath="{.data.admin-password}" | base64 -d ; echo
                    
                    Access Grafana local instance:
                    
                      export POD_NAME=$(kubectl --namespace monitoring get pod -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=kube-prometheus-stack" -oname)
                      kubectl --namespace monitoring port-forward $POD_NAME 3000
                    
                    Get your grafana admin user password by running:
                    
                      kubectl get secret --namespace monitoring -l app.kubernetes.io/component=admin-secret -o jsonpath="{.items[0].data.admin-password}" | base64 --decode ; echo
                    
                    
                    Visit https://github.com/prometheus-operator/kube-prometheus for instructions on how to create & configure Alertmanager and Prometheus instances using the Operator.
                    
                    1. Get the application URL by running these commands:
                      export POD_NAME=$(kubectl get pods --namespace monitoring -l "app.kubernetes.io/name=prometheus-node-exporter,app.kubernetes.io/instance=kube-prometheus-stack" -o jsonpath="{.items[0].metadata.name}")
                      echo "Visit http://127.0.0.1:9100 to use your application"
                      kubectl port-forward --namespace monitoring $POD_NAME 9100
                    kube-state-metrics is a simple service that listens to the Kubernetes API server and generates metrics about the state of the objects.
                    The exposed metrics can be found here:
                    https://github.com/kubernetes/kube-state-metrics/blob/master/docs/README.md#exposed-metrics
                    
                    The metrics are exported on the HTTP endpoint /metrics on the listening port.
                    In your case, kube-prometheus-stack-kube-state-metrics.monitoring.svc.cluster.local:8080/metrics
                    
                    They are served either as plaintext or protobuf depending on the Accept header.
                    They are designed to be consumed either by Prometheus itself or by a scraper that is compatible with scraping a Prometheus client endpoint.
                EOT
              - revision       = 22
              - values         = jsonencode(
                    {
                      - additionalPrometheusRules = [
                          - {
                              - groups = [
                                  - {
                                      - name  = "pod-health"
                                      - rules = [
                                          - {
                                              - alert       = "PodRestartStorm"
                                              - annotations = {
                                                  - description = "Pod {{ $labels.namespace }}/{{ $labels.pod }} has restarted {{ $value }} times in the last 15 minutes."
                                                  - summary     = "Pod {{ $labels.namespace }}/{{ $labels.pod }} restarting frequently"
                                                }
                                              - expr        = "increase(kube_pod_container_status_restarts_total[15m]) > 3"
                                              - for         = "0m"
                                              - labels      = {
                                                  - severity = "warning"
                                                }
                                            },
                                          - {
                                              - alert       = "OOMKilled"
                                              - annotations = {
                                                  - description = "Container {{ $labels.container }} in pod {{ $labels.namespace }}/{{ $labels.pod }} was OOMKilled."
                                                  - summary     = "Pod {{ $labels.namespace }}/{{ $labels.pod }} OOMKilled"
                                                }
                                              - expr        = "kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\"} > 0"
                                              - for         = "15m"
                                              - labels      = {
                                                  - severity = "critical"
                                                }
                                            },
                                        ]
                                    },
                                  - {
                                      - name  = "node-health"
                                      - rules = [
                                          - {
                                              - alert       = "DiskPressure"
                                              - annotations = {
                                                  - description = "Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} has only {{ $value | printf \"%.1f\" }}% space remaining."
                                                  - summary     = "Disk pressure on {{ $labels.instance }}"
                                                }
                                              - expr        = "(node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 < 15"
                                              - for         = "5m"
                                              - labels      = {
                                                  - severity = "critical"
                                                }
                                            },
                                        ]
                                    },
                                  - {
                                      - name  = "target-health"
                                      - rules = [
                                          - {
                                              - alert       = "TargetDown"
                                              - annotations = {
                                                  - description = "Target {{ $labels.job }}/{{ $labels.instance }} has been down for more than 5 minutes."
                                                  - summary     = "Target {{ $labels.instance }} is down"
                                                }
                                              - expr        = "up == 0"
                                              - for         = "5m"
                                              - labels      = {
                                                  - severity = "warning"
                                                }
                                            },
                                        ]
                                    },
                                  - {
                                      - name  = "mac-agent-health"
                                      - rules = [
                                          - {
                                              - alert       = "MacAgentDown"
                                              - annotations = {
                                                  - description = "Mac build agent (lucass-macbook-air-1) node-exporter has been down for more than 5 minutes. iOS CI builds are unavailable."
                                                  - summary     = "Mac build agent is unreachable"
                                                }
                                              - expr        = "up{job=\"mac-node-exporter\"} == 0"
                                              - for         = "5m"
                                              - labels      = {
                                                  - severity = "critical"
                                                }
                                            },
                                        ]
                                    },
                                ]
                              - name   = "platform-alerts"
                            },
                        ]
                      - alertmanager              = {
                          - alertmanagerSpec = {
                              - resources = {
                                  - limits   = {
                                      - memory = "128Mi"
                                    }
                                  - requests = {
                                      - cpu    = "10m"
                                      - memory = "64Mi"
                                    }
                                }
                              - storage   = {
                                  - volumeClaimTemplate = {
                                      - spec = {
                                          - accessModes      = [
                                              - "ReadWriteOnce",
     
...(truncated)
## Tofu Plan Output (full) ``` module.networking.tailscale_acl.this: Refreshing state... [id=acl] module.database.data.kubernetes_namespace_v1.basketball_api: Reading... module.database.kubernetes_namespace_v1.postgres: Refreshing state... [id=postgres] module.staging.kubernetes_namespace_v1.staging: Refreshing state... [id=staging] module.networking.kubernetes_namespace_v1.tailscale: Refreshing state... [id=tailscale] module.keycloak.kubernetes_namespace_v1.keycloak: Refreshing state... [id=keycloak] module.monitoring.kubernetes_namespace_v1.monitoring: Refreshing state... [id=monitoring] module.database.kubernetes_namespace_v1.cnpg_system: Refreshing state... [id=cnpg-system] module.forgejo.kubernetes_namespace_v1.forgejo: Refreshing state... [id=forgejo] module.database.data.kubernetes_namespace_v1.basketball_api: Read complete after 0s [id=basketball-api] module.database.data.kubernetes_namespace_v1.pal_e_production: Reading... module.database.data.kubernetes_namespace_v1.westside_admin: Reading... module.database.data.kubernetes_namespace_v1.pal_e_production: Read complete after 0s [id=pal-e-app] module.keycloak.kubernetes_config_map_v1.keycloak_westside_theme: Refreshing state... [id=keycloak/keycloak-westside-theme] module.keycloak.kubernetes_persistent_volume_claim_v1.keycloak_data: Refreshing state... [id=keycloak/keycloak-data] module.keycloak.kubernetes_service_v1.keycloak: Refreshing state... [id=keycloak/keycloak] module.database.data.kubernetes_namespace_v1.westside_admin: Read complete after 0s [id=westside-admin] module.keycloak.kubernetes_secret_v1.keycloak_admin: Refreshing state... [id=keycloak/keycloak-admin] module.forgejo.kubernetes_secret_v1.forgejo_oidc: Refreshing state... [id=forgejo/forgejo-oidc] module.monitoring.helm_release.loki_stack: Refreshing state... [id=loki-stack] module.database.kubernetes_job_v1.admin_app_user_provision: Refreshing state... [id=basketball-api/admin-app-user-provision-c5662180] module.monitoring.kubernetes_secret_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] module.monitoring.helm_release.kube_prometheus_stack: Refreshing state... [id=kube-prometheus-stack] module.monitoring.kubernetes_service_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] module.monitoring.kubernetes_config_map_v1.uptime_dashboard: Refreshing state... [id=monitoring/uptime-dashboard] module.database.helm_release.cnpg: Refreshing state... [id=cnpg] module.database.kubernetes_secret_v1.paledocs_db_url: Refreshing state... [id=pal-e-app/paledocs-db-url] module.networking.helm_release.tailscale_operator: Refreshing state... [id=tailscale-operator] module.keycloak.kubernetes_deployment_v1.keycloak: Refreshing state... [id=keycloak/keycloak] module.forgejo.helm_release.forgejo: Refreshing state... [id=forgejo] kubernetes_manifest.netpol_basketball_api: Refreshing state... kubernetes_manifest.netpol_keycloak: Refreshing state... kubernetes_manifest.netpol_postgres: Refreshing state... kubernetes_manifest.netpol_forgejo: Refreshing state... kubernetes_manifest.netpol_monitoring: Refreshing state... module.database.kubernetes_secret_v1.admin_app_db_url: Refreshing state... [id=basketball-api/admin-app-db-url] module.database.kubernetes_secret_v1.admin_app_db_url_westside_admin: Refreshing state... [id=westside-admin/admin-app-db-url] kubernetes_manifest.netpol_cnpg_system: Refreshing state... kubernetes_manifest.netpol_staging: Refreshing state... module.monitoring.helm_release.blackbox_exporter: Refreshing state... [id=blackbox-exporter] module.monitoring.kubernetes_config_map_v1.basketball_api_dashboard: Refreshing state... [id=monitoring/basketball-api-dashboard] module.monitoring.kubernetes_config_map_v1.playme2k_dashboard: Refreshing state... [id=monitoring/playme2k-dashboard] module.monitoring.kubernetes_manifest.blackbox_alerts: Refreshing state... module.monitoring.kubernetes_manifest.payment_pipeline_alerts: Refreshing state... module.monitoring.kubernetes_manifest.embedding_alerts: Refreshing state... module.monitoring.kubernetes_deployment_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] module.monitoring.kubernetes_config_map_v1.dora_dashboard: Refreshing state... [id=monitoring/dora-dashboard] module.monitoring.kubernetes_config_map_v1.grafana_loki_datasource: Refreshing state... [id=monitoring/grafana-loki-datasource] module.monitoring.kubernetes_config_map_v1.pal_e_production_dashboard: Refreshing state... [id=monitoring/pal-e-app-dashboard] module.monitoring.kubernetes_config_map_v1.mac_agent_dashboard: Refreshing state... [id=monitoring/mac-agent-dashboard] module.monitoring.kubernetes_manifest.gmail_oauth_expiry_alert: Refreshing state... module.monitoring.kubernetes_manifest.dora_exporter_service_monitor: Refreshing state... module.monitoring.kubernetes_manifest.embedding_worker_service_monitor: Refreshing state... module.networking.kubernetes_ingress_v1.keycloak_funnel: Refreshing state... [id=keycloak/keycloak-funnel] module.networking.kubernetes_ingress_v1.alertmanager_funnel: Refreshing state... [id=monitoring/alertmanager-funnel] module.networking.kubernetes_ingress_v1.forgejo_funnel: Refreshing state... [id=forgejo/forgejo-funnel] module.networking.kubernetes_ingress_v1.grafana_funnel: Refreshing state... [id=monitoring/grafana-funnel] module.networking.kubernetes_manifest.tailscale_subnet_router: Refreshing state... module.harbor.kubernetes_namespace_v1.harbor: Refreshing state... [id=harbor] module.storage.kubernetes_namespace_v1.minio: Refreshing state... [id=minio] module.storage.kubernetes_config_map_v1.minio_console_nginx: Refreshing state... [id=minio/minio-console-nginx] module.storage.kubernetes_config_map_v1.minio_console_css: Refreshing state... [id=minio/minio-console-css] module.harbor.kubernetes_config_map_v1.harbor_portal_nginx: Refreshing state... [id=harbor/harbor-portal-nginx] module.storage.kubernetes_service_v1.minio_console_proxy: Refreshing state... [id=minio/minio-console-proxy] module.storage.helm_release.minio: Refreshing state... [id=minio] module.harbor.kubernetes_config_map_v1.harbor_portal_css: Refreshing state... [id=harbor/harbor-portal-css] module.harbor.kubernetes_service_v1.harbor_portal_proxy: Refreshing state... [id=harbor/harbor-portal-proxy] module.harbor.helm_release.harbor: Refreshing state... [id=harbor] module.networking.kubernetes_ingress_v1.harbor_funnel: Refreshing state... [id=harbor/harbor-funnel] module.networking.kubernetes_ingress_v1.minio_funnel: Refreshing state... [id=minio/minio-funnel] kubernetes_manifest.netpol_harbor: Refreshing state... module.networking.kubernetes_ingress_v1.minio_api_funnel: Refreshing state... [id=minio/minio-api-funnel] kubernetes_manifest.netpol_minio: Refreshing state... module.storage.minio_iam_policy.tf_backup: Refreshing state... [id=tf-backup] module.storage.minio_iam_user.tf_backup: Refreshing state... [id=tf-backup] module.storage.minio_iam_policy.cnpg_wal: Refreshing state... [id=cnpg-wal] module.storage.minio_s3_bucket.tf_state_backups: Refreshing state... [id=tf-state-backups] module.storage.minio_s3_bucket.postgres_wal: Refreshing state... [id=postgres-wal] module.storage.minio_iam_user.cnpg: Refreshing state... [id=cnpg] module.storage.minio_s3_bucket.assets: Refreshing state... [id=assets] module.storage.minio_iam_user_policy_attachment.tf_backup: Refreshing state... [id=tf-backup-20260314163610110100000001] module.storage.minio_iam_user_policy_attachment.cnpg: Refreshing state... [id=cnpg-20260302210642491000000001] module.storage.minio_s3_bucket_policy.assets_public_read: Refreshing state... [id=assets] module.database.kubernetes_secret_v1.cnpg_s3_creds: Refreshing state... [id=postgres/cnpg-s3-creds] module.storage.kubernetes_deployment_v1.minio_console_proxy: Refreshing state... [id=minio/minio-console-proxy] module.database.kubernetes_cron_job_v1.cnpg_backup_verify: Refreshing state... [id=postgres/cnpg-backup-verify] module.ops.kubernetes_namespace_v1.ollama: Refreshing state... [id=ollama] module.ops.kubernetes_service_account_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] module.ops.kubernetes_secret_v1.tf_backup_s3_creds: Refreshing state... [id=tofu-state/tf-backup-s3-creds] module.ops.kubernetes_role_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] module.ops.kubernetes_service_v1.embedding_worker_metrics: Refreshing state... [id=pal-e-app/embedding-worker-metrics] module.ops.helm_release.nvidia_device_plugin: Refreshing state... [id=nvidia-device-plugin] module.ops.kubernetes_role_binding_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] kubernetes_manifest.netpol_ollama: Refreshing state... module.ci.kubernetes_namespace_v1.woodpecker: Refreshing state... [id=woodpecker] module.ops.kubernetes_cron_job_v1.tf_state_backup: Refreshing state... [id=tofu-state/tf-state-backup] module.ci.kubernetes_secret_v1.woodpecker_cnpg_s3_creds: Refreshing state... [id=woodpecker/cnpg-s3-creds] module.ci.kubernetes_secret_v1.woodpecker_db_credentials: Refreshing state... [id=woodpecker/woodpecker-db-credentials] module.networking.kubernetes_ingress_v1.woodpecker_funnel: Refreshing state... [id=woodpecker/woodpecker-funnel] kubernetes_manifest.netpol_woodpecker: Refreshing state... module.ci.kubernetes_manifest.woodpecker_postgres: Refreshing state... module.harbor.kubernetes_deployment_v1.harbor_portal_proxy: Refreshing state... [id=harbor/harbor-portal-proxy] module.ci.helm_release.woodpecker: Refreshing state... [id=woodpecker] module.ci.kubernetes_manifest.woodpecker_postgres_scheduled_backup: Refreshing state... module.ci.kubernetes_manifest.woodpecker_postgres_podmonitor: Refreshing state... module.ops.helm_release.ollama: Refreshing state... [id=ollama] OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create ~ update in-place <= read (data resources) OpenTofu will perform the following actions: # kubernetes_manifest.netpol_basketball_api will be updated in-place ~ resource "kubernetes_manifest" "netpol_basketball_api" { ~ object = { ~ spec = { ~ ingress = [ # (4 unchanged elements hidden) { from = [ { ipBlock = { cidr = null except = null } namespaceSelector = { matchExpressions = null matchLabels = { "kubernetes.io/metadata.name" = "monitoring" } } podSelector = { matchExpressions = null matchLabels = null } }, ] ports = null }, - { - from = [ - { - ipBlock = { - cidr = null - except = null } - namespaceSelector = { - matchExpressions = null - matchLabels = { - "kubernetes.io/metadata.name" = "westside-admin" } } - podSelector = { - matchExpressions = null - matchLabels = null } }, ] - ports = null }, ] # (3 unchanged attributes hidden) } # (3 unchanged attributes hidden) } # (1 unchanged attribute hidden) # (1 unchanged block hidden) } # kubernetes_manifest.netpol_postgres will be updated in-place ~ resource "kubernetes_manifest" "netpol_postgres" { ~ object = { ~ spec = { ~ ingress = [ # (3 unchanged elements hidden) { from = [ { ipBlock = { cidr = null except = null } namespaceSelector = { matchExpressions = null matchLabels = { "kubernetes.io/metadata.name" = "monitoring" } } podSelector = { matchExpressions = null matchLabels = null } }, ] ports = null }, - { - from = [ - { - ipBlock = { - cidr = null - except = null } - namespaceSelector = { - matchExpressions = null - matchLabels = { - "kubernetes.io/metadata.name" = "westside-ror" } } - podSelector = { - matchExpressions = null - matchLabels = null } }, ] - ports = null }, - { - from = [ - { - ipBlock = { - cidr = null - except = null } - namespaceSelector = { - matchExpressions = null - matchLabels = { - "kubernetes.io/metadata.name" = "pal-e-ror" } } - podSelector = { - matchExpressions = null - matchLabels = null } }, ] - ports = null }, ] # (3 unchanged attributes hidden) } # (3 unchanged attributes hidden) } # (1 unchanged attribute hidden) # (1 unchanged block hidden) } # module.admin.kubernetes_deployment_v1.admin will be created + resource "kubernetes_deployment_v1" "admin" { + id = (known after apply) + wait_for_rollout = true + metadata { + generation = (known after apply) + labels = { + "app" = "pal-e-admin" } + name = "pal-e-admin" + namespace = "pal-e-admin" + resource_version = (known after apply) + uid = (known after apply) } + spec { + min_ready_seconds = 0 + paused = false + progress_deadline_seconds = 600 + replicas = "1" + revision_history_limit = 10 + selector { + match_labels = { + "app" = "pal-e-admin" } } + strategy (known after apply) + template { + metadata { + generation = (known after apply) + labels = { + "app" = "pal-e-admin" } + name = (known after apply) + resource_version = (known after apply) + uid = (known after apply) } + spec { + automount_service_account_token = true + dns_policy = "ClusterFirst" + enable_service_links = true + host_ipc = false + host_network = false + host_pid = false + hostname = (known after apply) + node_name = (known after apply) + restart_policy = "Always" + scheduler_name = (known after apply) + service_account_name = (known after apply) + share_process_namespace = false + termination_grace_period_seconds = 30 + container { + image = "harbor.tail5b443a.ts.net/pal-e-admin/pal-e-admin:latest" + image_pull_policy = (known after apply) + name = "admin" + stdin = false + stdin_once = false + termination_message_path = "/dev/termination-log" + termination_message_policy = (known after apply) + tty = false + env_from { + secret_ref { + name = "admin-auth" } } + port { + container_port = 3000 + protocol = "TCP" } + resources { + limits = { + "memory" = "256Mi" } + requests = { + "cpu" = "50m" + "memory" = "64Mi" } } } + image_pull_secrets { + name = "harbor-creds" } + readiness_gate (known after apply) } } } } # module.admin.kubernetes_namespace_v1.admin will be created + resource "kubernetes_namespace_v1" "admin" { + id = (known after apply) + wait_for_default_service_account = false + metadata { + generation = (known after apply) + labels = { + "name" = "pal-e-admin" } + name = "pal-e-admin" + resource_version = (known after apply) + uid = (known after apply) } } # module.admin.kubernetes_secret_v1.admin_auth will be created + resource "kubernetes_secret_v1" "admin_auth" { + data = (sensitive value) + id = (known after apply) + type = "Opaque" + wait_for_service_account_token = true + metadata { + generation = (known after apply) + name = "admin-auth" + namespace = "pal-e-admin" + resource_version = (known after apply) + uid = (known after apply) } } # module.admin.kubernetes_secret_v1.harbor_creds will be created + resource "kubernetes_secret_v1" "harbor_creds" { + data = (sensitive value) + id = (known after apply) + type = "kubernetes.io/dockerconfigjson" + wait_for_service_account_token = true + metadata { + generation = (known after apply) + name = "harbor-creds" + namespace = "pal-e-admin" + resource_version = (known after apply) + uid = (known after apply) } } # module.admin.kubernetes_service_v1.admin will be created + resource "kubernetes_service_v1" "admin" { + id = (known after apply) + status = (known after apply) + wait_for_load_balancer = true + metadata { + generation = (known after apply) + name = "pal-e-admin" + namespace = "pal-e-admin" + resource_version = (known after apply) + uid = (known after apply) } + spec { + allocate_load_balancer_node_ports = true + cluster_ip = (known after apply) + cluster_ips = (known after apply) + external_traffic_policy = (known after apply) + health_check_node_port = (known after apply) + internal_traffic_policy = (known after apply) + ip_families = (known after apply) + ip_family_policy = (known after apply) + publish_not_ready_addresses = false + selector = { + "app" = "pal-e-admin" } + session_affinity = "None" + type = "ClusterIP" + port { + node_port = (known after apply) + port = 80 + protocol = "TCP" + target_port = "3000" } + session_affinity_config (known after apply) } } # module.ci.helm_release.woodpecker will be updated in-place ~ resource "helm_release" "woodpecker" { id = "woodpecker" ~ metadata = [ - { - app_version = "3.13.0" - chart = "woodpecker" - first_deployed = 1773625582 - last_deployed = 1778034163 - name = "woodpecker" - namespace = "woodpecker" - notes = <<-EOT 1. Get the application URL by running these commands: export POD_NAME=$(kubectl get pods --namespace woodpecker -l "app.kubernetes.io/name=server,app.kubernetes.io/instance=woodpecker" -o jsonpath="{.items[0].metadata.name}") export CONTAINER_PORT=$(kubectl get pod --namespace woodpecker $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") echo "Visit http://127.0.0.1:8080 to use your application" kubectl --namespace woodpecker port-forward $POD_NAME 8080:$CONTAINER_PORT EOT - revision = 23 - values = jsonencode( { - agent = { - enabled = true - env = { - HARBOR_REGISTRY_INTERNAL = "harbor.harbor.svc.cluster.local" - WOODPECKER_AGENT_SECRET = "(sensitive value)" - WOODPECKER_BACKEND = "kubernetes" - WOODPECKER_BACKEND_K8S_NAMESPACE = "woodpecker" - WOODPECKER_BACKEND_K8S_STORAGE_CLASS = "local-path" - WOODPECKER_BACKEND_K8S_VOLUME_SIZE = "1Gi" - WOODPECKER_CONNECT_RETRY_COUNT = "10" - WOODPECKER_FILTER_LABELS = "platform=linux" - WOODPECKER_MAX_WORKFLOWS = "4" } - replicaCount = 1 - resources = { - limits = { - memory = "256Mi" } - requests = { - cpu = "50m" - memory = "64Mi" } } } - server = { - env = { - WOODPECKER_ADMIN = "forgejo_admin" - WOODPECKER_AGENT_SECRET = "(sensitive value)" - WOODPECKER_DATABASE_DATASOURCE = "postgres://woodpecker:kM3L4AhLNiuMhIY7tMQ@woodpecker-db-rw.woodpecker.svc.cluster.local:5432/woodpecker?sslmode=disable" - WOODPECKER_DATABASE_DRIVER = "postgres" - WOODPECKER_ENCRYPTION_KEY = "(sensitive value)" - WOODPECKER_FORGEJO = "true" - WOODPECKER_FORGEJO_CLIENT = "(sensitive value)" - WOODPECKER_FORGEJO_SECRET = "(sensitive value)" - WOODPECKER_FORGEJO_URL = "http://forgejo-http.forgejo.svc.cluster.local" - WOODPECKER_HOST = "https://woodpecker.tail5b443a.ts.net" } - persistentVolume = { - enabled = true - size = "5Gi" - storageClass = "local-path" } - resources = { - limits = { - memory = "512Mi" } - requests = { - cpu = "50m" - memory = "128Mi" } } - statefulSet = { - replicaCount = 1 } } } ) - version = "3.5.1" }, ] -> (known after apply) name = "woodpecker" ~ values = [ - (sensitive value), + (sensitive value), ] # (25 unchanged attributes hidden) # (5 unchanged blocks hidden) } # module.database.kubernetes_job_v1.admin_app_user_provision will be created + resource "kubernetes_job_v1" "admin_app_user_provision" { + id = (known after apply) + wait_for_completion = true + metadata { + generation = (known after apply) + labels = { + "app.kubernetes.io/managed-by" = "pal-e-platform-terraform" + "app.kubernetes.io/name" = "admin-app-user-provision" + "arch" = "postgres" + "story" = "admin-row-crud" } + name = (sensitive value) + namespace = "basketball-api" + resource_version = (known after apply) + uid = (known after apply) } + spec { + backoff_limit = 4 + completion_mode = (known after apply) + completions = 1 + parallelism = 1 + ttl_seconds_after_finished = "3600" + selector (known after apply) + template { + metadata { + generation = (known after apply) + labels = { + "app.kubernetes.io/name" = "admin-app-user-provision" } + name = (known after apply) + resource_version = (known after apply) + uid = (known after apply) } + spec { + automount_service_account_token = true + dns_policy = "ClusterFirst" + enable_service_links = true + host_ipc = false + host_network = false + host_pid = false + hostname = (known after apply) + node_name = (known after apply) + restart_policy = "OnFailure" + scheduler_name = (known after apply) + service_account_name = (known after apply) + share_process_namespace = false + termination_grace_period_seconds = 30 + container { + args = [ + <<-EOT set -euo pipefail echo "==> Provisioning admin_app role on ${PGHOST}/${PGDATABASE}" # Idempotent: CREATE ROLE if missing, otherwise rotate password. psql -v ON_ERROR_STOP=1 -v admin_pw="${ADMIN_APP_PASSWORD}" <<'SQL' -- Idempotent role creation/rotation. Conditional uses psql's \gset + \if -- (client-side) so we can use :'admin_pw' substitution. psql variable -- substitution does NOT work inside DO $$...$$ dollar-quoted blocks. SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'admin_app') AS role_exists \gset \if :role_exists ALTER ROLE admin_app WITH LOGIN PASSWORD :'admin_pw'; \else CREATE ROLE admin_app WITH LOGIN PASSWORD :'admin_pw'; \endif -- DML-only grants on schema public. Idempotent (re-grant is a no-op). GRANT USAGE ON SCHEMA public TO admin_app; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO admin_app; GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO admin_app; -- Forward grants for tables/sequences created later by Drizzle migrations -- (which run as the basketball superuser). ALTER DEFAULT PRIVILEGES FOR ROLE basketball IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO admin_app; ALTER DEFAULT PRIVILEGES FOR ROLE basketball IN SCHEMA public GRANT USAGE ON SEQUENCES TO admin_app; SQL echo "==> admin_app role provisioned successfully" EOT, ] + command = [ + "/bin/sh", + "-c", ] + image = "postgres:16-alpine" + image_pull_policy = (known after apply) + name = "psql" + stdin = false + stdin_once = false + termination_message_path = "/dev/termination-log" + termination_message_policy = (known after apply) + tty = false + env { + name = "PGHOST" + value = "postgres.basketball-api.svc.cluster.local" } + env { + name = "PGPORT" + value = "5432" } + env { + name = "PGUSER" + value = "basketball" } + env { + name = "PGDATABASE" + value = "basketball" } + env { + name = "PGPASSWORD" + value_from { + secret_key_ref { + key = "postgres-password" + name = "basketball-api-secrets" } } } + env { + name = "ADMIN_APP_PASSWORD" + value = (sensitive value) } + resources { + limits = { + "memory" = "128Mi" } + requests = { + "cpu" = "50m" + "memory" = "64Mi" } } } + image_pull_secrets (known after apply) + readiness_gate (known after apply) } } } + timeouts { + create = "5m" + update = "5m" } } # module.forgejo.helm_release.forgejo will be updated in-place ~ resource "helm_release" "forgejo" { id = "forgejo" ~ metadata = [ - { - app_version = "14.0.2" - chart = "forgejo" - first_deployed = 1771564458 - last_deployed = 1778313401 - name = "forgejo" - namespace = "forgejo" - notes = <<-EOT 1. Get the application URL by running these commands: echo "Visit http://127.0.0.1:80 to use your application" kubectl --namespace forgejo port-forward svc/forgejo-http 80:80 2. Review these warnings: - Forgejo uses 'memory' for caching which is not recommended for production use. See https://forgejo.org/docs/latest/admin/config-cheat-sheet/#cache-cache for available options. - Forgejo uses 'leveldb' for queue actions which is not recommended for production use. See https://forgejo.org/docs/latest/admin/config-cheat-sheet/#queue-queue-and-queue for available options. - Forgejo uses 'memory' for sessions which is not recommended for production use. See https://forgejo.org/docs/latest/admin/config-cheat-sheet/#session-session for available options. EOT - revision = 12 - values = jsonencode( { - gitea = { - admin = { - email = "admin@forgejo.local" - password = "(sensitive value)" - passwordMode = "keepUpdated" - username = "forgejo_admin" } - config = { - oauth2_client = { - ACCOUNT_LINKING = "auto" - ENABLE_AUTO_REGISTRATION = true - USERNAME = "email" } - server = { - DOMAIN = "forgejo.tail5b443a.ts.net" - HTTP_ADDR = "0.0.0.0" - ROOT_URL = "https://forgejo.tail5b443a.ts.net/" - SSH_DOMAIN = "forgejo.tail5b443a.ts.net" } - session = { - COOKIE_SECURE = true - SESSION_LIFE_TIME = 604800 } - ui = { - DEFAULT_THEME = "forgejo-dark" } - webhook = { - ALLOWED_HOST_LIST = "external,loopback" } } - oauth = [ - { - autoDiscoverUrl = "https://keycloak.tail5b443a.ts.net/realms/platform/.well-known/openid-configuration" - existingSecret = "forgejo-oidc" - name = "Keycloak" - provider = "openidConnect" - scopes = "openid profile email" }, ] } - ingress = { - enabled = false } - persistence = { - enabled = true - size = "10Gi" - storageClass = "local-path" } - resources = { - limits = { - memory = "2Gi" } - requests = { - cpu = "100m" - memory = "512Mi" } } - service = { - http = { - port = 80 - type = "ClusterIP" } - ssh = { - port = 22 - type = "ClusterIP" } } } ) - version = "16.2.0" }, ] -> (known after apply) name = "forgejo" ~ values = [ - <<-EOT "gitea": "admin": "email": "admin@forgejo.local" "passwordMode": "keepUpdated" "username": "forgejo_admin" "config": "oauth2_client": "ACCOUNT_LINKING": "auto" "ENABLE_AUTO_REGISTRATION": true "USERNAME": "email" "server": "DOMAIN": "forgejo.tail5b443a.ts.net" "HTTP_ADDR": "0.0.0.0" "ROOT_URL": "https://forgejo.tail5b443a.ts.net/" "SSH_DOMAIN": "forgejo.tail5b443a.ts.net" "session": "COOKIE_SECURE": true "SESSION_LIFE_TIME": 604800 "ui": "DEFAULT_THEME": "forgejo-dark" "webhook": "ALLOWED_HOST_LIST": "external,loopback" "oauth": - "autoDiscoverUrl": "https://keycloak.tail5b443a.ts.net/realms/platform/.well-known/openid-configuration" "existingSecret": "forgejo-oidc" "name": "Keycloak" "provider": "openidConnect" "scopes": "openid profile email" "ingress": "enabled": false "persistence": "enabled": true "size": "10Gi" "storageClass": "local-path" "resources": "limits": "memory": "2Gi" "requests": "cpu": "100m" "memory": "512Mi" "service": "http": "port": 80 "type": "ClusterIP" "ssh": "port": 22 "type": "ClusterIP" EOT, + <<-EOT "extraContainerVolumeMounts": - "mountPath": "/data/gitea/public/css/custom.css" "name": "custom-css" "readOnly": true "subPath": "custom.css" "extraVolumes": - "configMap": "name": "forgejo-custom-css" "name": "custom-css" "gitea": "admin": "email": "admin@forgejo.local" "passwordMode": "keepUpdated" "username": "forgejo_admin" "config": "oauth2_client": "ACCOUNT_LINKING": "auto" "ENABLE_AUTO_REGISTRATION": true "USERNAME": "email" "server": "DOMAIN": "forgejo.tail5b443a.ts.net" "HTTP_ADDR": "0.0.0.0" "ROOT_URL": "https://forgejo.tail5b443a.ts.net/" "SSH_DOMAIN": "forgejo.tail5b443a.ts.net" "session": "COOKIE_SECURE": true "SESSION_LIFE_TIME": 604800 "ui": "DEFAULT_THEME": "forgejo-dark" "webhook": "ALLOWED_HOST_LIST": "external,loopback" "oauth": - "autoDiscoverUrl": "https://keycloak.tail5b443a.ts.net/realms/platform/.well-known/openid-configuration" "existingSecret": "forgejo-oidc" "name": "Keycloak" "provider": "openidConnect" "scopes": "openid profile email" "ingress": "enabled": false "persistence": "enabled": true "size": "10Gi" "storageClass": "local-path" "resources": "limits": "memory": "2Gi" "requests": "cpu": "100m" "memory": "512Mi" "service": "http": "port": 80 "type": "ClusterIP" "ssh": "port": 22 "type": "ClusterIP" EOT, ] # (26 unchanged attributes hidden) # (1 unchanged block hidden) } # module.forgejo.kubernetes_config_map_v1.forgejo_custom_css will be created + resource "kubernetes_config_map_v1" "forgejo_custom_css" { + data = { + "custom.css" = <<-EOT /* * Forgejo mobile-responsive overrides * * Mobile-first: base styles target phones (≤599px). * Desktop overrides via @media (min-width: 600px). * * Must not break the forgejo-dark theme. * Only adds responsive behavior -- no desktop layout changes. */ /* ── Mobile base (phones, ≤599px) ── */ /* Shrink top navbar so it doesn't eat screen real estate */ .ui.secondary.pointing.menu .item { padding: 0.5em 0.6em; font-size: 0.85rem; } /* Make the hamburger menu button easier to tap */ #navbar .item.icon { min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; } /* Full-width mobile nav dropdown */ .ui.secondary.pointing.menu .ui.dropdown .menu { min-width: 100vw; left: 0; right: 0; } /* Readable font on small screens */ .repository .diff-file-body .code-diff td.lines-code { font-size: 0.75rem; word-break: break-all; white-space: pre-wrap; } /* Prevent horizontal overflow on diff views */ .diff-file-body { overflow-x: auto; -webkit-overflow-scrolling: touch; } /* Stack PR/issue header metadata vertically */ .issue-content-right { width: 100%; } /* Make comment boxes full-width */ .timeline .comment .content { max-width: 100%; } /* Repo file list: tighter rows on mobile */ #repo-files-table td { padding: 0.4em 0.5em; font-size: 0.85rem; } /* Repo header: allow wrapping */ .repo-header .repo-header-title { flex-wrap: wrap; gap: 0.25rem; } /* Dashboard cards: full-width stack */ .dashboard .activity-card { width: 100% !important; } /* ── Desktop restore (≥600px) ── */ @media (min-width: 600px) { .ui.secondary.pointing.menu .item { padding: revert; font-size: revert; } .repository .diff-file-body .code-diff td.lines-code { font-size: revert; word-break: normal; white-space: pre; } #repo-files-table td { padding: revert; font-size: revert; } } EOT } + id = (known after apply) + metadata { + generation = (known after apply) + name = "forgejo-custom-css" + namespace = "forgejo" + resource_version = (known after apply) + uid = (known after apply) } } # module.harbor.null_resource.harbor_oidc will be created + resource "null_resource" "harbor_oidc" { + id = (known after apply) + triggers = { + "oidc_endpoint" = "https://keycloak.tail5b443a.ts.net/realms/platform" + "oidc_secret_hash" = (sensitive value) } } # module.monitoring.helm_release.kube_prometheus_stack will be updated in-place ~ resource "helm_release" "kube_prometheus_stack" { id = "kube-prometheus-stack" ~ metadata = [ - { - app_version = "v0.89.0" - chart = "kube-prometheus-stack" - first_deployed = 1771560679 - last_deployed = 1778034131 - name = "kube-prometheus-stack" - namespace = "monitoring" - notes = <<-EOT 1. Get your 'admin' user password by running: kubectl get secret --namespace monitoring kube-prometheus-stack-grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo 2. The Grafana server can be accessed via port 80 on the following DNS name from within your cluster: kube-prometheus-stack-grafana.monitoring.svc.cluster.local Get the Grafana URL to visit by running these commands in the same shell: export POD_NAME=$(kubectl get pods --namespace monitoring -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=kube-prometheus-stack" -o jsonpath="{.items[0].metadata.name}") kubectl --namespace monitoring port-forward $POD_NAME 3000 3. Login with the password from step 1 and the username: admin kube-prometheus-stack has been installed. Check its status by running: kubectl --namespace monitoring get pods -l "release=kube-prometheus-stack" Get Grafana 'admin' user password by running: kubectl --namespace monitoring get secrets kube-prometheus-stack-grafana -o jsonpath="{.data.admin-password}" | base64 -d ; echo Access Grafana local instance: export POD_NAME=$(kubectl --namespace monitoring get pod -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=kube-prometheus-stack" -oname) kubectl --namespace monitoring port-forward $POD_NAME 3000 Get your grafana admin user password by running: kubectl get secret --namespace monitoring -l app.kubernetes.io/component=admin-secret -o jsonpath="{.items[0].data.admin-password}" | base64 --decode ; echo Visit https://github.com/prometheus-operator/kube-prometheus for instructions on how to create & configure Alertmanager and Prometheus instances using the Operator. 1. Get the application URL by running these commands: export POD_NAME=$(kubectl get pods --namespace monitoring -l "app.kubernetes.io/name=prometheus-node-exporter,app.kubernetes.io/instance=kube-prometheus-stack" -o jsonpath="{.items[0].metadata.name}") echo "Visit http://127.0.0.1:9100 to use your application" kubectl port-forward --namespace monitoring $POD_NAME 9100 kube-state-metrics is a simple service that listens to the Kubernetes API server and generates metrics about the state of the objects. The exposed metrics can be found here: https://github.com/kubernetes/kube-state-metrics/blob/master/docs/README.md#exposed-metrics The metrics are exported on the HTTP endpoint /metrics on the listening port. In your case, kube-prometheus-stack-kube-state-metrics.monitoring.svc.cluster.local:8080/metrics They are served either as plaintext or protobuf depending on the Accept header. They are designed to be consumed either by Prometheus itself or by a scraper that is compatible with scraping a Prometheus client endpoint. EOT - revision = 22 - values = jsonencode( { - additionalPrometheusRules = [ - { - groups = [ - { - name = "pod-health" - rules = [ - { - alert = "PodRestartStorm" - annotations = { - description = "Pod {{ $labels.namespace }}/{{ $labels.pod }} has restarted {{ $value }} times in the last 15 minutes." - summary = "Pod {{ $labels.namespace }}/{{ $labels.pod }} restarting frequently" } - expr = "increase(kube_pod_container_status_restarts_total[15m]) > 3" - for = "0m" - labels = { - severity = "warning" } }, - { - alert = "OOMKilled" - annotations = { - description = "Container {{ $labels.container }} in pod {{ $labels.namespace }}/{{ $labels.pod }} was OOMKilled." - summary = "Pod {{ $labels.namespace }}/{{ $labels.pod }} OOMKilled" } - expr = "kube_pod_container_status_last_terminated_reason{reason=\"OOMKilled\"} > 0" - for = "15m" - labels = { - severity = "critical" } }, ] }, - { - name = "node-health" - rules = [ - { - alert = "DiskPressure" - annotations = { - description = "Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} has only {{ $value | printf \"%.1f\" }}% space remaining." - summary = "Disk pressure on {{ $labels.instance }}" } - expr = "(node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 < 15" - for = "5m" - labels = { - severity = "critical" } }, ] }, - { - name = "target-health" - rules = [ - { - alert = "TargetDown" - annotations = { - description = "Target {{ $labels.job }}/{{ $labels.instance }} has been down for more than 5 minutes." - summary = "Target {{ $labels.instance }} is down" } - expr = "up == 0" - for = "5m" - labels = { - severity = "warning" } }, ] }, - { - name = "mac-agent-health" - rules = [ - { - alert = "MacAgentDown" - annotations = { - description = "Mac build agent (lucass-macbook-air-1) node-exporter has been down for more than 5 minutes. iOS CI builds are unavailable." - summary = "Mac build agent is unreachable" } - expr = "up{job=\"mac-node-exporter\"} == 0" - for = "5m" - labels = { - severity = "critical" } }, ] }, ] - name = "platform-alerts" }, ] - alertmanager = { - alertmanagerSpec = { - resources = { - limits = { - memory = "128Mi" } - requests = { - cpu = "10m" - memory = "64Mi" } } - storage = { - volumeClaimTemplate = { - spec = { - accessModes = [ - "ReadWriteOnce", ...(truncated) ```
forgejo_admin deleted branch 332-salt-k3s-maxpods 2026-05-09 16:34:24 +00:00
Sign in to join this conversation.
No description provided.