Skip to content

GCP infrastructure support - #73

Closed
PoslavskySV wants to merge 39 commits into
mainfrom
gcp-infrastructure
Closed

GCP infrastructure support#73
PoslavskySV wants to merge 39 commits into
mainfrom
gcp-infrastructure

Conversation

@PoslavskySV

Copy link
Copy Markdown
Member

No description provided.

…hook

The pre-install Job mounted the workspace PVC to run chown, but the PVC
was rendered as a regular manifest — so on fresh install the hook fires
before the PVC exists, leaving the Job stuck in Pending forever.

Move the chown into a Deployment-level initContainer, conditional on the
same workspace types (fsxLustre, filestore, nfs, pvc with chownOnCreate).
The initContainer runs after the PVC is bound, so timing is correct by
construction. Slightly increases every pod restart cost (chown -R is a
no-op after first run), but eliminates the install deadlock.

Delete hook-chown-workspace.yaml; the initContainer replaces it.
Spike implementation of the end-to-end GCP install matching the AWS
CloudFormation runbook's surface. Provisioned via `tofu apply` locally;
destined to be wrapped in Google Infrastructure Manager as the Tier 1
one-click flow.

What's here:
- APIs: enables container, file, dns, iam, secretmanager, certificatemanager,
  servicenetworking, config (IM), storage, and related services.
- Network: VPC + subnet with VPC-native secondary ranges (pods/services)
  and a PSA global address + service-networking peering so Filestore Zonal/
  Regional/Enterprise tiers can mount via private service access.
- GKE Standard zonal cluster with VPC-native, Workload Identity, Gateway
  API, and the Filestore + PD CSI drivers. Three node pools:
    system (always-on n2d-standard-4 × 2)
    ui     (0-4 n2d-standard-4, tainted dedicated=ui)
    batch  (0-N n2d-highmem-64, tainted dedicated=batch)
- Storage: Filestore (default ZONAL SSD per CTO validation — HDD does not
  handle real bioinformatics load), primary GCS bucket with CORS + uniform
  access, random suffix to avoid naming collisions.
- IAM: two GCP service accounts (platforma-server, platforma-jobs), each
  granted roles/storage.objectAdmin on the bucket and roles/iam.workload
  IdentityUser for the matching K8s SA. Both also granted roles/iam.service
  AccountTokenCreator on themselves so Platforma can presign GCS URLs for
  the Desktop App (signBlob isn't in the default WI scope).
- Controllers: Kueue Helm release (matching AWS values-aws file's feature
  gates + integrations) and AppWrapper install.yaml applied via the
  kubectl provider.
- Platforma Helm release: points at the local chart (..charts/platforma)
  for the spike; production will pull from ghcr.io. Passes cluster-derived
  values for Filestore, GCS, service-account emails, license secret,
  auto-generated htpasswd admin credentials (stored in Secret Manager),
  Kueue queue quotas, and node selectors/tolerations matching the pool
  labels.

Known gaps (follow-up work):
- No ingress / TLS yet — server validated via kubectl port-forward. Add
  static IP + Cloud DNS A record + Certificate Manager cert + Gateway
  when productizing.
- Infrastructure Manager metadata.yaml not yet written.
- No pre-flight quota check — fresh GCP projects will fail on N2D_CPUS,
  Enterprise Storage (for SSD Filestore), and CPU-all-regions global.
  Will add as a required resource before node pool creation.
- No quota_preference resources — need to bundle for auto-request.
- Local state file uses a hardcoded GCS backend bucket
  (platforma-gcp-test-tfstate); IM users will need their own.
…libraries

Phase 1+2 work on top of the initial GCP Terraform module commit:

* Deployment size presets (small/medium/large/xlarge) — single
  var.deployment_size drives node pool maxes, Kueue queue quotas,
  Filestore default capacity, and quota-request amounts. Per-job cap
  fixed at 62 vCPU / 500 GiB to match AWS CloudFormation parallelism.

* HTTPS ingress — gated on var.ingress_enabled. Provisions a global
  static IP, Cloud DNS A record, Certificate Manager managed cert
  with DNS authorization, GKE Gateway (Gateway API) bound to the cert
  via certmap annotation, and an HTTPRoute. The Gateway listener
  omits the tls block so the CEL "certificateRefs or options must be
  specified when mode is Terminate" rule doesn't fire — cert comes
  from the certmap annotation alone.

* gRPC backend — Platforma server speaks gRPC on port 6345 (HTTP/2
  only). The chart's Service has no appProtocol set so GKE Gateway
  defaults to HTTP/1.1 and marks the backend unhealthy. We provision
  a parallel Service "platforma-grpc" with appProtocol=kubernetes.io/h2c
  plus a HealthCheckPolicy doing GRPC health checks on the serving
  port. HTTPRoute targets the parallel Service. (Will be replaced once
  chart PR #71 lands and we set app.service.appProtocols.grpc on the
  chart's own Service.)

* Quota auto-request — google_cloud_quotas_quota_preference resources
  for CPUS_ALL_REGIONS (global), N2D_CPUS / SSD / INSTANCES (per
  region), and Filestore Zonal storage. Skip-list var
  (skip_quota_requests) for projects that already have user-managed
  preferences for the same (service, quota_id, dimensions) — Cloud
  Quotas API rejects duplicate creates and exposes no DELETE.

* Data libraries — var.data_libraries accepts a mixed list of
  GCS-typed (Workload Identity for same-project, no creds) and
  S3-typed (cross-account access keys) read-only buckets. Demo
  library toggle (var.enable_demo_data_library, default true) mounts
  MiLaboratories' AWS S3 demo bucket cross-cloud, mirroring the AWS
  CloudFormation default.

* Outputs — pragmatic set covering both basic users (platforma_url,
  default_username, password_secret_console_url, post_deploy_steps)
  and operators (kubectl_credentials_command, port_forward_command,
  cluster_name, region, gcs_bucket).

* Provider user_project_override + billing_project — required for
  the Cloud Quotas API when authenticating via local Application
  Default Credentials.

* deploy_platforma toggle — count-gated helm_release.platforma so
  infra + cluster controllers (Kueue, AppWrapper) can be installed
  without the application for isolated infra testing.

* platforma_image_override — passes a custom image:tag to the chart.

* Cluster sized n2d-highmem-64 batch nodes (62 vCPU / ~500 GiB
  allocatable per node) so a single 50 vCPU / 400 GiB job fits.
… for IM

Phase 3 prep. Infrastructure Manager's managed Terraform runner manages
state internally and rejects bundles that include their own backend
block. The block stays on disk for local-development workflows; the CI
packaging step (Phase 4) excludes backend.tf when zipping the source for
upload to gs://platforma-infrastructure-manager/.

Also lower required_version from >= 1.9 to >= 1.5 — IM runs Terraform
1.5.7 and the higher floor would block the IM path. Local OpenTofu
1.11+ and Terraform 1.5+ both still work.
Triggers on `gcp-im-v*` tags (or manual dispatch). Stages the Terraform
module, strips local-only files (backend.tf, terraform.tfvars,
.terraform/), bundles the chart at terraform/platforma/ for the path
helm_release expects in the IM context, rewrites the chart reference in
app.tf accordingly, and uploads the tarball to
gs://platforma-infrastructure-manager/.

On a tagged release, also updates platforma-gcp-latest.tar.gz so the
Cloud Shell tutorial (Phase 5) can default to the latest stable version.

Auth: uses GCP_PUBLISHER_SA_KEY repo secret (service-account JSON key)
for now. WIF (workload identity federation) upgrade is a follow-up.
Adds the Tier-1 user path from the GCP runbook plan: a one-click "Open in
Cloud Shell" experience that walks a non-IT user through the few inputs
needed (deployment name, region, size, domain, DNS zone, license key,
contact email) and submits a Google Cloud Infrastructure Manager
deployment pointing at the published TF bundle in
gs://platforma-infrastructure-manager/.

Files:
  - tutorial.md  Cloud Shell walkthrough rendered in the side panel
  - install.sh   driver script (preflight checks, prompts/env vars,
                 service account creation, IM apply, poll, outputs)
  - README.md    usage notes including the Open-in-Cloud-Shell URL and
                 instructions for bumping IM_BUNDLE_VERSION on each release

The bundle URL in install.sh is intentionally versioned (mirroring the
AWS CloudFormation runbook's versioned-template-URL approach). Tutorial
will be updated on each release by bumping IM_BUNDLE_VERSION and merging
to main; users always pick up the latest tutorial code on their next
Cloud Shell click since Cloud Shell clones HEAD.
Phase 5 follow-up — adds the missing auth and data-library inputs that
the install path needs for real production use. Without these, a user
can only test with the bundled demo data and an auto-generated random
password (testing only).

Terraform module:
* New auth_method variable (htpasswd|ldap; default htpasswd).
* New htpasswd_content variable for user-supplied bcrypted content.
  Empty (default) keeps the auto-gen path; non-empty creates a
  kubernetes_secret 'platforma-htpasswd-provided' that the chart
  references via auth.htpasswd.secretName.
* New ldap_* variables — ldap_server, ldap_start_tls, ldap_bind_dn
  (direct-bind), ldap_search_rules / ldap_search_user /
  ldap_search_password (search-bind). Search password lands in a
  kubernetes_secret referenced via auth.ldap.searchPasswordSecretRef
  (chart's standard pattern).
* app.tf: refactored auth = {...} block to a locals.auth_helm_value
  that branches on auth_method + htpasswd_content presence. Added
  conditional kubernetes_secret resources (count = 0 when not used).
* terraform.tfvars.example documents all new fields plus the
  production-vs-testing tradeoff for htpasswd auto-gen.

Cloud Shell installer (install.sh):
* New 'Authentication' section. Default htpasswd path is silent (warn
  about testing-only). LDAP path prompts for the 6 LDAP fields. Power
  users skip prompts entirely by exporting HTPASSWD_CONTENT before
  running.
* New 'Data libraries' section. Prompts for ENABLE_DEMO + an integer
  count of additional libraries (0-3), then loops once per library
  collecting name/type/bucket/prefix/region/keys. Power users skip
  prompts by exporting DATA_LIBRARIES_YAML with arbitrary YAML.
* LDAP search rules use ; separator (commas appear inside DNs).
* Switched IM submission from --input-values=k=v,… to YAML
  --input-values-file=… because data_libraries / ldap_search_rules
  are list/object types not expressible in the comma form.
* Added a YAML pretty-print of the inputs file (with secrets
  redacted) before submission so the user can sanity-check.
install.sh queries the Cloud Quotas API at startup, finds any
user-managed QuotaPreferences that would collide with our auto-request
resources (Cloud Quotas API rejects duplicate creates and exposes no
DELETE), and:

  1. Auto-populates skip_quota_requests in the inputs file so the
     deployment doesn't fail with a confusing 409.
  2. Compares each existing preferred_value against what the chosen
     deployment_size needs and warns when it's below — the deployment
     would still create successfully but jobs may fail to scale up
     later. User can confirm continue or abort.

Lookup tables for required values mirror presets.tf — keep in sync.
Surfaces the deployment_size preset values (parallel jobs, UI nodes,
Filestore size, all 5 quota amounts) in three places:

  - infrastructure/gcp/cloudshell/tutorial.md — short version users
    see during install (just parallel-jobs / UI / Filestore, plus a
    note about quota auto-request and human-review thresholds).
  - infrastructure/gcp/cloudshell/README.md — internal/dev-facing
    table including the keep-in-sync notes for install.sh's lookup
    tables and the quota collision handling logic.
  - infrastructure/gcp/README.md — new stub runbook (full docs come
    in Phase 7) with the user-facing preset table, three install
    tiers (Cloud Shell / local CLI / local Terraform), auth options
    (htpasswd auto/content / LDAP), data library options.

Source of truth remains presets.tf — comments in install.sh and the
internal README call out the keep-in-sync requirement.
…NAME

Previous prompts asked users to type DOMAIN_NAME and DNS_ZONE_NAME
free-form, which is error-prone — a non-IT user might type any nice
URL without realizing it must be a subdomain of a Cloud DNS managed
zone they own.

Now the installer queries gcloud dns managed-zones list, presents a
numbered picker (auto-selects when there's only one zone), and prompts
only for a SUBDOMAIN PREFIX. The full FQDN is derived as
<prefix>.<zone-dns-name>.

Errors out with actionable guidance when the project has zero zones
(pointing at the create command and domain-guide.md).

Both DOMAIN_NAME and DNS_ZONE_NAME env vars still bypass the picker
for non-interactive runs.
Replace the hidden license key prompt with a visible one (so users can
spot paste typos) and validate the entered key against
licensing-api.milaboratories.com/refresh-token before proceeding.
Same endpoint the Platforma server hits at runtime — a key that passes
here is effectively certain to start the cluster successfully.

Behaviour:
  - 200 from licensing API: accept and continue
  - 4xx (e.g. 403 unknown key): print HTTP code + extract any
    .message / .error / .errors field from the response body, re-prompt
  - Network failure: shows curl exit and re-prompts (user can Ctrl-C)
  - LICENSE_KEY env var: validated up-front; if it fails, fall through
    to interactive prompt rather than failing the whole install

Override the endpoint with LICENSING_API_URL for staging tests.
…ty default"

Previously prompt_var rejected empty input whenever the default was the
empty string, which misfired on three legitimately-optional prompts:

  - data-library prefix
  - data-library access_key (empty = use Workload Identity / IAM role)
  - LDAP bind DN (empty = use search-bind path)

Bash can't distinguish "no 3rd argument" from "3rd argument is empty
string" via "${3:-}" alone. Use the arg count instead: passing an
empty default explicitly marks the prompt as optional and lets empty
input through.
The previous attempt used --input-values-file=… which doesn't actually
exist in gcloud. gcloud's --input-values flag is comma-separated
KEY=VALUE pairs only — no way to express list/object types like
data_libraries or ldap_search_rules.

Pivot: download the published bundle locally, write user inputs as
inputs.auto.tfvars.json (Terraform auto-loads any *.auto.tfvars.json),
submit via --local-source. Same end result, no flag-shape limitations.

Other fixes:
* Replaced YAML output with JSON (cleaner construction via jq)
* Moved temp directory + EXIT trap to the top of the script so
  per-function locals don't go out of scope before cleanup runs
  (was triggering "inputs_file: unbound variable" under set -u)
On a fresh GCP project, the first 'gcloud infra-manager deployments apply'
fails with:

    Creating the root Cloud Storage bucket failed: Not found;
    Spanner: Not found ServiceAccountInfoDataType

…because the Infrastructure Manager service identity
(service-<project_num>@gcp-sa-config.iam.gserviceaccount.com) is not
provisioned automatically when the API is enabled — only when something
explicitly requests it. Subsequent calls then work because the identity
gets created on first failure, but the failure is the apply itself.

Fix: call 'gcloud beta services identity create --service=config.googleapis.com'
in preflight, right after enabling the API. Returns immediately with the
SA email and provisions the underlying Spanner record. Fully idempotent.

Also passed shellcheck (-S warning) and bash -n.
Two bugs that OpenTofu 1.11 (used locally) silently accepted but TF 1.5.7
(used by Infrastructure Manager) rejects at plan time:

1. auth_helm_value conditional type mismatch
   The htpasswd-content branch produced { secretName, secretKey } while
   the htpasswd-auto branch produced { credentials = [...] }. TF 1.5
   strictly unifies conditional branch types and rejects with:
   "Inconsistent conditional result types ... Type mismatch for object
    attribute htpasswd: The 'true' value includes object attribute
    secretKey, which is absent in the 'false' value."

   Refactor: split the three auth shapes into named locals and merge()
   mutually-exclusive single-key objects with empty fallbacks. merge
   doesn't trigger the unify check because each branch evaluates
   independently.

2. workspace_capacity_gb validation short-circuit
   'var.workspace_capacity_gb == null || var.workspace_capacity_gb >= 1024'
   evaluates BOTH operands in TF 1.5 (no short-circuit inside validation
   blocks), and 'null >= 1024' errors with "argument must not be null"
   before we ever check for null. Replace || with explicit ternary.

Validated end-to-end: deployed to a real fresh project with these
inputs; reached state=ACTIVE with cluster RUNNING and Filestore READY.
If a previous IM apply failed before any revision was created (e.g.
because the IM service identity wasn't yet provisioned, the
configured service account lacked permissions, or — historically — the
TF source had a plan-time error and never reached r-0), gcloud's apply
command crashes with 'IndexError: list index out of range' when trying
to read latestRevision on the next apply attempt.

Detect this state explicitly: when describe returns
state=FAILED|ACTIVE with empty latestRevision, delete the deployment
first, then create fresh.
… checkout

Source-of-truth simplification. Previously install.sh downloaded a published
tarball from gs://platforma-infrastructure-manager/, which required a CI
workflow, a service-account JSON key as a GitHub secret, a public bucket,
and a separate IM_BUNDLE_VERSION constant kept in lockstep with releases.

The bundle the user actually needs is whatever's in their git checkout —
Cloud Shell already clones the repo, and standalone runners need the repo
anyway. install.sh now assembles the IM source bundle in-process from
sibling directories (../terraform + ../../charts/platforma), applies the
same transformations the workflow used to do (drop backend.tf, bundle the
chart, rewrite the chart path in app.tf), and submits via --local-source.

Versioning + updates simplify accordingly:
  - default Cloud Shell button → main → latest
  - pinned production releases → cloudshell_git_branch=gcp-im-v0.1.0 in
    button URL → that tag
  - updates: re-open Cloud Shell (re-clones HEAD), re-run install.sh,
    IM creates a new revision against the new TF source

What's gone:
  - .github/workflows/publish-gcp-im.yaml
  - GCS bucket dependency (gs://platforma-infrastructure-manager — the
    bucket itself can be deleted manually; nothing references it now)
  - GCP_PUBLISHER_SA_KEY GitHub repo secret (no longer used)
  - IM_BUNDLE_VERSION / IM_BUNDLE_URL constants in install.sh

Also (Q3): htpasswd content can now be supplied as a file path
  - HTPASSWD_FILE env var (path), or
  - interactive prompt asking for path (empty = auto-gen testing path)
  - HTPASSWD_CONTENT env var still works (priority over HTPASSWD_FILE)

Validated: bundle assembly produces a tofu-valid module on the local
checkout (head of gcp-infrastructure branch).
The 'Proceed? [y/N]' confirmation at the end of input collection treated
empty Enter as 'no' and aborted, forcing the user to re-enter all 10+
prompts. Same behaviour on the 'Continue anyway?' confirmation when
existing quotas are below preset requirements.

Replace both call sites with a new prompt_yn helper that loops on empty
or invalid input ('Please answer y or n.') and only returns when an
explicit y or n is given. No default — accidental Enter no longer wipes
out a long input session.
…A resources

Several resources in dns_tls.tf and network.tf had no depends_on on
google_project_service.enabled, so on a fresh project they could race
with API enablement and fail with:

  Error 403: Certificate Manager API has not been used in project ...
  before or it is disabled.

(Hit on platforma-gcp-test-2.)

Affected resources:
  - google_dns_record_set.platforma
  - google_dns_record_set.cert_validation
  - google_certificate_manager_dns_authorization.platforma
  - google_certificate_manager_certificate.platforma
  - google_certificate_manager_certificate_map.platforma
  - google_certificate_manager_certificate_map_entry.platforma
  - google_compute_global_address.private_service_access

Other resources without explicit depends_on are fine — they reference
parent resources (cluster, bucket, SA, etc.) that already wait for
google_project_service.enabled, so the dependency holds transitively.
…ction

Cloud Shell sessions disconnect after ~1 hour idle. The 20-25 min
deployment poll loop in install.sh dies with the session, but the
deployment itself continues in the background. Users need to know
where to resume watching from a browser.

install.sh: print Infrastructure Manager + Cloud Build console URLs
immediately after submitting the deployment (before the long poll
loop), so they're in the terminal scrollback even if the session
later disconnects.

tutorial.md: new "Monitoring + recovery" section pointing at the same
two URLs, listing common failure modes (quota, cert validation), and
documenting that re-running install.sh on an existing deployment is
safe (it updates in place via a new IM revision).
…ced guides

Phase 7 of the GCP rollout. Four new/expanded docs covering everything
beyond the Cloud Shell tutorial:

  * README.md            promoted from stub to full runbook landing page —
                         architecture diagram, prerequisites, cost notes,
                         install paths overview, pricing, auth + data-library
                         reference, troubleshooting (cert validation,
                         quota errors, "no healthy upstream"), tearing down,
                         tracking issues for the open chart-side PRs.

  * domain-guide.md      step-by-step Cloud DNS zone setup + NS delegation
                         from external registrars: Route53, Cloudflare,
                         GoDaddy, Namecheap, generic. Verification via dig.
                         Common pitfalls (trailing dot, propagation, root
                         delegation). Multiple-deployment patterns.

  * permissions.md       12-role fine-grained IAM set replacing roles/owner
                         on the deployer service account. Per-role
                         justification mapped to the resources the TF module
                         creates. Apply-script + cross-project DNS notes
                         + org-policy interactions (DRS, VPC-SC).

  * advanced-installation.md   Tier-3 power-user path: local Terraform
                         without Infrastructure Manager. State backend
                         setup, gcloud auth, customization recipes (LDAP,
                         pre-bcrypted htpasswd, mixed GCS+S3 data libraries,
                         cross-project DNS, custom node-pool sizing,
                         skip_quota_requests), updates, destruction,
                         comparison to the IM path.
Step 2 (with-ingress) and step 3 (port-forward) of post_deploy_steps now
branch on auth_method:

- ldap → "Log in with your LDAP credentials (server: <ldap_server>)."
- htpasswd + user-supplied content → "Log in with one of the user/password
  pairs from your htpasswd_content."
- htpasswd + auto-generated → existing Secret Manager URL + CLI command

Final step now reads "Add Connection → Remote Server, connect to <url>."
without the "log in as ... with the password above" tail, since the
credential location depends on auth method.

The htpasswd_content emptiness check is wrapped in nonsensitive() because
htpasswd_content is sensitive; without the wrap, the entire
post_deploy_steps output inherits sensitivity and TF refuses to print it.
Catch the most common first-time-user failure mode early: a Cloud DNS zone
exists, but the parent zone has not delegated NS authority to it. Without
this check, install.sh runs all the way through provisioning and then
Cert Manager sits in PROVISIONING / AUTHORIZATION_ISSUE indefinitely —
costing the user 20+ minutes to discover the problem.

verify_dns_delegation() runs after collect_inputs and before
detect_existing_quota_prefs:
- describes the chosen zone, gets dnsName + nameServers
- runs `dig +short NS <dnsName>` from the user's machine
- compares to the 4 ns-cloud-*.googledomains.com nameservers
- on empty/mismatch: prints the exact NS records to add at the parent zone,
  points at domain-guide.md, asks "proceed anyway?" (allows debugging
  flows that aren't first-install)

Skipped when INGRESS_ENABLED=false (no cert validation, no delegation
needed) or when `dig` is unavailable (warns instead of failing).

Tested locally against:
- a valid delegation (gcp-test.platforma.bio in platforma-gcp-test-3) — passes
- non-ingress mode — correctly skipped
- a synthesized undelegated zone — fails with the expected guidance
The previous failure message said "add NS record at the parent zone" —
correct for a delegated subdomain, but misleading when the user owns the
root domain (e.g. bought through Cloud Domains, or registered at GoDaddy
and pointed nameservers at Cloud DNS). In that case there's no parent
zone they control; they need to update nameservers at the registrar.

New message lists both options:
  - If <dnsName> is a domain you own at a registrar: update NS in the
    registrar admin.
  - If <dnsName> is a subdomain of a zone you already control: add an NS
    record on the parent zone.

The detection logic itself was already correct for both cases — only the
remediation guidance needed widening.
Three-tier validation runs after DNS precheck, before quota / IM SA setup:

Tier 1 (syntax — hard fail):
  - LDAP_SERVER parses as ldap://host[:port] or ldaps://host[:port]
  - default port inferred (389 / 636) when absent
  - StartTLS+ldaps mismatch warned
  - exactly one bind mode set (direct-bind XOR search-bind)
  - direct-bind: warn if LDAP_BIND_DN lacks '%u' placeholder
  - search-bind: each rule matches '(filter)|baseDN', warn if no '%u'

Tier 2 (network — warn only):
  - TCP probe via nc -z -w 5 (portable across BSD nc / Linux nc)
  - TLS handshake via openssl s_client for ldaps:// — verify cert chain
  - warn-only because this machine's reachability != GKE cluster's
    reachability (Cloud Shell is behind GCP NAT, typically can't reach
    corporate LDAP, but the cluster with VPN/peering can)

Tier 3 (bind — warn only, search-bind only):
  - if ldapsearch is available and creds set, attempt anonymous-readable
    search at the first rule's baseDN
  - skipped for direct-bind (needs a real user's password to test)

Validated against:
  - htpasswd auth method → no-op
  - bad URL → fail with clear message
  - missing bind mode → fail
  - both modes set → fail
  - malformed search rule → fail
  - bind DN without %u → warn
  - reachable TLS endpoint (google:443 standing in for ldaps) → TLS OK
  - unreachable host → warn, continues
Cloud Shell ships without nc, so the previous nc-only probe printed "nc
not found — skipping TCP reachability check" instead of doing the work.

New chain tries python3 first (universally available on Cloud Shell and
modern macOS, has clean socket timeout via socket.create_connection),
falls back to nc (BSD or Linux flag form -z -w 5), and finally bash's
/dev/tcp builtin (no clean timeout but fast-fails on most networks).

The success line now reports which method ran:
  ✓ TCP host:port reachable from this machine (python3)

Verified against ldap.eu.foxpass.com:636 with all three paths exercised
(real run + simulated nc-missing → python3 fallback).
The previous two-sentence stub was uninformative for the most common
question: "I want to add a data library / pull a new chart version /
change auth — what do I actually do?"

New section covers:

- What you typically update (releases, libraries, auth, resize) and what
  data survives across updates (Filestore PVC, GCS bucket).

- Path 1 — re-run install.sh with env-var pre-fill. Concrete copy-paste
  block listing every variable, with the data-libraries YAML caveat that
  the list is replaced as a whole (so include existing libraries when
  adding a new one). Notes that env-var pre-fill is a workaround until we
  read inputs back from the IM revision directly.

- Path 2 — edit inputs in the IM Console. Quickest for one-off changes
  without setting up env vars.

- Path 3 — Tier-3 local Terraform. Recommended for ongoing ops; one-line
  edit + tofu apply. Points at advanced-installation.md for migration.

- Rollbacks via IM Console Revisions tab (Tier-1/2) or git revert + tofu
  apply (Tier-3).
…ckets

Previously the user had to run `gcloud storage buckets add-iam-policy-binding`
manually before listing a same-project GCS bucket in data_libraries. Skipping
the grant produced silent pod-init failures with "AccessDenied" — discovered
only when the pod couldn't initialise its data sources.

Two new google_storage_bucket_iam_member resources, one for platforma-server
and one for platforma-jobs, granting roles/storage.objectViewer on every
data library entry where:
  - type == "gcs"
  - project_id == "" (defaults to the deployment project) OR
    project_id == var.project_id (explicit same-project)

Cross-project entries (project_id set to a different project) are skipped —
the deployer SA can't touch IAM on a foreign project without extra setup;
those still require pre-grant.

S3 entries are unaffected (auth via HMAC keys, not Workload Identity).

Updated README and advanced-installation.md to drop the "grant the role
BEFORE installing" instruction and clarify when manual grant is still needed
(cross-project only).
…em-64

The previous default (n2d-standard-16) was inconsistent with everything
else in the module: kueue_max_job_cpu defaults to 62 vCPU, the small
preset's n2d_cpus_quota requests 512 vCPU (sized for 4 × n2d-highmem-64),
the README docs name "n2d-highmem-64 instance-hours" as the active-job
cost, and the quota auto-request justification literally says "Platforma
batch pool uses n2d-highmem-64 nodes".

What that mismatch produced in practice:

A typical Platforma job pod requests 16 vCPU + 64 GiB. After GKE
daemonset/system overhead, an n2d-standard-16 node has only ~15.9 vCPU
and ~57 GiB allocatable — pods short by 110m CPU and 7 GiB memory, sit
Pending forever, autoscaler can't provision more (max reached or
"CapacityIsNotFound"), and MiXCR clonotyping (or any real workload) is
silently broken until the user diagnoses node-pool sizing.

The new default has 64 vCPU / 512 GiB → ~62 vCPU / 500 GiB allocatable,
matching kueue_max_job_cpu/memory exactly. One large job per node.

Variable description now flags the trap explicitly: smaller types appear
cheaper but require also lowering kueue_max_job_cpu/memory; otherwise pods
sit Pending. Plus a note about N2D-CPUS-per-project-region quota
implications.

This is a default change — existing deployments need
`tofu apply` (Tier-3) or re-running install.sh (Tier-1/2) to pick it up.
The change recreates the batch node pool (machine_type is recreate-only).
…pools)

Previously the GCP module had a single batch node pool with one machine
type (n2d-highmem-64 after the recent default fix). Every job — whether
it asks for 16 vCPU or 60 vCPU — got a whole 64-vCPU / 512-GiB node, paying
for idle capacity proportional to the gap.

AWS already does this right: 5 batch node groups (m7i.4xlarge / .8xlarge /
.16xlarge + r7i.8xlarge / .16xlarge) sharing the same K8s label and taint.
The autoscaler picks the smallest-fitting group per pending pod. CTO
flagged that GCP wasn't matching this; this commit closes the gap.

Implementation:

- presets.tf grows a batch_pool_specs map with 5 shapes (16c-64g, 32c-128g,
  64c-256g, 32c-256g, 64c-512g), each pinned to the matching n2d-* machine
  type. Per-preset batch_pool_max_nodes mirror AWS's MaxBatch16c64g /
  MaxBatch32c128g / etc. exactly so the same deployment_size label means
  the same parallelism on both clouds.

- gke.tf becomes for_each over the spec map. Pool names are batch-${shape}.
  All pools carry the same role=batch label, dedicated=batch:NoSchedule
  taint, and an extra platforma.bio/batch-pool=<shape> label for diagnostics.

- google_container_cluster.primary now sets cluster_autoscaling
  autoscaling_profile = OPTIMIZE_UTILIZATION — the GKE-side counterpart of
  AWS's "least-waste" expander. Without it, the autoscaler picks pools at
  random when multiple match a pending pod, defeating the multi-pool win.

- variables.tf drops batch_pool_machine_type and batch_pool_max_nodes
  (single-pool concepts); adds batch_pool_max_nodes_overrides map keyed by
  shape ID for per-pool overrides.

- Kueue ClusterQueue total CPU/memory now derives from
  sum(pool_count × pool_capacity), so admission gate matches the cluster's
  real upper bound rather than parallel_jobs × per-job-cap. Per-preset
  totals (small=320c/1792Gi, medium=640c/3584Gi, large=1280c/7168Gi,
  xlarge=2560c/14336Gi) validated via tofu console.

- quotas.tf justifications updated to reflect 5-pool layout.

- README adds per-pool max-node table per preset, pool-shape mapping with
  AWS counterparts, and revised cost notes ($/hour per pool shape so users
  can see the multi-pool win — a 16c MiXCR job on 16c-64g costs ~$0.90/h
  vs ~$3.60/h on 64c-256g).

- advanced-installation.md replaces the old single-pool override snippet
  with the new map-based syntax.

Chart unchanged: ResourceFlavor selects on role=batch and tolerates
dedicated=batch — both shared across all 5 pools, so a single flavor still
covers everything.
Fresh-project apply intermittently fails with:
  Error 400: Identity Pool does not exist
when google_service_account_iam_member.{server,jobs}_wi tries to add a
WI binding for <project>.svc.id.goog before GKE has finished provisioning
the cluster's Identity Pool.

The pool is created lazily by GKE after the cluster comes up. Without an
explicit dep, Terraform parallelises and the IAM binding API call beats
the pool's existence — race window of seconds-to-minutes on a cold
project where everything provisions concurrently.

Fix: add depends_on = [google_container_cluster.primary] on both WI
bindings so TF serialises cluster-first, bindings-after.

Reproducible on platforma-gcp project on first apply; resolved by re-
applying after a few minutes (the pool exists by then). This commit
removes the need to retry.
Lightweight ADR log capturing the non-obvious choices made during the
GCP module build. 21 entries covering: three-tier install model, deployment
size presets matching AWS, per-job 62/500 cap, multi-pool batch design,
Filestore Zonal SSD default, Workload Identity for runtime SAs, htpasswd
auto-gen vs user-supplied, Cloud Quotas best-effort, --local-source bundle,
bundled chart workaround, parallel grpc Service workaround, chown
initContainer Filestore constraint, auto-grant SA on same-project GCS,
IM as source of truth, custom HCL vs standard modules, deployer SA roles,
gcp-im-v tag prefix, license validation, DNS delegation precheck, LDAP
3-tier precheck, demo data library cross-cloud.

Each ADR follows a Decision / Why / Trade-offs / Revisit shape — small
enough to scan, structured enough to cross-reference. Numbered for stable
references; do not renumber existing entries.

The "custom HCL vs standard modules" entry (ADR 15) addresses the question
the CTO raised about maintainability: keep custom for v1.0, plan a
network.tf migration to terraform-google-modules/network/google for v1.1
as a low-risk first step.

Linked from the README's Files section so future maintainers find it
before making structural changes.
Three changes that fix the same root cause: with 5 batch pool shapes plus
UI + system pools, peak node count at xlarge is ~30. Each public-IP node
consumed 1 IN_USE_ADDRESSES slot — far above the GCP default of 8.
Fresh-project installs hit the cap during scale-up with confusing Cluster
Autoscaler errors.

1. Private nodes (gke.tf)

   private_cluster_config now sets enable_private_nodes = true,
   enable_private_endpoint = false. Worker nodes have no external IPs;
   control plane stays public so kubectl from any machine still works
   without a bastion or IAP tunnel. master_ipv4_cidr_block defaults to
   10.10.0.0/28 (configurable via variable).

2. Cloud NAT for egress (network.tf)

   New google_compute_router + google_compute_router_nat. AUTO_ONLY IP
   allocation (Google manages NAT IPs, typically 1-2 per region with
   dynamic port allocation). Provides egress for nodes pulling container
   images, reaching cross-cloud demo data (S3), external LDAP, and the
   licensing API. Google APIs (Storage, IAM, Logging, Monitoring) bypass
   NAT via Private Google Access on the nodes subnet — faster and avoids
   NAT processing fees on the high-volume GCS traffic that bioinformatics
   workloads generate.

   Cluster depends_on the NAT so the first system-pool nodes don't hang
   trying to pull from gcr.io with no egress path.

3. IN_USE_ADDRESSES auto-request (quotas.tf, presets.tf)

   With private nodes + NAT, IN_USE_ADDRESSES usage drops to ~2-3 per
   region (NAT IPs + GKE Gateway static IP). Default 8 would suffice but
   we request modest headroom: 16/16/24/32 across small/medium/large/
   xlarge. Without this preset entry, the previous public-nodes design
   was silently broken for any preset above small on a fresh project.

Cost trade-off:
  - Cloud NAT base ~$0.045/h + $0.045/GB egress processed.
  - GCS traffic bypasses NAT (Private Google Access), so per-GB cost is
    bounded to image pulls + occasional non-Google egress — typically
    pennies per day for a small deployment.

Other updates:
  - architecture-decisions.md ADR 22: documents the design + revisit
    triggers (static egress IPs if customers whitelist us; per-cluster NAT
    primitives if GCP ships one).
  - README architecture diagram redrawn to show NAT + PGA flows.
  - README deployment-size table adds the IN_USE_ADDRESSES column.
  - permissions.md: clarifies compute.networkAdmin now also covers Cloud
    Router + Cloud NAT.

For existing deployments updating to this commit: the cluster recreates
node pools (private_nodes is on cluster.private_cluster_config, which is
recreate-only in TF). Plan output will show this; not in-place. After
recreate, jobs resume on private nodes with NAT egress.
The previous commit (43908a1) made private nodes unconditional. That works
cleanly for fresh installs but breaks the upgrade path for existing
public-nodes deployments — private_cluster_config.enable_private_nodes is
ForceNew, so flipping it would force cluster recreation, and TF can't
plan kubernetes_namespace / helm_release / kubectl_manifest resources
when their underlying cluster is being replaced (the kubernetes provider
falls back to localhost:80 because google_container_cluster.endpoint is
unknown during plan).

Add var.enable_private_nodes (default true). New installs get private
nodes + Cloud NAT as designed. Existing public-nodes deployments override
to false on their next apply, keeping cluster intact; they migrate to
private nodes via a deliberate destroy+recreate when convenient.

Both private_cluster_config (gke.tf) and the Cloud Router + NAT
(network.tf) are now gated on the variable. Cluster's depends_on still
references google_compute_router_nat.primary — TF resolves the empty case
(count = 0) as a no-op dependency.

Migration notes:
- Existing deployments upgrading to this commit must set
  enable_private_nodes = false in their inputs to avoid plan failure.
  Cloud NAT and the IN_USE_ADDRESSES quota auto-request remain unused on
  these clusters until the user does a destroy+recreate to flip the flag.
- Fresh deployments after this commit get the full private + NAT design
  with no extra config required.
@mike-ainsel

Copy link
Copy Markdown
Member

One additional GKE-specific improvement we could make involves the autoscaling behavior. Instead of the current chart default (best-effort-atomic-scale-up), we could use GKE Flex-start (queued-provisioning.gke.io). This allows the system to queue and wait for capacity, making it better for large, bursty jobs that can tolerate waiting. However, this requires additional Helm chart parameters, which may increase configuration complexity.

@mike-ainsel mike-ainsel closed this May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants