Add landscaping-assistant to postgres NetworkPolicy #376

Merged
ldraney merged 1 commit from feat/landscaping-assistant-netpol into main 2026-05-24 00:36:32 +00:00
Owner

Closes ldraney/landscaping-assistant#1

Summary

  • Add landscaping-assistant namespace to the postgres NetworkPolicy ingress allowlist
  • Enables the new Rails app to reach the shared CNPG PostgreSQL cluster

Changes

  • terraform/network-policies.tf: Added landscaping-assistant as the 7th entry in the netpol_postgres ingress rules, following the existing pattern

Discovered Scope

None

Terraform Changes

tofu plan output
Single-line addition to kubernetes_manifest.netpol_postgres ingress rules.
Adds one namespace selector entry for landscaping-assistant.
  • tofu fmt passed
  • tofu validate passed

Test Plan

  • tofu fmt verified locally
  • tofu plan will show an in-place update to kubernetes_manifest.netpol_postgres
  • After apply, verify landscaping-assistant pods can reach postgres on port 5432

README Impact

  • README roadmap updated (or N/A)

Review Checklist

  • Review-fix loop passed (clean review, zero issues)
  • User approved merge
  • Cross-repo dependency for ldraney/landscaping-assistant#1 (Scaffold Rails app with dev/prod infrastructure and CI pipeline)
  • One of three cross-repo infra PRs required for the landscaping-assistant onboarding
Closes ldraney/landscaping-assistant#1 ## Summary - Add `landscaping-assistant` namespace to the postgres NetworkPolicy ingress allowlist - Enables the new Rails app to reach the shared CNPG PostgreSQL cluster ## Changes - `terraform/network-policies.tf`: Added `landscaping-assistant` as the 7th entry in the `netpol_postgres` ingress rules, following the existing pattern ## Discovered Scope None ## Terraform Changes <details> <summary>tofu plan output</summary> ``` Single-line addition to kubernetes_manifest.netpol_postgres ingress rules. Adds one namespace selector entry for landscaping-assistant. ``` </details> - [x] `tofu fmt` passed - [ ] `tofu validate` passed ## Test Plan - `tofu fmt` verified locally - `tofu plan` will show an in-place update to `kubernetes_manifest.netpol_postgres` - After apply, verify `landscaping-assistant` pods can reach postgres on port 5432 ## README Impact - [x] README roadmap updated (or N/A) ## Review Checklist - [ ] Review-fix loop passed (clean review, zero issues) - [ ] User approved merge ## Related Notes - Cross-repo dependency for ldraney/landscaping-assistant#1 (Scaffold Rails app with dev/prod infrastructure and CI pipeline) - One of three cross-repo infra PRs required for the landscaping-assistant onboarding
Add landscaping-assistant to postgres NetworkPolicy allowlist
All checks were successful
ci/woodpecker/push/terraform Pipeline was successful
ci/woodpecker/pr/terraform Pipeline was successful
ci/woodpecker/pull_request_closed/terraform Pipeline was successful
032bbbbac5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author
Owner

PR Review: #376 -- Add landscaping-assistant to postgres NetworkPolicy

Scope: 1 file changed, 1 addition, 0 deletions

Findings

No issues found. This is a clean, minimal change:

  • Pattern compliance: The new ingress entry exactly matches the structure of the existing 6 entries in netpol_postgres
  • Namespace naming: landscaping-assistant is consistent with the project/repo name
  • Formatting: Indentation and HCL style match the surrounding code
  • Placement: Appended at end of list, consistent with the file's convention
  • tofu fmt: Verified passed

VERDICT: PASS

## PR Review: #376 -- Add landscaping-assistant to postgres NetworkPolicy **Scope**: 1 file changed, 1 addition, 0 deletions ### Findings No issues found. This is a clean, minimal change: - **Pattern compliance**: The new ingress entry exactly matches the structure of the existing 6 entries in `netpol_postgres` - **Namespace naming**: `landscaping-assistant` is consistent with the project/repo name - **Formatting**: Indentation and HCL style match the surrounding code - **Placement**: Appended at end of list, consistent with the file's convention - **tofu fmt**: Verified passed ### VERDICT: PASS
Contributor

Tofu Plan Output (full)

module.networking.tailscale_acl.this: Refreshing state... [id=acl]
module.database.data.kubernetes_namespace_v1.westside_admin: Reading...
module.database.kubernetes_namespace_v1.postgres: Refreshing state... [id=postgres]
module.staging.kubernetes_namespace_v1.staging: Refreshing state... [id=staging]
module.forgejo.kubernetes_namespace_v1.forgejo: Refreshing state... [id=forgejo]
module.monitoring.kubernetes_namespace_v1.monitoring: Refreshing state... [id=monitoring]
module.keycloak.kubernetes_namespace_v1.keycloak: Refreshing state... [id=keycloak]
module.database.data.kubernetes_namespace_v1.pal_e_production: Reading...
module.database.data.kubernetes_namespace_v1.pal_e_production: Read complete after 0s [id=pal-e-app]
module.database.data.kubernetes_namespace_v1.westside_admin: Read complete after 0s [id=westside-admin]
module.database.data.kubernetes_namespace_v1.basketball_api: Reading...
module.database.kubernetes_namespace_v1.cnpg_system: Refreshing state... [id=cnpg-system]
module.database.data.kubernetes_namespace_v1.basketball_api: Read complete after 0s [id=basketball-api]
module.networking.kubernetes_namespace_v1.tailscale: Refreshing state... [id=tailscale]
module.database.kubernetes_secret_v1.paledocs_db_url: Refreshing state... [id=pal-e-app/paledocs-db-url]
module.keycloak.kubernetes_config_map_v1.keycloak_westside_theme: Refreshing state... [id=keycloak/keycloak-westside-theme]
module.forgejo.kubernetes_config_map_v1.forgejo_custom_css: Refreshing state... [id=forgejo/forgejo-custom-css]
module.forgejo.kubernetes_secret_v1.forgejo_oidc: Refreshing state... [id=forgejo/forgejo-oidc]
module.keycloak.kubernetes_secret_v1.keycloak_admin: Refreshing state... [id=keycloak/keycloak-admin]
module.keycloak.kubernetes_service_v1.keycloak: Refreshing state... [id=keycloak/keycloak]
module.keycloak.kubernetes_persistent_volume_claim_v1.keycloak_data: Refreshing state... [id=keycloak/keycloak-data]
module.database.kubernetes_job_v1.admin_app_user_provision: Refreshing state... [id=basketball-api/admin-app-user-provision-c5662180]
module.database.helm_release.cnpg: Refreshing state... [id=cnpg]
module.networking.helm_release.tailscale_operator: Refreshing state... [id=tailscale-operator]
module.monitoring.kubernetes_config_map_v1.uptime_dashboard: Refreshing state... [id=monitoring/uptime-dashboard]
module.monitoring.kubernetes_secret_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
module.monitoring.helm_release.loki_stack: Refreshing state... [id=loki-stack]
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_secret_v1.grafana_oidc: Refreshing state... [id=monitoring/grafana-oidc]
kubernetes_manifest.netpol_basketball_api: Refreshing state...
kubernetes_manifest.netpol_postgres: Refreshing state...
kubernetes_manifest.netpol_staging: Refreshing state...
kubernetes_manifest.netpol_keycloak: Refreshing state...
kubernetes_manifest.netpol_forgejo: Refreshing state...
kubernetes_manifest.netpol_cnpg_system: Refreshing state...
module.forgejo.helm_release.forgejo: Refreshing state... [id=forgejo]
module.keycloak.kubernetes_deployment_v1.keycloak: Refreshing state... [id=keycloak/keycloak]
module.database.kubernetes_secret_v1.admin_app_db_url: Refreshing state... [id=basketball-api/admin-app-db-url]
kubernetes_manifest.netpol_monitoring: Refreshing state...
module.database.kubernetes_secret_v1.admin_app_db_url_westside_admin: Refreshing state... [id=westside-admin/admin-app-db-url]
module.admin.kubernetes_namespace_v1.admin: Refreshing state... [id=pal-e-admin]
module.admin.kubernetes_secret_v1.harbor_creds: Refreshing state... [id=pal-e-admin/harbor-creds]
module.admin.kubernetes_secret_v1.admin_auth: Refreshing state... [id=pal-e-admin/admin-auth]
module.admin.kubernetes_service_v1.admin: Refreshing state... [id=pal-e-admin/pal-e-admin]
module.admin.kubernetes_deployment_v1.admin: Refreshing state... [id=pal-e-admin/pal-e-admin]
module.networking.kubernetes_ingress_v1.keycloak_funnel: Refreshing state... [id=keycloak/keycloak-funnel]
module.networking.kubernetes_ingress_v1.admin_funnel: Refreshing state... [id=pal-e-admin/admin-funnel]
module.networking.kubernetes_ingress_v1.forgejo_funnel: Refreshing state... [id=forgejo/forgejo-funnel]
module.networking.kubernetes_ingress_v1.alertmanager_funnel: Refreshing state... [id=monitoring/alertmanager-funnel]
module.networking.kubernetes_ingress_v1.grafana_funnel: Refreshing state... [id=monitoring/grafana-funnel]
module.networking.kubernetes_manifest.tailscale_subnet_router: Refreshing state...
module.monitoring.kubernetes_config_map_v1.mac_agent_dashboard: Refreshing state... [id=monitoring/mac-agent-dashboard]
module.monitoring.kubernetes_config_map_v1.dora_dashboard: Refreshing state... [id=monitoring/dora-dashboard]
module.monitoring.helm_release.blackbox_exporter: Refreshing state... [id=blackbox-exporter]
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.gmail_oauth_expiry_alert: Refreshing state...
module.monitoring.kubernetes_manifest.payment_pipeline_alerts: Refreshing state...
module.monitoring.kubernetes_manifest.embedding_alerts: Refreshing state...
module.monitoring.kubernetes_config_map_v1.grafana_loki_datasource: Refreshing state... [id=monitoring/grafana-loki-datasource]
module.monitoring.kubernetes_deployment_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter]
module.monitoring.kubernetes_config_map_v1.basketball_api_dashboard: Refreshing state... [id=monitoring/basketball-api-dashboard]
module.monitoring.kubernetes_config_map_v1.pal_e_production_dashboard: Refreshing state... [id=monitoring/pal-e-app-dashboard]
module.monitoring.kubernetes_manifest.dora_exporter_service_monitor: Refreshing state...
module.monitoring.kubernetes_manifest.embedding_worker_service_monitor: Refreshing state...
module.storage.kubernetes_namespace_v1.minio: Refreshing state... [id=minio]
module.harbor.kubernetes_namespace_v1.harbor: Refreshing state... [id=harbor]
module.storage.kubernetes_config_map_v1.minio_console_css: Refreshing state... [id=minio/minio-console-css]
module.storage.kubernetes_config_map_v1.minio_console_nginx: Refreshing state... [id=minio/minio-console-nginx]
module.storage.kubernetes_service_v1.minio_console_proxy: Refreshing state... [id=minio/minio-console-proxy]
module.networking.kubernetes_ingress_v1.harbor_funnel: Refreshing state... [id=harbor/harbor-funnel]
module.harbor.kubernetes_config_map_v1.harbor_portal_nginx: Refreshing state... [id=harbor/harbor-portal-nginx]
module.storage.helm_release.minio: Refreshing state... [id=minio]
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.harbor.kubernetes_config_map_v1.harbor_portal_css: Refreshing state... [id=harbor/harbor-portal-css]
module.networking.kubernetes_ingress_v1.minio_api_funnel: Refreshing state... [id=minio/minio-api-funnel]
kubernetes_manifest.netpol_harbor: Refreshing state...
module.networking.kubernetes_ingress_v1.minio_funnel: Refreshing state... [id=minio/minio-funnel]
kubernetes_manifest.netpol_minio: Refreshing state...
module.storage.minio_iam_policy.tf_backup: Refreshing state... [id=tf-backup]
module.storage.minio_s3_bucket.tf_state_backups: Refreshing state... [id=tf-state-backups]
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.assets: Refreshing state... [id=assets]
module.storage.minio_iam_user.cnpg: Refreshing state... [id=cnpg]
module.storage.minio_s3_bucket.postgres_wal: Refreshing state... [id=postgres-wal]
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.database.kubernetes_secret_v1.cnpg_s3_creds: Refreshing state... [id=postgres/cnpg-s3-creds]
module.storage.minio_s3_bucket_policy.assets_public_read: Refreshing state... [id=assets]
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.data.kubernetes_namespace_v1.tofu_state: Reading...
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.data.kubernetes_namespace_v1.tofu_state: Read complete after 0s [id=tofu-state]
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_account_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
module.ops.kubernetes_role_binding_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup]
module.ci.kubernetes_namespace_v1.woodpecker: Refreshing state... [id=woodpecker]
kubernetes_manifest.netpol_ollama: Refreshing state...
module.ci.kubernetes_secret_v1.woodpecker_cnpg_s3_creds: Refreshing state... [id=woodpecker/cnpg-s3-creds]
module.networking.kubernetes_ingress_v1.woodpecker_funnel: Refreshing state... [id=woodpecker/woodpecker-funnel]
module.ops.kubernetes_cron_job_v1.tf_state_backup: Refreshing state... [id=tofu-state/tf-state-backup]
module.ci.kubernetes_secret_v1.woodpecker_db_credentials: Refreshing state... [id=woodpecker/woodpecker-db-credentials]
kubernetes_manifest.netpol_woodpecker: Refreshing state...
module.ci.kubernetes_manifest.woodpecker_postgres: Refreshing state...
module.harbor.null_resource.harbor_oidc: Refreshing state... [id=1192032061072951447]
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]

Note: Objects have changed outside of OpenTofu

OpenTofu detected the following changes made outside of OpenTofu since the
last "tofu apply" which may have affected this plan:

  # module.database.kubernetes_secret_v1.admin_app_db_url_westside_admin has been deleted
  - resource "kubernetes_secret_v1" "admin_app_db_url_westside_admin" {
        id                             = "westside-admin/admin-app-db-url"
        # (4 unchanged attributes hidden)

      - metadata {
            name             = "admin-app-db-url"
          - namespace        = "westside-admin" -> null
            # (5 unchanged attributes hidden)
        }
    }

  # module.harbor.kubernetes_config_map_v1.harbor_portal_nginx has changed
  ~ resource "kubernetes_config_map_v1" "harbor_portal_nginx" {
      ~ data        = {
          ~ "nginx.conf" = <<-EOT
                worker_processes 1;
                events { worker_connections 128; }
                
                http {
                  include       /etc/nginx/mime.types;
                  default_type  application/octet-stream;
              +   client_max_body_size 0;
                
                  server {
                    listen 8080;
                
                    # Serve custom CSS from ConfigMap mount
                    location /custom/ {
                      alias /usr/share/nginx/custom/;
                      expires 1h;
                      add_header Cache-Control "public, immutable";
                    }
                
                    # Proxy everything to Harbor's internal nginx (which routes to portal, core, registry, etc.)
                    location / {
                      proxy_pass http://harbor:80;
                      proxy_http_version 1.1;
                
                      # Pass through headers for auth and websocket
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                      proxy_set_header X-Forwarded-Proto https;
                      proxy_set_header Upgrade $http_upgrade;
                      proxy_set_header Connection "upgrade";
                
                      # Disable compression so sub_filter can operate on the response body
                      proxy_set_header Accept-Encoding "";
                
                      # Inject CSS link before </head>
                      sub_filter '</head>' '<link rel="stylesheet" href="/custom/mobile.css"></head>';
                      sub_filter_once on;
                      sub_filter_types text/html;
                    }
                  }
                }
            EOT
        }
        id          = "harbor/harbor-portal-nginx"
        # (2 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }


Unless you have made equivalent changes to your configuration, or ignored the
relevant attributes using ignore_changes, the following plan may include
actions to undo or respond to these changes.

─────────────────────────────────────────────────────────────────────────────

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
-/+ destroy and then create replacement

OpenTofu will perform the following actions:

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

        # (1 unchanged block 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.database.kubernetes_secret_v1.admin_app_db_url_westside_admin will be created
  + resource "kubernetes_secret_v1" "admin_app_db_url_westside_admin" {
      + data                           = (sensitive value)
      + id                             = (known after apply)
      + type                           = "Opaque"
      + wait_for_service_account_token = true

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

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

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

  # module.harbor.kubernetes_config_map_v1.harbor_portal_nginx will be updated in-place
  ~ resource "kubernetes_config_map_v1" "harbor_portal_nginx" {
      ~ data        = {
          ~ "nginx.conf" = <<-EOT
                worker_processes 1;
                events { worker_connections 128; }
                
                http {
                  include       /etc/nginx/mime.types;
                  default_type  application/octet-stream;
              -   client_max_body_size 0;
                
                  server {
                    listen 8080;
                
                    # Serve custom CSS from ConfigMap mount
                    location /custom/ {
                      alias /usr/share/nginx/custom/;
                      expires 1h;
                      add_header Cache-Control "public, immutable";
                    }
                
                    # Proxy everything to Harbor's internal nginx (which routes to portal, core, registry, etc.)
                    location / {
                      proxy_pass http://harbor:80;
                      proxy_http_version 1.1;
                
                      # Pass through headers for auth and websocket
                      proxy_set_header Host $host;
                      proxy_set_header X-Real-IP $remote_addr;
                      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                      proxy_set_header X-Forwarded-Proto https;
                      proxy_set_header Upgrade $http_upgrade;
                      proxy_set_header Connection "upgrade";
                
                      # Disable compression so sub_filter can operate on the response body
                      proxy_set_header Accept-Encoding "";
                
                      # Inject CSS link before </head>
                      sub_filter '</head>' '<link rel="stylesheet" href="/custom/mobile.css"></head>';
                      sub_filter_once on;
                      sub_filter_types text/html;
                    }
                  }
                }
            EOT
        }
        id          = "harbor/harbor-portal-nginx"
        # (2 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.harbor.kubernetes_deployment_v1.harbor_portal_proxy will be updated in-place
  ~ resource "kubernetes_deployment_v1" "harbor_portal_proxy" {
        id               = "harbor/harbor-portal-proxy"
        # (1 unchanged attribute hidden)

      ~ spec {
            # (5 unchanged attributes hidden)

          ~ template {
              ~ metadata {
                  ~ annotations = {
                      - "kubectl.kubernetes.io/restartedAt" = "2026-05-10T14:37:48-06:00" -> null
                        # (2 unchanged elements hidden)
                    }
                    # (2 unchanged attributes hidden)
                }

                # (1 unchanged block hidden)
            }

            # (2 unchanged blocks hidden)
        }

        # (1 unchanged block hidden)
    }

  # module.harbor.null_resource.harbor_oidc is tainted, so it must be replaced
-/+ resource "null_resource" "harbor_oidc" {
      ~ id       = "1192032061072951447" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

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

      + metadata {
          + generation       = (known after apply)
          + labels           = {
              + "app" = "pal-e-app-embedding-worker"
            }
          + name             = "embedding-worker-metrics"
          + namespace        = "pal-e-app"
          + 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-app-embedding-worker"
            }
          + session_affinity                  = "None"
          + type                              = "ClusterIP"

          + port {
              + name        = "metrics"
              + node_port   = (known after apply)
              + port        = 8001
              + protocol    = "TCP"
              + target_port = "8001"
            }

          + session_affinity_config (known after apply)
        }
    }

Plan: 5 to add, 3 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so OpenTofu can't
guarantee to take exactly these actions if you run "tofu apply" now.

## Tofu Plan Output (full) ``` module.networking.tailscale_acl.this: Refreshing state... [id=acl] module.database.data.kubernetes_namespace_v1.westside_admin: Reading... module.database.kubernetes_namespace_v1.postgres: Refreshing state... [id=postgres] module.staging.kubernetes_namespace_v1.staging: Refreshing state... [id=staging] module.forgejo.kubernetes_namespace_v1.forgejo: Refreshing state... [id=forgejo] module.monitoring.kubernetes_namespace_v1.monitoring: Refreshing state... [id=monitoring] module.keycloak.kubernetes_namespace_v1.keycloak: Refreshing state... [id=keycloak] module.database.data.kubernetes_namespace_v1.pal_e_production: Reading... module.database.data.kubernetes_namespace_v1.pal_e_production: Read complete after 0s [id=pal-e-app] module.database.data.kubernetes_namespace_v1.westside_admin: Read complete after 0s [id=westside-admin] module.database.data.kubernetes_namespace_v1.basketball_api: Reading... module.database.kubernetes_namespace_v1.cnpg_system: Refreshing state... [id=cnpg-system] module.database.data.kubernetes_namespace_v1.basketball_api: Read complete after 0s [id=basketball-api] module.networking.kubernetes_namespace_v1.tailscale: Refreshing state... [id=tailscale] module.database.kubernetes_secret_v1.paledocs_db_url: Refreshing state... [id=pal-e-app/paledocs-db-url] module.keycloak.kubernetes_config_map_v1.keycloak_westside_theme: Refreshing state... [id=keycloak/keycloak-westside-theme] module.forgejo.kubernetes_config_map_v1.forgejo_custom_css: Refreshing state... [id=forgejo/forgejo-custom-css] module.forgejo.kubernetes_secret_v1.forgejo_oidc: Refreshing state... [id=forgejo/forgejo-oidc] module.keycloak.kubernetes_secret_v1.keycloak_admin: Refreshing state... [id=keycloak/keycloak-admin] module.keycloak.kubernetes_service_v1.keycloak: Refreshing state... [id=keycloak/keycloak] module.keycloak.kubernetes_persistent_volume_claim_v1.keycloak_data: Refreshing state... [id=keycloak/keycloak-data] module.database.kubernetes_job_v1.admin_app_user_provision: Refreshing state... [id=basketball-api/admin-app-user-provision-c5662180] module.database.helm_release.cnpg: Refreshing state... [id=cnpg] module.networking.helm_release.tailscale_operator: Refreshing state... [id=tailscale-operator] module.monitoring.kubernetes_config_map_v1.uptime_dashboard: Refreshing state... [id=monitoring/uptime-dashboard] module.monitoring.kubernetes_secret_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] module.monitoring.helm_release.loki_stack: Refreshing state... [id=loki-stack] 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_secret_v1.grafana_oidc: Refreshing state... [id=monitoring/grafana-oidc] kubernetes_manifest.netpol_basketball_api: Refreshing state... kubernetes_manifest.netpol_postgres: Refreshing state... kubernetes_manifest.netpol_staging: Refreshing state... kubernetes_manifest.netpol_keycloak: Refreshing state... kubernetes_manifest.netpol_forgejo: Refreshing state... kubernetes_manifest.netpol_cnpg_system: Refreshing state... module.forgejo.helm_release.forgejo: Refreshing state... [id=forgejo] module.keycloak.kubernetes_deployment_v1.keycloak: Refreshing state... [id=keycloak/keycloak] module.database.kubernetes_secret_v1.admin_app_db_url: Refreshing state... [id=basketball-api/admin-app-db-url] kubernetes_manifest.netpol_monitoring: Refreshing state... module.database.kubernetes_secret_v1.admin_app_db_url_westside_admin: Refreshing state... [id=westside-admin/admin-app-db-url] module.admin.kubernetes_namespace_v1.admin: Refreshing state... [id=pal-e-admin] module.admin.kubernetes_secret_v1.harbor_creds: Refreshing state... [id=pal-e-admin/harbor-creds] module.admin.kubernetes_secret_v1.admin_auth: Refreshing state... [id=pal-e-admin/admin-auth] module.admin.kubernetes_service_v1.admin: Refreshing state... [id=pal-e-admin/pal-e-admin] module.admin.kubernetes_deployment_v1.admin: Refreshing state... [id=pal-e-admin/pal-e-admin] module.networking.kubernetes_ingress_v1.keycloak_funnel: Refreshing state... [id=keycloak/keycloak-funnel] module.networking.kubernetes_ingress_v1.admin_funnel: Refreshing state... [id=pal-e-admin/admin-funnel] module.networking.kubernetes_ingress_v1.forgejo_funnel: Refreshing state... [id=forgejo/forgejo-funnel] module.networking.kubernetes_ingress_v1.alertmanager_funnel: Refreshing state... [id=monitoring/alertmanager-funnel] module.networking.kubernetes_ingress_v1.grafana_funnel: Refreshing state... [id=monitoring/grafana-funnel] module.networking.kubernetes_manifest.tailscale_subnet_router: Refreshing state... module.monitoring.kubernetes_config_map_v1.mac_agent_dashboard: Refreshing state... [id=monitoring/mac-agent-dashboard] module.monitoring.kubernetes_config_map_v1.dora_dashboard: Refreshing state... [id=monitoring/dora-dashboard] module.monitoring.helm_release.blackbox_exporter: Refreshing state... [id=blackbox-exporter] 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.gmail_oauth_expiry_alert: Refreshing state... module.monitoring.kubernetes_manifest.payment_pipeline_alerts: Refreshing state... module.monitoring.kubernetes_manifest.embedding_alerts: Refreshing state... module.monitoring.kubernetes_config_map_v1.grafana_loki_datasource: Refreshing state... [id=monitoring/grafana-loki-datasource] module.monitoring.kubernetes_deployment_v1.dora_exporter: Refreshing state... [id=monitoring/dora-exporter] module.monitoring.kubernetes_config_map_v1.basketball_api_dashboard: Refreshing state... [id=monitoring/basketball-api-dashboard] module.monitoring.kubernetes_config_map_v1.pal_e_production_dashboard: Refreshing state... [id=monitoring/pal-e-app-dashboard] module.monitoring.kubernetes_manifest.dora_exporter_service_monitor: Refreshing state... module.monitoring.kubernetes_manifest.embedding_worker_service_monitor: Refreshing state... module.storage.kubernetes_namespace_v1.minio: Refreshing state... [id=minio] module.harbor.kubernetes_namespace_v1.harbor: Refreshing state... [id=harbor] module.storage.kubernetes_config_map_v1.minio_console_css: Refreshing state... [id=minio/minio-console-css] module.storage.kubernetes_config_map_v1.minio_console_nginx: Refreshing state... [id=minio/minio-console-nginx] module.storage.kubernetes_service_v1.minio_console_proxy: Refreshing state... [id=minio/minio-console-proxy] module.networking.kubernetes_ingress_v1.harbor_funnel: Refreshing state... [id=harbor/harbor-funnel] module.harbor.kubernetes_config_map_v1.harbor_portal_nginx: Refreshing state... [id=harbor/harbor-portal-nginx] module.storage.helm_release.minio: Refreshing state... [id=minio] 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.harbor.kubernetes_config_map_v1.harbor_portal_css: Refreshing state... [id=harbor/harbor-portal-css] module.networking.kubernetes_ingress_v1.minio_api_funnel: Refreshing state... [id=minio/minio-api-funnel] kubernetes_manifest.netpol_harbor: Refreshing state... module.networking.kubernetes_ingress_v1.minio_funnel: Refreshing state... [id=minio/minio-funnel] kubernetes_manifest.netpol_minio: Refreshing state... module.storage.minio_iam_policy.tf_backup: Refreshing state... [id=tf-backup] module.storage.minio_s3_bucket.tf_state_backups: Refreshing state... [id=tf-state-backups] 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.assets: Refreshing state... [id=assets] module.storage.minio_iam_user.cnpg: Refreshing state... [id=cnpg] module.storage.minio_s3_bucket.postgres_wal: Refreshing state... [id=postgres-wal] 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.database.kubernetes_secret_v1.cnpg_s3_creds: Refreshing state... [id=postgres/cnpg-s3-creds] module.storage.minio_s3_bucket_policy.assets_public_read: Refreshing state... [id=assets] 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.data.kubernetes_namespace_v1.tofu_state: Reading... 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.data.kubernetes_namespace_v1.tofu_state: Read complete after 0s [id=tofu-state] 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_account_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] module.ops.kubernetes_role_binding_v1.tf_backup: Refreshing state... [id=tofu-state/tf-state-backup] module.ci.kubernetes_namespace_v1.woodpecker: Refreshing state... [id=woodpecker] kubernetes_manifest.netpol_ollama: Refreshing state... module.ci.kubernetes_secret_v1.woodpecker_cnpg_s3_creds: Refreshing state... [id=woodpecker/cnpg-s3-creds] module.networking.kubernetes_ingress_v1.woodpecker_funnel: Refreshing state... [id=woodpecker/woodpecker-funnel] module.ops.kubernetes_cron_job_v1.tf_state_backup: Refreshing state... [id=tofu-state/tf-state-backup] module.ci.kubernetes_secret_v1.woodpecker_db_credentials: Refreshing state... [id=woodpecker/woodpecker-db-credentials] kubernetes_manifest.netpol_woodpecker: Refreshing state... module.ci.kubernetes_manifest.woodpecker_postgres: Refreshing state... module.harbor.null_resource.harbor_oidc: Refreshing state... [id=1192032061072951447] 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] Note: Objects have changed outside of OpenTofu OpenTofu detected the following changes made outside of OpenTofu since the last "tofu apply" which may have affected this plan: # module.database.kubernetes_secret_v1.admin_app_db_url_westside_admin has been deleted - resource "kubernetes_secret_v1" "admin_app_db_url_westside_admin" { id = "westside-admin/admin-app-db-url" # (4 unchanged attributes hidden) - metadata { name = "admin-app-db-url" - namespace = "westside-admin" -> null # (5 unchanged attributes hidden) } } # module.harbor.kubernetes_config_map_v1.harbor_portal_nginx has changed ~ resource "kubernetes_config_map_v1" "harbor_portal_nginx" { ~ data = { ~ "nginx.conf" = <<-EOT worker_processes 1; events { worker_connections 128; } http { include /etc/nginx/mime.types; default_type application/octet-stream; + client_max_body_size 0; server { listen 8080; # Serve custom CSS from ConfigMap mount location /custom/ { alias /usr/share/nginx/custom/; expires 1h; add_header Cache-Control "public, immutable"; } # Proxy everything to Harbor's internal nginx (which routes to portal, core, registry, etc.) location / { proxy_pass http://harbor:80; proxy_http_version 1.1; # Pass through headers for auth and websocket proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Disable compression so sub_filter can operate on the response body proxy_set_header Accept-Encoding ""; # Inject CSS link before </head> sub_filter '</head>' '<link rel="stylesheet" href="/custom/mobile.css"></head>'; sub_filter_once on; sub_filter_types text/html; } } } EOT } id = "harbor/harbor-portal-nginx" # (2 unchanged attributes hidden) # (1 unchanged block hidden) } Unless you have made equivalent changes to your configuration, or ignored the relevant attributes using ignore_changes, the following plan may include actions to undo or respond to these changes. ───────────────────────────────────────────────────────────────────────────── OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create ~ update in-place -/+ destroy and then create replacement OpenTofu will perform the following actions: # kubernetes_manifest.netpol_postgres will be updated in-place ~ resource "kubernetes_manifest" "netpol_postgres" { ~ manifest = { ~ spec = { ~ ingress = [ # (5 unchanged elements hidden) { from = [ { namespaceSelector = { matchLabels = { "kubernetes.io/metadata.name" = "westside-ror" } } }, ] }, + { + from = [ + { + namespaceSelector = { + matchLabels = { + "kubernetes.io/metadata.name" = "landscaping-assistant" } } }, ] }, ] # (2 unchanged attributes hidden) } # (3 unchanged attributes hidden) } ~ object = { ~ spec = { ~ ingress = [ # (5 unchanged elements hidden) { from = [ { ipBlock = { cidr = null except = null } namespaceSelector = { matchExpressions = null matchLabels = { "kubernetes.io/metadata.name" = "westside-ror" } } podSelector = { matchExpressions = null matchLabels = null } }, ] ports = null }, ~ { ~ from = [ ~ { ~ namespaceSelector = { ~ matchLabels = { ~ "kubernetes.io/metadata.name" = "pal-e-docs" -> "landscaping-assistant" } # (1 unchanged attribute hidden) } # (2 unchanged attributes hidden) }, ] # (1 unchanged attribute hidden) }, ] # (3 unchanged attributes hidden) } # (3 unchanged attributes hidden) } # (1 unchanged block 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.database.kubernetes_secret_v1.admin_app_db_url_westside_admin will be created + resource "kubernetes_secret_v1" "admin_app_db_url_westside_admin" { + data = (sensitive value) + id = (known after apply) + type = "Opaque" + wait_for_service_account_token = true + metadata { + generation = (known after apply) + labels = { + "app.kubernetes.io/managed-by" = "pal-e-platform-terraform" + "arch" = "postgres" + "mirror-of" = "basketball-api.admin-app-db-url" + "story" = "admin-row-crud" } + name = "admin-app-db-url" + namespace = "westside-admin" + resource_version = (known after apply) + uid = (known after apply) } } # module.database.kubernetes_secret_v1.paledocs_db_url will be created + resource "kubernetes_secret_v1" "paledocs_db_url" { + data = (sensitive value) + id = (known after apply) + type = "Opaque" + wait_for_service_account_token = true + metadata { + generation = (known after apply) + name = "paledocs-db-url" + namespace = "pal-e-app" + resource_version = (known after apply) + uid = (known after apply) } } # module.harbor.kubernetes_config_map_v1.harbor_portal_nginx will be updated in-place ~ resource "kubernetes_config_map_v1" "harbor_portal_nginx" { ~ data = { ~ "nginx.conf" = <<-EOT worker_processes 1; events { worker_connections 128; } http { include /etc/nginx/mime.types; default_type application/octet-stream; - client_max_body_size 0; server { listen 8080; # Serve custom CSS from ConfigMap mount location /custom/ { alias /usr/share/nginx/custom/; expires 1h; add_header Cache-Control "public, immutable"; } # Proxy everything to Harbor's internal nginx (which routes to portal, core, registry, etc.) location / { proxy_pass http://harbor:80; proxy_http_version 1.1; # Pass through headers for auth and websocket proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Disable compression so sub_filter can operate on the response body proxy_set_header Accept-Encoding ""; # Inject CSS link before </head> sub_filter '</head>' '<link rel="stylesheet" href="/custom/mobile.css"></head>'; sub_filter_once on; sub_filter_types text/html; } } } EOT } id = "harbor/harbor-portal-nginx" # (2 unchanged attributes hidden) # (1 unchanged block hidden) } # module.harbor.kubernetes_deployment_v1.harbor_portal_proxy will be updated in-place ~ resource "kubernetes_deployment_v1" "harbor_portal_proxy" { id = "harbor/harbor-portal-proxy" # (1 unchanged attribute hidden) ~ spec { # (5 unchanged attributes hidden) ~ template { ~ metadata { ~ annotations = { - "kubectl.kubernetes.io/restartedAt" = "2026-05-10T14:37:48-06:00" -> null # (2 unchanged elements hidden) } # (2 unchanged attributes hidden) } # (1 unchanged block hidden) } # (2 unchanged blocks hidden) } # (1 unchanged block hidden) } # module.harbor.null_resource.harbor_oidc is tainted, so it must be replaced -/+ resource "null_resource" "harbor_oidc" { ~ id = "1192032061072951447" -> (known after apply) # (1 unchanged attribute hidden) } # module.ops.kubernetes_service_v1.embedding_worker_metrics will be created + resource "kubernetes_service_v1" "embedding_worker_metrics" { + id = (known after apply) + status = (known after apply) + wait_for_load_balancer = true + metadata { + generation = (known after apply) + labels = { + "app" = "pal-e-app-embedding-worker" } + name = "embedding-worker-metrics" + namespace = "pal-e-app" + 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-app-embedding-worker" } + session_affinity = "None" + type = "ClusterIP" + port { + name = "metrics" + node_port = (known after apply) + port = 8001 + protocol = "TCP" + target_port = "8001" } + session_affinity_config (known after apply) } } Plan: 5 to add, 3 to change, 1 to destroy. ───────────────────────────────────────────────────────────────────────────── Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if you run "tofu apply" now. ```
Author
Owner

PR #376 Review

DOMAIN REVIEW

Tech stack: Terraform (HCL) -- Kubernetes NetworkPolicy managed via kubernetes_manifest resource.

This is a single-line addition to the netpol_postgres ingress rules in terraform/network-policies.tf. The change adds landscaping-assistant as the 7th namespace allowed to reach the shared CNPG PostgreSQL cluster.

Terraform patterns:

  • The new entry follows the exact same structure as the existing six entries: { from = [{ namespaceSelector = { matchLabels = { "kubernetes.io/metadata.name" = "landscaping-assistant" } } }] }
  • This is an in-place update to an existing kubernetes_manifest resource -- no state-breaking changes.
  • No new resources, no module changes, no variable changes.
  • tofu fmt is reported as passing. The indentation and trailing comma are consistent with the existing entries.

k8s security:

  • NetworkPolicy change is additive only -- opens ingress from one additional namespace.
  • Does not weaken existing restrictions. Other namespaces' access is unchanged.
  • The namespace name landscaping-assistant matches the expected app namespace from the parent issue.

CNPG considerations:

  • Allowing a new app namespace to reach the postgres namespace is the expected pattern for onboarding a new service to the shared database cluster.
  • No changes to backup policy, connection pooling, or extensions.

No issues found in domain review.

BLOCKERS

None.

This is a one-line infrastructure change with no application logic, no secrets, no user input handling, and no auth paths. None of the BLOCKER criteria apply:

  • No new functionality requiring test coverage (infrastructure NetworkPolicy addition tested via tofu plan and post-apply connectivity verification)
  • No user input to validate
  • No secrets or credentials in code
  • No auth/security logic duplication

NITS

  1. PR body checkbox: tofu validate is unchecked. This is a trivial change unlikely to fail validation, but completing the checkbox before merge would be tidy.

  2. Branch naming: Branch is feat/landscaping-assistant-netpol rather than the SOP pattern {issue-number}-{kebab-case-purpose}. Since this is a cross-repo PR (parent issue is in ldraney/landscaping-assistant, not ldraney/pal-e-platform), the feat/ prefix convention is a reasonable alternative. Noting for consistency tracking only.

SOP COMPLIANCE

  • Branch named after issue -- Branch is feat/landscaping-assistant-netpol. Cross-repo PR, so no local issue number to reference. Acceptable but non-standard.
  • PR body follows template -- Has Summary, Changes, Test Plan, Related sections. Well-structured.
  • Related references plan slug -- No plan slug provided (confirmed by caller). Related section references the parent issue instead.
  • No secrets committed -- Single-line namespace name addition. No credentials.
  • No unnecessary file changes -- Exactly one file changed, one line added. Zero scope creep.
  • Commit messages -- N/A (single commit PR, title is descriptive).

PROCESS OBSERVATIONS

  • Change failure risk: Very low. Additive NetworkPolicy change following an established pattern with six prior entries. Worst case: the namespace name is wrong and the app cannot connect, which is a no-op failure (connectivity stays blocked, same as before this PR).
  • Deployment frequency: This is one of three cross-repo infra PRs for the landscaping-assistant onboarding. Merging promptly unblocks downstream work.
  • Documentation: PR body is thorough for the scope of the change. No additional documentation needed.

VERDICT: APPROVED

## PR #376 Review ### DOMAIN REVIEW **Tech stack**: Terraform (HCL) -- Kubernetes NetworkPolicy managed via `kubernetes_manifest` resource. This is a single-line addition to the `netpol_postgres` ingress rules in `terraform/network-policies.tf`. The change adds `landscaping-assistant` as the 7th namespace allowed to reach the shared CNPG PostgreSQL cluster. **Terraform patterns**: - The new entry follows the exact same structure as the existing six entries: `{ from = [{ namespaceSelector = { matchLabels = { "kubernetes.io/metadata.name" = "landscaping-assistant" } } }] }` - This is an in-place update to an existing `kubernetes_manifest` resource -- no state-breaking changes. - No new resources, no module changes, no variable changes. - `tofu fmt` is reported as passing. The indentation and trailing comma are consistent with the existing entries. **k8s security**: - NetworkPolicy change is additive only -- opens ingress from one additional namespace. - Does not weaken existing restrictions. Other namespaces' access is unchanged. - The namespace name `landscaping-assistant` matches the expected app namespace from the parent issue. **CNPG considerations**: - Allowing a new app namespace to reach the postgres namespace is the expected pattern for onboarding a new service to the shared database cluster. - No changes to backup policy, connection pooling, or extensions. No issues found in domain review. ### BLOCKERS None. This is a one-line infrastructure change with no application logic, no secrets, no user input handling, and no auth paths. None of the BLOCKER criteria apply: - No new functionality requiring test coverage (infrastructure NetworkPolicy addition tested via `tofu plan` and post-apply connectivity verification) - No user input to validate - No secrets or credentials in code - No auth/security logic duplication ### NITS 1. **PR body checkbox**: `tofu validate` is unchecked. This is a trivial change unlikely to fail validation, but completing the checkbox before merge would be tidy. 2. **Branch naming**: Branch is `feat/landscaping-assistant-netpol` rather than the SOP pattern `{issue-number}-{kebab-case-purpose}`. Since this is a cross-repo PR (parent issue is in `ldraney/landscaping-assistant`, not `ldraney/pal-e-platform`), the `feat/` prefix convention is a reasonable alternative. Noting for consistency tracking only. ### SOP COMPLIANCE - [ ] Branch named after issue -- Branch is `feat/landscaping-assistant-netpol`. Cross-repo PR, so no local issue number to reference. Acceptable but non-standard. - [x] PR body follows template -- Has Summary, Changes, Test Plan, Related sections. Well-structured. - [ ] Related references plan slug -- No plan slug provided (confirmed by caller). Related section references the parent issue instead. - [x] No secrets committed -- Single-line namespace name addition. No credentials. - [x] No unnecessary file changes -- Exactly one file changed, one line added. Zero scope creep. - [x] Commit messages -- N/A (single commit PR, title is descriptive). ### PROCESS OBSERVATIONS - **Change failure risk**: Very low. Additive NetworkPolicy change following an established pattern with six prior entries. Worst case: the namespace name is wrong and the app cannot connect, which is a no-op failure (connectivity stays blocked, same as before this PR). - **Deployment frequency**: This is one of three cross-repo infra PRs for the landscaping-assistant onboarding. Merging promptly unblocks downstream work. - **Documentation**: PR body is thorough for the scope of the change. No additional documentation needed. ### VERDICT: APPROVED
ldraney deleted branch feat/landscaping-assistant-netpol 2026-05-24 00:36:32 +00:00
Sign in to join this conversation.
No description provided.